Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

fsbraun
Copy link
Member

@fsbraun fsbraun commented Sep 12, 2025

Description

Related resources

  • #...
  • #...

Checklist

Summary by Sourcery

Optimize placeholder and plugin utilities by adding caching layers, precomputing configurations, reducing redundant queries, and refactoring core utility functions to improve performance and reliability

New Features:

  • Add override_placeholder_conf context manager to refresh placeholder configuration cache in tests
  • Introduce DJANGO_TESTS environment variable in manage.py for conditional caching

Bug Fixes:

  • Detect and raise an error on circular inheritance in CMS_PLACEHOLDER_CONF during resolution

Enhancements:

  • Precompute and cache PLACEHOLDER_CONF into optimized lookup structures for faster get_placeholder_conf
  • Cache parsed placeholder templates in production to avoid repeated template rendering
  • Optimize rescan_placeholders_for_obj by preserving placeholder order and bulk-inserting new placeholders
  • Use aggregated database queries for plugin limit checks and streamline get_plugin_restrictions caching
  • Refactor nested plugin unpacking into a generator and cache plugin URL templates on CMSPlugin for repeated use
  • Simplify template_slot_caching decorator and reduce redundant plugin discovery calls in PluginPool

Tests:

  • Add tests for circular inheritance in placeholder configuration
  • Update tests to use override_placeholder_conf context manager and clear cached placeholder settings

Copy link
Contributor

sourcery-ai bot commented Sep 12, 2025

Reviewer's Guide

This PR optimizes placeholder and plugin utilities by caching placeholder configuration, simplifying scanning and traversal logic, introducing bulk placeholder creation, streamlining plugin class retrieval and restriction evaluation, and updating tests to use a new override context manager for placeholder settings.

File-Level Changes

Change Details Files
Cache and simplify placeholder configuration access
  • Introduce _get_placeholder_settings to preprocess and cache PLACEHOLDER_CONF
  • Replace get_placeholder_conf with lookup into precomputed settings and simplified key resolution
  • Add _clear_placeholder_conf_cache and override_placeholder_conf context manager for test isolation
cms/utils/placeholder.py
cms/test_utils/util/context_managers.py
cms/tests/test_placeholder.py
Refactor placeholder scanning functions and cache placeholder rendering
  • Merge static and dynamic placeholder scanning into get_placeholders returning DeclaredPlaceholder
  • Cache get_placeholders in production only to reflect template changes in development
  • Simplify cms_js_tags to iterate over rescan output values directly
cms/utils/placeholder.py
cms/templatetags/cms_js_tags.py
Bulk-create missing placeholders and preserve order in rescan
  • Initialize slot order from declared placeholders using OrderedDict
  • Bulk-create new placeholders when supported by DB, fallback to save for older MySQL
  • Prefetch empty CMSPlugin sets on newly created placeholders
cms/utils/placeholder.py
Optimize plugin restrictions cache initialization and usage
  • Build global_restrictions_cache only for placeholder keys with parent/child overrides
  • Defer discover_plugins sorting by module and name after autodiscovery
  • Aggregate plugin counts via Django ORM in has_reached_plugin_limit
  • Relocate get_restrictions_cache call and adjust only_uncached flag logic
cms/utils/plugins.py
cms/plugin_pool.py
Convert plugin unpacking to generators and streamline traversal
  • Change _unpack_plugins to a recursive generator with yield and yield from
  • Use yield from in get_plugins_to_render to flatten plugin lists
  • Remove redundant loops and list accumulation in plugin rendering
cms/plugin_rendering.py
Cache plugin_class on CMSPlugin and simplify retrieval
  • Add cached_property plugin_class to CMSPlugin
  • Replace repeated plugin_pool.get_plugin calls with plugin_class attribute
  • Adjust get_plugin_name, get_plugin_info, and media path logic accordingly
cms/models/pluginmodel.py
Simplify template_slot_caching decorator and child class filtering
  • Remove wrapper in template_slot_caching and mark methods directly
  • Eliminate only_uncached branch in get_child_classes
  • Simplify installed plugin candidate filtering
cms/plugin_base.py
Enable DJANGO_TESTS env var and adjust manage.py imports
  • Set DJANGO_TESTS default in manage.py for test detection
  • Reorder import of execute_from_command_line
  • Convert gettext definition to function
manage.py

Possibly linked issues

  • Cross Domain Request Error Β #1: The PR optimizes and re-implements the processing of PLACEHOLDER_CONF, which includes the retrieval and application of default_plugins, directly addressing the issue's reported removal of this functionality.
  • Cross Domain Request Error Β #1: The PR refactors the placeholder configuration lookup, which is expected to fix the bug where CMS_PLACEHOLDER_CONF was not applied correctly for non-page objects using get_template.

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

πŸ‘‹ Hi there!

Please remember to MERGE COMMIT pull requests from main!

Do not SQUASH commits to preserve history for the changelog.

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes and they look great!

Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments

### Comment 1
<location> `cms/utils/placeholder.py:62` </location>
<code_context>
+    conf = get_cms_setting("PLACEHOLDER_CONF")
+    template_in_conf = any(".htm" in key for key in conf if key)
+
+    def resolve_inheritance(key, visited=None):
+        if visited is None:
+            visited = set()
+        if key in visited:
+            raise ImproperlyConfigured(f"Circular inheritance detected in CMS_PLACEHOLDER_CONF at key '{key}'")
+        visited.add(key)
+        if "inherit" in conf[key]:
+            return resolve_inheritance(conf[key]['inherit'], visited) | {k: v for k, v in conf.items() if k != "inherit"}
+        return conf[key]
</code_context>

<issue_to_address>
The inheritance resolution logic may not correctly merge inherited configs.

Using set union (`|`) with dicts does not merge them as expected. Use `{**resolve_inheritance(...), **...}` or `dict.update()` for correct merging.
</issue_to_address>

### Comment 2
<location> `cms/plugin_base.py:752` </location>
<code_context>
+            # Override skips check if current class is valid parent of child classes
             return [plugin.__name__ for plugin in installed_plugins if plugin.__name__ in child_classes]

+        if only_uncached:
+            installed_plugins = [plugin for plugin in installed_plugins if not plugin.cache_parent_classes]
+
         child_classes = []
</code_context>

<issue_to_address>
Filtering for uncached plugins may not be compatible with all plugin pool usages.

Check that excluding plugins based on `cache_parent_classes` does not unintentionally omit required plugins in certain scenarios.

Suggested implementation:

```python
        if only_uncached:
            # Exclude plugins with cache_parent_classes only if they are not required
            def is_uncached_and_not_required(plugin):
                # If plugin has 'required_plugin' attribute and it's True, do not exclude
                if getattr(plugin, 'required_plugin', False):
                    return True
                return not plugin.cache_parent_classes
            installed_plugins = [plugin for plugin in installed_plugins if is_uncached_and_not_required(plugin)]

```

If your plugins do not have a `required_plugin` attribute, you should define what makes a plugin "required" in your context and adjust the check accordingly. You may need to update plugin classes to include this attribute or use another mechanism to mark required plugins.
</issue_to_address>

### Comment 3
<location> `cms/models/pluginmodel.py:214` </location>
<code_context>
         return _("<Empty>")

-    def get_plugin_class(self):
+    @cached_property
+    def plugin_class(self):
         from cms.plugin_pool import plugin_pool

</code_context>

<issue_to_address>
Using a cached property for plugin_class improves performance but may cause stale data if plugin_type changes.

If `plugin_type` is mutable, the cache may not reflect updates. Consider cache invalidation or switching to a regular property if this scenario is likely.
</issue_to_address>

### Comment 4
<location> `cms/models/pluginmodel.py:452` </location>
<code_context>
         This method replaces the set of legacy methods `get_add_url`, ``get_edit_url`, `get_move_url`,
         `get_delete_url`, `get_copy_url`.
         """
+        if not hasattr(CMSPlugin, '_edit_url'):
+            CMSPlugin._edit_url = admin_reverse('cms_placeholder_edit_plugin', args=(0,))
+            CMSPlugin._add_url = admin_reverse('cms_placeholder_add_plugin')
+            CMSPlugin._delete_url = admin_reverse('cms_placeholder_delete_plugin', args=(0,))
+            CMSPlugin._move_url = admin_reverse('cms_placeholder_move_plugin')
+            CMSPlugin._copy_url = admin_reverse('cms_placeholder_copy_plugins')
+
         if js_compat:
</code_context>

<issue_to_address>
Storing URLs as class attributes may cause issues in multi-site or multi-threaded environments.

If URLs vary by site or request, storing them as class attributes may result in incorrect values. Please verify that this approach is safe for all use cases.
</issue_to_address>

### Comment 5
<location> `cms/plugin_pool.py:259` </location>
<code_context>
         else:
             template = ""

-        if f"{template} {slot}" in self.global_restrictions_cache:
+        if template and f"{template} {slot}" in self.global_restrictions_cache:
             return self.global_restrictions_cache[f"{template} {slot}"]
         if template and template in self.global_restrictions_cache:
</code_context>

<issue_to_address>
Adding a template check before key lookup may change fallback behavior.

If `template` is falsy, the new condition prevents checking for keys like `' {slot}'` that may exist. Please verify this does not unintentionally alter fallback behavior.
</issue_to_address>

### Comment 6
<location> `cms/templatetags/cms_js_tags.py:37` </location>
<code_context>
 def render_cms_structure_js(context, renderer, obj):
     markup_bits = []
     obj_placeholders_by_slot = rescan_placeholders_for_obj(obj)
-    declared_placeholders = get_declared_placeholders_for_obj(obj)
     try:
         lang = context["request"].toolbar.request_language
     except AttributeError:
         lang = None

-    for placeholder_node in declared_placeholders:
-        obj_placeholder = obj_placeholders_by_slot.get(placeholder_node.slot)
-
</code_context>

<issue_to_address>
Iterating over obj_placeholders_by_slot.values() may change rendering order.

If rendering order matters, ensure the dictionary iteration matches the intended placeholder sequence.
</issue_to_address>

### Comment 7
<location> `cms/utils/placeholder.py:52` </location>
<code_context>
         return {}


+def _get_placeholder_settings():
+    """Convert CMS_PLACEHOLDER_CONF into a faster to access format since it is accessed many times
+    in each request response cycle:
</code_context>

<issue_to_address>
Consider replacing the upfront global cache and settings flattening with an on-demand, memoized lookup using a single function and lru_cache.

The new `_get_placeholder_settings` bit is a big abstraction and adds quite a bit of code (plus globals) just to do an occasional lookup. You can simplify it massively by in-lining the cache on your single lookup function, removing the globals entirely, and doing an on-demand, cached, single pass resolution:

```python
from functools import lru_cache

@lru_cache(maxsize=None)
def get_placeholder_conf(
    setting: str,
    placeholder: Optional[str],
    template: Optional[str] = None,
    default=None
):
    """
    Returns the placeholder config, supporting optional
    template-scoped keys and single-level inherit.
    """
    conf = get_cms_setting("PLACEHOLDER_CONF")
    # build the list of keys to check in order
    if template and any(".htm" in k for k in conf if k):
        keys = [f"{template} {placeholder}", placeholder, str(template), None]
    else:
        keys = [placeholder, None]

    # 1) try direct settings
    for key in keys:
        section = conf.get(key, {})
        if setting in section:
            return section[setting]

    # 2) fallback via inheritance
    for key in keys:
        section = conf.get(key, {})
        inherit = section.get("inherit")
        if not inherit:
            continue
        # allow "parentKey" or "None parentKey"
        if isinstance(inherit, str) and " " in inherit:
            tpl_key, ph_key = inherit.split(" ", 1)
        else:
            tpl_key, ph_key = None, inherit
        val = get_placeholder_conf(setting, ph_key, tpl_key, default)
        if val is not None:
            return val

    return default
```

What this gives you:

1) no global cache state or clear function  
2) no separate β€œflatten everything up front” pass  
3) inheritance in one place, directly in your lookup  
4) automatic memoization of every distinct (setting, placeholder, template) triplet

You can now delete `_get_placeholder_settings`, `_clear_placeholder_conf_cache`, and the module-level `_placeholder_settings` / `_template_in_conf`.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click πŸ‘ or πŸ‘Ž on each comment and I'll use the feedback to improve your reviews.

@@ -43,6 +49,52 @@ def get_context():
return {}


def _get_placeholder_settings():
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider replacing the upfront global cache and settings flattening with an on-demand, memoized lookup using a single function and lru_cache.

The new _get_placeholder_settings bit is a big abstraction and adds quite a bit of code (plus globals) just to do an occasional lookup. You can simplify it massively by in-lining the cache on your single lookup function, removing the globals entirely, and doing an on-demand, cached, single pass resolution:

from functools import lru_cache

@lru_cache(maxsize=None)
def get_placeholder_conf(
    setting: str,
    placeholder: Optional[str],
    template: Optional[str] = None,
    default=None
):
    """
    Returns the placeholder config, supporting optional
    template-scoped keys and single-level inherit.
    """
    conf = get_cms_setting("PLACEHOLDER_CONF")
    # build the list of keys to check in order
    if template and any(".htm" in k for k in conf if k):
        keys = [f"{template} {placeholder}", placeholder, str(template), None]
    else:
        keys = [placeholder, None]

    # 1) try direct settings
    for key in keys:
        section = conf.get(key, {})
        if setting in section:
            return section[setting]

    # 2) fallback via inheritance
    for key in keys:
        section = conf.get(key, {})
        inherit = section.get("inherit")
        if not inherit:
            continue
        # allow "parentKey" or "None parentKey"
        if isinstance(inherit, str) and " " in inherit:
            tpl_key, ph_key = inherit.split(" ", 1)
        else:
            tpl_key, ph_key = None, inherit
        val = get_placeholder_conf(setting, ph_key, tpl_key, default)
        if val is not None:
            return val

    return default

What this gives you:

  1. no global cache state or clear function
  2. no separate β€œflatten everything up front” pass
  3. inheritance in one place, directly in your lookup
  4. automatic memoization of every distinct (setting, placeholder, template) triplet

You can now delete _get_placeholder_settings, _clear_placeholder_conf_cache, and the module-level _placeholder_settings / _template_in_conf.

Comment on lines 678 to 687
self.assertEqual(force_str(placeholder_1.get_label()), "left column")
self.assertEqual(force_str(placeholder_2.get_label()), "renamed left column")
self.assertEqual(force_str(placeholder_3.get_label()), "fallback")

del TEST_CONF[None]

with self.settings(CMS_PLACEHOLDER_CONF=TEST_CONF):
with override_placeholder_conf(CMS_PLACEHOLDER_CONF=TEST_CONF):
self.assertEqual(force_str(placeholder_1.get_label()), "left column")
self.assertEqual(force_str(placeholder_2.get_label()), "renamed left column")
self.assertEqual(force_str(placeholder_3.get_label()), "No_Name")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): Extract duplicate code into method (extract-duplicate-method)

@@ -239,7 +240,7 @@ def test_excluded_plugin(self):
add_page_endpoint = self.get_page_add_uri("en")

# try to add a new text plugin
with self.settings(CMS_PLACEHOLDER_CONF=CMS_PLACEHOLDER_CONF):
with override_placeholder_conf(CMS_PLACEHOLDER_CONF=CMS_PLACEHOLDER_CONF):
page_data = self.get_new_page_data()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): Extract duplicate code into method (extract-duplicate-method)

def rescan_placeholders_for_obj(obj):
from cms.models import Placeholder
def rescan_placeholders_for_obj(obj: models.Model) -> dict[str, Placeholder]:
from cms.models import CMSPlugin, Placeholder
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): We've found these issues:

Copy link

πŸ‘‹ Hi there!

Please remember to MERGE COMMIT pull requests from main!

Do not SQUASH commits to preserve history for the changelog.

Copy link

πŸ‘‹ Hi there!

Please remember to MERGE COMMIT pull requests from main!

Do not SQUASH commits to preserve history for the changelog.

@fsbraun fsbraun merged commit 9375101 into release/5.0.x Sep 15, 2025
67 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant