Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit a1c8c30

Browse filesBrowse files
feat: Allow plugins to restrict themselves to specific models (#8395)
* feat: Allow restricting plugins to certain models * fix: linting issues * Update cms/plugin_base.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * feat: auto lower-case valid models * docs: Add release note feature description * feat: Filter non-root plugins * fix: ruff issue * feat: Allow models to restrict plugin types * remove debug statement * feat: refactor for speed and consistency * fix: Update tests to reflect changes * chore: Remove merge issue * chore: Add tests * docs: Update docstrings * docs: Update for new features * docs: add references * chore: Add placeholder test * chore: Add tests for plugin restriction cache * chore: Update docs * fix tests --------- Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
1 parent 17b407a commit a1c8c30
Copy full SHA for a1c8c30
Expand file treeCollapse file tree

20 files changed

+1195
-213
lines changed
Open diff view settings
Collapse file

‎cms/plugin_base.py‎

Copy file name to clipboardExpand all lines: cms/plugin_base.py
+32-2Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,18 @@ def __new__(cls, name, bases, attrs):
8181

8282
if "get_extra_plugin_menu_items" in attrs:
8383
new_plugin._has_extra_plugin_menu_items = True
84+
85+
# Normalize allowed_models entries to lowercase (app_label.model)
86+
# to allow case-insensitive configuration such as "cms.PageContent".
87+
# ContentType.app_label and .model are lowercase, so we match that.
88+
allowed_models = getattr(new_plugin, "allowed_models", None)
89+
if allowed_models is not None:
90+
# Accept any iterable (list/tuple/set) or a single string
91+
if isinstance(allowed_models, (list, tuple, set)):
92+
new_plugin.allowed_models = [str(item).lower() for item in allowed_models]
93+
else:
94+
# Coerce single value into list
95+
new_plugin.allowed_models = [str(allowed_models).lower()]
8496
return new_plugin
8597

8698

@@ -174,8 +186,26 @@ class MyPlugin(CMSPluginBase):
174186
#: Set to ``True`` if this plugin should only be used in a placeholder that is attached to a django CMS page,
175187
#: and not other models with ``PlaceholderRelationFields``. See also: :attr:`child_classes`, :attr:`parent_classes`,
176188
#: :attr:`require_parent`.
189+
#:
190+
#: Deprecated: Use allowed_models attribute instead (e.g., `allowed_models = ["cms.pagecontent"]`)
177191
page_only = False
178192

193+
allowed_models = None
194+
"""Plugin-level restriction: A list of valid models where this plugin can be added.
195+
196+
Each entry must be the dotted path to the model in the format ``"app_label.modelname"``,
197+
e.g., ``["cms.pagecontent", "myapp.mymodel"]``.
198+
199+
- If ``None`` (default): The plugin can be added to any model that has placeholders.
200+
- If a list/tuple is provided: The plugin can only be added to models in this list.
201+
- If an empty list ``[]``: The plugin cannot be added to any model.
202+
203+
Note: This can be combined with the model's ``allowed_plugins`` attribute for fine-grained control.
204+
Both filters must pass for a plugin to be available on a model.
205+
206+
See also: Model's ``allowed_plugins`` attribute for model-level restrictions.
207+
"""
208+
179209
allow_children = False
180210
"""Allows this plugin to have child plugins - other plugins placed inside it?
181211
@@ -734,7 +764,7 @@ def get_child_plugin_candidates(cls, slot: str, page: Page | None = None) -> lis
734764
@template_slot_caching
735765
def get_child_classes(
736766
cls, slot, page: Page | None = None, instance: CMSPlugin | None = None, only_uncached: bool = False
737-
) -> list:
767+
) -> list[str]:
738768
"""
739769
Returns a list of plugin types that can be added
740770
as children to this plugin.
@@ -773,7 +803,7 @@ def get_child_classes(
773803

774804
@classmethod
775805
@template_slot_caching
776-
def get_parent_classes(cls, slot: str, page: Page | None = None, instance: CMSPlugin | None = None):
806+
def get_parent_classes(cls, slot: str, page: Page | None = None, instance: CMSPlugin | None = None) -> list[str]:
777807
from cms.utils.placeholder import get_placeholder_conf
778808

779809
template = cls._get_template_for_conf(page, instance)
Collapse file

‎cms/plugin_pool.py‎

Copy file name to clipboardExpand all lines: cms/plugin_pool.py
+61-26Lines changed: 61 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
from collections import defaultdict
2+
from functools import lru_cache
13
from operator import attrgetter
24

35
from django.core.exceptions import ImproperlyConfigured
6+
from django.db import models
47
from django.template import TemplateDoesNotExist, TemplateSyntaxError
58
from django.template.defaultfilters import slugify
69
from django.urls import URLResolver, include, re_path
@@ -10,26 +13,22 @@
1013
from django.utils.translation import activate, deactivate_all, get_language
1114

1215
from cms.exceptions import PluginAlreadyRegistered, PluginNotRegistered
13-
from cms.models.pagemodel import Page
16+
from cms.models.placeholdermodel import Placeholder
1417
from cms.plugin_base import CMSPluginBase
15-
from cms.utils.conf import get_cms_setting
1618
from cms.utils.helpers import normalize_name
1719

1820

1921
class PluginPool:
2022
def __init__(self):
2123
self.plugins = {}
24+
self.root_plugin_cache = {}
2225
self.discovered = False
23-
self.global_restrictions_cache = {
24-
# Initialize the global restrictions cache for each CMS_PLACEHOLDER_CONF
25-
# granularity that contains "parent_classes" or "child_classes" overwrites
26-
None: {},
27-
**{key: {} for key, value in get_cms_setting("PLACEHOLDER_CONF").items()
28-
if "parent_classes" in value or "child_classes" in value},
29-
}
26+
self.global_restrictions_cache = defaultdict(dict)
3027
self.global_template_restrictions = any(".htm" in (key or "") for key in self.global_restrictions_cache)
3128

3229
def _clear_cached(self):
30+
self.root_plugin_cache = {}
31+
self.get_all_plugins_for_model.cache_clear()
3332
if "registered_plugins" in self.__dict__:
3433
del self.__dict__["registered_plugins"]
3534
if "plugins_with_extra_menu" in self.__dict__:
@@ -118,9 +117,9 @@ def register_plugin(self, plugin):
118117
raise PluginAlreadyRegistered(
119118
f"Cannot register {plugin!r}, a plugin with this name ({plugin_name!r}) is already registered."
120119
)
121-
122120
plugin.value = plugin_name
123121
self.plugins[plugin_name] = plugin
122+
self._clear_cached()
124123
return plugin
125124

126125
def unregister_plugin(self, plugin):
@@ -133,13 +132,46 @@ def unregister_plugin(self, plugin):
133132
if plugin_name not in self.plugins:
134133
raise PluginNotRegistered("The plugin %r is not registered" % plugin)
135134
del self.plugins[plugin_name]
135+
self._clear_cached()
136+
137+
@lru_cache # noqa: B019
138+
def get_all_plugins_for_model(self, model: type[models.Model]) -> list[type[CMSPluginBase]]:
139+
"""
140+
Retrieve all plugins that can be used to edit the given model.
141+
142+
This method applies two levels of filtering:
143+
144+
1. Plugin-level filtering (allowed_models on plugin):
145+
- If a plugin has allowed_models defined, the model must be in that list
146+
- If allowed_models is None, the plugin is available for all models
147+
148+
2. Model-level filtering (allowed_plugins on model):
149+
- If the model has allowed_plugins defined, only those plugins are returned
150+
- If allowed_plugins is None, all plugins (passing filter 1) are returned
151+
- If allowed_plugins is an empty list [], no plugins are returned
152+
153+
Args:
154+
model: The Django model class to get plugins for
155+
156+
Returns:
157+
List of plugin classes that can be used with this model
158+
"""
159+
obj_type = f"{model._meta.app_label}.{model._meta.model_name}" if model else "None"
160+
assert obj_type != "cms.page"
161+
obj_allowed_plugins = getattr(model, "allowed_plugins", None)
162+
# Filters for allowed_models
163+
plugins = (plugin for plugin in self.plugins.values() if not plugin.allowed_models or obj_type in plugin.allowed_models)
164+
# Filters for allowed_plugins
165+
if obj_allowed_plugins is not None:
166+
plugins = (plugin for plugin in plugins if plugin.__name__ in obj_allowed_plugins)
167+
return list(plugins)
136168

137169
def get_all_plugins(
138-
self, placeholder=None, page=None, setting_key="plugins", include_page_only=True, root_plugin=True
170+
self, placeholder=None, page=None, setting_key="plugins", include_page_only=True, root_plugin=False
139171
):
140172
from cms.utils.placeholder import get_placeholder_conf
141173

142-
plugins = self.plugins.values()
174+
plugins = self.get_all_plugins_for_model(page.__class__) if page else self.plugins.values()
143175
template = (
144176
lazy(page.get_template, str)() if page and hasattr(page, "get_template") else None
145177
) # Make template lazy to avoid unnecessary db access
@@ -161,11 +193,6 @@ def get_all_plugins(
161193
or ()
162194
)
163195

164-
if not include_page_only:
165-
# Filters out any plugin marked as page only because
166-
# the include_page_only flag has been set to False
167-
plugins = (plugin for plugin in plugins if not plugin.page_only)
168-
169196
if allowed_plugins:
170197
# Check that plugins are in the list of the allowed ones
171198
plugins = (plugin for plugin in plugins if plugin.__name__ in allowed_plugins)
@@ -179,6 +206,13 @@ def get_all_plugins(
179206
plugins = (plugin for plugin in plugins if not plugin.requires_parent_plugin(placeholder, page))
180207
return plugins
181208

209+
def get_root_plugins(self, placeholder: Placeholder) -> list[type[CMSPluginBase]]:
210+
template = placeholder.source.get_template() if hasattr(placeholder.source, "get_template") else "None"
211+
key = f"{template}:{placeholder.slot}"
212+
if key not in self.root_plugin_cache:
213+
self.root_plugin_cache[key] =list(self.get_all_plugins(placeholder.slot, placeholder.source, root_plugin=True))
214+
return self.root_plugin_cache[key]
215+
182216
def get_text_enabled_plugins(self, placeholder, page) -> list[type[CMSPluginBase]]:
183217
plugins = set(self.get_all_plugins(placeholder, page, root_plugin=False))
184218
plugins.update(self.get_all_plugins(placeholder, page, setting_key="text_only_plugins", root_plugin=False))
@@ -228,7 +262,7 @@ def plugins_with_extra_placeholder_menu(self) -> list[type[CMSPluginBase]]:
228262
plugin_classes = [cls for cls in self.registered_plugins if cls._has_extra_placeholder_menu_items]
229263
return plugin_classes
230264

231-
def get_restrictions_cache(self, request_cache: dict, instance: CMSPluginBase, page: Page | None = None):
265+
def get_restrictions_cache(self, request_cache: dict, instance: CMSPluginBase, obj: models.Model) -> defaultdict[str, dict]:
232266
"""
233267
Retrieve the restrictions cache for a given plugin instance.
234268
@@ -249,21 +283,22 @@ def get_restrictions_cache(self, request_cache: dict, instance: CMSPluginBase, p
249283
dict: The restrictions cache for the given plugin instance - or the cache valid for the request.
250284
"""
251285
plugin_class = self.get_plugin(instance.plugin_type)
286+
object_class = f"{obj._meta.app_label}.{obj._meta.model_name}" if obj else ""
252287
if not self.can_cache_globally(plugin_class):
253288
return request_cache
254289
slot = instance.placeholder.slot
255290
if self.global_template_restrictions:
256-
template = plugin_class._get_template_for_conf(page)
291+
template = plugin_class._get_template_for_conf(obj) if obj else ""
257292
else:
258293
template = ""
259294

260-
if template and f"{template} {slot}" in self.global_restrictions_cache:
261-
return self.global_restrictions_cache[f"{template} {slot}"]
262-
if template and template in self.global_restrictions_cache:
263-
return self.global_restrictions_cache[template]
264-
if slot and slot in self.global_restrictions_cache:
265-
return self.global_restrictions_cache[slot]
266-
return self.global_restrictions_cache[None]
295+
if template and f"{object_class}:{template} {slot}" in self.global_restrictions_cache:
296+
return self.global_restrictions_cache[f"{object_class}:{template} {slot}"]
297+
if template and f"{object_class}:{template}" in self.global_restrictions_cache:
298+
return self.global_restrictions_cache[f"{object_class}:{template}"]
299+
if slot and f"{object_class}:{slot}" in self.global_restrictions_cache:
300+
return self.global_restrictions_cache[f"{object_class}:{slot}"]
301+
return self.global_restrictions_cache[object_class]
267302

268303
restriction_methods = ("get_require_parent", "get_child_class_overrides", "get_parent_classes")
269304

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.