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