From 51d9e65115b36a20d759aa13006bf87839c27e11 Mon Sep 17 00:00:00 2001
From: Paul J Stevens
Date: Thu, 5 Feb 2026 11:26:52 +0100
Subject: [PATCH 1/2] bump required version of django-otp and update middleware
fixes: #261
---
CHANGES | 5 +++++
setup.py | 2 +-
src/wagtail_2fa/middleware.py | 2 +-
3 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/CHANGES b/CHANGES
index e2ef646..7dc216c 100644
--- a/CHANGES
+++ b/CHANGES
@@ -1,3 +1,8 @@
+1.8.0 (2026-02-05)
+==================
+ - bump required version of django-otp and update middleware
+
+
1.7.1 (2025-07-24)
=================
- Update python release workflow to use Python 3.11
diff --git a/setup.py b/setup.py
index 2568b21..5958983 100644
--- a/setup.py
+++ b/setup.py
@@ -5,7 +5,7 @@
install_requires = [
"Django>=3.2",
"Wagtail>=4.1",
- "django-otp>=0.8.1",
+ "django-otp>=1.7.0",
"six>=1.14.0",
"qrcode>=6.1",
]
diff --git a/src/wagtail_2fa/middleware.py b/src/wagtail_2fa/middleware.py
index f2c8890..9773a78 100644
--- a/src/wagtail_2fa/middleware.py
+++ b/src/wagtail_2fa/middleware.py
@@ -36,7 +36,7 @@ def __call__(self, request):
def process_request(self, request):
if request.user:
request.user = SimpleLazyObject(
- partial(self._verify_user, request, request.user)
+ partial(self._verify_user_sync, request, request.user)
)
user = request.user
if self._require_verified_user(request):
From 659c9ea10ba329b1263530f45bff7aa8e0fadd0c Mon Sep 17 00:00:00 2001
From: Paul J Stevens
Date: Mon, 2 Mar 2026 16:21:25 +0100
Subject: [PATCH 2/2] update test matrix
test with python 3.13 and 3.14, django-5.2, wagtail-7.0
---
.github/workflows/python-tox.yml | 2 +-
sandbox/sandbox/apps/user/admin.py | 40 ++++----
sandbox/sandbox/settings.py | 143 +++++++++++++----------------
setup.py | 20 ++--
tests/conftest.py | 18 ++--
tests/test_hooks.py | 7 +-
tests/test_middleware.py | 42 ++++++---
tests/test_mixins.py | 9 +-
tox.ini | 25 ++---
9 files changed, 152 insertions(+), 154 deletions(-)
diff --git a/.github/workflows/python-tox.yml b/.github/workflows/python-tox.yml
index 3406317..9398cda 100644
--- a/.github/workflows/python-tox.yml
+++ b/.github/workflows/python-tox.yml
@@ -7,7 +7,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- python: ["3.8", "3.9", "3.10", "3.11"]
+ python: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v4
diff --git a/sandbox/sandbox/apps/user/admin.py b/sandbox/sandbox/apps/user/admin.py
index 02378cd..9834a73 100644
--- a/sandbox/sandbox/apps/user/admin.py
+++ b/sandbox/sandbox/apps/user/admin.py
@@ -12,31 +12,29 @@ class UserAdmin(BaseUserAdmin):
# The fields to be used in displaying the User model.
# These override the definitions on the base UserAdmin
# that reference specific fields on auth.User.
- list_display = ['email']
- list_filter = ['is_superuser']
+ list_display = ["email"]
+ list_filter = ["is_superuser"]
fieldsets = (
- (None, {
- 'fields': ['email', 'password']
- }),
- ('Personal info', {
- 'fields': ['first_name', 'last_name']
- }),
- ('Permissions', {
- 'fields': [
- 'is_active', 'is_staff', 'is_superuser',
- 'groups', 'user_permissions'
- ]
- }),
+ (None, {"fields": ["email", "password"]}),
+ ("Personal info", {"fields": ["first_name", "last_name"]}),
+ (
+ "Permissions",
+ {
+ "fields": [
+ "is_active",
+ "is_staff",
+ "is_superuser",
+ "groups",
+ "user_permissions",
+ ]
+ },
+ ),
)
- # add_fieldsets is not a standard ModelAdmin attribute. UserAdmin
# overrides get_fieldsets to use this attribute when creating a user.
add_fieldsets = (
- (None, {
- 'classes': ('wide',),
- 'fields': ['email', 'password1', 'password2']
- }),
+ (None, {"classes": ("wide",), "fields": ["email", "password1", "password2"]}),
)
- search_fields = ['first_name', 'last_name', 'email']
- ordering = ['email']
+ search_fields = ["first_name", "last_name", "email"]
+ ordering = ["email"]
filter_horizontal = []
diff --git a/sandbox/sandbox/settings.py b/sandbox/sandbox/settings.py
index 158a57b..5ce9f87 100644
--- a/sandbox/sandbox/settings.py
+++ b/sandbox/sandbox/settings.py
@@ -21,10 +21,10 @@
DEBUG = True
# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = 'VnFOCFWNnyeYRsyjerSCvxOiKf0fvH57ajVBZIG8ie4xlSTLAzRkj7aYYEqJAKpR'
+SECRET_KEY = "VnFOCFWNnyeYRsyjerSCvxOiKf0fvH57ajVBZIG8ie4xlSTLAzRkj7aYYEqJAKpR"
-EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
+EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/
@@ -33,90 +33,79 @@
# Application definition
INSTALLED_APPS = [
-
- 'wagtail.contrib.forms',
- 'wagtail.contrib.redirects',
- 'wagtail.embeds',
- 'wagtail.sites',
- 'wagtail.users',
- 'wagtail.snippets',
- 'wagtail.documents',
- 'wagtail.images',
- 'wagtail.search',
- 'wagtail.admin',
- 'wagtail',
- # 'wagtail_modeladmin', # if Wagtail >=5.1; Don't repeat if it's there already
- 'wagtail.contrib.modeladmin', # if Wagtail <5.1; Don't repeat if it's there already
- 'wagtail.contrib.styleguide',
-
- 'modelcluster',
- 'taggit',
- 'debug_toolbar',
-
- 'django.contrib.admin',
- 'django.contrib.auth',
- 'django.contrib.contenttypes',
- 'django.contrib.sessions',
- 'django.contrib.messages',
- 'django.contrib.staticfiles',
-
- 'wagtail_2fa',
- 'django_otp',
- 'django_otp.plugins.otp_totp',
-
- 'sandbox.apps.home',
- 'sandbox.apps.user',
-
+ "wagtail.contrib.forms",
+ "wagtail.contrib.redirects",
+ "wagtail.embeds",
+ "wagtail.sites",
+ "wagtail.users",
+ "wagtail.snippets",
+ "wagtail.documents",
+ "wagtail.images",
+ "wagtail.search",
+ "wagtail.admin",
+ "wagtail",
+ "wagtail.contrib.styleguide",
+ "modelcluster",
+ "taggit",
+ "debug_toolbar",
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ "wagtail_2fa",
+ "django_otp",
+ "django_otp.plugins.otp_totp",
+ "sandbox.apps.home",
+ "sandbox.apps.user",
]
MIDDLEWARE = [
- 'debug_toolbar.middleware.DebugToolbarMiddleware',
-
- 'django.middleware.security.SecurityMiddleware',
- 'django.contrib.sessions.middleware.SessionMiddleware',
- 'django.middleware.common.CommonMiddleware',
- 'django.middleware.csrf.CsrfViewMiddleware',
- 'django.contrib.auth.middleware.AuthenticationMiddleware',
- 'django.contrib.messages.middleware.MessageMiddleware',
- 'django.middleware.clickjacking.XFrameOptionsMiddleware',
-
- 'wagtail_2fa.middleware.VerifyUserPermissionsMiddleware',
-
- 'wagtail.contrib.redirects.middleware.RedirectMiddleware',
+ "debug_toolbar.middleware.DebugToolbarMiddleware",
+ "django.middleware.security.SecurityMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
+ "wagtail_2fa.middleware.VerifyUserPermissionsMiddleware",
+ "wagtail.contrib.redirects.middleware.RedirectMiddleware",
]
-ROOT_URLCONF = 'sandbox.urls'
+ROOT_URLCONF = "sandbox.urls"
TEMPLATES = [
{
- 'BACKEND': 'django.template.backends.django.DjangoTemplates',
- 'DIRS': [
- os.path.join(PROJECT_DIR, 'templates'),
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [
+ os.path.join(PROJECT_DIR, "templates"),
],
- 'APP_DIRS': True,
- 'OPTIONS': {
- 'context_processors': [
- 'django.template.context_processors.debug',
- 'django.template.context_processors.request',
- 'django.contrib.auth.context_processors.auth',
- 'django.contrib.messages.context_processors.messages',
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
],
},
},
]
-WSGI_APPLICATION = 'sandbox.wsgi.application'
+WSGI_APPLICATION = "sandbox.wsgi.application"
-AUTH_USER_MODEL = 'user.User'
+AUTH_USER_MODEL = "user.User"
# Database
# https://docs.djangoproject.com/en/1.11/ref/settings/#databases
DATABASES = {
- 'default': {
- 'ENGINE': 'django.db.backends.sqlite3',
- 'NAME': 'db.sqlite3',
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": "db.sqlite3",
}
}
@@ -124,9 +113,9 @@
# Internationalization
# https://docs.djangoproject.com/en/1.11/topics/i18n/
-LANGUAGE_CODE = 'en-us'
+LANGUAGE_CODE = "en-us"
-TIME_ZONE = 'UTC'
+TIME_ZONE = "UTC"
USE_I18N = True
@@ -139,19 +128,19 @@
# https://docs.djangoproject.com/en/1.11/howto/static-files/
STATICFILES_FINDERS = [
- 'django.contrib.staticfiles.finders.FileSystemFinder',
- 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
+ "django.contrib.staticfiles.finders.FileSystemFinder",
+ "django.contrib.staticfiles.finders.AppDirectoriesFinder",
]
STATICFILES_DIRS = [
- os.path.join(PROJECT_DIR, 'static'),
+ os.path.join(PROJECT_DIR, "static"),
]
-STATIC_ROOT = os.path.join(BASE_DIR, 'static')
-STATIC_URL = '/static/'
+STATIC_ROOT = os.path.join(BASE_DIR, "static")
+STATIC_URL = "/static/"
-MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
-MEDIA_URL = '/media/'
+MEDIA_ROOT = os.path.join(BASE_DIR, "media")
+MEDIA_URL = "/media/"
# Wagtail settings
@@ -161,11 +150,11 @@
# Base URL to use when referring to full URLs within the Wagtail admin backend -
# e.g. in notification emails. Don't include '/admin' or a trailing slash
-WAGTAILADMIN_BASE_URL = 'http://example.com'
+WAGTAILADMIN_BASE_URL = "http://example.com"
-INTERNAL_IPS = ['127.0.0.1']
+INTERNAL_IPS = ["127.0.0.1"]
WAGTAIL_2FA_REQUIRED = True
-DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
+DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
diff --git a/setup.py b/setup.py
index 5958983..3d900b7 100644
--- a/setup.py
+++ b/setup.py
@@ -3,8 +3,8 @@
from setuptools import find_packages, setup
install_requires = [
- "Django>=3.2",
- "Wagtail>=4.1",
+ "Django>=5.2",
+ "Wagtail>=7.0",
"django-otp>=1.7.0",
"six>=1.14.0",
"qrcode>=6.1",
@@ -17,14 +17,14 @@
tests_require = [
"coverage==5.5",
- "pytest==7.2.2",
- "pytest-cov==2.12.1",
- "pytest-django==4.4.0",
+ "pytest>=7.2.2",
+ "pytest-cov>=2.12.1",
+ "pytest-django>=4.4.0",
# Linting
- "flake8==3.9.2", # 3.7.9
- "isort==5.12.0",
- "flake8-blind-except==0.2.0",
- "flake8-debugger==4.0.0",
+ "flake8>=3.9.2", # 3.7.9
+ "isort>=5.12.0",
+ "flake8-blind-except>=0.2.0",
+ "flake8-debugger>=4.0.0",
]
with open("README.rst") as fh:
@@ -46,7 +46,7 @@
"docs": docs_require,
"test": tests_require,
},
- python_requires=">=3.8",
+ python_requires=">=3.10",
use_scm_version=True,
entry_points={},
package_dir={"": "src"},
diff --git a/tests/conftest.py b/tests/conftest.py
index c902c29..ea99830 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,5 @@
import pytest
+from unittest import mock
from django.conf import settings
@@ -29,8 +30,6 @@ def pytest_configure():
"wagtail.search",
"wagtail.admin",
"wagtail",
- # "wagtail_modeladmin", # if Wagtail >=5.1; Don't repeat if it's there already
- "wagtail.contrib.modeladmin", # if Wagtail <5.1; Don't repeat if it's there already
"modelcluster",
"taggit",
"django.contrib.admin",
@@ -99,18 +98,25 @@ def user(django_user_model):
@pytest.fixture
-def verified_user(django_user_model, rf):
+def otpmiddleware():
+ from django_otp.middleware import OTPMiddleware
+
+ get_response = mock.MagicMock()
+ return OTPMiddleware(get_response)
+
+
+@pytest.fixture
+def verified_user(django_user_model, rf, otpmiddleware):
"""Create a user and verify it using the OTP middleware. Add a device
to complete the verification for the user."""
- from django_otp.middleware import OTPMiddleware as _OTPMiddleware
from django_otp.plugins.otp_totp.models import TOTPDevice
user = django_user_model.objects.create(username="verified-user")
device = TOTPDevice.objects.create(user=user, confirmed=True)
request = rf.get("/foo/")
request.user = user
- middleware = _OTPMiddleware()
- user = middleware._verify_user(request, user)
+ middleware = otpmiddleware
+ user = middleware._verify_user_sync(request, user)
user.otp_device = device
return user
diff --git a/tests/test_hooks.py b/tests/test_hooks.py
index e8fa265..3f8a654 100644
--- a/tests/test_hooks.py
+++ b/tests/test_hooks.py
@@ -1,18 +1,17 @@
from django.test import override_settings
from django_otp import user_has_device
-from django_otp.middleware import OTPMiddleware as _OTPMiddleware
from wagtail.admin.menu import MenuItem
from wagtail_2fa.wagtail_hooks import remove_menu_if_unverified
class TestHooks:
- def test_remove_menu_if_unverified(self, user, rf):
+ def test_remove_menu_if_unverified(self, user, rf, otpmiddleware):
with override_settings(WAGTAIL_2FA_REQUIRED=True):
request = rf.get("/cms/")
request.user = user
- middleware = _OTPMiddleware()
- user = middleware._verify_user(request, user)
+ middleware = otpmiddleware
+ user = middleware._verify_user_sync(request, user)
assert not user_has_device(user)
assert user.is_authenticated
diff --git a/tests/test_middleware.py b/tests/test_middleware.py
index 2d1faf5..96d89bb 100644
--- a/tests/test_middleware.py
+++ b/tests/test_middleware.py
@@ -1,3 +1,4 @@
+from unittest import mock
import pytest
from django.contrib.auth.models import Permission
from django.test import override_settings
@@ -5,8 +6,7 @@
from django_otp import login as otp_login
from django_otp.plugins.otp_totp.models import TOTPDevice
-from wagtail_2fa.middleware import (
- VerifyUserMiddleware, VerifyUserPermissionsMiddleware)
+from wagtail_2fa.middleware import VerifyUserMiddleware, VerifyUserPermissionsMiddleware
def test_verified_request(rf, superuser):
@@ -15,7 +15,8 @@ def test_verified_request(rf, superuser):
device = TOTPDevice.objects.create(user=superuser, confirmed=True)
otp_login(request, device)
- middleware = VerifyUserMiddleware()
+ get_response = mock.MagicMock()
+ middleware = VerifyUserMiddleware(get_response)
response = middleware.process_request(request)
assert response is None
@@ -25,7 +26,8 @@ def test_superuser_force_mfa_auth(rf, superuser):
request.user = superuser
TOTPDevice.objects.create(user=superuser, confirmed=True)
- middleware = VerifyUserMiddleware(lambda x: x)
+ get_response = mock.MagicMock()
+ middleware = VerifyUserMiddleware(get_response)
with override_settings(WAGTAIL_2FA_REQUIRED=True):
response = middleware(request)
assert response.url == "%s?next=/admin/" % reverse("wagtail_2fa_auth")
@@ -34,7 +36,8 @@ def test_superuser_force_mfa_auth(rf, superuser):
def test_superuser_require_register_device(rf, superuser):
request = rf.get("/admin/")
request.user = superuser
- middleware = VerifyUserMiddleware(lambda x: x)
+ get_response = mock.MagicMock()
+ middleware = VerifyUserMiddleware(get_response)
with override_settings(WAGTAIL_2FA_REQUIRED=True):
response = middleware(request)
assert response.url == "%s?next=/admin/" % reverse("wagtail_2fa_device_new")
@@ -46,7 +49,8 @@ def test_superuser_dont_require_register_device(rf, superuser, settings):
request = rf.get("/admin/")
request.user = superuser
- middleware = VerifyUserMiddleware(lambda x: x)
+ get_response = mock.MagicMock()
+ middleware = VerifyUserMiddleware(get_response)
response = middleware.process_request(request)
assert response is None
@@ -62,7 +66,8 @@ def test_adding_new_device_requires_verification_when_user_has_device(
request = rf.get(url_new_device)
request.user = superuser
- middleware = VerifyUserMiddleware(lambda x: x)
+ get_response = mock.MagicMock()
+ middleware = VerifyUserMiddleware(get_response)
with override_settings(WAGTAIL_2FA_REQUIRED=True):
response = middleware(request)
@@ -93,7 +98,8 @@ def test_always_require_verification_when_user_has_device(rf, user, settings):
request = rf.get("/admin/")
request.user = user
- middleware = VerifyUserMiddleware(lambda x: x)
+ get_response = mock.MagicMock()
+ middleware = VerifyUserMiddleware(get_response)
with override_settings(WAGTAIL_2FA_REQUIRED=True):
response = middleware(request)
@@ -108,7 +114,9 @@ def test_enable_2fa_permission_does_require_verification(self, rf, staff_user):
request = rf.get("/admin/")
request.user = user_no_2fa
- middleware = VerifyUserPermissionsMiddleware(lambda x: x)
+
+ get_response = mock.MagicMock()
+ middleware = VerifyUserPermissionsMiddleware(get_response)
with override_settings(WAGTAIL_2FA_REQUIRED=True):
result = middleware._require_verified_user(request)
@@ -122,7 +130,9 @@ def test_no_enable_2fa_permission_no_device_does_not_require_verification(
request = rf.get("/admin/")
request.user = user_2fa
- middleware = VerifyUserPermissionsMiddleware(lambda x: x)
+
+ get_response = mock.MagicMock()
+ middleware = VerifyUserPermissionsMiddleware(get_response)
with override_settings(WAGTAIL_2FA_REQUIRED=True):
result = middleware._require_verified_user(request)
@@ -137,7 +147,9 @@ def test_no_enable_2fa_permission_with_device_does_require_verification(
request = rf.get("/admin/")
request.user = user_2fa
- middleware = VerifyUserPermissionsMiddleware(lambda x: x)
+
+ get_response = mock.MagicMock()
+ middleware = VerifyUserPermissionsMiddleware(get_response)
with override_settings(WAGTAIL_2FA_REQUIRED=True):
result = middleware._require_verified_user(request)
@@ -153,7 +165,9 @@ def test_process_request_enable_2fa_permission_sets_attribute_on_user_to_true(
request = rf.get("/admin/")
request.user = user_no_2fa
- middleware = VerifyUserPermissionsMiddleware(lambda x: x)
+
+ get_response = mock.MagicMock()
+ middleware = VerifyUserPermissionsMiddleware(get_response)
with override_settings(WAGTAIL_2FA_REQUIRED=True):
middleware.process_request(request)
@@ -167,7 +181,9 @@ def test_process_no_request_enable_2fa_permission_sets_attribute_on_user_to_fals
request = rf.get("/admin/")
request.user = user_2fa
- middleware = VerifyUserPermissionsMiddleware(lambda x: x)
+
+ get_response = mock.MagicMock()
+ middleware = VerifyUserPermissionsMiddleware(get_response)
with override_settings(WAGTAIL_2FA_REQUIRED=True):
middleware.process_request(request)
diff --git a/tests/test_mixins.py b/tests/test_mixins.py
index db4ee9a..444068c 100644
--- a/tests/test_mixins.py
+++ b/tests/test_mixins.py
@@ -3,7 +3,6 @@
from django.test import override_settings
from django.urls import reverse
from django_otp import user_has_device
-from django_otp.middleware import OTPMiddleware as _OTPMiddleware
from django_otp.plugins.otp_totp.models import TOTPDevice
from wagtail_2fa.mixins import OtpRequiredMixin
@@ -50,12 +49,14 @@ def test_user_allowed_with_verified_user_returns_true(self, rf, verified_user):
result = mixin.user_allowed(user)
assert result is True
- def test_user_allowed_when_no_device_and_if_configured_returns_true(self, rf, user):
+ def test_user_allowed_when_no_device_and_if_configured_returns_true(
+ self, rf, user, otpmiddleware
+ ):
with override_settings(WAGTAIL_2FA_REQUIRED=True):
request = rf.get("/admin/")
request.user = user
- middleware = _OTPMiddleware()
- user = middleware._verify_user(request, user)
+ middleware = otpmiddleware
+ user = middleware._verify_user_sync(request, user)
assert not user_has_device(user)
assert user.is_authenticated
diff --git a/tox.ini b/tox.ini
index 2c5cfb8..53f6768 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,33 +1,22 @@
[tox]
envlist =
- python{3.8,3.9,3.10}-django{3.2,4.1}-wagtail{4.1,4.2,5.0,5.1}
- python3.11-django4.1-wagtail{4.1,4.2,5.0,5.1}
- python3.11-django4.2-wagtail{5.0,5.1}
+ python{3.13,3.14}-django{5.2}-wagtail{7.0}
[gh-actions]
python =
- 3.8: python3.8
- 3.9: python3.9
- 3.10: python3.10
- 3.11: python3.11
+ 3.13: python3.13
+ 3.14: python3.14
[testenv]
commands = coverage run --parallel -m pytest {posargs} -vvv
basepython =
- python3.8: python3.8
- python3.9: python3.9
- python3.10: python3.10
- python3.11: python3.11
+ python3.13: python3.13
+ python3.14: python3.14
deps =
- django3.2: Django>=3.2,<4.0
- django4.1: Django>=4.1,<4.2
- django4.2: Django>=4.2,<4.3
- wagtail4.1: wagtail>=4.1,<4.2
- wagtail4.2: wagtail>=4.2,<5.0
- wagtail5.0: wagtail>=5.0,<5.1
- wagtail5.1: wagtail>=5.1,<5.2
+ django5.2: Django>=5.2,<6.0
+ wagtail7.0: wagtail>=7.0,<7.1
extras = test