diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 97e1f6ef07..de1effe130 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -14,6 +14,7 @@ Changelog * Added limit image upload size by number of pixels (Thomas Elliott) * Added `manage.py wagtail_update_index` alias to avoid clashes with `update_index` commands from other packages (Matt Westcott) * Renamed `target_model` argument on `PageChooserBlock` to `page_type` (Loic Teixeira) + * `edit_handler` and `panels` can now be defined on a `ModelAdmin` definition (Thomas Kremmel) * Fix: Set `SERVER_PORT` to 443 in `Page.dummy_request()` for HTTPS sites (Sergey Fedoseev) * Fix: Include port number in `Host` header of `Page.dummy_request()` (Sergey Fedoseev) * Fix: Validation error messages in `InlinePanel` no longer count towards `max_num` when disabling the 'add' button (Todd Dembrey, Thibaud Colas) diff --git a/CONTRIBUTORS.rst b/CONTRIBUTORS.rst index 4d11838d37..31a909bc9a 100644 --- a/CONTRIBUTORS.rst +++ b/CONTRIBUTORS.rst @@ -351,6 +351,7 @@ Contributors * Evan Winter * Neil Lyons * Gassan Gousseinov +* Thomas Kremmel Translators =========== diff --git a/docs/reference/contrib/modeladmin/create_edit_delete_views.rst b/docs/reference/contrib/modeladmin/create_edit_delete_views.rst index e7fc133af1..cc85baa8f0 100644 --- a/docs/reference/contrib/modeladmin/create_edit_delete_views.rst +++ b/docs/reference/contrib/modeladmin/create_edit_delete_views.rst @@ -58,6 +58,38 @@ Or alternatively: # or edit_handler = TabbedInterface([ObjectList(custom_panels), ObjectList(...)]) + +.. versionadded:: 2.5 + ``edit_handler`` and ``panels`` can alternatively be + defined on a ``ModelAdmin`` definition. This feature is especially useful + for use cases where you have to work with models that are + 'out of reach' (due to being part of a third-party package, for example). + +.. code-block:: python + + class BookAdmin(ModelAdmin): + model = Book + + panels = [ + FieldPanel('title'), + FieldPanel('author'), + ] + +Or alternatively: + + +.. code-block:: python + + class BookAdmin(ModelAdmin): + model = Book + + custom_panels = [ + FieldPanel('title'), + FieldPanel('author'), + ] + edit_handler = ObjectList(custom_panels) + + .. _modeladmin_form_view_extra_css: ----------------------------------- @@ -164,3 +196,17 @@ value will be passed to the edit form, so that any named fields will be excluded from the form. This is particularly useful when registering ModelAdmin classes for models from third-party apps, where defining panel configurations on the Model itself is more complicated. + + +----------------------------------- +``ModelAdmin.get_edit_handler()`` +----------------------------------- +.. versionadded:: 2.5 + +**Must return**: An instance of ``wagtail.admin.edit_handlers.ObjectList`` + +Returns the appropriate ``edit_handler`` for the modeladmin class. +``edit_handlers`` can be defined either on the model itself or on the +modeladmin (as property ``edit_handler`` or ``panels``). Falls back to +extracting panel / edit handler definitions from the model class. + diff --git a/docs/releases/2.5.rst b/docs/releases/2.5.rst index 083a8fba65..d47dfc2152 100644 --- a/docs/releases/2.5.rst +++ b/docs/releases/2.5.rst @@ -24,6 +24,7 @@ Other features * Added limit image upload size by number of pixels (Thomas Elliott) * Added ``manage.py wagtail_update_index`` alias to avoid clashes with ``update_index`` commands from other packages (Matt Westcott) * Renamed ``target_model`` argument on ``PageChooserBlock`` to ``page_type`` (Loic Teixeira) + * ``edit_handler`` and ``panels`` can now be defined on a ``ModelAdmin`` definition (Thomas Kremmel) Bug fixes diff --git a/wagtail/contrib/modeladmin/options.py b/wagtail/contrib/modeladmin/options.py index bf82ced59b..6c185afa12 100644 --- a/wagtail/contrib/modeladmin/options.py +++ b/wagtail/contrib/modeladmin/options.py @@ -6,6 +6,7 @@ from django.db.models import Model from django.utils.safestring import mark_safe from wagtail.admin.checks import check_panels_in_model +from wagtail.admin.edit_handlers import ObjectList, extract_panel_definitions_from_model_class from wagtail.core import hooks from wagtail.core.models import Page @@ -372,6 +373,29 @@ class ModelAdmin(WagtailRegisterable): view_class = self.delete_view_class return view_class.as_view(**kwargs)(request) + def get_edit_handler(self, instance, request): + """ + Returns the appropriate edit_handler for this modeladmin class. + edit_handlers can be defined either on the model itself or on the + modeladmin (as property edit_handler or panels). Falls back to + extracting panel / edit handler definitions from the model class. + """ + if hasattr(self, 'edit_handler'): + edit_handler = self.edit_handler + elif hasattr(self, 'panels'): + panels = self.panels + edit_handler = ObjectList(panels) + elif hasattr(self.model, 'edit_handler'): + edit_handler = self.model.edit_handler + elif hasattr(self.model, 'panels'): + panels = self.model.panels + edit_handler = ObjectList(panels) + else: + fields_to_exclude = self.get_form_fields_exclude(request=request) + panels = extract_panel_definitions_from_model_class(self.model, exclude=fields_to_exclude) + edit_handler = ObjectList(panels) + return edit_handler + def get_templates(self, action='index'): """ Utility funtion that provides a list of templates to try for a given diff --git a/wagtail/contrib/modeladmin/tests/test_modeladmin_edit_handlers.py b/wagtail/contrib/modeladmin/tests/test_modeladmin_edit_handlers.py new file mode 100644 index 0000000000..8f1efe8dd7 --- /dev/null +++ b/wagtail/contrib/modeladmin/tests/test_modeladmin_edit_handlers.py @@ -0,0 +1,131 @@ +from unittest import mock + +from django.test import RequestFactory, TestCase + +from wagtail.admin.edit_handlers import FieldPanel, ObjectList, TabbedInterface +from wagtail.contrib.modeladmin.views import CreateView +from wagtail.tests.modeladmintest.models import Person +from wagtail.tests.modeladmintest.wagtail_hooks import PersonAdmin +from wagtail.tests.utils import WagtailTestUtils + + +class TestExtractPanelDefinitionsFromModelAdmin(TestCase, WagtailTestUtils): + """tests that edit_handler and panels can be defined on modeladmin""" + + def setUp(self): + self.factory = RequestFactory() + self.user = self.create_test_user() + self.login(user=self.user) + + def test_model_edit_handler(self): + """loads the 'create' view and verifies that form fields are returned + which have been defined via model Person.edit_handler""" + response = self.client.get('/admin/modeladmintest/person/create/') + self.assertEqual( + [field_name for field_name in response.context['form'].fields], + ['first_name', 'last_name', 'phone_number'] + ) + + @mock.patch('wagtail.contrib.modeladmin.views.ModelFormView.get_edit_handler') + def test_model_form_view_edit_handler_called(self, mock_modelformview_get_edit_handler): + """loads the ``create`` view and verifies that modelformview edit_handler is called""" + self.client.get('/admin/modeladmintest/person/create/') + self.assertGreater(len(mock_modelformview_get_edit_handler.call_args_list), 0) + + @mock.patch('wagtail.contrib.modeladmin.options.ModelAdmin.get_edit_handler') + def test_model_admin_edit_handler_called(self, mock_modeladmin_get_edit_handler): + """loads the ``create`` view and verifies that modeladmin edit_handler is called""" + # constructing the request in order to be able to assert it + request = self.factory.get('/admin/modeladmintest/person/create/') + request.user = self.user + view = CreateView.as_view(model_admin=PersonAdmin()) + view(request) + + edit_handler_call = mock_modeladmin_get_edit_handler.call_args_list[0] + call_args, call_kwargs = edit_handler_call + # not using CreateView.get_instance since + # CreateView.get_instance always returns a new instance + self.assertEqual(type(call_kwargs['instance']), Person) + self.assertEqual(call_kwargs['request'], request) + + def test_model_panels(self): + """loads the 'create' view and verifies that form fields are returned + which have been defined via model Friend.panels""" + response = self.client.get('/admin/modeladmintest/friend/create/') + self.assertEqual( + [field_name for field_name in response.context['form'].fields], + ['first_name', 'phone_number'] + ) + + def test_model_admin_edit_handler(self): + """loads the 'create' view and verifies that form fields are returned + which have been defined via model VisitorAdmin.edit_handler""" + response = self.client.get('/admin/modeladmintest/visitor/create/') + self.assertEqual( + [field_name for field_name in response.context['form'].fields], + ['last_name', 'phone_number', 'address'] + ) + + def test_model_admin_panels(self): + """loads the 'create' view and verifies that form fields are returned + which have been defined via model ContributorAdmin.panels""" + response = self.client.get('/admin/modeladmintest/contributor/create/') + self.assertEqual( + [field_name for field_name in response.context['form'].fields], + ['last_name', 'phone_number', 'address'] + ) + + def test_model_admin_panel_edit_handler_priority(self): + """verifies that model admin panels are preferred over model panels""" + # check if Person panel or edit_handler definition is used for + # form creation, since PersonAdmin has neither panels nor an + # edit_handler defined + model_admin = PersonAdmin() + edit_handler = model_admin.get_edit_handler(None, None) + edit_handler = edit_handler.bind_to(model=model_admin.model) + form_class = edit_handler.get_form_class() + form = form_class() + self.assertEqual( + [field_name for field_name in form.fields], + ['first_name', 'last_name', 'phone_number'] + ) + + # now add a panel definition to the PersonAdmin and verify that + # panel definition from PersonAdmin is used to construct the form + # and NOT the panel or edit_handler definition from the Person model + model_admin = PersonAdmin() + model_admin.panels = [ + FieldPanel('last_name'), + FieldPanel('phone_number'), + FieldPanel('address'), + ] + edit_handler = model_admin.get_edit_handler(None, None) + edit_handler = edit_handler.bind_to(model=model_admin.model) + form_class = edit_handler.get_form_class() + form = form_class() + self.assertEqual( + [field_name for field_name in form.fields], + ['last_name', 'phone_number', 'address'] + ) + + # now add a edit_handler definition to the PersonAdmin and verify that + # edit_handler definition from PersonAdmin is used to construct the + # form and NOT the panel or edit_handler definition from the + # Person model + model_admin = PersonAdmin() + model_admin.edit_handler = TabbedInterface([ + ObjectList( + [ + FieldPanel('phone_number'), + FieldPanel('address'), + ] + ), + ]) + edit_handler = model_admin.get_edit_handler(None, None) + edit_handler = edit_handler.bind_to(model=model_admin.model) + form_class = edit_handler.get_form_class() + form = form_class() + self.assertEqual( + [field_name for field_name in form.fields], + ['phone_number', 'address'] + ) diff --git a/wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py b/wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py index 6b753c4539..c23e471609 100644 --- a/wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py +++ b/wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py @@ -187,7 +187,7 @@ class TestCreateView(TestCase, WagtailTestUtils): def test_exclude_passed_to_extract_panel_definitions(self): path_to_form_fields_exclude_property = 'wagtail.contrib.modeladmin.options.ModelAdmin.form_fields_exclude' - with mock.patch('wagtail.contrib.modeladmin.views.extract_panel_definitions_from_model_class') as m: + with mock.patch('wagtail.contrib.modeladmin.options.extract_panel_definitions_from_model_class') as m: with mock.patch(path_to_form_fields_exclude_property, new_callable=mock.PropertyMock) as mock_form_fields_exclude: mock_form_fields_exclude.return_value = ['123'] @@ -315,7 +315,7 @@ class TestEditView(TestCase, WagtailTestUtils): def test_exclude_passed_to_extract_panel_definitions(self): path_to_form_fields_exclude_property = 'wagtail.contrib.modeladmin.options.ModelAdmin.form_fields_exclude' - with mock.patch('wagtail.contrib.modeladmin.views.extract_panel_definitions_from_model_class') as m: + with mock.patch('wagtail.contrib.modeladmin.options.extract_panel_definitions_from_model_class') as m: with mock.patch(path_to_form_fields_exclude_property, new_callable=mock.PropertyMock) as mock_form_fields_exclude: mock_form_fields_exclude.return_value = ['123'] diff --git a/wagtail/contrib/modeladmin/views.py b/wagtail/contrib/modeladmin/views.py index 33e1b4f1a1..8ae5e1e311 100644 --- a/wagtail/contrib/modeladmin/views.py +++ b/wagtail/contrib/modeladmin/views.py @@ -28,7 +28,6 @@ from django.views.generic import TemplateView from django.views.generic.edit import FormView from wagtail.admin import messages -from wagtail.admin.edit_handlers import ObjectList, extract_panel_definitions_from_model_class from .forms import ParentChooserForm @@ -115,13 +114,10 @@ class WMABaseView(TemplateView): class ModelFormView(WMABaseView, FormView): def get_edit_handler(self): - if hasattr(self.model, 'edit_handler'): - edit_handler = self.model.edit_handler - else: - fields_to_exclude = self.model_admin.get_form_fields_exclude(request=self.request) - panels = extract_panel_definitions_from_model_class(self.model, exclude=fields_to_exclude) - edit_handler = ObjectList(panels) - return edit_handler.bind_to(model=self.model) + edit_handler = self.model_admin.get_edit_handler( + instance=self.get_instance(), request=self.request + ) + return edit_handler.bind_to(model=self.model_admin.model) def get_form_class(self): return self.get_edit_handler().get_form_class() diff --git a/wagtail/tests/modeladmintest/migrations/0006_contributor_person_visitor.py b/wagtail/tests/modeladmintest/migrations/0006_contributor_person_visitor.py new file mode 100644 index 0000000000..8569533dac --- /dev/null +++ b/wagtail/tests/modeladmintest/migrations/0006_contributor_person_visitor.py @@ -0,0 +1,43 @@ +# Generated by Django 2.1.3 on 2018-11-14 15:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('modeladmintest', '0005_book_cover_image'), + ] + + operations = [ + migrations.CreateModel( + name='Contributor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=255)), + ('last_name', models.CharField(max_length=255)), + ('phone_number', models.CharField(max_length=255)), + ('address', models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name='Person', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=255)), + ('last_name', models.CharField(max_length=255)), + ('phone_number', models.CharField(max_length=255)), + ('address', models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name='Visitor', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=255)), + ('last_name', models.CharField(max_length=255)), + ('phone_number', models.CharField(max_length=255)), + ('address', models.CharField(max_length=255)), + ], + ), + ] diff --git a/wagtail/tests/modeladmintest/migrations/0007_friend.py b/wagtail/tests/modeladmintest/migrations/0007_friend.py new file mode 100644 index 0000000000..e12eb836eb --- /dev/null +++ b/wagtail/tests/modeladmintest/migrations/0007_friend.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1.3 on 2018-12-18 12:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('modeladmintest', '0006_contributor_person_visitor'), + ] + + operations = [ + migrations.CreateModel( + name='Friend', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('first_name', models.CharField(max_length=255)), + ('last_name', models.CharField(max_length=255)), + ('phone_number', models.CharField(max_length=255)), + ('address', models.CharField(max_length=255)), + ], + ), + ] diff --git a/wagtail/tests/modeladmintest/models.py b/wagtail/tests/modeladmintest/models.py index 1817cfc01d..0f7e2a1650 100644 --- a/wagtail/tests/modeladmintest/models.py +++ b/wagtail/tests/modeladmintest/models.py @@ -1,5 +1,6 @@ from django.db import models +from wagtail.admin.edit_handlers import FieldPanel, ObjectList, TabbedInterface from wagtail.core.models import Page from wagtail.search import index @@ -46,3 +47,61 @@ class Publisher(models.Model): class VenuePage(Page): address = models.CharField(max_length=300) capacity = models.IntegerField() + + +class Visitor(models.Model): + """model used to test modeladmin.edit_handler usage in get_edit_handler""" + first_name = models.CharField(max_length=255) + last_name = models.CharField(max_length=255) + phone_number = models.CharField(max_length=255) + address = models.CharField(max_length=255) + + def __str__(self): + return self.first_name + + +class Contributor(models.Model): + """model used to test modeladmin.panels usage in get_edit_handler""" + first_name = models.CharField(max_length=255) + last_name = models.CharField(max_length=255) + phone_number = models.CharField(max_length=255) + address = models.CharField(max_length=255) + + def __str__(self): + return self.first_name + + +class Person(models.Model): + """model used to test model.edit_handlers usage in get_edit_handler""" + first_name = models.CharField(max_length=255) + last_name = models.CharField(max_length=255) + phone_number = models.CharField(max_length=255) + address = models.CharField(max_length=255) + + panels = [ + FieldPanel('first_name'), + FieldPanel('last_name'), + FieldPanel('phone_number'), + ] + edit_handler = TabbedInterface([ + ObjectList(panels), + ]) + + def __str__(self): + return self.first_name + + +class Friend(models.Model): + """model used to test model.panels usage in get_edit_handler""" + first_name = models.CharField(max_length=255) + last_name = models.CharField(max_length=255) + phone_number = models.CharField(max_length=255) + address = models.CharField(max_length=255) + + panels = [ + FieldPanel('first_name'), + FieldPanel('phone_number'), + ] + + def __str__(self): + return self.first_name diff --git a/wagtail/tests/modeladmintest/wagtail_hooks.py b/wagtail/tests/modeladmintest/wagtail_hooks.py index 736ad5b194..0e16c88372 100644 --- a/wagtail/tests/modeladmintest/wagtail_hooks.py +++ b/wagtail/tests/modeladmintest/wagtail_hooks.py @@ -1,10 +1,11 @@ +from wagtail.admin.edit_handlers import FieldPanel, ObjectList, TabbedInterface from wagtail.contrib.modeladmin.options import ( ModelAdmin, ModelAdminGroup, ThumbnailMixin, modeladmin_register) from wagtail.contrib.modeladmin.views import CreateView from wagtail.tests.testapp.models import BusinessChild, EventPage, SingleEventPage from .forms import PublisherModelAdminForm -from .models import Author, Book, Publisher, Token, VenuePage +from .models import Author, Book, Contributor, Friend, Person, Publisher, Token, VenuePage, Visitor class AuthorModelAdmin(ModelAdmin): @@ -91,6 +92,37 @@ class VenuePageAdmin(ModelAdmin): exclude_from_explorer = True +class PersonAdmin(ModelAdmin): + model = Person + + +class FriendAdmin(ModelAdmin): + model = Friend + + +class VisitorAdmin(ModelAdmin): + model = Visitor + + panels = [ + FieldPanel('last_name'), + FieldPanel('phone_number'), + FieldPanel('address'), + ] + edit_handler = TabbedInterface([ + ObjectList(panels), + ]) + + +class ContributorAdmin(ModelAdmin): + model = Contributor + + panels = [ + FieldPanel('last_name'), + FieldPanel('phone_number'), + FieldPanel('address'), + ] + + class EventsAdminGroup(ModelAdminGroup): menu_label = "Events" items = (EventPageAdmin, SingleEventPageAdmin, VenuePageAdmin) @@ -109,3 +141,7 @@ modeladmin_register(TokenModelAdmin) modeladmin_register(PublisherModelAdmin) modeladmin_register(EventsAdminGroup) modeladmin_register(BusinessChildAdmin) +modeladmin_register(PersonAdmin) +modeladmin_register(FriendAdmin) +modeladmin_register(VisitorAdmin) +modeladmin_register(ContributorAdmin)