diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5f4d7c7..eab723e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,20 +9,19 @@ updates: ignore: - dependency-name: flake8 versions: - - 3.8.4 - - 3.9.0 + - 7.3.0 - dependency-name: coverage versions: - - "5.4" + - "7.10.1" - dependency-name: pytest versions: - - 6.2.2 + - 8.4.1 - dependency-name: isort versions: - - 5.7.0 + - 6.0.1 - dependency-name: django-debug-toolbar versions: - - "3.2" + - "6.0" - dependency-name: pytest-django versions: - - 4.1.0 + - 4.11.1 diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index bd7dec0..4dcb7eb 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -9,18 +9,14 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 uses: actions/setup-python@v4 with: python-version: 3.11 - - name: Install build requirements run: python -m pip install wheel - - name: Build package run: python setup.py sdist bdist_wheel - - name: Publish package uses: pypa/gh-action-pypi-publish@release/v1 with: diff --git a/.github/workflows/python-tox.yml b/.github/workflows/python-tox.yml index 3406317..4f27073 100644 --- a/.github/workflows/python-tox.yml +++ b/.github/workflows/python-tox.yml @@ -7,20 +7,17 @@ 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 - - - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python }} - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install tox tox-gh-actions - - - name: Test with tox - run: | + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install tox tox-gh-actions + - name: Test with tox + run: | tox diff --git a/CHANGES b/CHANGES index e2ef646..584fcf8 100644 --- a/CHANGES +++ b/CHANGES @@ -1,3 +1,11 @@ +Unreleased +========== + - Add testing for Python 3.14 and Wagtail 7.2 + - Update dependencies to latest versions + - Update Wagtail supported versions to 6.3, 7.0 and 7.1 (Currently supported versions) + - Update Django supported versions to 4.2, 5.1 and 5.2 (Currently supported versions) + - Removed old code for handling no longer supported versions + 1.7.1 (2025-07-24) ================= - Update python release workflow to use Python 3.11 @@ -6,7 +14,6 @@ 1.7.0 (2025-07-24) ================= - Added support for wagtail 6.2 and above - 1.6.9 (2023-12-20) ================= diff --git a/sandbox/requirements.txt b/sandbox/requirements.txt index aebb00e..490c563 100644 --- a/sandbox/requirements.txt +++ b/sandbox/requirements.txt @@ -1,4 +1,4 @@ -Django>=3.2 -wagtail>=4.1 -django-debug-toolbar==3.2.2 +Django>=4.2 +wagtail>=6.3 +django-debug-toolbar==4.4.6 -e .[docs,test] diff --git a/sandbox/sandbox/apps/user/models.py b/sandbox/sandbox/apps/user/models.py index 159740b..3051167 100644 --- a/sandbox/sandbox/apps/user/models.py +++ b/sandbox/sandbox/apps/user/models.py @@ -1,42 +1,115 @@ +from django.apps import apps +from django.contrib import auth +from django.contrib.auth.hashers import make_password from django.contrib.auth.models import ( - AbstractBaseUser, PermissionsMixin, UserManager) + AbstractBaseUser, + BaseUserManager, + PermissionsMixin, +) from django.core.mail import send_mail from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ +class UserManager(BaseUserManager): + use_in_migrations = True + + def _create_user(self, email, password, **extra_fields): + """ + Create and save a user with the given email and password. + """ + email = self.normalize_email(email) + # Lookup the real model class from the global app registry so this + # manager method can be used in migrations. This is fine because + # managers are by definition working on the real model. + GlobalUserModel = apps.get_model( + self.model._meta.app_label, self.model._meta.object_name + ) + user = self.model(email=email, **extra_fields) + user.password = make_password(password) + user.save(using=self._db) + return user + + def create_user(self, email=None, password=None, **extra_fields): + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user(email, password, **extra_fields) + + def create_superuser(self, email=None, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + + return self._create_user(email, password, **extra_fields) + + def with_perm( + self, perm, is_active=True, include_superusers=True, backend=None, obj=None + ): + if backend is None: + backends = auth._get_backends(return_tuples=True) + if len(backends) == 1: + backend, _ = backends[0] + else: + raise ValueError( + "You have multiple authentication backends configured and " + "therefore must provide the `backend` argument." + ) + elif not isinstance(backend, str): + raise TypeError( + "backend must be a dotted import path string (got %r)." % backend + ) + else: + backend = auth.load_backend(backend) + if hasattr(backend, "with_perm"): + return backend.with_perm( + perm, + is_active=is_active, + include_superusers=include_superusers, + obj=obj, + ) + return self.none() + + class User(AbstractBaseUser, PermissionsMixin): - """Cusomtized version of the default `AbstractUser` from Django. + """Cusomtized version of the default `AbstractUser` from Django.""" - """ - first_name = models.CharField(_('first name'), max_length=100, blank=True) - last_name = models.CharField(_('last name'), max_length=100, blank=True) - email = models.EmailField(_('email address'), blank=True, unique=True) + first_name = models.CharField(_("first name"), max_length=100, blank=True) + last_name = models.CharField(_("last name"), max_length=100, blank=True) + email = models.EmailField(_("email address"), blank=True, unique=True) is_staff = models.BooleanField( - _('staff status'), default=False, - help_text=_('Designates whether the user can log into this admin ' - 'site.')) + _("staff status"), + default=False, + help_text=_("Designates whether the user can log into this admin " "site."), + ) is_active = models.BooleanField( - _('active'), default=True, - help_text=_('Designates whether this user should be treated as ' - 'active. Unselect this instead of deleting accounts.')) - date_joined = models.DateTimeField(_('date joined'), default=timezone.now) + _("active"), + default=True, + help_text=_( + "Designates whether this user should be treated as " + "active. Unselect this instead of deleting accounts." + ), + ) + date_joined = models.DateTimeField(_("date joined"), default=timezone.now) objects = UserManager() - USERNAME_FIELD = 'email' + USERNAME_FIELD = "email" REQUIRED_FIELDS = [] class Meta: - verbose_name = _('user') - verbose_name_plural = _('users') + verbose_name = _("user") + verbose_name_plural = _("users") def get_full_name(self): """ Returns the first_name plus the last_name, with a space in between. """ - full_name = '%s %s' % (self.first_name, self.last_name) + full_name = "%s %s" % (self.first_name, self.last_name) return full_name.strip() def get_short_name(self): diff --git a/sandbox/sandbox/settings.py b/sandbox/sandbox/settings.py index 158a57b..6f7c006 100644 --- a/sandbox/sandbox/settings.py +++ b/sandbox/sandbox/settings.py @@ -45,8 +45,7 @@ '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_modeladmin', 'wagtail.contrib.styleguide', 'modelcluster', @@ -130,8 +129,6 @@ USE_I18N = True -USE_L10N = True - USE_TZ = True diff --git a/sandbox/sandbox/urls.py b/sandbox/sandbox/urls.py index 7439f5a..306044b 100644 --- a/sandbox/sandbox/urls.py +++ b/sandbox/sandbox/urls.py @@ -1,7 +1,7 @@ import debug_toolbar from django.conf import settings from django.contrib import admin -from django.urls import include, re_path +from django.urls import include, path, re_path from wagtail import urls as wagtail_urls from wagtail.admin import urls as wagtailadmin_urls @@ -9,9 +9,9 @@ urlpatterns = [ re_path(r'^admin/', admin.site.urls), - re_path(r'^cms/', include(wagtailadmin_urls)), - re_path(r'^documents/', include(wagtaildocs_urls)), - re_path(r'', include(wagtail_urls)), + path('cms/', include(wagtailadmin_urls)), + path('documents/', include(wagtaildocs_urls)), + path('', include(wagtail_urls)), ] @@ -24,5 +24,5 @@ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns = [ - re_path(r'^__debug__/', include(debug_toolbar.urls)), + path('__debug__/', include(debug_toolbar.urls)), ] + urlpatterns diff --git a/setup.py b/setup.py index 2568b21..d0fd24b 100644 --- a/setup.py +++ b/setup.py @@ -3,28 +3,29 @@ from setuptools import find_packages, setup install_requires = [ - "Django>=3.2", - "Wagtail>=4.1", - "django-otp>=0.8.1", - "six>=1.14.0", - "qrcode>=6.1", + "Django>=4.2", + "Wagtail>=6.3", + "django-otp>=1.6.1", + "six>=1.17.0", + "qrcode>=8.2", ] docs_require = [ - "sphinx>=1.4.1", - "sphinx_rtd_theme>=0.4.3", + "sphinx>=8.2.3", + "sphinx_rtd_theme>=3.0.2", ] tests_require = [ - "coverage==5.5", - "pytest==7.2.2", - "pytest-cov==2.12.1", - "pytest-django==4.4.0", + "coverage==7.10.1", + "pytest==8.4.1", + "pytest-cov==6.2.1", + "pytest-django==4.11.1", # Linting - "flake8==3.9.2", # 3.7.9 - "isort==5.12.0", - "flake8-blind-except==0.2.0", - "flake8-debugger==4.0.0", + "flake8==7.3.0", + "isort==6.0.1", + "flake8-blind-except==0.2.1", + "flake8-debugger==4.1.2", + "wagtail-modeladmin==2.2.0" ] with open("README.rst") as fh: @@ -46,7 +47,7 @@ "docs": docs_require, "test": tests_require, }, - python_requires=">=3.8", + python_requires=">=3.10", use_scm_version=True, entry_points={}, package_dir={"": "src"}, @@ -57,19 +58,19 @@ "Development Status :: 4 - Beta", "Environment :: Web Environment", "Framework :: Django", - "Framework :: Django :: 3.2", - "Framework :: Django :: 4.1", "Framework :: Django :: 4.2", + "Framework :: Django :: 5.1", + "Framework :: Django :: 5.2", "Framework :: Wagtail", - "Framework :: Wagtail :: 2", - "Framework :: Wagtail :: 3", - "Framework :: Wagtail :: 4", + "Framework :: Wagtail :: 6", + "Framework :: Wagtail :: 7", "License :: OSI Approved :: MIT License", "Programming Language :: Python", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ], zip_safe=False, ) diff --git a/src/wagtail_2fa/templates/wagtail_2fa/device_list.html b/src/wagtail_2fa/templates/wagtail_2fa/device_list.html index 918812e..ada83a0 100644 --- a/src/wagtail_2fa/templates/wagtail_2fa/device_list.html +++ b/src/wagtail_2fa/templates/wagtail_2fa/device_list.html @@ -46,7 +46,10 @@

{# Users can only add devices to their own account #} {% if user_id == request.user.id %} - {% trans 'New device' %} + + + {% trans 'New device' %} + {% endif %} {% endblock %} diff --git a/src/wagtail_2fa/templates/wagtail_2fa/legacy/otp_form.html b/src/wagtail_2fa/templates/wagtail_2fa/legacy/otp_form.html deleted file mode 100644 index 05b3dd1..0000000 --- a/src/wagtail_2fa/templates/wagtail_2fa/legacy/otp_form.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends "wagtailadmin/admin_base.html" %} -{% load i18n wagtailadmin_tags %} -{% block titletag %}{% trans "Sign in" %}{% endblock %} -{% block bodyclass %}login{% endblock %} - -{% block furniture %} -
-

{% block branding_login %}{% trans "Enter your two-factor authentication code" %}{% endblock %}

- -
- {# Always show messages div so it can be appended to by JS #} - {% if messages or form.errors %} -
    - {% if form.errors %} -
  • {% blocktrans %}Invalid code{% endblocktrans %}
  • - {% endif %} - {% for message in messages %} -
  • {{ message }}
  • - {% endfor %} -
- {% endif %} -
- - {% block above_login %}{% endblock %} - -
- {% block login_form %} - {% csrf_token %} - - {% url 'wagtailadmin_home' as home_url %} - - - {% block fields %} - {% field field=form.otp_token %}{% endfield %} - - {% block extra_fields %} - {% for field_name, field in form.extra_fields %} - {% field field=field %}{% endfield %} - {% endfor %} - {% endblock extra_fields %} - - {% endblock %} - {% endblock %} -
- {% block submit_buttons %} - - - {% trans 'Sign out' %} - - {% endblock %} -
-
- - {% block below_login %}{% endblock %} - - {% block branding_logo %} - - {% endblock %} -
-{% endblock %} diff --git a/src/wagtail_2fa/templates/wagtail_2fa/otp_form.html b/src/wagtail_2fa/templates/wagtail_2fa/otp_form.html index 4186232..03a5da1 100644 --- a/src/wagtail_2fa/templates/wagtail_2fa/otp_form.html +++ b/src/wagtail_2fa/templates/wagtail_2fa/otp_form.html @@ -31,11 +31,11 @@

{% block branding_login %}{% trans "Enter your two-factor authentication cod {% block fields %} - {% field field=form.otp_token %}{% endfield %} + {% formattedfield form.otp_token %} {% block extra_fields %} {% for field_name, field in form.extra_fields %} - {% field field=field %}{% endfield %} + {% formattedfield field %} {% endfor %} {% endblock extra_fields %} diff --git a/src/wagtail_2fa/templates/wagtail_2fa/otp_form_v6.html b/src/wagtail_2fa/templates/wagtail_2fa/otp_form_v6.html deleted file mode 100644 index d114d95..0000000 --- a/src/wagtail_2fa/templates/wagtail_2fa/otp_form_v6.html +++ /dev/null @@ -1,66 +0,0 @@ -{% extends "wagtailadmin/base.html" %} -{% load i18n wagtailadmin_tags %} -{% block titletag %}{% trans "Sign in" %}{% endblock %} -{% block bodyclass %}login{% endblock %} - -{% block furniture %} -
-

{% block branding_login %}{% trans "Enter your two-factor authentication code" %}{% endblock %}

- -
- {# Always show messages div so it can be appended to by JS #} - {% if messages or form.errors %} -
    - {% if form.errors %} -
  • {% blocktrans %}Invalid code{% endblocktrans %}
  • - {% endif %} - {% for message in messages %} -
  • {{ message }}
  • - {% endfor %} -
- {% endif %} -
- - {% block above_login %}{% endblock %} - -
- {% block login_form %} - {% csrf_token %} - - {% url 'wagtailadmin_home' as home_url %} - - - {% block fields %} - {% formattedfield field=form.otp_token %} - {% block extra_fields %} - {% for field_name, field in form.extra_fields %} - {% formattedfield field=field %} - {% endfor %} - {% endblock extra_fields %} - {% endblock %} - {% endblock %} -
- {% block submit_buttons %} - - - - - - {% endblock %} -
-
- - {% block below_login %}{% endblock %} - - {% block branding_logo %} - - {% endblock %} - - -
-{% endblock %} \ No newline at end of file diff --git a/src/wagtail_2fa/views.py b/src/wagtail_2fa/views.py index d2b445c..077c584 100644 --- a/src/wagtail_2fa/views.py +++ b/src/wagtail_2fa/views.py @@ -1,15 +1,9 @@ import qrcode import qrcode.image.svg -from django import VERSION as DJANGO_VERSION from django.conf import settings from django.contrib.auth import REDIRECT_FIELD_NAME -if DJANGO_VERSION >= (4, 1): - from django.contrib.auth.views import RedirectURLMixin -else: - from django.contrib.auth.views import SuccessURLAllowedHostsMixin as RedirectURLMixin - -from wagtail import VERSION as WAGTAIL_VERSION +from django.contrib.auth.views import RedirectURLMixin from django.core.exceptions import PermissionDenied from django.http import HttpResponse @@ -20,8 +14,7 @@ from django.utils.http import url_has_allowed_host_and_scheme from django.views.decorators.cache import never_cache from django.views.decorators.debug import sensitive_post_parameters -from django.views.generic import ( - DeleteView, FormView, ListView, UpdateView, View) +from django.views.generic import DeleteView, FormView, ListView, UpdateView, View from django_otp import login as otp_login from django_otp.plugins.otp_totp.models import TOTPDevice @@ -30,13 +23,7 @@ class LoginView(RedirectURLMixin, FormView): - - if WAGTAIL_VERSION >= (6, 0): - template_name = "wagtail_2fa/otp_form_v6.html" - elif WAGTAIL_VERSION < (6, 0) and WAGTAIL_VERSION >= (5, 0): - template_name = "wagtail_2fa/otp_form.html" - else: - template_name = "wagtail_2fa/legacy/otp_form.html" + template_name = "wagtail_2fa/otp_form.html" form_class = forms.TokenForm redirect_field_name = REDIRECT_FIELD_NAME diff --git a/src/wagtail_2fa/wagtail_hooks.py b/src/wagtail_2fa/wagtail_hooks.py index 17284cf..815a31b 100644 --- a/src/wagtail_2fa/wagtail_hooks.py +++ b/src/wagtail_2fa/wagtail_hooks.py @@ -1,23 +1,26 @@ from django.conf import settings from django.contrib.auth.models import Permission -from django.urls import path, re_path, reverse +from django.urls import path, reverse from django.utils.translation import gettext_lazy as _ -from wagtail import hooks +from wagtail import hooks, VERSION as WAGTAIL_VERSION from wagtail.admin.menu import MenuItem -from wagtail.users.widgets import UserListingButton -from wagtail_2fa import views +if WAGTAIL_VERSION >= (7, 1): + from wagtail.admin.widgets import Button +else: + from wagtail.users.widgets import UserListingButton + -from wagtail import VERSION as WAGTAIL_VERSION +from wagtail_2fa import views @hooks.register("register_admin_urls") def urlpatterns(): return [ path("2fa/auth", views.LoginView.as_view(), name="wagtail_2fa_auth"), - re_path( - r"^2fa/devices/(?P\d+)$", + path( + "2fa/devices/", views.DeviceListView.as_view(), name="wagtail_2fa_device_list", ), @@ -26,18 +29,18 @@ def urlpatterns(): views.DeviceCreateView.as_view(), name="wagtail_2fa_device_new", ), - re_path( - r"^2fa/devices/(?P\d+)/update$", + path( + "2fa/devices//update", views.DeviceUpdateView.as_view(), name="wagtail_2fa_device_update", ), - re_path( - r"^2fa/devices/(?P\d+)/remove$", + path( + "2fa/devices//remove", views.DeviceDeleteView.as_view(), name="wagtail_2fa_device_remove", ), - re_path( - r"^2fa/devices/qr-code$", + path( + "2fa/devices/qr-code", views.DeviceQRCodeView.as_view(), name="wagtail_2fa_device_qrcode", ), @@ -70,18 +73,16 @@ def register(request): } -if WAGTAIL_VERSION >= (6, 0): - @hooks.register("register_user_listing_buttons") - def register_user_listing_buttons(user, request_user): - yield UserListingButton( +@hooks.register("register_user_listing_buttons") +def register_user_listing_buttons(user, request_user): + if WAGTAIL_VERSION >= (7, 1): + yield Button( _("Manage 2FA"), reverse("wagtail_2fa_device_list", kwargs={"user_id": user.id}), attrs={"title": _("Edit this user")}, priority=100, ) -else: - @hooks.register("register_user_listing_buttons") - def register_user_listing_buttons(context, user): + else: yield UserListingButton( _("Manage 2FA"), reverse("wagtail_2fa_device_list", kwargs={"user_id": user.id}), @@ -90,7 +91,6 @@ def register_user_listing_buttons(context, user): ) - @hooks.register("register_permissions") def register_2fa_permission(): if "wagtail_2fa.middleware.VerifyUserPermissionsMiddleware" in settings.MIDDLEWARE: diff --git a/tests/conftest.py b/tests/conftest.py index c902c29..ec12f0a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,8 +29,7 @@ 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 + "wagtail_modeladmin", "modelcluster", "taggit", "django.contrib.admin", diff --git a/tox.ini b/tox.ini index 2c5cfb8..a84ad5f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,33 +1,34 @@ [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.10,3.11,3.12}-django4.2-wagtail{6.3,7.0,7.2} + python{3.10,3.11,3.12,3.13}-django{5.1,5.2}-wagtail{6.3,7.0,7.2} + python3.14-django5.2-wagtail7.2 [gh-actions] python = - 3.8: python3.8 - 3.9: python3.9 3.10: python3.10 3.11: python3.11 + 3.12: python3.12 + 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.12: python3.12 + 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.1: Django>=5.1,<5.2 + django5.2: Django>=5.2,<5.3 + wagtail6.3: wagtail>=6.3,<6.4 + wagtail7.0: wagtail>=7.0,<7.1 + wagtail7.2: wagtail>=7.1,<7.2 extras = test