diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index a41d7c20929..86c49b01573 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -7,7 +7,7 @@ concurrency:
cancel-in-progress: true
jobs:
- database-postgres:
+ postgres:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -18,10 +18,16 @@ jobs:
django-4.0.txt,
django-4.1.txt,
django-4.2.txt,
+ django-5.0.txt
]
os: [
ubuntu-20.04,
]
+ exclude:
+ - requirements-file: django-5.0.txt
+ python-version: 3.8
+ - requirements-file: django-5.0.txt
+ python-version: 3.9
services:
postgres:
@@ -59,7 +65,7 @@ jobs:
DATABASE_URL: postgres://postgres:postgres@127.0.0.1/postgres
- database-mysql:
+ mysql:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -70,10 +76,16 @@ jobs:
django-4.0.txt,
django-4.1.txt,
django-4.2.txt,
+ django-5.0.txt
]
os: [
ubuntu-20.04,
]
+ exclude:
+ - requirements-file: django-5.0.txt
+ python-version: 3.8
+ - requirements-file: django-5.0.txt
+ python-version: 3.9
services:
mysql:
@@ -111,7 +123,7 @@ jobs:
env:
DATABASE_URL: mysql://root@127.0.0.1/djangocms_test
- database-sqlite:
+ sqlite:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -122,10 +134,16 @@ jobs:
django-4.0.txt,
django-4.1.txt,
django-4.2.txt,
+ django-5.0.txt
]
os: [
ubuntu-20.04,
]
+ exclude:
+ - requirements-file: django-5.0.txt
+ python-version: 3.8
+ - requirements-file: django-5.0.txt
+ python-version: 3.9
steps:
- uses: actions/checkout@v3
diff --git a/cms/cms_toolbars.py b/cms/cms_toolbars.py
index 1d27b5e81f3..6c04b0de71a 100644
--- a/cms/cms_toolbars.py
+++ b/cms/cms_toolbars.py
@@ -16,6 +16,7 @@
from cms.toolbar_base import CMSToolbar
from cms.toolbar_pool import toolbar_pool
from cms.utils import get_language_from_request, page_permissions
+from cms.utils.compat import DJANGO_4_2
from cms.utils.conf import get_cms_setting
from cms.utils.i18n import get_language_dict, get_language_tuple
from cms.utils.page_permissions import user_can_change_page, user_can_delete_page, user_can_publish_page
@@ -225,7 +226,7 @@ def add_logout_button(self, parent):
action=admin_reverse('logout'),
active=True,
on_success=on_success,
- method='GET',
+ method='GET' if DJANGO_4_2 else 'POST',
)
def add_language_menu(self):
diff --git a/cms/forms/fields.py b/cms/forms/fields.py
index 82b12585d08..c00ddab8c94 100644
--- a/cms/forms/fields.py
+++ b/cms/forms/fields.py
@@ -1,12 +1,14 @@
from django import forms
from django.contrib.admin.widgets import RelatedFieldWidgetWrapper
from django.core.validators import EMPTY_VALUES
+from django.forms import ChoiceField
from django.utils.translation import gettext_lazy as _
from cms.forms.utils import get_page_choices, get_site_choices
from cms.forms.validators import validate_url_extra
from cms.forms.widgets import PageSelectWidget, PageSmartLinkWidget
from cms.models.pagemodel import Page
+from cms.utils.compat import DJANGO_4_2
class SuperLazyIterator:
@@ -18,11 +20,20 @@ def __iter__(self):
class LazyChoiceField(forms.ChoiceField):
- def _set_choices(self, value):
- # we overwrite this function so no list(value) is called
- self._choices = self.widget.choices = value
- choices = property(forms.ChoiceField._get_choices, _set_choices)
+
+ @property
+ def choices(self):
+ return super().choices()
+
+ @choices.setter
+ def choices(self, value):
+ # we overwrite this function so no list(value) or normalize_choices(value) is called
+ # also, do not call the widget's setter as of Django 5
+ if DJANGO_4_2:
+ self._choices = self.widget.choices = value
+ else:
+ self._choices = self.widget._choices = value
class PageSelectFormField(forms.MultiValueField):
@@ -38,14 +49,12 @@ def __init__(self, queryset=None, empty_label="---------", cache_choices=False,
errors = self.default_error_messages.copy()
if 'error_messages' in kwargs:
errors.update(kwargs['error_messages'])
- site_choices = SuperLazyIterator(get_site_choices)
- page_choices = SuperLazyIterator(get_page_choices)
self.limit_choices_to = limit_choices_to
kwargs['required'] = required
kwargs.pop('blank', None)
fields = (
- LazyChoiceField(choices=site_choices, required=False, error_messages={'invalid': errors['invalid_site']}),
- LazyChoiceField(choices=page_choices, required=False, error_messages={'invalid': errors['invalid_page']}),
+ ChoiceField(choices=get_site_choices, required=False, error_messages={'invalid': errors['invalid_site']}),
+ ChoiceField(choices=get_page_choices, required=False, error_messages={'invalid': errors['invalid_page']}),
)
super().__init__(fields, *args, **kwargs)
diff --git a/cms/tests/test_forms.py b/cms/tests/test_forms.py
index 651ad3187fa..f764f40cb63 100644
--- a/cms/tests/test_forms.py
+++ b/cms/tests/test_forms.py
@@ -13,7 +13,7 @@
ViewRestrictionInlineAdminForm,
)
from cms.api import assign_user_to_page, create_page, create_title
-from cms.forms.fields import PageSelectFormField, SuperLazyIterator
+from cms.forms.fields import LazyChoiceField, PageSelectFormField, SuperLazyIterator
from cms.forms.utils import get_page_choices, get_site_choices, update_site_and_page_choices
from cms.forms.widgets import ApplicationConfigSelect
from cms.models import ACCESS_PAGE, ACCESS_PAGE_AND_CHILDREN
@@ -238,6 +238,17 @@ def test_superlazy_iterator_behaves_properly_for_pages(self):
self.assertEqual(normal_result, list(lazy_result))
+ def test_lazy_choice_field_behaves_properly(self):
+ """Ensure LazyChoiceField is really lazy"""
+ choices_called = False
+ def get_choices():
+ nonlocal choices_called
+ choices_called = True
+ return ("", "-----"),
+
+ LazyChoiceField(choices=SuperLazyIterator(get_choices))
+ self.assertFalse(choices_called, "Lazy choice function called")
+
class PermissionFormTestCase(CMSTestCase):
diff --git a/cms/tests/test_page_admin.py b/cms/tests/test_page_admin.py
index c0182abfea0..7a508141163 100644
--- a/cms/tests/test_page_admin.py
+++ b/cms/tests/test_page_admin.py
@@ -38,6 +38,7 @@
CMSTestCase,
)
from cms.test_utils.util.context_managers import LanguageOverride, UserLoginContext
+from cms.utils.compat import DJANGO_4_2
from cms.utils.compat.dj import installed_apps
from cms.utils.conf import get_cms_setting
from cms.utils.page import get_page_from_request
@@ -1373,6 +1374,10 @@ def test_set_overwrite_url(self):
expected = (
''
+ ) if DJANGO_4_2 else (
+ ''
)
changelist = self.get_admin_url(Page, 'changelist')
endpoint = self.get_admin_url(Page, 'advanced', cms_page.pk)
@@ -1425,6 +1430,9 @@ def test_remove_overwrite_url(self):
expected = (
''
+ ) if DJANGO_4_2 else (
+ ''
)
changelist = self.get_admin_url(Page, 'changelist')
endpoint = self.get_admin_url(Page, 'advanced', cms_page.pk)
diff --git a/cms/tests/test_plugins.py b/cms/tests/test_plugins.py
index 229beecf43f..01b99ec871b 100644
--- a/cms/tests/test_plugins.py
+++ b/cms/tests/test_plugins.py
@@ -884,8 +884,9 @@ def test_empty_plugin_description(self):
plugin_type='TextPlugin',
placeholder=placeholder,
position=1,
- language=self.FIRST_LANG
+ language=self.FIRST_LANG,
)
+ a.save()
self.assertEqual(a.get_short_description(), "")
diff --git a/cms/tests/test_sitemap.py b/cms/tests/test_sitemap.py
index 79634b357cf..1efa821bd41 100644
--- a/cms/tests/test_sitemap.py
+++ b/cms/tests/test_sitemap.py
@@ -4,8 +4,10 @@
from cms.models import Page, Title
from cms.sitemaps import CMSSitemap
from cms.test_utils.testcases import CMSTestCase
+from cms.utils.compat import DJANGO_4_2
from cms.utils.conf import get_cms_setting
+protocol = "http" if DJANGO_4_2 else "https"
class SitemapTestCase(CMSTestCase):
def setUp(self):
@@ -93,9 +95,9 @@ def test_sitemap_items_location(self):
urlset = sitemap.get_urls()
for item in urlset:
if item['item'].path:
- url = 'http://example.com/{}/{}/'.format(item['item'].language, item['item'].path)
+ url = f'{protocol}://example.com/{item["item"].language}/{item["item"].path}/'
else:
- url = 'http://example.com/{}/{}'.format(item['item'].language, item['item'].path)
+ url = f'{protocol}://example.com/{item["item"].language}/'
self.assertEqual(item['location'], url)
def test_sitemap_published_titles(self):
@@ -110,9 +112,9 @@ def test_sitemap_published_titles(self):
for title in Title.objects.public():
page = title.page.get_public_object()
if title.path:
- url = f'http://example.com/{title.language}/{title.path}/'
+ url = f'{protocol}://example.com/{title.language}/{title.path}/'
else:
- url = f'http://example.com/{title.language}/{title.path}'
+ url = f'{protocol}://example.com/{title.language}/{title.path}'
if page.is_published('en') and not page.publisher_is_draft:
self.assertTrue(url in locations)
else:
@@ -142,9 +144,9 @@ def test_sitemap_unpublished_titles(self):
for path in unpublished_titles:
title = Title.objects.get(path=path)
if title.path:
- url = f'http://example.com/{title.language}/{title.path}/'
+ url = f'{protocol}://example.com/{title.language}/{title.path}/'
else:
- url = f'http://example.com/{title.language}/{title.path}'
+ url = f'{protocol}://example.com/{title.language}/{title.path}'
self.assertFalse(url in locations)
def test_sitemap_uses_public_languages_only(self):
@@ -159,7 +161,7 @@ def test_sitemap_uses_public_languages_only(self):
with self.settings(CMS_LANGUAGES=lang_settings):
for item in CMSSitemap().get_urls():
- url = 'http://example.com/en/'
+ url = f'{protocol}://example.com/en/'
if item['item'].path:
url += item['item'].path + '/'
diff --git a/cms/tests/test_toolbar.py b/cms/tests/test_toolbar.py
index 4e958ad8cf6..5f40b9454d9 100644
--- a/cms/tests/test_toolbar.py
+++ b/cms/tests/test_toolbar.py
@@ -40,6 +40,7 @@
from cms.toolbar.items import AjaxItem, Break, ItemSearchResult, LinkItem, SubMenu, ToolbarAPIMixin
from cms.toolbar.toolbar import CMSToolbar
from cms.toolbar_pool import toolbar_pool
+from cms.utils.compat import DJANGO_4_2
from cms.utils.conf import get_cms_setting
from cms.utils.i18n import get_language_tuple
from cms.utils.urlutils import admin_reverse
@@ -640,7 +641,10 @@ def test_hide_toolbar_login_nonstaff(self):
def test_admin_logout_staff(self):
with override_settings(CMS_PERMISSION=True):
with self.login_user_context(self.get_staff()):
- response = self.client.get('/en/admin/logout/')
+ if DJANGO_4_2:
+ response = self.client.get('/en/admin/logout/')
+ else:
+ response = self.client.post('/en/admin/logout/')
self.assertEqual(response.status_code, 200)
def test_show_toolbar_without_edit(self):
@@ -1519,7 +1523,7 @@ def test_filters_date(self):
''
''.format(
'placeholderapp', 'example1', 'date_field', ex1.pk,
- ex1.date_field.strftime("%b. %d, %Y")))
+ ex1.date_field.strftime("%b. %d, %Y" if DJANGO_4_2 else "%b. %-d, %Y")))
template_text = '''{% extends "base.html" %}
{% load cms_tags %}
diff --git a/cms/toolbar/items.py b/cms/toolbar/items.py
index 65173839119..2e92ba330cb 100644
--- a/cms/toolbar/items.py
+++ b/cms/toolbar/items.py
@@ -7,6 +7,7 @@
from django.utils.functional import Promise
from cms.constants import LEFT, REFRESH_PAGE, RIGHT, URL_CHANGE
+from cms.utils.compat import DJANGO_4_2
class ItemSearchResult:
@@ -24,11 +25,18 @@ def __int__(self):
return self.index
-def may_be_lazy(thing):
- if isinstance(thing, Promise):
- return thing._proxy____args[0]
- else:
- return thing
+if DJANGO_4_2:
+ def may_be_lazy(thing):
+ if isinstance(thing, Promise):
+ return thing._proxy____args[0]
+ else:
+ return thing
+else:
+ def may_be_lazy(thing):
+ if isinstance(thing, Promise):
+ return thing._args[0]
+ else:
+ return thing
class ToolbarAPIMixin(metaclass=ABCMeta):
diff --git a/cms/utils/compat/__init__.py b/cms/utils/compat/__init__.py
index 78eaf918303..8a07cc79ada 100644
--- a/cms/utils/compat/__init__.py
+++ b/cms/utils/compat/__init__.py
@@ -13,3 +13,4 @@
DJANGO_3_2 = Version(DJANGO_VERSION) < Version('4.0')
DJANGO_3 = DJANGO_3_2
DJANGO_4_1 = Version(DJANGO_VERSION) < Version('4.2')
+DJANGO_4_2 = Version(DJANGO_VERSION) < Version('4.3')
diff --git a/setup.py b/setup.py
index b2c1bd14f73..91e5d5c95de 100644
--- a/setup.py
+++ b/setup.py
@@ -6,7 +6,7 @@
REQUIREMENTS = [
- 'Django>=3.2,<5.0',
+ 'Django>=3.2',
'django-classy-tags>=0.7.2',
'django-formtools>=2.1',
'django-treebeard>=4.3',
@@ -35,6 +35,7 @@
'Framework :: Django :: 4.0',
'Framework :: Django :: 4.1',
'Framework :: Django :: 4.2',
+ 'Framework :: Django :: 5.0',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Internet :: WWW/HTTP :: Dynamic Content',
'Topic :: Software Development',
diff --git a/test_requirements/django-5.0.txt b/test_requirements/django-5.0.txt
new file mode 100644
index 00000000000..439f96ad9a6
--- /dev/null
+++ b/test_requirements/django-5.0.txt
@@ -0,0 +1,2 @@
+-r requirements_base.txt
+Django>=5.0,<5.1