From 63d581099e90894ce51f63f78c30bca712c49e43 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sun, 16 Apr 2017 11:27:03 +0200 Subject: [PATCH 1/4] Initial compatibility --- .travis.yml | 32 ++++++ cms/forms/widgets.py | 110 +++++++++++++-------- cms/templates/cms/widgets/pagewidgets.html | 2 + cms/templatetags/cms_tags.py | 3 +- cms/test_utils/project/sampleapp/forms.py | 2 +- cms/tests/test_cache.py | 10 +- cms/utils/compat/__init__.py | 1 + manage.py | 6 +- test_requirements/django-1.11.txt | 6 ++ 9 files changed, 122 insertions(+), 50 deletions(-) create mode 100644 cms/templates/cms/widgets/pagewidgets.html create mode 100644 test_requirements/django-1.11.txt diff --git a/.travis.yml b/.travis.yml index c02b3f1a6bc..829d59b768c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,7 @@ language: python python: + - 3.6 - 3.5 - 3.4 - 3.3 @@ -35,6 +36,11 @@ env: - FRONTEND=1 INTEGRATION=1 INTEGRATION_TESTS_BUCKET=1 DJANGO=1.8 DATABASE_URL='sqlite://localhost/testdb.sqlite' MIGRATE_OPTION='--migrate' - FRONTEND=1 INTEGRATION=1 INTEGRATION_TESTS_BUCKET=2 DJANGO=1.8 DATABASE_URL='sqlite://localhost/testdb.sqlite' MIGRATE_OPTION='--migrate' - FRONTEND=1 INTEGRATION=1 INTEGRATION_TESTS_BUCKET=3 DJANGO=1.8 DATABASE_URL='sqlite://localhost/testdb.sqlite' MIGRATE_OPTION='--migrate' + - DJANGO=1.11 DATABASE_URL='sqlite://localhost/:memory:' MIGRATE_OPTION='--migrate' + - DJANGO=1.11 DATABASE_URL='mysql://root@127.0.0.1/djangocms_test' + - DJANGO=1.11 DATABASE_URL='postgres://postgres@127.0.0.1/djangocms_test' MIGRATE_OPTION='--migrate' + - DJANGO=1.11 DATABASE_URL='postgres://postgres@127.0.0.1/djangocms_test' AUTH_USER_MODEL='emailuserapp.EmailUser' + - DJANGO=1.11 DATABASE_URL='postgres://postgres@127.0.0.1/djangocms_test' AUTH_USER_MODEL='customuserapp.User' - DJANGO=1.10 DATABASE_URL='sqlite://localhost/:memory:' MIGRATE_OPTION='--migrate' - DJANGO=1.10 DATABASE_URL='mysql://root@127.0.0.1/djangocms_test' - DJANGO=1.10 DATABASE_URL='postgres://postgres@127.0.0.1/djangocms_test' MIGRATE_OPTION='--migrate' @@ -92,6 +98,17 @@ notifications: matrix: exclude: + - python: 3.3 + env: DJANGO=1.11 DATABASE_URL='sqlite://localhost/:memory:' MIGRATE_OPTION='--migrate' + - python: 3.3 + env: DJANGO=1.11 DATABASE_URL='mysql://root@127.0.0.1/djangocms_test' + - python: 3.3 + env: DJANGO=1.11 DATABASE_URL='postgres://postgres@127.0.0.1/djangocms_test' MIGRATE_OPTION='--migrate' + - python: 3.3 + env: DJANGO=1.11 DATABASE_URL='postgres://postgres@127.0.0.1/djangocms_test' AUTH_USER_MODEL='emailuserapp.EmailUser' + - python: 3.3 + env: DJANGO=1.11 DATABASE_URL='postgres://postgres@127.0.0.1/djangocms_test' AUTH_USER_MODEL='customuserapp.User' + - python: 3.3 env: DJANGO=1.10 DATABASE_URL='sqlite://localhost/:memory:' MIGRATE_OPTION='--migrate' - python: 3.3 @@ -135,6 +152,10 @@ matrix: env: FRONTEND=1 UNIT=1 - python: 3.5 env: FRONTEND=1 LINT=1 + - python: 3.6 + env: FRONTEND=1 UNIT=1 + - python: 3.6 + env: FRONTEND=1 LINT=1 - python: 3.3 env: FRONTEND=1 INTEGRATION=1 INTEGRATION_TESTS_BUCKET=1 DJANGO=1.8 DATABASE_URL='sqlite://localhost/testdb.sqlite' MIGRATE_OPTION='--migrate' @@ -142,6 +163,8 @@ matrix: env: FRONTEND=1 INTEGRATION=1 INTEGRATION_TESTS_BUCKET=1 DJANGO=1.8 DATABASE_URL='sqlite://localhost/testdb.sqlite' MIGRATE_OPTION='--migrate' - python: 3.5 env: FRONTEND=1 INTEGRATION=1 INTEGRATION_TESTS_BUCKET=1 DJANGO=1.8 DATABASE_URL='sqlite://localhost/testdb.sqlite' MIGRATE_OPTION='--migrate' + - python: 3.6 + env: FRONTEND=1 INTEGRATION=1 INTEGRATION_TESTS_BUCKET=1 DJANGO=1.8 DATABASE_URL='sqlite://localhost/testdb.sqlite' MIGRATE_OPTION='--migrate' - python: 3.3 env: FRONTEND=1 INTEGRATION=1 INTEGRATION_TESTS_BUCKET=2 DJANGO=1.8 DATABASE_URL='sqlite://localhost/testdb.sqlite' MIGRATE_OPTION='--migrate' @@ -149,6 +172,8 @@ matrix: env: FRONTEND=1 INTEGRATION=1 INTEGRATION_TESTS_BUCKET=2 DJANGO=1.8 DATABASE_URL='sqlite://localhost/testdb.sqlite' MIGRATE_OPTION='--migrate' - python: 3.5 env: FRONTEND=1 INTEGRATION=1 INTEGRATION_TESTS_BUCKET=2 DJANGO=1.8 DATABASE_URL='sqlite://localhost/testdb.sqlite' MIGRATE_OPTION='--migrate' + - python: 3.6 + env: FRONTEND=1 INTEGRATION=1 INTEGRATION_TESTS_BUCKET=2 DJANGO=1.8 DATABASE_URL='sqlite://localhost/testdb.sqlite' MIGRATE_OPTION='--migrate' - python: 3.3 env: FRONTEND=1 INTEGRATION=1 INTEGRATION_TESTS_BUCKET=3 DJANGO=1.8 DATABASE_URL='sqlite://localhost/testdb.sqlite' MIGRATE_OPTION='--migrate' @@ -156,6 +181,8 @@ matrix: env: FRONTEND=1 INTEGRATION=1 INTEGRATION_TESTS_BUCKET=3 DJANGO=1.8 DATABASE_URL='sqlite://localhost/testdb.sqlite' MIGRATE_OPTION='--migrate' - python: 3.5 env: FRONTEND=1 INTEGRATION=1 INTEGRATION_TESTS_BUCKET=3 DJANGO=1.8 DATABASE_URL='sqlite://localhost/testdb.sqlite' MIGRATE_OPTION='--migrate' + - python: 3.6 + env: FRONTEND=1 INTEGRATION=1 INTEGRATION_TESTS_BUCKET=3 DJANGO=1.8 DATABASE_URL='sqlite://localhost/testdb.sqlite' MIGRATE_OPTION='--migrate' allow_failures: @@ -174,5 +201,10 @@ matrix: - python: 3.5 env: DJANGO=1.9 DATABASE_URL='mysql://root@127.0.0.1/djangocms_test' + - python: 3.6 + env: DJANGO=1.8 DATABASE_URL='mysql://root@127.0.0.1/djangocms_test' + - python: 3.6 + env: DJANGO=1.9 DATABASE_URL='mysql://root@127.0.0.1/djangocms_test' + fast_finish: true diff --git a/cms/forms/widgets.py b/cms/forms/widgets.py index 1220b062ffd..74af93a18ee 100644 --- a/cms/forms/widgets.py +++ b/cms/forms/widgets.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- - +from cms.utils.compat import DJANGO_1_10 from django.contrib.admin.templatetags.admin_static import static from django.contrib.auth import get_permission_codename from django.contrib.sites.models import Site @@ -19,6 +19,7 @@ class PageSelectWidget(MultiWidget): """A widget that allows selecting a page by first selecting a site and then a page on that site in a two step process. """ + template_name = 'cms/widgets/pagewidgets.html' class Media: js = ( @@ -67,13 +68,7 @@ def _has_changed(self, initial, data): return True return False - def render(self, name, value, attrs=None): - # THIS IS A COPY OF django.forms.widgets.MultiWidget.render() - # (except for the last line) - - # value is a list of values, each corresponding to a widget - # in self.widgets. - + def _build_widgets(self): site_choices = get_site_choices() page_choices = get_page_choices() self.site_choices = site_choices @@ -83,37 +78,58 @@ def render(self, name, value, attrs=None): Select(choices=self.choices, attrs={'style': "display:none;"} ), ) - if not isinstance(value, list): - value = self.decompress(value) - output = [] - final_attrs = self.build_attrs(attrs) - id_ = final_attrs.get('id', None) - for i, widget in enumerate(self.widgets): - try: - widget_value = value[i] - except IndexError: - widget_value = None - if id_: - final_attrs = dict(final_attrs, id='%s_%s' % (id_, i)) - output.append(widget.render(name + '_%s' % i, widget_value, final_attrs)) - output.append(r'''""" % { + 'name': name + } + + def get_context(self, name, value, attrs): + self._build_widgets() + context = super(PageSelectWidget, self).get_context(name, value, attrs) + context['script_init'] = self._build_script(name) + return context - CMS.Widgets = CMS.Widgets || {}; - CMS.Widgets._pageSelectWidgets = CMS.Widgets._pageSelectWidgets || []; - CMS.Widgets._pageSelectWidgets.push({ - name: '%(name)s' - }); - ''' % { - 'name': name - }) - return mark_safe(self.format_output(output)) + def render(self, name, value, attrs=None): + if DJANGO_1_10: + # THIS IS A COPY OF django.forms.widgets.MultiWidget.render() + # (except for the last line) + + # value is a list of values, each corresponding to a widget + # in self.widgets. + self._build_widgets() + + if not isinstance(value, list): + value = self.decompress(value) + output = [] + final_attrs = self.build_attrs(attrs) + id_ = final_attrs.get('id', None) + for i, widget in enumerate(self.widgets): + try: + widget_value = value[i] + except IndexError: + widget_value = None + if id_: + final_attrs = dict(final_attrs, id='%s_%s' % (id_, i)) + output.append(widget.render(name + '_%s' % i, widget_value, final_attrs)) + output.append(self._build_script(name)) + return mark_safe(self.format_output(output)) + else: + return super(PageSelectWidget, self).render(name, value, attrs) def format_output(self, rendered_widgets): return u' '.join(rendered_widgets) class PageSmartLinkWidget(TextInput): + template_name = 'cms/widgets/pagewidgets.html' class Media: css = { @@ -138,11 +154,8 @@ def get_ajax_url(self, ajax_view): 'You should provide an ajax_view argument that can be reversed to the PageSmartLinkWidget' ) - def render(self, name=None, value=None, attrs=None): - final_attrs = self.build_attrs(attrs) - id_ = final_attrs.get('id', None) - - output = [r'''''' % { - 'element_id': id_, - 'placeholder_text': final_attrs.get('placeholder_text', ''), + """ % { + 'element_id': context.get('id', ''), + 'placeholder_text': context.get('placeholder_text', ''), 'language_code': self.language, 'ajax_url': force_text(self.ajax_url) - }] + } - output.append(super(PageSmartLinkWidget, self).render(name, value, attrs)) - return mark_safe(u''.join(output)) + def get_context(self, name, value, attrs): + context = super(PageSmartLinkWidget, self).get_context(name, value, attrs) + context['script_init'] = self._build_script(name, context['widget']) + return context + + def render(self, name=None, value=None, attrs=None): + if DJANGO_1_10: + final_attrs = self.build_attrs(attrs) + output = list(self._build_script(name, final_attrs)) + output.append(super(PageSmartLinkWidget, self).render(name, value, attrs)) + return mark_safe(u''.join(output)) + else: + return super(PageSmartLinkWidget, self).render(name, value, attrs) class UserSelectAdminWidget(Select): diff --git a/cms/templates/cms/widgets/pagewidgets.html b/cms/templates/cms/widgets/pagewidgets.html new file mode 100644 index 00000000000..c1035c7e817 --- /dev/null +++ b/cms/templates/cms/widgets/pagewidgets.html @@ -0,0 +1,2 @@ +{% include 'django/forms/widgets/multiwidget.html' %} +{{ script_init }} diff --git a/cms/templatetags/cms_tags.py b/cms/templatetags/cms_tags.py index 40af9434ffd..524e36e30e4 100644 --- a/cms/templatetags/cms_tags.py +++ b/cms/templatetags/cms_tags.py @@ -12,7 +12,6 @@ from django.core.urlresolvers import reverse from django.db.models import Model from django.middleware.common import BrokenLinkEmailsMiddleware -from django.template import Context from django.template.loader import render_to_string from django.utils import six from django.utils.encoding import smart_text, force_text @@ -311,7 +310,7 @@ def render_extra_menu_items(context, obj, template='cms/toolbar/dragitem_extra_m if not items: return '' - return template.render(Context({'items': items})) + return template.render({'items': items}) class PageAttribute(AsTag): diff --git a/cms/test_utils/project/sampleapp/forms.py b/cms/test_utils/project/sampleapp/forms.py index 16bd7e4ddbd..cd474ee9782 100644 --- a/cms/test_utils/project/sampleapp/forms.py +++ b/cms/test_utils/project/sampleapp/forms.py @@ -11,5 +11,5 @@ class LoginForm2(AuthenticationForm): class LoginForm3(AuthenticationForm): - def __init__(self, argument, request=None, *args, **kwargs): + def __init__(self, request=None, *args, **kwargs): super(LoginForm3, self).__init__(request, *args, **kwargs) diff --git a/cms/tests/test_cache.py b/cms/tests/test_cache.py index d1f775be04a..1c593bedb6b 100644 --- a/cms/tests/test_cache.py +++ b/cms/tests/test_cache.py @@ -2,6 +2,7 @@ import time +from cms.utils.compat import DJANGO_1_10 from django.conf import settings from django.template import Context @@ -346,7 +347,8 @@ def test_expiration_cache_plugins(self): self.assertTrue('max-age=40' in response['Cache-Control'], response['Cache-Control']) # noqa cache_control1 = response['Cache-Control'] expires1 = response['Expires'] - last_modified1 = response['Last-Modified'] + # No longer set / needed by Django 1.11 + last_modified1 = response.get('Last-Modified', None) time.sleep(1) # This ensures that the cache has aged measurably @@ -363,8 +365,10 @@ def test_expiration_cache_plugins(self): self.assertNotEqual(response['Cache-Control'], cache_control1) # However, the Expires timestamp will be the same self.assertEqual(response['Expires'], expires1) - # As will the Last-Modified timestamp. - self.assertEqual(response['Last-Modified'], last_modified1) + # No longer set / needed by Django 1.11 + if DJANGO_1_10: + # As will the Last-Modified timestamp. + self.assertEqual(response['Last-Modified'], last_modified1) plugin_pool.unregister_plugin(TTLCacheExpirationPlugin) plugin_pool.unregister_plugin(DateTimeCacheExpirationPlugin) diff --git a/cms/utils/compat/__init__.py b/cms/utils/compat/__init__.py index 9482d645a6a..602d24b2048 100644 --- a/cms/utils/compat/__init__.py +++ b/cms/utils/compat/__init__.py @@ -6,3 +6,4 @@ DJANGO_1_8 = LooseVersion(django.get_version()) < LooseVersion('1.9') DJANGO_1_9 = LooseVersion(django.get_version()) < LooseVersion('1.10') DJANGO_1_10 = LooseVersion(django.get_version()) < LooseVersion('1.11') +DJANGO_1_11 = LooseVersion(django.get_version()) < LooseVersion('2.0') diff --git a/manage.py b/manage.py index c9c75a4e512..53f41defdcb 100755 --- a/manage.py +++ b/manage.py @@ -133,7 +133,10 @@ def __contains__(self, item): return True def __getitem__(self, item): - return 'notmigrations' + if DJANGO_1_9: + return 'notmigrations' + else: + return None dynamic_configs['MIGRATION_MODULES'] = DisableMigrations() if 'test' in sys.argv: @@ -154,6 +157,7 @@ def __getitem__(self, item): 'cms.middleware.user.CurrentUserMiddleware', 'cms.middleware.page.CurrentPageMiddleware', 'cms.middleware.toolbar.ToolbarMiddleware', + 'debug_toolbar.middleware.DebugToolbarMiddleware', 'django.middleware.cache.FetchFromCacheMiddleware', ] if not DJANGO_1_9: diff --git a/test_requirements/django-1.11.txt b/test_requirements/django-1.11.txt new file mode 100644 index 00000000000..7929ba473bc --- /dev/null +++ b/test_requirements/django-1.11.txt @@ -0,0 +1,6 @@ +-r requirements_base.txt +Django>=1.11,<1.12 +django-reversion>=2.0 +https://github.com/KristianOellegaard/django-hvad/archive/master.zip +django-formtools +django-debug-toolbar From 7fbedb3e3aa9f876b104aac269e35fe35fd5af90 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sun, 16 Apr 2017 13:38:24 +0200 Subject: [PATCH 2/4] Tests pass --- cms/tests/test_admin.py | 7 ++++- cms/tests/test_page_user_admin.py | 40 ++++++++++++++++++++--- cms/tests/test_page_user_group_admin.py | 42 ++++++++++++++++++++++--- test_requirements/django-1.11.txt | 1 + 4 files changed, 81 insertions(+), 9 deletions(-) diff --git a/cms/tests/test_admin.py b/cms/tests/test_admin.py index 43b5debabb5..82dfe6fe490 100644 --- a/cms/tests/test_admin.py +++ b/cms/tests/test_admin.py @@ -37,6 +37,7 @@ ) from cms.test_utils.util.fuzzy_int import FuzzyInt from cms.utils import get_cms_setting +from cms.utils.compat import DJANGO_1_10 from cms.utils.i18n import force_language from cms.utils.urlutils import admin_reverse @@ -386,7 +387,11 @@ def test_unihandecode_doesnt_break_404_in_admin(self): self.client.login(username='admin', password='admin') response = self.client.get(URL_CMS_PAGE_CHANGE_LANGUAGE % (1, 'en')) - self.assertEqual(response.status_code, 404) + # Since Django 1.11 404 results in redirect to the admin home + if DJANGO_1_10: + self.assertEqual(response.status_code, 404) + else: + self.assertRedirects(response, reverse('admin:index')) def test_empty_placeholder_with_nested_plugins(self): # It's important that this test clears a placeholder diff --git a/cms/tests/test_page_user_admin.py b/cms/tests/test_page_user_admin.py index deb05ce110b..987e06942e1 100644 --- a/cms/tests/test_page_user_admin.py +++ b/cms/tests/test_page_user_admin.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- from django.contrib.auth import get_permission_codename, get_user_model +from django.contrib.messages.storage.cookie import CookieStorage from django.forms.models import model_to_dict from django.test.utils import override_settings from cms.models.permissionmodels import PageUser from cms.test_utils.testcases import CMSTestCase +from cms.utils.compat import DJANGO_1_10 from cms.utils.urlutils import admin_reverse @@ -371,6 +373,7 @@ def test_user_cant_change_self(self): admin = self.get_superuser() staff_user = self.get_staff_page_user(created_by=admin) endpoint = self.get_admin_url(PageUser, 'change', staff_user.pk) + redirect_to = admin_reverse('index') data = model_to_dict(staff_user, exclude=['date_joined']) data['_continue'] = '1' @@ -393,7 +396,13 @@ def test_user_cant_change_self(self): with self.login_user_context(staff_user): response = self.client.post(endpoint, data) - self.assertEqual(response.status_code, 404) + if DJANGO_1_10: + self.assertEqual(response.status_code, 404) + else: + self.assertRedirects(response, redirect_to) + msgs = CookieStorage(response)._decode(response.cookies['messages'].value) + self.assertTrue(msgs[0], PageUser._meta.verbose_name) + self.assertTrue(msgs[0], 'ID "%s"' % staff_user.pk) self.assertFalse(self._user_exists(username)) def test_user_cant_change_others(self): @@ -405,6 +414,7 @@ def test_user_cant_change_others(self): staff_user = self.get_staff_user_with_no_permissions() staff_user_2 = self.get_staff_page_user(created_by=admin) endpoint = self.get_admin_url(PageUser, 'change', staff_user_2.pk) + redirect_to = admin_reverse('index') data = model_to_dict(staff_user_2, exclude=['date_joined']) data['_continue'] = '1' @@ -427,7 +437,13 @@ def test_user_cant_change_others(self): with self.login_user_context(staff_user): response = self.client.post(endpoint, data) - self.assertEqual(response.status_code, 404) + if DJANGO_1_10: + self.assertEqual(response.status_code, 404) + else: + self.assertRedirects(response, redirect_to) + msgs = CookieStorage(response)._decode(response.cookies['messages'].value) + self.assertTrue(msgs[0], PageUser._meta.verbose_name) + self.assertTrue(msgs[0], 'ID "%s"' % staff_user_2.pk) self.assertFalse(self._user_exists(username)) def test_user_can_delete_subordinate(self): @@ -485,6 +501,7 @@ def test_user_cant_delete_self(self): admin = self.get_superuser() staff_user = self.get_staff_page_user(created_by=admin) endpoint = self.get_admin_url(PageUser, 'delete', staff_user.pk) + redirect_to = admin_reverse('index') data = {'post': 'yes'} self.add_permission(staff_user, self._get_delete_perm()) @@ -503,7 +520,14 @@ def test_user_cant_delete_self(self): # that the user has permissions for. # This queryset is used to fetch the object # from the request, resulting in a 404. - self.assertEqual(response.status_code, 404) + # Since Django 1.11 404 results in redirect to the admin home + if DJANGO_1_10: + self.assertEqual(response.status_code, 404) + else: + self.assertRedirects(response, redirect_to) + msgs = CookieStorage(response)._decode(response.cookies['messages'].value) + self.assertTrue(msgs[0], PageUser._meta.verbose_name) + self.assertTrue(msgs[0], 'ID "%s"' % staff_user.pk) self.assertTrue(self._user_exists(username)) def test_user_cant_delete_others(self): @@ -515,6 +539,7 @@ def test_user_cant_delete_others(self): staff_user = self.get_staff_user_with_no_permissions() staff_user_2 = self.get_staff_page_user(created_by=admin) endpoint = self.get_admin_url(PageUser, 'delete', staff_user_2.pk) + redirect_to = admin_reverse('index') data = {'post': 'yes'} @@ -534,5 +559,12 @@ def test_user_cant_delete_others(self): # that the user has permissions for. # This queryset is used to fetch the object # from the request, resulting in a 404. - self.assertEqual(response.status_code, 404) + # Since Django 1.11 404 results in redirect to the admin home + if DJANGO_1_10: + self.assertEqual(response.status_code, 404) + else: + self.assertRedirects(response, redirect_to) + msgs = CookieStorage(response)._decode(response.cookies['messages'].value) + self.assertTrue(msgs[0], PageUser._meta.verbose_name) + self.assertTrue(msgs[0], 'ID "%s"' % staff_user_2.pk) self.assertTrue(self._user_exists(username)) diff --git a/cms/tests/test_page_user_group_admin.py b/cms/tests/test_page_user_group_admin.py index edeff0e4fa9..0f69030b89e 100644 --- a/cms/tests/test_page_user_group_admin.py +++ b/cms/tests/test_page_user_group_admin.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- +from django.contrib.messages.storage.cookie import CookieStorage from django.forms.models import model_to_dict from django.test.utils import override_settings from cms.models.permissionmodels import PageUserGroup from cms.test_utils.testcases import CMSTestCase +from cms.utils.compat import DJANGO_1_10 from cms.utils.urlutils import admin_reverse @@ -322,6 +324,7 @@ def test_user_cant_change_own_group(self): staff_user = self.get_staff_user_with_no_permissions() staff_user.groups.add(group) endpoint = self.get_admin_url(PageUserGroup, 'change', group.pk) + redirect_to = admin_reverse('index') data = model_to_dict(group) data['_continue'] = '1' @@ -336,7 +339,14 @@ def test_user_cant_change_own_group(self): with self.login_user_context(staff_user): response = self.client.post(endpoint, data) - self.assertEqual(response.status_code, 404) + # Since Django 1.11 404 results in redirect to the admin home + if DJANGO_1_10: + self.assertEqual(response.status_code, 404) + else: + self.assertRedirects(response, redirect_to) + msgs = CookieStorage(response)._decode(response.cookies['messages'].value) + self.assertTrue(msgs[0], PageUserGroup._meta.verbose_name) + self.assertTrue(msgs[0], 'ID "%s"' % group.pk) self.assertFalse(self._group_exists('New test group')) def test_user_cant_change_others_group(self): @@ -348,6 +358,7 @@ def test_user_cant_change_others_group(self): group = self._get_group(created_by=admin) staff_user = self.get_staff_user_with_no_permissions() endpoint = self.get_admin_url(PageUserGroup, 'change', group.pk) + redirect_to = admin_reverse('index') data = model_to_dict(group) data['_continue'] = '1' @@ -362,7 +373,14 @@ def test_user_cant_change_others_group(self): with self.login_user_context(staff_user): response = self.client.post(endpoint, data) - self.assertEqual(response.status_code, 404) + # Since Django 1.11 404 results in redirect to the admin home + if DJANGO_1_10: + self.assertEqual(response.status_code, 404) + else: + self.assertRedirects(response, redirect_to) + msgs = CookieStorage(response)._decode(response.cookies['messages'].value) + self.assertTrue(msgs[0], PageUserGroup._meta.verbose_name) + self.assertTrue(msgs[0], 'ID "%s"' % group.pk) self.assertFalse(self._group_exists('New test group')) def test_user_can_delete_subordinate_group(self): @@ -421,6 +439,7 @@ def test_user_cant_delete_own_group(self): staff_user = self.get_staff_user_with_no_permissions() staff_user.groups.add(group) endpoint = self.get_admin_url(PageUserGroup, 'delete', group.pk) + redirect_to = admin_reverse('index') data = {'post': 'yes'} self.add_permission(staff_user, 'delete_group') @@ -438,7 +457,14 @@ def test_user_cant_delete_own_group(self): # that the user has permissions for. # This queryset is used to fetch the object # from the request, resulting in a 404. - self.assertEqual(response.status_code, 404) + # Since Django 1.11 404 results in redirect to the admin home + if DJANGO_1_10: + self.assertEqual(response.status_code, 404) + else: + self.assertRedirects(response, redirect_to) + msgs = CookieStorage(response)._decode(response.cookies['messages'].value) + self.assertTrue(msgs[0], PageUserGroup._meta.verbose_name) + self.assertTrue(msgs[0], 'ID "%s"' % group.pk) self.assertTrue(self._group_exists()) def test_user_cant_delete_others_group(self): @@ -450,6 +476,7 @@ def test_user_cant_delete_others_group(self): group = self._get_group(created_by=admin) staff_user = self.get_staff_user_with_no_permissions() endpoint = self.get_admin_url(PageUserGroup, 'delete', group.pk) + redirect_to = admin_reverse('index') data = {'post': 'yes'} self.add_permission(staff_user, 'delete_group') @@ -467,5 +494,12 @@ def test_user_cant_delete_others_group(self): # that the user has permissions for. # This queryset is used to fetch the object # from the request, resulting in a 404. - self.assertEqual(response.status_code, 404) + # Since Django 1.11 404 results in redirect to the admin home + if DJANGO_1_10: + self.assertEqual(response.status_code, 404) + else: + self.assertRedirects(response, redirect_to) + msgs = CookieStorage(response)._decode(response.cookies['messages'].value) + self.assertTrue(msgs[0], PageUserGroup._meta.verbose_name) + self.assertTrue(msgs[0], 'ID "%s"' % group.pk) self.assertTrue(self._group_exists()) diff --git a/test_requirements/django-1.11.txt b/test_requirements/django-1.11.txt index 7929ba473bc..5c236316b97 100644 --- a/test_requirements/django-1.11.txt +++ b/test_requirements/django-1.11.txt @@ -1,6 +1,7 @@ -r requirements_base.txt Django>=1.11,<1.12 django-reversion>=2.0 +https://github.com/yakky/djangocms-attributes-field/archive/feature/django_1_11.zip https://github.com/KristianOellegaard/django-hvad/archive/master.zip django-formtools django-debug-toolbar From 3d591503cf0d5e335912d28569db5b14726ca176 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Mon, 17 Apr 2017 17:58:06 +0200 Subject: [PATCH 3/4] More work on widgets --- cms/admin/forms.py | 2 +- cms/forms/widgets.py | 105 +++++++++++------- .../3.4.2/bundle.forms.apphookselect.min.js | 60 +++++++++- .../cms/widgets/applicationconfigselect.html | 7 ++ ...pagewidgets.html => pageselectwidget.html} | 2 +- .../cms/widgets/pagesmartlinkwidget.html | 2 + cms/templates/cms/wizards/start.html | 9 +- .../cms/wizards/wizardoptionwidget.html | 8 ++ cms/templatetags/cms_tags.py | 3 +- cms/wizards/forms.py | 9 +- manage.py | 16 +-- 11 files changed, 162 insertions(+), 61 deletions(-) create mode 100644 cms/templates/cms/widgets/applicationconfigselect.html rename cms/templates/cms/widgets/{pagewidgets.html => pageselectwidget.html} (64%) create mode 100644 cms/templates/cms/widgets/pagesmartlinkwidget.html create mode 100644 cms/templates/cms/wizards/wizardoptionwidget.html diff --git a/cms/admin/forms.py b/cms/admin/forms.py index 59ef11e8b77..cf80e8c4d15 100644 --- a/cms/admin/forms.py +++ b/cms/admin/forms.py @@ -234,7 +234,7 @@ class AdvancedSettingsForm(forms.ModelForm): # This is really a 'fake' field which does not correspond to any Page attribute # But creates a stub field to be populate by js application_configs = forms.ChoiceField(label=_('Application configurations'), - choices=(), required=False,) + choices=(), required=False, widget=ApplicationConfigSelect) fieldsets = ( (None, { 'fields': ('overwrite_url', 'redirect'), diff --git a/cms/forms/widgets.py b/cms/forms/widgets.py index 74af93a18ee..957ea09a27e 100644 --- a/cms/forms/widgets.py +++ b/cms/forms/widgets.py @@ -19,7 +19,7 @@ class PageSelectWidget(MultiWidget): """A widget that allows selecting a page by first selecting a site and then a page on that site in a two step process. """ - template_name = 'cms/widgets/pagewidgets.html' + template_name = 'cms/widgets/pageselectwidget.html' class Media: js = ( @@ -78,8 +78,8 @@ def _build_widgets(self): Select(choices=self.choices, attrs={'style': "display:none;"} ), ) - def _build_script(self, name): - return r"""""" % { - 'element_id': context.get('id', ''), - 'placeholder_text': context.get('placeholder_text', ''), + 'element_id': attrs.get('id', ''), + 'placeholder_text': attrs.get('placeholder_text', ''), 'language_code': self.language, 'ajax_url': force_text(self.ajax_url) } def get_context(self, name, value, attrs): context = super(PageSmartLinkWidget, self).get_context(name, value, attrs) - context['script_init'] = self._build_script(name, context['widget']) + context['widget']['script_init'] = self._build_script(name, value, context['widget']['attrs']) return context - def render(self, name=None, value=None, attrs=None): + def render(self, name, value, attrs=None, renderer=None): if DJANGO_1_10: final_attrs = self.build_attrs(attrs) output = list(self._build_script(name, final_attrs)) output.append(super(PageSmartLinkWidget, self).render(name, value, attrs)) return mark_safe(u''.join(output)) else: - return super(PageSmartLinkWidget, self).render(name, value, attrs) + return super(PageSmartLinkWidget, self).render(name, value, attrs, renderer) class UserSelectAdminWidget(Select): @@ -223,7 +223,13 @@ def __init__(self, attrs=None, choices=(), app_namespaces={}): self.app_namespaces = app_namespaces super(AppHookSelect, self).__init__(attrs, choices) - def render_option(self, selected_choices, option_value, option_label): + def create_option(self, name, value, label, selected, index, subindex=None, attrs=None): + option = super(AppHookSelect, self).create_option(name, value, label, selected, index, subindex, attrs) + if value in self.app_namespaces: + option['attrs']['data-namespace'] = escape(self.app_namespaces[value]) + return option + + def _build_option(self, selected_choices, option_value, option_label): if option_value is None: option_value = '' option_value = force_text(option_value) @@ -239,13 +245,11 @@ def render_option(self, selected_choices, option_value, option_label): data_html = mark_safe(' data-namespace="%s"' % escape(self.app_namespaces[option_value])) else: data_html = '' + return option_value, selected_html, data_html, force_text(option_label) - return '' % ( - option_value, - selected_html, - data_html, - force_text(option_label), - ) + def render_option(self, selected_choices, option_value, option_label): + option_data = self._build_option(selected_choices, option_value, option_label) + return '' % option_data class ApplicationConfigSelect(Select): @@ -259,6 +263,7 @@ class ApplicationConfigSelect(Select): A stub 'add-another' link is created and filled in with the correct URL by the same javascript. """ + template_name = 'cms/widgets/applicationconfigselect.html' class Media: js = ( @@ -269,23 +274,43 @@ def __init__(self, attrs=None, choices=(), app_configs={}): self.app_configs = app_configs super(ApplicationConfigSelect, self).__init__(attrs, choices) - def render(self, name, value, attrs=None, choices=()): - output = list(super(ApplicationConfigSelect, self).render(name, value, attrs)) - output.append('') - - related_url = '' - output.append(' ' - % (related_url, name)) - output.append('%s' - % (static('admin/img/icon_addlink.gif'), _('Add Another'))) - return mark_safe(''.join(output)) + urls.append("'%s': '%s'" % (application, cms_app.get_config_add_url())) + return r"""""" % { + 'apphooks_configurations': ','.join(configs), + 'apphooks_url': ','.join(urls), + 'apphooks_value': value, + } + + def get_context(self, name, value, attrs): + context = super(ApplicationConfigSelect, self).get_context(name, value, attrs) + context['widget']['script_init'] = self._build_script(name, value, context['widget']['attrs']) + return context + + def render(self, name, value, attrs=None, renderer=None): + if DJANGO_1_10: + output = list(super(ApplicationConfigSelect, self).render(name, value, attrs)) + output.append(self._build_script(name, value, attrs)) + + related_url = '' + output.append(' ' + % (related_url, name)) + output.append('%s' + % (static('admin/img/icon_addlink.gif'), _('Add Another'))) + return mark_safe(''.join(output)) + else: + return super(ApplicationConfigSelect, self).render(name, value, attrs, renderer) diff --git a/cms/static/cms/js/dist/3.4.2/bundle.forms.apphookselect.min.js b/cms/static/cms/js/dist/3.4.2/bundle.forms.apphookselect.min.js index fcf0d324faa..b41cbd18415 100644 --- a/cms/static/cms/js/dist/3.4.2/bundle.forms.apphookselect.min.js +++ b/cms/static/cms/js/dist/3.4.2/bundle.forms.apphookselect.min.js @@ -1 +1,59 @@ -!function(e){function a(n){if(t[n])return t[n].exports;var o=t[n]={exports:{},id:n,loaded:!1};return e[n].call(o.exports,o,o.exports,a),o.loaded=!0,o.exports}var n=window.cmsWebpackJsonp;window.cmsWebpackJsonp=function(t,i){for(var r,p,l=0,s=[];l0&&t[e.val()]){p.html("");for(var n=0;n'+t[e.val()][n][1]+"")}l.attr("href",window.apphooks_configuration_url[e.val()]+(window.showRelatedObjectPopup?"?_popup=1":"")),r.removeClass("hidden"),o.addClass("hidden")}else r.addClass("hidden"),e.data("namespace")?o.removeClass("hidden"):o.addClass("hidden")},e.setupNamespaces(),e.on("change",function(){var t=a(this),o=t.find("option:selected");e.setupNamespaces(),t.val()||(i.val(""),i.removeAttr("value")),n.val()===o.val()?s&&i.val(s):o.data("namespace")?i.val(o.data("namespace")):(i.val(""),i.removeAttr("value"))})})})},6:function(e,a){var n=function(e){var a=new RegExp(e+"(\\?.*)?$","gi");if(document.currentScript)return document.currentScript.src.replace(a,"");var n,t,o=function(a,n){for(var t,o,i=0;i 0 && t[e.val()]) { + p.html(""); + for (var n = 0; n < t[e.val()].length; n++) { + var i = ""; + t[e.val()][n][0] === window.apphooks_configuration_value && (i = 'selected="selected"'), p.append("") + } + l.attr("href", window.apphooks_configuration_url[e.val()] + (window.showRelatedObjectPopup ? "?_popup=1" : "")), r.removeClass("hidden"), o.addClass("hidden") + } else r.addClass("hidden"), e.data("namespace") ? o.removeClass("hidden") : o.addClass("hidden") + }, e.setupNamespaces(), e.on("change", function () { + var t = a(this), o = t.find("option:selected"); + e.setupNamespaces(), t.val() || (i.val(""), i.removeAttr("value")), n.val() === o.val() ? s && i.val(s) : o.data("namespace") ? i.val(o.data("namespace")) : (i.val(""), i.removeAttr("value")) + }) + }) + }) + }, 6: function (e, a) { + var n = function (e) { + var a = new RegExp(e + "(\\?.*)?$", "gi"); + if (document.currentScript)return document.currentScript.src.replace(a, ""); + var n, t, o = function (a, n) { + for (var t, o, + i = 0; i < a.length; i++)if (o = null, void 0 !== a[i].getAttribute.length && (o = a[i].getAttribute(n, 2)), o && (t = o, t = t.split("?")[0].split("/").pop(), t === e))return o + }; + return n = document.getElementsByTagName("script"), t = o(n, "src"), t ? t.replace(a, "") : "" + }; + e.exports = n + } +}); diff --git a/cms/templates/cms/widgets/applicationconfigselect.html b/cms/templates/cms/widgets/applicationconfigselect.html new file mode 100644 index 00000000000..f15a33255da --- /dev/null +++ b/cms/templates/cms/widgets/applicationconfigselect.html @@ -0,0 +1,7 @@ +{% load i18n static %} +{% include 'django/forms/widgets/select.html' %} +{{ widget.script_init|safe }} +{% trans 'Add Another' %} + diff --git a/cms/templates/cms/widgets/pagewidgets.html b/cms/templates/cms/widgets/pageselectwidget.html similarity index 64% rename from cms/templates/cms/widgets/pagewidgets.html rename to cms/templates/cms/widgets/pageselectwidget.html index c1035c7e817..db16509a92c 100644 --- a/cms/templates/cms/widgets/pagewidgets.html +++ b/cms/templates/cms/widgets/pageselectwidget.html @@ -1,2 +1,2 @@ {% include 'django/forms/widgets/multiwidget.html' %} -{{ script_init }} +{{ widget.script_init|safe }} diff --git a/cms/templates/cms/widgets/pagesmartlinkwidget.html b/cms/templates/cms/widgets/pagesmartlinkwidget.html new file mode 100644 index 00000000000..81514a118d8 --- /dev/null +++ b/cms/templates/cms/widgets/pagesmartlinkwidget.html @@ -0,0 +1,2 @@ +{% include 'django/forms/widgets/text.html' %} +{{ widget.script_init|safe }} diff --git a/cms/templates/cms/wizards/start.html b/cms/templates/cms/wizards/start.html index ea5140390f8..b623eef3101 100755 --- a/cms/templates/cms/wizards/start.html +++ b/cms/templates/cms/wizards/start.html @@ -17,14 +17,7 @@

{% trans "Create" %}

{% endif %}
- {% for entry in form.entry %} - {% cms_wizard entry.choice_value as wizard %} - - {% endfor %} + {{ form.entry }}
diff --git a/cms/templates/cms/wizards/wizardoptionwidget.html b/cms/templates/cms/wizards/wizardoptionwidget.html new file mode 100644 index 00000000000..73fe7fae815 --- /dev/null +++ b/cms/templates/cms/wizards/wizardoptionwidget.html @@ -0,0 +1,8 @@ +{% for entry in form.entry %} + {% cms_wizard entry.choice_value as wizard %} + +{% endfor %} diff --git a/cms/templatetags/cms_tags.py b/cms/templatetags/cms_tags.py index 524e36e30e4..4fe61c79783 100644 --- a/cms/templatetags/cms_tags.py +++ b/cms/templatetags/cms_tags.py @@ -95,9 +95,10 @@ def _get_page_by_untyped_arg(page_lookup, request, site_id): if settings.DEBUG: raise Page.DoesNotExist(body) else: + mw = getattr(settings, 'MIDDLEWARE', getattr(settings, 'MIDDLEWARE_CLASSES')) if getattr(settings, 'SEND_BROKEN_LINK_EMAILS', False): mail_managers(subject, body, fail_silently=True) - elif 'django.middleware.common.BrokenLinkEmailsMiddleware' in settings.MIDDLEWARE_CLASSES: + elif 'django.middleware.common.BrokenLinkEmailsMiddleware' in mw: middle = BrokenLinkEmailsMiddleware() domain = request.get_host() path = request.get_full_path() diff --git a/cms/wizards/forms.py b/cms/wizards/forms.py index 72e37e65aa8..4e5b07deb70 100755 --- a/cms/wizards/forms.py +++ b/cms/wizards/forms.py @@ -43,6 +43,13 @@ def optional_fields(self): return [f for f in self.visible_fields() if not f.field.required] +class WizardOptionWidgets(forms.RadioSelect): + template_name = 'cms/wizards/wizardoptionwidget.html' + + def get_context(self, name, value, attrs): + return super(WizardOptionWidgets, self).get_context(name, value, attrs) + + class WizardStep1Form(BaseFormMixin, forms.Form): class Media: @@ -62,7 +69,7 @@ class Media: widget=forms.HiddenInput ) language = forms.CharField(widget=forms.HiddenInput) - entry = forms.ChoiceField(choices=[], widget=forms.RadioSelect()) + entry = forms.ChoiceField(choices=[], widget=WizardOptionWidgets()) def __init__(self, *args, **kwargs): super(WizardStep1Form, self).__init__(*args, **kwargs) diff --git a/manage.py b/manage.py index 53f41defdcb..f3842037873 100755 --- a/manage.py +++ b/manage.py @@ -94,16 +94,16 @@ def _get_migration_modules(apps): 'OPTIONS': { 'debug': True, 'context_processors': [ - "django.contrib.auth.context_processors.auth", + 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', - "django.template.context_processors.i18n", - "django.template.context_processors.debug", - "django.template.context_processors.request", - "django.template.context_processors.media", + 'django.template.context_processors.i18n', + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.template.context_processors.media', 'django.template.context_processors.csrf', - "cms.context_processors.cms_settings", - "sekizai.context_processors.sekizai", - "django.template.context_processors.static", + 'cms.context_processors.cms_settings', + 'sekizai.context_processors.sekizai', + 'django.template.context_processors.static', ], 'loaders': ( 'django.template.loaders.filesystem.Loader', From 5d84d1e57f8bb7775f5502bb95c79fb1a3bbf739 Mon Sep 17 00:00:00 2001 From: Iacopo Spalletti Date: Sat, 29 Apr 2017 18:00:47 +0200 Subject: [PATCH 4/4] Rewrite wizard form --- .../cms/wizards/wizardoptionwidget.html | 15 ++-- cms/templatetags/cms_wizard_tags.py | 23 +++---- cms/wizards/forms.py | 68 +++++++++++++++++-- cms/wizards/wizard_base.py | 13 ++++ 4 files changed, 93 insertions(+), 26 deletions(-) diff --git a/cms/templates/cms/wizards/wizardoptionwidget.html b/cms/templates/cms/wizards/wizardoptionwidget.html index 73fe7fae815..1c971cfb46b 100644 --- a/cms/templates/cms/wizards/wizardoptionwidget.html +++ b/cms/templates/cms/wizards/wizardoptionwidget.html @@ -1,8 +1,9 @@ -{% for entry in form.entry %} - {% cms_wizard entry.choice_value as wizard %} - +{% for group, entries, index in widget.optgroups %} + {% for entry in entries %} + + {% endfor %} {% endfor %} diff --git a/cms/templatetags/cms_wizard_tags.py b/cms/templatetags/cms_wizard_tags.py index 258a5f10508..227e90719fc 100644 --- a/cms/templatetags/cms_wizard_tags.py +++ b/cms/templatetags/cms_wizard_tags.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +import warnings from django import template @@ -11,6 +12,7 @@ register = template.Library() +@register.tag(name='cms_wizard') class WizardProperty(AsTag): name = 'cms_wizard' @@ -27,21 +29,12 @@ def get_value(self, context, wizard_id, property=None): identified by «wizard_id». If no «property», just return the entire wizard object. """ + warnings.warn( + "Templatetag cms_wizard will be removed in django CMS 3.5", + PendingDeprecationWarning + ) try: wizard = wizard_pool.get_entry(wizard_id) + return wizard.widget_attributes.get(property, wizard) except ValueError: - wizard = None - - if wizard: - if property in ['description', 'title', 'weight']: - # getters - getter = getattr(wizard, "get_{0}".format(property), None) - return getter() - elif property in ['id', 'form', 'model', 'template_name']: - # properties - return getattr(wizard, property, None) - else: - return wizard - return None - -register.tag(WizardProperty) + return None diff --git a/cms/wizards/forms.py b/cms/wizards/forms.py index 4e5b07deb70..5e782587412 100755 --- a/cms/wizards/forms.py +++ b/cms/wizards/forms.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- - from django import forms +from django.utils.html import format_html, force_text +from django.utils.safestring import mark_safe from cms.models import Page +from cms.utils.compat import DJANGO_1_10 from cms.utils.urlutils import static_with_version -from .wizard_pool import entry_choices +from .wizard_pool import entry_choices, wizard_pool def step2_form_factory(mixin_cls, entry_form_class, attrs=None): @@ -44,10 +46,68 @@ def optional_fields(self): class WizardOptionWidgets(forms.RadioSelect): + if DJANGO_1_10: + class WizardOptionRenderer(forms.widgets.RadioFieldRenderer): + class WizardOptionInput(forms.widgets.RadioChoiceInput): + + def __init__(self, name, value, attrs, choice, index): + super(WizardOptionWidgets.WizardOptionRenderer.WizardOptionInput, self).__init__( + name, value, attrs, choice, index + ) + try: + wizard = wizard_pool.get_entry(choice[0]) + self.label = force_text(choice[1]) + self.description = wizard.widget_attributes['description'] + except (ValueError, KeyError): + pass + + def __str__(self): + return self.render() + + def is_checked(self): + return self.index == 0 + + def render(self, name=None, value=None, attrs=None): + attrs = dict(self.attrs, **attrs) if attrs else self.attrs + return format_html( + '', **{ + 'tag': self.tag(attrs), 'label': self.label, 'description': self.description, + 'active_class': ' active' if self.is_checked() else '' + } + ) + + outer_html = '{content}' + inner_html = '{choice_value}{sub_widgets}' + choice_input_class = WizardOptionInput + + def render(self): + """ + Outputs a
    for this set of choice fields. + If an id was given to the field, it is applied to the
      (each + item in the list will get an id of `$id_$i`). + """ + id_ = self.attrs.get('id') + output = [] + for i, choice in enumerate(self.choices): + w = self.choice_input_class(self.name, self.value, self.attrs.copy(), choice, i) + output.append(format_html(self.inner_html, choice_value='', sub_widgets=w.render())) + return format_html( + self.outer_html, + id_attr=format_html(' id="{}"', id_) if id_ else '', + content=mark_safe('\n'.join(output)), + ) + + renderer = WizardOptionRenderer template_name = 'cms/wizards/wizardoptionwidget.html' - def get_context(self, name, value, attrs): - return super(WizardOptionWidgets, self).get_context(name, value, attrs) + def create_option(self, name, value, label, selected, index, subindex=None, attrs=None): + try: + wizard = wizard_pool.get_entry(value) + attrs.update(wizard.widget_attributes) + except ValueError: + pass + return super(WizardOptionWidgets, self).create_option(name, value, label, selected, index, subindex, attrs) class WizardStep1Form(BaseFormMixin, forms.Form): diff --git a/cms/wizards/wizard_base.py b/cms/wizards/wizard_base.py index 8ed4f078012..9bae9b6ab2b 100644 --- a/cms/wizards/wizard_base.py +++ b/cms/wizards/wizard_base.py @@ -5,6 +5,7 @@ from django.core.exceptions import ImproperlyConfigured from django.forms.models import ModelForm from django.utils.encoding import python_2_unicode_compatible +from django.utils.functional import cached_property from django.utils.translation import ( override as force_language, @@ -138,3 +139,15 @@ def get_model(self): return model raise ImproperlyConfigured(u"Please set entry 'model' attribute or use " u"ModelForm subclass as a form") + + @cached_property + def widget_attributes(self): + return { + 'description': self.get_description(), + 'title': self.get_title(), + 'weight': self.get_weight(), + 'id': self.id, + 'form': self.form, + 'model': self.model, + 'template_name': self.template_name + }