Repository: wagtail/wagtail

Issue:
Add a system check for incorrect use of content_panels on non-page models
Using `content_panels` instead of `panels` on non-page models (including snippets, ModelAdmin, wagtailsettings, and InlinePanel child models) seems to be a very common mistake:

http://stackoverflow.com/questions/38207208/add-a-multifieldpanel-in-django-wagtail-basesettings
https://groups.google.com/d/msg/wagtail/nqb24PZRlhk/oKkbmUXZAgAJ
https://github.com/torchbox/wagtail/issues/1456
https://groups.google.com/d/msg/wagtail/hs_mX0xOoyY/fr7ktSTRAAAJ
https://github.com/torchbox/wagtail/issues/1912

I suggest we add a system check to warn about models that set a `content_panels` attribute when a `panels` attribute is expected instead. (The solution would need to take care to avoid false positives, since it _is_ valid to set `content_panels` if the edit_handler property is overridden.)



Discussion:
@gasman I have started this and have this mostly working but am stuck with something.

I cannot get the checks to automatically run for `contrib.settings`:

* I added a new checks.py file: [`wagtail/contrib/settings/checks.py`](https://github.com/wagtail/wagtail/compare/master...lb-:2822_panel_usage_checks#diff-515aa016f646d57b4416d128b7464c55)
* I then imported the checks in `wagtail/contrib/settings/apps.py` via `from . import checks  # NOQA`
* I then installed the app in BakeryDemo, ran some testing against settings models that are incorrectly configured.
* The code in contrib/settings/checks.py is never called/run - even though I did a very similar method for snippets and it works fine.

Full Code: https://github.com/wagtail/wagtail/compare/master...lb-:2822_panel_usage_checks

Note: I am still working on the tests so I have not put in a PR yet.
@lb- Try merging in my commit from #4164. While working on that fix, I noticed that the appconf for `wagtail.contrib.settings` wasn't being pulled in via `wagtail/contrib/settings/__init__.py`, which would explain why code in apps.py isn't being run...
Thanks @gasman - the line : `default_app_config = 'wagtail.contrib.settings.apps.WagtailSettingsAppConfig'` in /contrib/settings/__init__.py did the trick.



Resolving patch:
diff --git a/wagtail/admin/checks.py b/wagtail/admin/checks.py
--- a/wagtail/admin/checks.py
+++ b/wagtail/admin/checks.py
@@ -70,3 +70,83 @@ def get_form_class_check(app_configs, **kwargs):
                 id='wagtailadmin.E002'))
 
     return errors
+
+
+@register('panels')
+def inline_panel_model_panels_check(app_configs, **kwargs):
+    from wagtail.core.models import get_page_models
+
+    errors = []
+    page_models = get_page_models()
+
+    for cls in page_models:
+        errors.extend(check_panels_in_model(cls))
+
+    # filter out duplicate errors found for the same model
+    unique_errors = []
+    for error in errors:
+        if error.msg not in [e.msg for e in unique_errors]:
+            unique_errors.append(error)
+    return unique_errors
+
+
+def check_panels_in_model(cls, context='model'):
+    """Check panels configuration uses `panels` when `edit_handler` not in use."""
+    from wagtail.core.models import Page
+    from wagtail.admin.edit_handlers import InlinePanel
+
+    errors = []
+
+    if hasattr(cls, 'get_edit_handler'):
+        # must check the InlinePanel related models
+        edit_handler = cls.get_edit_handler()
+        for tab in edit_handler.children:
+            inline_panel_children = [
+                panel for panel in tab.children if isinstance(panel, InlinePanel)]
+            for inline_panel_child in inline_panel_children:
+                errors.extend(check_panels_in_model(
+                    inline_panel_child.db_field.related_model,
+                    context='InlinePanel model',
+                ))
+
+    if issubclass(cls, Page) or hasattr(cls, 'edit_handler'):
+        # Pages do not need to be checked for standalone tabbed_panel usage
+        # if edit_handler is used on any model, assume config is correct
+        return errors
+
+    tabbed_panels = [
+        'content_panels',
+        'promote_panels',
+        'settings_panels',
+    ]
+
+    for panel_name in tabbed_panels:
+        class_name = cls.__name__
+        if not hasattr(cls, panel_name):
+            continue
+
+        panel_name_short = panel_name.replace('_panels', '').title()
+        error_title = "{}.{} will have no effect on {} editing".format(
+            class_name, panel_name, context)
+
+        if 'InlinePanel' in context:
+            error_hint = """Ensure that {} uses `panels` instead of `{}`.
+There are no tabs on non-Page model editing within InlinePanels.""".format(
+                class_name, panel_name)
+        else:
+            error_hint = """Ensure that {} uses `panels` instead of `{}`\
+or set up an `edit_handler` if you want a tabbed editing interface.
+There are no default tabs on non-Page models so there will be no \
+{} tab for the {} to render in.""".format(
+                class_name, panel_name, panel_name_short, panel_name)
+
+        error = Warning(
+            error_title,
+            hint=error_hint,
+            obj=cls,
+            id='wagtailadmin.W002'
+        )
+
+        errors.append(error)
+
+    return errors
diff --git a/wagtail/contrib/modeladmin/options.py b/wagtail/contrib/modeladmin/options.py
--- a/wagtail/contrib/modeladmin/options.py
+++ b/wagtail/contrib/modeladmin/options.py
@@ -1,9 +1,11 @@
 from django.conf.urls import url
 from django.contrib.auth.models import Permission
+from django.core import checks
 from django.core.exceptions import ImproperlyConfigured
 from django.db.models import Model
 from django.utils.safestring import mark_safe
 
+from wagtail.admin.checks import check_panels_in_model
 from wagtail.core import hooks
 from wagtail.core.models import Page
 
@@ -497,6 +499,14 @@ def modify_explorer_page_queryset(self, parent_page, queryset, request):
             queryset = queryset.not_type(self.model)
         return queryset
 
+    def register_with_wagtail(self):
+        super().register_with_wagtail()
+
+        @checks.register('panels')
+        def modeladmin_model_check(app_configs, **kwargs):
+            errors = check_panels_in_model(self.model, 'modeladmin')
+            return errors
+
 
 class ModelAdminGroup(WagtailRegisterable):
     """
@@ -584,6 +594,16 @@ def modify_explorer_page_queryset(self, parent_page, queryset, request):
                 parent_page, queryset, request)
         return queryset
 
+    def register_with_wagtail(self):
+        super().register_with_wagtail()
+
+        @checks.register('panels')
+        def modeladmin_model_check(app_configs, **kwargs):
+            errors = []
+            for modeladmin_class in self.items:
+                errors.extend(check_panels_in_model(modeladmin_class.model))
+            return errors
+
 
 def modeladmin_register(modeladmin_class):
     """
diff --git a/wagtail/snippets/models.py b/wagtail/snippets/models.py
--- a/wagtail/snippets/models.py
+++ b/wagtail/snippets/models.py
@@ -1,6 +1,8 @@
 from django.contrib.admin.utils import quote
+from django.core import checks
 from django.urls import reverse
 
+from wagtail.admin.checks import check_panels_in_model
 from wagtail.admin.utils import get_object_usage
 
 SNIPPET_MODELS = []
@@ -16,6 +18,12 @@ def register_snippet(model):
         model.usage_url = get_snippet_usage_url
         SNIPPET_MODELS.append(model)
         SNIPPET_MODELS.sort(key=lambda x: x._meta.verbose_name)
+
+        @checks.register('panels')
+        def modeladmin_model_check(app_configs, **kwargs):
+            errors = check_panels_in_model(model, 'snippets')
+            return errors
+
     return model
 
 


Test patch:
diff --git a/wagtail/admin/tests/test_edit_handlers.py b/wagtail/admin/tests/test_edit_handlers.py
--- a/wagtail/admin/tests/test_edit_handlers.py
+++ b/wagtail/admin/tests/test_edit_handlers.py
@@ -8,8 +8,9 @@
 from django.test import RequestFactory, TestCase, override_settings
 
 from wagtail.admin.edit_handlers import (
-    FieldPanel, FieldRowPanel, InlinePanel, ObjectList, PageChooserPanel, RichTextFieldPanel,
-    TabbedInterface, extract_panel_definitions_from_model_class, get_form_for_model)
+    FieldPanel, FieldRowPanel, InlinePanel, ObjectList, PageChooserPanel,
+    RichTextFieldPanel, TabbedInterface, extract_panel_definitions_from_model_class,
+    get_form_for_model)
 from wagtail.admin.forms import WagtailAdminModelForm, WagtailAdminPageForm
 from wagtail.admin.rich_text import DraftailRichTextArea
 from wagtail.admin.widgets import AdminAutoHeightTextInput, AdminDateInput, AdminPageChooser
@@ -17,7 +18,8 @@
 from wagtail.images.edit_handlers import ImageChooserPanel
 from wagtail.tests.testapp.forms import ValidatedPageForm
 from wagtail.tests.testapp.models import (
-    EventPage, EventPageChooserModel, EventPageSpeaker, PageChooserModel, SimplePage, ValidatedPage)
+    EventPage, EventPageChooserModel, EventPageSpeaker, PageChooserModel,
+    SimplePage, ValidatedPage)
 from wagtail.tests.utils import WagtailTestUtils
 
 
@@ -890,3 +892,84 @@ def test_invalid_inlinepanel_declaration(self):
         with self.ignore_deprecation_warnings():
             self.assertRaises(TypeError, lambda: InlinePanel(label="Speakers"))
             self.assertRaises(TypeError, lambda: InlinePanel(EventPage, 'speakers', label="Speakers", bacon="chunky"))
+
+
+class TestInlinePanelRelatedModelPanelConfigChecks(TestCase):
+
+    def setUp(self):
+        self.original_panels = EventPageSpeaker.panels
+        delattr(EventPageSpeaker, 'panels')
+
+        def get_checks_result():
+            # run checks only with the 'panels' tag
+            checks_result = checks.run_checks(tags=['panels'])
+            return [warning for warning in checks_result if warning.obj == EventPageSpeaker]
+
+        self.warning_id = 'wagtailadmin.W002'
+        self.get_checks_result = get_checks_result
+
+    def tearDown(self):
+        EventPageSpeaker.panels = self.original_panels
+
+    def test_page_with_inline_model_with_tabbed_panel_only(self):
+        """Test that checks will warn against setting single tabbed panel on InlinePanel model"""
+
+        EventPageSpeaker.settings_panels = [FieldPanel('first_name'), FieldPanel('last_name')]
+
+        warning = checks.Warning(
+            "EventPageSpeaker.settings_panels will have no effect on InlinePanel model editing",
+            hint="""Ensure that EventPageSpeaker uses `panels` instead of `settings_panels`.
+There are no tabs on non-Page model editing within InlinePanels.""",
+            obj=EventPageSpeaker,
+            id=self.warning_id,
+        )
+
+        checks_results = self.get_checks_result()
+
+        self.assertIn(warning, checks_results)
+
+        delattr(EventPageSpeaker, 'settings_panels')
+
+    def test_page_with_inline_model_with_two_tabbed_panels(self):
+        """Test that checks will warn against multiple tabbed panels on InlinePanel models"""
+
+        EventPageSpeaker.content_panels = [FieldPanel('first_name')]
+        EventPageSpeaker.promote_panels = [FieldPanel('last_name')]
+
+        warning_1 = checks.Warning(
+            "EventPageSpeaker.content_panels will have no effect on InlinePanel model editing",
+            hint="""Ensure that EventPageSpeaker uses `panels` instead of `content_panels`.
+There are no tabs on non-Page model editing within InlinePanels.""",
+            obj=EventPageSpeaker,
+            id=self.warning_id,
+        )
+        warning_2 = checks.Warning(
+            "EventPageSpeaker.promote_panels will have no effect on InlinePanel model editing",
+            hint="""Ensure that EventPageSpeaker uses `panels` instead of `promote_panels`.
+There are no tabs on non-Page model editing within InlinePanels.""",
+            obj=EventPageSpeaker,
+            id=self.warning_id,
+        )
+
+        checks_results = self.get_checks_result()
+
+        self.assertIn(warning_1, checks_results)
+        self.assertIn(warning_2, checks_results)
+
+        delattr(EventPageSpeaker, 'content_panels')
+        delattr(EventPageSpeaker, 'promote_panels')
+
+    def test_page_with_inline_model_with_edit_handler(self):
+        """Checks should NOT warn if InlinePanel models use tabbed panels AND edit_handler"""
+
+        EventPageSpeaker.content_panels = [FieldPanel('first_name')]
+        EventPageSpeaker.edit_handler = TabbedInterface(
+            ObjectList([FieldPanel('last_name')], heading='test')
+        )
+
+        # should not be any errors
+        self.assertEqual(self.get_checks_result(), [])
+
+        # clean up for future checks
+        delattr(EventPageSpeaker, 'edit_handler')
+        delattr(EventPageSpeaker, 'content_panels')
diff --git a/wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py b/wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py
--- a/wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py
+++ b/wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py
@@ -2,8 +2,10 @@
 
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import Group
+from django.core import checks
 from django.test import TestCase
 
+from wagtail.admin.edit_handlers import FieldPanel, TabbedInterface
 from wagtail.images.models import Image
 from wagtail.images.tests.utils import get_test_image_file
 from wagtail.tests.modeladmintest.models import Author, Book, Publisher, Token
@@ -515,3 +517,89 @@ def test_choose_inspect_model(self):
         position_of_header_close = content_str.index('</header>')
         position_of_breadcrumbs = content_str.index('<ul class="breadcrumb">')
         self.assertGreater(position_of_header_close, position_of_breadcrumbs)
+
+
+class TestPanelConfigurationChecks(TestCase, WagtailTestUtils):
+
+    def setUp(self):
+        self.warning_id = 'wagtailadmin.W002'
+
+        def get_checks_result():
+            # run checks only with the 'panels' tag
+            checks_result = checks.run_checks(tags=['panels'])
+            return [
+                warning for warning in
+                checks_result if warning.id == self.warning_id]
+
+        self.get_checks_result = get_checks_result
+
+
+    def test_model_with_single_tabbed_panel_only(self):
+
+        Publisher.content_panels = [FieldPanel('name'), FieldPanel('headquartered_in')]
+
+        warning = checks.Warning(
+            "Publisher.content_panels will have no effect on modeladmin editing",
+            hint="""Ensure that Publisher uses `panels` instead of `content_panels`\
+or set up an `edit_handler` if you want a tabbed editing interface.
+There are no default tabs on non-Page models so there will be no\
+ Content tab for the content_panels to render in.""",
+            obj=Publisher,
+            id='wagtailadmin.W002',
+        )
+
+        checks_results = self.get_checks_result()
+
+        self.assertIn(warning, checks_results)
+
+        # clean up for future checks
+        delattr(Publisher, 'content_panels')
+
+
+    def test_model_with_two_tabbed_panels_only(self):
+
+        Publisher.settings_panels = [FieldPanel('name')]
+        Publisher.promote_panels = [FieldPanel('headquartered_in')]
+
+
+        warning_1 = checks.Warning(
+            "Publisher.promote_panels will have no effect on modeladmin editing",
+            hint="""Ensure that Publisher uses `panels` instead of `promote_panels`\
+or set up an `edit_handler` if you want a tabbed editing interface.
+There are no default tabs on non-Page models so there will be no\
+ Promote tab for the promote_panels to render in.""",
+            obj=Publisher,
+            id='wagtailadmin.W002',
+        )
+
+        warning_2 = checks.Warning(
+            "Publisher.settings_panels will have no effect on modeladmin editing",
+            hint="""Ensure that Publisher uses `panels` instead of `settings_panels`\
+or set up an `edit_handler` if you want a tabbed editing interface.
+There are no default tabs on non-Page models so there will be no\
+ Settings tab for the settings_panels to render in.""",
+            obj=Publisher,
+            id='wagtailadmin.W002',
+        )
+
+        checks_results = self.get_checks_result()
+
+        self.assertIn(warning_1, checks_results)
+        self.assertIn(warning_2, checks_results)
+
+        # clean up for future checks
+        delattr(Publisher, 'settings_panels')
+        delattr(Publisher, 'promote_panels')
+
+
+    def test_model_with_single_tabbed_panel_and_edit_handler(self):
+
+        Publisher.content_panels = [FieldPanel('name'), FieldPanel('headquartered_in')]
+        Publisher.edit_handler = TabbedInterface(Publisher.content_panels)
+
+        # no errors should occur
+        self.assertEqual(self.get_checks_result(), [])
+
+        # clean up for future checks
+        delattr(Publisher, 'content_panels')
+        delattr(Publisher, 'edit_handler')
diff --git a/wagtail/snippets/tests.py b/wagtail/snippets/tests.py
--- a/wagtail/snippets/tests.py
+++ b/wagtail/snippets/tests.py
@@ -3,6 +3,7 @@
 from django.contrib.admin.utils import quote
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import AnonymousUser, Permission
+from django.core import checks
 from django.core.exceptions import ValidationError
 from django.core.files.base import ContentFile
 from django.core.files.uploadedfile import SimpleUploadedFile
@@ -11,6 +12,7 @@
 from django.urls import reverse
 from taggit.models import Tag
 
+from wagtail.admin.edit_handlers import FieldPanel
 from wagtail.admin.forms import WagtailAdminModelForm
 from wagtail.core.models import Page
 from wagtail.snippets.blocks import SnippetChooserBlock
@@ -1142,3 +1144,39 @@ def test_choose_a_page(self):
         response = self.get(pk=AdvertWithCustomUUIDPrimaryKey.objects.all()[0].pk)
         response_json = json.loads(response.content.decode())
         self.assertEqual(response_json['step'], 'chosen')
+
+
+class TestPanelConfigurationChecks(TestCase, WagtailTestUtils):
+
+    def setUp(self):
+        self.warning_id = 'wagtailadmin.W002'
+
+        def get_checks_result():
+            # run checks only with the 'panels' tag
+            checks_result = checks.run_checks(tags=['panels'])
+            return [
+                warning for warning in
+                checks_result if warning.id == self.warning_id]
+
+        self.get_checks_result = get_checks_result
+
+    def test_model_with_single_tabbed_panel_only(self):
+
+        StandardSnippet.content_panels = [FieldPanel('text')]
+
+        warning = checks.Warning(
+            "StandardSnippet.content_panels will have no effect on snippets editing",
+            hint="""Ensure that StandardSnippet uses `panels` instead of `content_panels`\
+or set up an `edit_handler` if you want a tabbed editing interface.
+There are no default tabs on non-Page models so there will be no\
+ Content tab for the content_panels to render in.""",
+            obj=StandardSnippet,
+            id='wagtailadmin.W002',
+        )
+
+        checks_results = self.get_checks_result()
+
+        self.assertEqual([warning], checks_results)
+
+        # clean up for future checks
+        delattr(StandardSnippet, 'content_panels')