diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d1be0c959c..64278fbb9a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -53,12 +53,12 @@ jobs: - tox-env: "py310-dj32" python-version: "3.10" # Django 4.0 - # - tox-env: "py38-dj40" - # python-version: "3.8" - # - tox-env: "py39-dj40" - # python-version: "3.9" - # - tox-env: "py310-dj40" - # python-version: "3.10" + - tox-env: "py38-dj40" + python-version: "3.8" + - tox-env: "py39-dj40" + python-version: "3.9" + - tox-env: "py310-dj40" + python-version: "3.10" steps: - uses: actions/checkout@v2 diff --git a/docs/overview.rst b/docs/overview.rst index 809a9d7321..905983fecc 100644 --- a/docs/overview.rst +++ b/docs/overview.rst @@ -45,8 +45,8 @@ standard Django environment), with the following dependencies, which unless noted as optional, should be installed automatically following the above instructions: -* `Python`_ 3.6 to 3.9 -* `Django`_ 2.2 to 3.2 +* `Python`_ 3.6 to 3.10 +* `Django`_ 2.2 to 4.0 * `django-contrib-comments`_ - for built-in threaded comments * `Pillow`_ - for image resizing (`Python Imaging Library`_ fork) * `grappelli-safe`_ - admin skin (`Grappelli`_ fork) diff --git a/mezzanine/accounts/urls.py b/mezzanine/accounts/urls.py index 81f3de7d1d..0872fc29c6 100644 --- a/mezzanine/accounts/urls.py +++ b/mezzanine/accounts/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import url +from django.urls import re_path from mezzanine.accounts import views from mezzanine.conf import settings @@ -28,32 +28,36 @@ _slash = "/" if settings.APPEND_SLASH else "" urlpatterns = [ - url(r"^{}{}$".format(LOGIN_URL.strip("/"), _slash), views.login, name="login"), - url(r"^{}{}$".format(LOGOUT_URL.strip("/"), _slash), views.logout, name="logout"), - url(r"^{}{}$".format(SIGNUP_URL.strip("/"), _slash), views.signup, name="signup"), - url( + re_path(r"^{}{}$".format(LOGIN_URL.strip("/"), _slash), views.login, name="login"), + re_path( + r"^{}{}$".format(LOGOUT_URL.strip("/"), _slash), views.logout, name="logout" + ), + re_path( + r"^{}{}$".format(SIGNUP_URL.strip("/"), _slash), views.signup, name="signup" + ), + re_path( r"^{}{}{}$".format(SIGNUP_VERIFY_URL.strip("/"), _verify_pattern, _slash), views.signup_verify, name="signup_verify", ), - url( + re_path( r"^{}{}$".format(PROFILE_UPDATE_URL.strip("/"), _slash), views.profile_update, name="profile_update", ), - url( + re_path( r"^{}{}$".format(PASSWORD_RESET_URL.strip("/"), _slash), views.password_reset, name="mezzanine_password_reset", ), - url( + re_path( r"^{}{}{}$".format( PASSWORD_RESET_VERIFY_URL.strip("/"), _verify_pattern, _slash ), views.password_reset_verify, name="password_reset_verify", ), - url( + re_path( r"^{}{}$".format(ACCOUNT_URL.strip("/"), _slash), views.account_redirect, name="account_redirect", @@ -62,12 +66,12 @@ if settings.ACCOUNTS_PROFILE_VIEWS_ENABLED: urlpatterns += [ - url( + re_path( r"^{}{}$".format(PROFILE_URL.strip("/"), _slash), views.profile_redirect, name="profile_redirect", ), - url( + re_path( r"^{}/(?P.*){}$".format(PROFILE_URL.strip("/"), _slash), views.profile, name="profile", diff --git a/mezzanine/boot/lazy_admin.py b/mezzanine/boot/lazy_admin.py index c2cdc1e13e..83c82e8f37 100644 --- a/mezzanine/boot/lazy_admin.py +++ b/mezzanine/boot/lazy_admin.py @@ -1,9 +1,10 @@ from django.conf import settings -from django.conf.urls import include, url +from django.conf.urls import include from django.contrib.admin.sites import AdminSite, AlreadyRegistered, NotRegistered from django.contrib.admin.sites import site as default_site from django.contrib.auth import get_user_model from django.shortcuts import redirect +from django.urls import re_path from mezzanine.utils.importing import import_dotted_path @@ -71,12 +72,12 @@ def urls(self): # doesn't provide), so that we can target it in the # ADMIN_MENU_ORDER setting, allowing each view to correctly # highlight its left-hand admin nav item. - url( + re_path( r"^media-library/$", lambda r: redirect("fb_browse"), name="media-library", ), - url(r"^media-library/", include(fb_urls)), + re_path(r"^media-library/", include(fb_urls)), ] # Give the urlpattern for the user password change view an @@ -88,7 +89,7 @@ def urls(self): if user_change_password: bits = (User._meta.app_label, User._meta.object_name.lower()) urls += [ - url( + re_path( r"^%s/%s/(\d+)/password/$" % bits, self.admin_view(user_change_password), name="user_change_password", @@ -102,13 +103,13 @@ def urls(self): from mezzanine.generic.views import admin_keywords_submit urls += [ - url( + re_path( r"^admin_keywords_submit/$", admin_keywords_submit, name="admin_keywords_submit", ), - url(r"^asset_proxy/$", static_proxy, name="static_proxy"), - url( + re_path(r"^asset_proxy/$", static_proxy, name="static_proxy"), + re_path( r"^displayable_links.js$", displayable_links_js, name="displayable_links_js", @@ -118,11 +119,11 @@ def urls(self): from mezzanine.pages.views import admin_page_ordering urls += [ - url( + re_path( r"^admin_page_ordering/$", admin_page_ordering, name="admin_page_ordering", ) ] - return urls + [url(r"", super().urls)] + return urls + [re_path(r"", super().urls)] diff --git a/mezzanine/conf/admin.py b/mezzanine/conf/admin.py index 404159fdf5..5c9b0662ec 100644 --- a/mezzanine/conf/admin.py +++ b/mezzanine/conf/admin.py @@ -3,7 +3,7 @@ from django.contrib import admin from django.contrib.messages import info from django.http import HttpResponseRedirect -from django.utils.encoding import force_text +from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ from mezzanine.conf import settings @@ -53,7 +53,7 @@ def changelist_view(self, request, extra_context=None): extra_context["settings_form"] = settings_form extra_context["title"] = "{} {}".format( _("Change"), - force_text(Setting._meta.verbose_name_plural), + force_str(Setting._meta.verbose_name_plural), ) return super().changelist_view(request, extra_context) diff --git a/mezzanine/core/admin.py b/mezzanine/core/admin.py index 672f904cda..7f8b025fa9 100644 --- a/mezzanine/core/admin.py +++ b/mezzanine/core/admin.py @@ -335,7 +335,7 @@ def __init__(self, *args, **kwargs): @property def base_concrete_modeladmin(self): - """ The class inheriting directly from ContentModelAdmin. """ + """The class inheriting directly from ContentModelAdmin.""" candidates = [self.__class__] while candidates: candidate = candidates.pop() @@ -368,7 +368,7 @@ def change_view(self, request, object_id, **kwargs): return super().change_view(request, object_id, **kwargs) def changelist_view(self, request, extra_context=None): - """ Redirect to the changelist view for subclasses. """ + """Redirect to the changelist view for subclasses.""" if self.model is not self.concrete_model: return HttpResponseRedirect(admin_url(self.concrete_model, "changelist")) @@ -378,7 +378,7 @@ def changelist_view(self, request, extra_context=None): return super().changelist_view(request, extra_context) def get_content_models(self): - """ Return all subclasses that are admin registered. """ + """Return all subclasses that are admin registered.""" models = [] for model in self.concrete_model.get_content_models(): diff --git a/mezzanine/core/middleware.py b/mezzanine/core/middleware.py index 2793b8c363..224488ec36 100755 --- a/mezzanine/core/middleware.py +++ b/mezzanine/core/middleware.py @@ -33,7 +33,11 @@ nevercache_token, ) from mezzanine.utils.conf import middlewares_or_subclasses_installed -from mezzanine.utils.deprecation import MiddlewareMixin, is_authenticated +from mezzanine.utils.deprecation import ( + MiddlewareMixin, + get_middleware_request, + is_authenticated, +) from mezzanine.utils.sites import current_site_id from mezzanine.utils.urls import next_url @@ -207,7 +211,7 @@ def process_response(self, request, response): # the cookie will be correctly set for the the response if csrf_middleware_installed(): response.csrf_processing_done = False - csrf_mw = CsrfViewMiddleware() + csrf_mw = CsrfViewMiddleware(get_middleware_request) csrf_mw.process_response(request, response) return response diff --git a/mezzanine/core/models.py b/mezzanine/core/models.py index 6f7a3434da..040ee7cc2d 100644 --- a/mezzanine/core/models.py +++ b/mezzanine/core/models.py @@ -578,7 +578,7 @@ def get_content_model_name(cls): @classmethod def get_content_models(cls): - """ Return all subclasses of the concrete model. """ + """Return all subclasses of the concrete model.""" concrete_model = base_concrete_model(ContentTyped, cls) return [ m diff --git a/mezzanine/forms/admin.py b/mezzanine/forms/admin.py index 28fa441277..c7e512f397 100644 --- a/mezzanine/forms/admin.py +++ b/mezzanine/forms/admin.py @@ -5,14 +5,14 @@ from mimetypes import guess_type from os.path import join -from django.conf.urls import url from django.contrib import admin from django.contrib.messages import info from django.core.files.storage import FileSystemStorage from django.http import HttpResponse, HttpResponseRedirect from django.shortcuts import get_object_or_404, render +from django.urls import re_path from django.utils.translation import gettext_lazy as _ -from django.utils.translation import ungettext +from django.utils.translation import ngettext from mezzanine.conf import settings from mezzanine.core.admin import TabularDynamicInlineAdmin @@ -112,12 +112,12 @@ def get_urls(self): """ urls = super().get_urls() extra_urls = [ - url( + re_path( r"^(?P\d+)/entries/$", self.admin_site.admin_view(self.entries_view), name="form_entries", ), - url( + re_path( r"^file/(?P\d+)/$", self.admin_site.admin_view(self.file_view), name="form_file", @@ -170,7 +170,7 @@ def entries_view(self, request, form_id): count = entries.count() if count > 0: entries.delete() - message = ungettext( + message = ngettext( "1 entry deleted", "%(count)s entries deleted", count ) info(request, message % {"count": count}) diff --git a/mezzanine/forms/signals.py b/mezzanine/forms/signals.py index d5afcbf643..86ef3d5f6d 100644 --- a/mezzanine/forms/signals.py +++ b/mezzanine/forms/signals.py @@ -1,4 +1,4 @@ from django.dispatch import Signal -form_invalid = Signal(providing_args=["form"]) -form_valid = Signal(providing_args=["form", "entry"]) +form_invalid = Signal() +form_valid = Signal() diff --git a/mezzanine/galleries/models.py b/mezzanine/galleries/models.py index 062beffc91..c0368fad63 100644 --- a/mezzanine/galleries/models.py +++ b/mezzanine/galleries/models.py @@ -7,7 +7,7 @@ from django.core.files.base import ContentFile from django.core.files.storage import default_storage from django.db import models -from django.utils.encoding import force_text +from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ from mezzanine.conf import settings @@ -151,7 +151,7 @@ def save(self, *args, **kwargs): file name. """ if not self.id and not self.description: - name = force_text(self.file) + name = force_str(self.file) name = name.rsplit("/", 1)[-1].rsplit(".", 1)[0] name = name.replace("'", "") name = "".join(c if c not in punctuation else " " for c in name) diff --git a/mezzanine/generic/views.py b/mezzanine/generic/views.py index eb1c69d138..908b28fcaf 100644 --- a/mezzanine/generic/views.py +++ b/mezzanine/generic/views.py @@ -15,7 +15,7 @@ from mezzanine.generic.forms import RatingForm, ThreadedCommentForm from mezzanine.generic.models import Keyword from mezzanine.utils.cache import add_cache_bypass -from mezzanine.utils.deprecation import is_authenticated +from mezzanine.utils.deprecation import is_authenticated, request_is_ajax from mezzanine.utils.importing import import_dotted_path from mezzanine.utils.views import is_spam, set_cookie @@ -87,7 +87,7 @@ def initial_validation(request, prefix): except (TypeError, ObjectDoesNotExist, LookupError): redirect_url = "/" if redirect_url: - if request.is_ajax(): + if request_is_ajax(request): return HttpResponse(dumps({"location": redirect_url})) else: return redirect(redirect_url) @@ -117,7 +117,7 @@ def comment(request, template="generic/comments.html", extra_context=None): cookie_value = post_data.get(field, "") set_cookie(response, cookie_name, cookie_value) return response - elif request.is_ajax() and form.errors: + elif request_is_ajax(request) and form.errors: return HttpResponse(dumps({"errors": form.errors})) # Show errors with stand-alone comment form. context = {"obj": obj, "posted_comment_form": form} @@ -139,7 +139,7 @@ def rating(request): rating_form = RatingForm(request, obj, post_data) if rating_form.is_valid(): rating_form.save() - if request.is_ajax(): + if request_is_ajax(request): # Reload the object and return the rating fields as json. obj = obj.__class__.objects.get(id=obj.id) rating_name = obj.get_ratingfield_name() diff --git a/mezzanine/utils/deprecation.py b/mezzanine/utils/deprecation.py index 29f43e9356..ccbdefa0bf 100644 --- a/mezzanine/utils/deprecation.py +++ b/mezzanine/utils/deprecation.py @@ -16,6 +16,24 @@ class MiddlewareMixin: pass +def request_is_ajax(request): + """ + request.is_ajax() is deprecated. Check the content_type + + Returns true if request CONTENT_TYPE is "application/json" + """ + return request.META.get("CONTENT_TYPE") == "application/json" + + +def get_middleware_request(request): + """ + Middlewares require get_request in after django4.0 + + Returns the passed request object + """ + return request + + def get_middleware_setting_name(): """ Returns the name of the middleware setting. diff --git a/mezzanine/utils/urls.py b/mezzanine/utils/urls.py index 2c8efb27d1..69ce1c22d7 100644 --- a/mezzanine/utils/urls.py +++ b/mezzanine/utils/urls.py @@ -5,8 +5,13 @@ from django.shortcuts import redirect from django.urls import NoReverseMatch, get_script_prefix, resolve, reverse from django.utils import translation -from django.utils.encoding import smart_text -from django.utils.http import is_safe_url +from django.utils.encoding import smart_str + +try: + from django.utils.http import url_has_allowed_host_and_scheme +except ImportError: # for Django2.2 support + from django.utils.http import is_safe_url as url_has_allowed_host_and_scheme + from mezzanine.conf import settings from mezzanine.utils.importing import import_dotted_path @@ -55,7 +60,7 @@ def slugify_unicode(s): Adopted from https://github.com/mozilla/unicode-slugify/ """ chars = [] - for char in str(smart_text(s)): + for char in str(smart_str(s)): cat = unicodedata.category(char)[0] if cat in "LN" or char in "-_~": chars.append(char) @@ -89,7 +94,11 @@ def next_url(request): """ next = request.GET.get("next", request.POST.get("next", "")) host = request.get_host() - return next if next and is_safe_url(next, allowed_hosts=host) else None + return ( + next + if next and url_has_allowed_host_and_scheme(next, allowed_hosts=host) + else None + ) def login_redirect(request): diff --git a/setup.cfg b/setup.cfg index 188a4a23da..94633949e2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,7 +48,8 @@ install_requires = requests-oauthlib >= 1.3 pillow >= 7 chardet >= 4 - filebrowser_safe@https://github.com/stephenmcd/filebrowser-safe/archive/master.tar.gz + pytz + filebrowser_safe@https://github.com/tswfi/filebrowser-safe/archive/refs/heads/django_4.0.0_compatibility.zip grappelli_safe@https://github.com/stephenmcd/grappelli-safe/archive/master.tar.gz [options.extras_require] diff --git a/tests/test_blog.py b/tests/test_blog.py index 58db8433f2..c4cfe09bc1 100644 --- a/tests/test_blog.py +++ b/tests/test_blog.py @@ -88,7 +88,7 @@ def make_blog_post(*datetime_args): @override_settings(USE_TZ=True) def test_blog_months_timezone(self): - """ Months should be relative to timezone. """ + """Months should be relative to timezone.""" blog_post = BlogPost.objects.create( user=self._user, status=CONTENT_STATUS_PUBLISHED, diff --git a/tests/test_core.py b/tests/test_core.py index 3815559ddb..f8c247a023 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -6,7 +6,6 @@ import pkg_resources import pytest import pytz -from django.conf.urls import url from django.contrib.admin import AdminSite from django.contrib.admin.options import InlineModelAdmin from django.contrib.sites.models import Site @@ -21,7 +20,7 @@ from django.template.context import Context from django.templatetags.static import static from django.test.utils import override_settings -from django.urls import reverse +from django.urls import re_path, reverse from django.utils.encoding import force_str from django.utils.html import strip_tags from django.utils.timezone import datetime, now, timedelta @@ -612,7 +611,7 @@ def nevercache_view(request): return HttpResponse(rendered) urlpatterns = [ - url(r"^nevercache_view/", nevercache_view), + re_path(r"^nevercache_view/", nevercache_view), ] diff --git a/tests/test_pages.py b/tests/test_pages.py index 7110e2321e..3408ea6af0 100644 --- a/tests/test_pages.py +++ b/tests/test_pages.py @@ -1,5 +1,5 @@ from unittest import skipUnless -from urllib.parse import urlparse +from urllib.parse import quote_plus, urlparse from django.apps import apps from django.contrib.auth import get_user_model @@ -11,7 +11,6 @@ from django.shortcuts import resolve_url from django.template import Context, Template, TemplateSyntaxError from django.test.utils import override_settings -from django.utils.http import urlquote_plus from django.utils.translation import get_language from mezzanine.conf import settings @@ -22,6 +21,7 @@ from mezzanine.pages.fields import MenusField from mezzanine.pages.models import Page, RichTextPage from mezzanine.urls import PAGES_SLUG +from mezzanine.utils.deprecation import get_middleware_request from mezzanine.utils.sites import override_current_site_id from mezzanine.utils.tests import TestCase @@ -204,7 +204,7 @@ def test_login_required(self): if redirects_count > 1: # With LocaleMiddleware and a string LOGIN_URL there can be # a second redirect that encodes the next parameter. - login_next = urlquote_plus(login_next) + login_next = quote_plus(login_next) login = f"{login_prefix}{login_url}?next={login_next}" if accounts_installed: # For an inaccessible page with mezzanine.accounts we should @@ -414,7 +414,10 @@ def test_page_processor(request, page): request = self._request_factory.get("/foo/bar/") request.user = self._user - response = PageMiddleware().process_view(request, page_view, [], {}) + + response = PageMiddleware(get_middleware_request).process_view( + request, page_view, [], {} + ) self.assertTrue(isinstance(response, HttpResponse)) self.assertContains(response, "bar") diff --git a/tox.ini b/tox.ini index 929786ae6d..44396d40ab 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{36,37,38,39,310}-dj{22,30,31,32} + py{36,37,38,39,310}-dj{22,30,31,32,40} package lint @@ -13,6 +13,7 @@ deps = dj30: Django>=3.0, <3.1 dj31: Django>=3.1, <3.2 dj32: Django>=3.2, <3.3 + dj40: Django>=4.0, <4.1 commands = pytest --basetemp="{envtmpdir}" --junitxml="junit/TEST-{envname}.xml" {posargs}