Skip to content

Conversation

@fsbraun
Copy link
Member

@fsbraun fsbraun commented Oct 30, 2025

Description

Allow opening advances settings to change permissions, if a user has permissions to change permissions, even if advanced settings permissions are not given.

In this case the advanced settings form does not show any of the advanced settings but only the page permissions.

Closes a gap where users with the right to change page permissions but no rights to the advanced settings could not edit permissions.

Bug Fixes:

  • Improve modal JS to detect Django admin popup save responses via script tag detection

Enhancements:

  • Use named object_id parameters in page admin URLs
  • Render an empty form for users without advanced settings permission
  • Unify advanced settings and permission checks in the page tree rendering and toolbar menu
  • Fix popup variable naming and inline formset rendering in page change template
  • Append &_popup=1 to modal URLs in the toolbar

Tests:

  • Update toolbar frontend unit test to expect the &_popup=1 parameter in modal URLs

fixes #7825

needs test for ui

Related resources

Checklist

Summary by Sourcery

Add a unified page permissions menu by extending the advanced settings modal, enforce consistent permission checks across admin views, the toolbar, and page tree, and enhance modal popup handling to recognize successful saves and include the required popup flag.

New Features:

  • Add permissions tab to the page advanced settings modal

Bug Fixes:

  • Improve modal JavaScript to detect Django admin popup save responses via script tag detection

Enhancements:

  • Use named object_id parameters in page admin URLs
  • Render an empty settings form for users without advanced settings permission
  • Unify advanced settings and permission checks across the admin views, toolbar menu, and page tree
  • Append &_popup=1 to toolbar modal URLs and fix popup variable naming in templates

Tests:

  • Add backend tests for advanced settings endpoint permission enforcement
  • Add toolbar entry tests for advanced settings visibility
  • Update toolbar frontend unit test to expect the &_popup=1 parameter

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Oct 30, 2025

Reviewer's Guide

This PR adds a unified page permissions menu by extending the advanced settings view with a permission tab and enforcing consistent permission checks across admin view, page tree, and toolbar, refactors page admin URLs to use named object_id parameters, enhances modal popup JS to detect successful Django admin popup saves via script tags, and updates frontend and backend tests to expect the new &_popup=1 parameter.

Sequence diagram for modal popup save detection enhancement

sequenceDiagram
    participant User
    participant Modal
    participant DjangoAdmin
    User->>Modal: Submit form in modal
    Modal->>DjangoAdmin: Send form data
    DjangoAdmin-->>Modal: Return response (may include script#django-admin-popup-response-constants)
    Modal->>Modal: Check for script tag in response
    Modal->>User: Indicate successful save and close modal
Loading

Class diagram for updated PageAdmin permission checks

classDiagram
    class PageAdmin {
        +get_form(request, obj=None, **kwargs)
        +advanced(request, object_id)
        +get_tree_rows(request, pages, language, depth=1, follow_descendants=True)
        -has_change_advanced_settings_permission(request, obj)
        -has_change_permissions_permission(request, obj)
    }
    PageAdmin <|-- ModelAdmin
    class PagePermissions {
        +user_can_add_subpage(user, target)
        +user_can_change_page(user, page, site)
        +user_can_change_page_advanced_settings(user, page, site)
        +user_can_change_page_permissions(user, page, site)
    }
    PageAdmin --> PagePermissions
Loading

File-Level Changes

Change Details Files
Unified page permissions menu and advanced settings enforcement
  • Combined advanced settings and permissions checks in the advanced() view, raising PermissionDenied if neither applies
  • Rendered an empty form when the user lacks advanced settings permission
  • Updated show_permissions logic and dynamic modal title based on advanced vs. permissions scope
  • Extended page tree row rendering to treat permission editing as granting advanced settings rights
cms/admin/pageadmin.py
cms/templates/admin/cms/page/change_form.html
Refactor page admin URL patterns to named parameters
  • Replaced positional regex groups with named object_id in actions-menu, advanced-settings, and set-home URL patterns
cms/admin/pageadmin.py
Improve modal popup save detection
  • Enhanced cms.modal.js to mark saved and saveSuccess when detecting a django-admin-popup-response-constants script tag in the response
cms/static/cms/js/modules/cms.modal.js
Toolbar popup URL parameter and test updates
  • Appended &_popup=1 to modal URLs opened by the toolbar
  • Updated frontend unit test to expect the &_popup=1 parameter
  • Added backend tests for advanced settings endpoint permission levels and toolbar entry visibility
cms/static/cms/js/modules/cms.toolbar.js
cms/tests/frontend/unit/cms.toolbar.test.js
cms/tests/test_admin.py

Assessment against linked issues

Issue Objective Addressed Explanation
#7825 Allow users with only the 'change page permissions' permission (without 'advanced page permissions') to change the permissions of a page, as was possible in CMS3.
#7825 Ensure the UI (toolbar, advanced settings modal) presents the option to change page permissions to users with 'change page permissions' permission, even if they lack 'advanced page permissions'.
#7825 Unify and update permission checks and related code paths so that permission changes are accessible and consistent across admin views, toolbar, and page tree.

Possibly linked issues


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

@fsbraun fsbraun marked this pull request as draft October 30, 2025 06:42
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/admin/pageadmin.py:274` </location>
<code_context>
+            form._request = request
+        else:
+            # In case it's not the advanced view, return an empty form
+            class Meta:
+                model = Page
+                fields = []
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring by moving the EmptyPageForm class definition outside the get_form method and consolidating repeated permission checks into a helper function.

```python
# Move this out of get_form and only define it once per module
class EmptyPageForm(ModelForm):
    class Meta:
        model = Page
        fields = []

# -----------------------------
def get_form(self, request, obj=None, **kwargs):
    """Get PageForm or a no-op form if not in advanced settings."""
    if not getattr(request, '_advanced_settings', False):
        return EmptyPageForm
    FormClass = super().get_form(request, obj, **kwargs)
    FormClass._site = get_site(request)
    FormClass._request = request
    return FormClass

# -----------------------------
# Helper to collapse repeated permission checks
def _ensure_permission(self, request, obj, check_fn, message):
    if not check_fn(request, obj):
        raise PermissionDenied(message)

def change_permissions(self, request, page_id):
    page = self.get_object(request, object_id=page_id)
    if page is None:
        raise self._get_404_exception(page_id)

    # combine your two permission checks into one call
    self._ensure_permission(
        request,
        page,
        self.has_change_advanced_settings_permission,
        "No permission for editing advanced settings"
    )

    request._change_page_permissions = True
    show_permissions = (
        get_cms_setting('PERMISSION') and
        self.has_change_permissions_permission(request, obj=page)
    )
    context = {
        'title': _("Change Page Permissions"),
        'show_permissions': show_permissions,
    }
    return self.change_view(request, page_id, extra_context=context)
```
</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.

@codecov
Copy link

codecov bot commented Oct 31, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 89.77%. Comparing base (81729af) to head (c61323a).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #8387      +/-   ##
==========================================
+ Coverage   89.72%   89.77%   +0.05%     
==========================================
  Files         129      129              
  Lines       12749    12755       +6     
==========================================
+ Hits        11439    11451      +12     
+ Misses       1310     1304       -6     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@fsbraun fsbraun marked this pull request as ready for review October 31, 2025 15:53
@fsbraun fsbraun changed the title feat: Add page permission menu feat: Allow page permission change from advanced settings Oct 31, 2025
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 - here's some feedback:

  • The logic that combines advanced‐settings and permissions checks is duplicated across the admin view, toolbar, and tree rendering—consider extracting it into a single helper to keep behaviors consistent.
  • The dynamic EmptyForm creation in get_form is clever but a bit opaque—extract it into a well-named private method or inner class to make the fallback intent clearer.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The logic that combines advanced‐settings and permissions checks is duplicated across the admin view, toolbar, and tree rendering—consider extracting it into a single helper to keep behaviors consistent.
- The dynamic EmptyForm creation in get_form is clever but a bit opaque—extract it into a well-named private method or inner class to make the fallback intent clearer.

## Individual Comments

### Comment 1
<location> `cms/admin/pageadmin.py:307-315` </location>
<code_context>
             show_permissions = False
-        context = {"title": _("Advanced Settings"), "show_permissions": show_permissions}
+
+        show_advanced_settings = self.has_change_advanced_settings_permission(request, obj=page)
+
+        if not show_permissions and not show_advanced_settings:
+            raise PermissionDenied("No permission for editing advanced settings")
+
</code_context>

<issue_to_address>
**🚨 suggestion (security):** Permission check order may leak information about permissions.

Check if the page exists before performing permission checks to avoid revealing permission information for non-existent pages.

```suggestion
        if not page:
            from django.http import Http404
            raise Http404("Page does not exist")

        if get_cms_setting("PERMISSION"):
            show_permissions = self.has_change_permissions_permission(request, obj=page)
        else:
            show_permissions = False

        show_advanced_settings = self.has_change_advanced_settings_permission(request, obj=page)

        if not show_permissions and not show_advanced_settings:
            raise PermissionDenied("No permission for editing advanced settings")
```
</issue_to_address>

### Comment 2
<location> `cms/admin/pageadmin.py:1389-1390` </location>
<code_context>
                 "has_add_page_permission": user_can_add(user, target=page),
                 "has_change_permission": user_can_change(request.user, page, site),
-                "has_change_advanced_settings_permission": user_can_change_advanced(request.user, page, site),
+                "has_change_advanced_settings_permission": (
+                    user_can_change_advanced(request.user, page, site) or user_can_change_permissions(request.user, page, site)
+                ),
                 "has_move_page_permission": has_move_page_permission,
</code_context>

<issue_to_address>
**🚨 question (security):** Combining advanced settings and permissions checks may blur permission boundaries.

Please verify that expanding access via either advanced settings or permissions checks does not unintentionally allow users elevated privileges beyond your intended model.
</issue_to_address>

### Comment 3
<location> `cms/static/cms/js/modules/cms.modal.js:909-910` </location>
<code_context>
             // If the response contains the close-frame (and potentially the data bridge),
             // the form was saved successfully
-            that.saved = that.saved || body.hasClass('cms-close-frame');
+            that.saved = that.saved || body.hasClass('cms-close-frame')
+                || body.find('script#django-admin-popup-response-constants').length > 0;

             // tabindex is required for keyboard navigation
</code_context>

<issue_to_address>
**suggestion:** Checking for script tag may be fragile if admin markup changes.

Using a specific script tag as a success indicator may break if Django admin markup changes. Please evaluate if a more stable method exists.

Suggested implementation:

```javascript
            that.saved = that.saved || body.hasClass('cms-close-frame')
                || body.find('.messagelist :not(.error)').length > 0;

```

```javascript
            var saveSuccess = Boolean(contents.find('.messagelist :not(.error)').length) ||
                body.find('.messagelist :not(.error)').length > 0;

```
</issue_to_address>

### Comment 4
<location> `cms/static/cms/js/modules/cms.modal.js:944-945` </location>
<code_context>
             }

-            var saveSuccess = Boolean(contents.find('.messagelist :not(".error")').length);
+            var saveSuccess = Boolean(contents.find('.messagelist :not(".error")').length) ||
+                body.find('script#django-admin-popup-response-constants').length > 0;

             // in case message didn't appear, assume that admin page is actually a success
</code_context>

<issue_to_address>
**suggestion:** Duplicated logic for save success could be refactored.

Refactor the save success logic into a shared helper to avoid duplication and improve maintainability.

Suggested implementation:

```javascript
            function isSaveSuccess(contents, body) {
                return Boolean(contents.find('.messagelist :not(".error")').length) ||
                    body.find('script#django-admin-popup-response-constants').length > 0;
            }

            that.saved = that.saved || body.hasClass('cms-close-frame')
                || isSaveSuccess(contents, body);

```

```javascript
            var saveSuccess = isSaveSuccess(contents, body);

```
</issue_to_address>

### Comment 5
<location> `cms/tests/test_admin.py:873-882` </location>
<code_context>
+    @override_settings(CMS_PERMISSION=True)
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding tests for users with only permission to change permissions (not advanced settings).

Please add a test for users with only change permissions to verify they can access the advanced settings endpoint as intended.
</issue_to_address>

### Comment 6
<location> `cms/static/cms/js/modules/cms.modal.js:944-945` </location>
<code_context>

</code_context>

<issue_to_address>
**issue (code-quality):** Use `const` or `let` instead of `var`. ([`avoid-using-var`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/JavaScript/Default-Rules/avoid-using-var))

<details><summary>Explanation</summary>`const` is preferred as it ensures you cannot reassign references (which can lead to buggy and confusing code).
`let` may be used if you need to reassign references - it's preferred to `var` because it is block- rather than
function-scoped.

From the [Airbnb JavaScript Style Guide](https://airbnb.io/javascript/#references--prefer-const)
</details>
</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.

@fsbraun
Copy link
Member Author

fsbraun commented Nov 6, 2025

@jrief This approach is a bit different from your local solution. It also allows users with no permission for advanced settings to change page permissions from both the page tree and the toolbar in the frontend editor.

The advanced settings dialog only contains the settings if the user has the permissions for it, otherwise it only contains the permission settings.

On the plus side:

  • Less page settings menu (settings, advanced settings, no permission settings)
  • Reachable from the page tree

On the minus side:

  • You have to know permissions belong to the advanced settings (which is true for Django CMS 4)

What do you think?

@fsbraun fsbraun requested a review from jrief November 6, 2025 08:32
@fsbraun fsbraun added this to the 5.1 milestone Nov 6, 2025
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.

[BUG] Only users with advanced page permissions can change page permissions

2 participants