Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ Next version
- Fixed the ordering calculation again.
- Added ``role="button"`` to plugin buttons to avoid link styles.
- Added testing using Django 6.0 and Python 3.14.
- Added a Django system check (with INFO severity) to detect non-abstract base
classes when creating plugins. The check does not warn about proxy models
since they are expected to inherit from non-abstract base classes.


8.0 (2025-08-25)
Expand Down
10 changes: 10 additions & 0 deletions content_editor/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.apps import AppConfig


class ContentEditorConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "content_editor"

def ready(self):
# Import checks to register them with Django's check framework
from content_editor import checks # noqa: F401, PLC0415
54 changes: 54 additions & 0 deletions content_editor/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from django.apps import apps
from django.core.checks import Info, register
from django.db import models

from content_editor.models import PluginBase


@register()
def check_plugin_bases(app_configs, **kwargs):
"""
Check for unexpected non-abstract base classes in plugin models.

This check helps identify potential issues where plugin models inherit from
non-abstract base classes (other than the expected PluginBase), which can
lead to unexpected database table structures and relationships.
"""
infos = []

# Get all models from the specified app_configs or all apps
if app_configs is None:
models_to_check = apps.get_models()
else:
models_to_check = []
for app_config in app_configs:
models_to_check.extend(app_config.get_models())

for model in models_to_check:
# Skip proxy models - they're expected to have non-abstract parents
if model._meta.proxy:
continue

# Check if this model inherits from PluginBase
if not issubclass(model, PluginBase):
continue

# Check for non-abstract base classes
non_abstract_bases = [
base
for base in model.__bases__
if (issubclass(base, models.Model) and not base._meta.abstract)
]

if non_abstract_bases:
infos.append(
Info(
f"Found unexpected non-abstract base classes when creating {model.__module__}.{model.__qualname__}",
hint=f"The following base classes are non-abstract: {', '.join(f'{base.__module__}.{base.__qualname__}' for base in non_abstract_bases)}. "
"Consider making them abstract by adding 'class Meta: abstract = True'.",
obj=model,
id="content_editor.I001",
)
)

return infos
30 changes: 21 additions & 9 deletions content_editor/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,25 @@ def __init__(self, **kwargs):
)


class PluginBase(models.Model):
"""
Abstract base class for content editor plugins.
This class is used by create_plugin_base() to create plugin base classes.
It serves as a marker to identify plugin models in system checks.
"""

class Meta:
abstract = True

def __str__(self):
return f"{self._meta.label}<region={self.region} ordering={self.ordering} pk={self.pk}>" # pragma: no cover

@classmethod
def get_queryset(cls):
return cls.objects.all()


def create_plugin_base(content_base):
"""
Create and return a base class for plugins
Expand All @@ -62,7 +81,7 @@ def create_plugin_base(content_base):
``region`` and ``ordering`` fields.
"""

class PluginBase(models.Model):
class PluginBaseImpl(PluginBase):
parent = models.ForeignKey(
content_base,
related_name="%(app_label)s_%(class)s_set",
Expand All @@ -76,11 +95,4 @@ class Meta:
app_label = content_base._meta.app_label
ordering = ["ordering"]

def __str__(self):
return f"{self._meta.label}<region={self.region} ordering={self.ordering} pk={self.pk}>" # pragma: no cover

@classmethod
def get_queryset(cls):
return cls.objects.all()

return PluginBase
return PluginBaseImpl
19 changes: 19 additions & 0 deletions tests/testapp/test_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from django.test.utils import isolate_apps

from content_editor.admin import ContentEditor, ContentEditorInline
from content_editor.checks import check_plugin_bases
from testapp.models import Article, RichText


Expand Down Expand Up @@ -79,3 +80,21 @@ class ArticleAdmin(ContentEditor):
id="content_editor.E003",
),
]


def test_plugin_base_checks():
"""Test that the check runs and doesn't error on existing models."""
# Run the check on all existing models - should not raise an error
# and should not produce any infos (all our test models are correctly structured)
infos = check_plugin_bases(app_configs=None)

# None of the existing testapp models should trigger the check
# because they all properly use abstract base classes
testapp_infos = [
info
for info in infos
if hasattr(info.obj, "_meta") and info.obj._meta.app_label == "testapp"
]
assert len(testapp_infos) == 0, (
f"Unexpected infos for testapp models: {testapp_infos}"
)