Skip to content

Commit d68445a

Browse files
committed
Emit warnings when using non-abstract base classes when creating plugins
1 parent b3af54c commit d68445a

File tree

5 files changed

+107
-9
lines changed

5 files changed

+107
-9
lines changed

CHANGELOG.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ Next version
1111
- Fixed the ordering calculation again.
1212
- Added ``role="button"`` to plugin buttons to avoid link styles.
1313
- Added testing using Django 6.0 and Python 3.14.
14+
- Added a Django system check (with INFO severity) to detect non-abstract base
15+
classes when creating plugins. The check does not warn about proxy models
16+
since they are expected to inherit from non-abstract base classes.
1417

1518

1619
8.0 (2025-08-25)

content_editor/apps.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from django.apps import AppConfig
2+
3+
4+
class ContentEditorConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "content_editor"
7+
8+
def ready(self):
9+
# Import checks to register them with Django's check framework
10+
from content_editor import checks # noqa: F401, PLC0415

content_editor/checks.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
from django.apps import apps
2+
from django.core.checks import Info, register
3+
from django.db import models
4+
5+
from content_editor.models import PluginBase
6+
7+
8+
@register()
9+
def check_plugin_bases(app_configs, **kwargs):
10+
"""
11+
Check for unexpected non-abstract base classes in plugin models.
12+
13+
This check helps identify potential issues where plugin models inherit from
14+
non-abstract base classes (other than the expected PluginBase), which can
15+
lead to unexpected database table structures and relationships.
16+
"""
17+
infos = []
18+
19+
# Get all models from the specified app_configs or all apps
20+
if app_configs is None:
21+
models_to_check = apps.get_models()
22+
else:
23+
models_to_check = []
24+
for app_config in app_configs:
25+
models_to_check.extend(app_config.get_models())
26+
27+
for model in models_to_check:
28+
# Skip proxy models - they're expected to have non-abstract parents
29+
if model._meta.proxy:
30+
continue
31+
32+
# Check if this model inherits from PluginBase
33+
if not issubclass(model, PluginBase):
34+
continue
35+
36+
# Check for non-abstract base classes
37+
non_abstract_bases = [
38+
base
39+
for base in model.__bases__
40+
if (issubclass(base, models.Model) and not base._meta.abstract)
41+
]
42+
43+
if non_abstract_bases:
44+
infos.append(
45+
Info(
46+
f"Found unexpected non-abstract base classes when creating {model.__module__}.{model.__qualname__}",
47+
hint=f"The following base classes are non-abstract: {', '.join(f'{base.__module__}.{base.__qualname__}' for base in non_abstract_bases)}. "
48+
"Consider making them abstract by adding 'class Meta: abstract = True'.",
49+
obj=model,
50+
id="content_editor.I001",
51+
)
52+
)
53+
54+
return infos

content_editor/models.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,25 @@ def __init__(self, **kwargs):
5454
)
5555

5656

57+
class PluginBase(models.Model):
58+
"""
59+
Abstract base class for content editor plugins.
60+
61+
This class is used by create_plugin_base() to create plugin base classes.
62+
It serves as a marker to identify plugin models in system checks.
63+
"""
64+
65+
class Meta:
66+
abstract = True
67+
68+
def __str__(self):
69+
return f"{self._meta.label}<region={self.region} ordering={self.ordering} pk={self.pk}>" # pragma: no cover
70+
71+
@classmethod
72+
def get_queryset(cls):
73+
return cls.objects.all()
74+
75+
5776
def create_plugin_base(content_base):
5877
"""
5978
Create and return a base class for plugins
@@ -62,7 +81,7 @@ def create_plugin_base(content_base):
6281
``region`` and ``ordering`` fields.
6382
"""
6483

65-
class PluginBase(models.Model):
84+
class PluginBaseImpl(PluginBase):
6685
parent = models.ForeignKey(
6786
content_base,
6887
related_name="%(app_label)s_%(class)s_set",
@@ -76,11 +95,4 @@ class Meta:
7695
app_label = content_base._meta.app_label
7796
ordering = ["ordering"]
7897

79-
def __str__(self):
80-
return f"{self._meta.label}<region={self.region} ordering={self.ordering} pk={self.pk}>" # pragma: no cover
81-
82-
@classmethod
83-
def get_queryset(cls):
84-
return cls.objects.all()
85-
86-
return PluginBase
98+
return PluginBaseImpl

tests/testapp/test_checks.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from django.test.utils import isolate_apps
55

66
from content_editor.admin import ContentEditor, ContentEditorInline
7+
from content_editor.checks import check_plugin_bases
78
from testapp.models import Article, RichText
89

910

@@ -79,3 +80,21 @@ class ArticleAdmin(ContentEditor):
7980
id="content_editor.E003",
8081
),
8182
]
83+
84+
85+
def test_plugin_base_checks():
86+
"""Test that the check runs and doesn't error on existing models."""
87+
# Run the check on all existing models - should not raise an error
88+
# and should not produce any infos (all our test models are correctly structured)
89+
infos = check_plugin_bases(app_configs=None)
90+
91+
# None of the existing testapp models should trigger the check
92+
# because they all properly use abstract base classes
93+
testapp_infos = [
94+
info
95+
for info in infos
96+
if hasattr(info.obj, "_meta") and info.obj._meta.app_label == "testapp"
97+
]
98+
assert len(testapp_infos) == 0, (
99+
f"Unexpected infos for testapp models: {testapp_infos}"
100+
)

0 commit comments

Comments
 (0)