Skip to content

Conversation

@fsbraun
Copy link
Member

@fsbraun fsbraun commented Dec 1, 2025

Description

Enable placeholders backed by published versions to expose metadata for creating a new draft when content is not directly editable.

New Features:

  • Expose new_draft label, method, and URL on placeholders whose content requires a new draft to be created before editing.

Tests:

  • Add test coverage for setting or omitting new_draft properties on placeholders across draft, published, unpublished, and archived version states.

Fixes django-cms/django-cms#8415

Related resources

Checklist

  • I have opened this pull request against master
  • I have added or modified the tests when changing logic
  • I have followed the conventional commits guidelines to add meaningful information into the changelog
  • I have read the contribution guidelines and I have joined #workgroup-pr-review on
    Slack to find a “pr review buddy” who is going to review my pull request.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Dec 1, 2025

Reviewer's Guide

Adds logic in is_content_editable to expose "new draft" metadata on placeholders for non-draft, editable versions, and introduces tests ensuring these properties are only set for published placeholders with edit-redirect enabled while keeping behavior unchanged for other states.

Sequence diagram for exposing new draft metadata on placeholders

sequenceDiagram
    actor Editor
    participant CMSView as CMSView
    participant Placeholder as Placeholder
    participant Helpers as is_content_editable
    participant Versionables as versionables
    participant VersionModel as Version
    participant UrlResolver as reverse

    Editor->>CMSView: Request published page
    CMSView->>Placeholder: Iterate placeholders
    CMSView->>Helpers: is_content_editable(placeholder, user)
    Helpers->>Versionables: for_content(placeholder.source)
    Versionables-->>Helpers: proxy_model
    Helpers->>VersionModel: get_for_content(placeholder.source)
    VersionModel-->>Helpers: version
    alt version.state is DRAFT
        Helpers-->>CMSView: True
        CMSView->>Placeholder: Render edit UI (no new draft button)
    else version.check_edit_redirect.as_bool(user) is True
        Helpers->>UrlResolver: reverse(admin:proxy_model_edit_redirect, version.pk)
        UrlResolver-->>Helpers: new_draft_url
        Helpers->>Placeholder: Set new_draft
        Helpers->>Placeholder: Set new_draft_method
        Helpers->>Placeholder: Set new_draft_url
        Helpers-->>CMSView: False
        CMSView->>Placeholder: Render toolbar with Create new draft button
    else other states or no redirect
        Helpers-->>CMSView: False
        CMSView->>Placeholder: Render without new draft button
    end
    CMSView-->>Editor: Response HTML
Loading

Class diagram for updated is_content_editable placeholder and version interactions

classDiagram
    class Placeholder {
        +object source
        +str new_draft
        +str new_draft_method
        +str new_draft_url
    }

    class Version {
        +int pk
        +str state
        +CheckEditRedirect check_edit_redirect
        +Version get_for_content(object content)
    }

    class CheckEditRedirect {
        +bool as_bool(object user)
    }

    class VersionablesRegistry {
        +Versionable for_content(object content)
    }

    class Versionable {
        +ProxyModel version_model_proxy
    }

    class ProxyModel {
        +Meta _meta
    }

    class Meta {
        +str app_label
        +str model_name
    }

    class Helpers {
        +bool is_content_editable(Placeholder placeholder, object user)
    }

    Placeholder --> Version : source_version_resolved_by
    Helpers --> Placeholder : reads_and_sets_metadata
    Helpers --> VersionablesRegistry : uses_for_content
    VersionablesRegistry --> Versionable : returns
    Versionable --> ProxyModel : provides_version_model_proxy
    ProxyModel --> Meta : has_meta
    Helpers --> Version : uses_get_for_content
    Version --> CheckEditRedirect : has_check_edit_redirect
    Helpers --> CheckEditRedirect : calls_as_bool
    Helpers --> ProxyModel : uses_for_admin_url_generation
Loading

File-Level Changes

Change Details Files
Extend is_content_editable to attach "new draft" button metadata to placeholders backed by versioned content when the version is not a draft but can be edited via an edit-redirect view.
  • Look up the version model proxy from the versionable configuration for the placeholder source to build admin URLs.
  • Short-circuit the function to immediately return True when the version state is DRAFT instead of falling through.
  • When the version is not a draft and check_edit_redirect returns truthy for the user, add new_draft, new_draft_method, and new_draft_url attributes to the placeholder, pointing at the model’s admin edit_redirect view.
  • Keep the final return value based on version.state == DRAFT so external callers still rely on the same boolean contract.
djangocms_versioning/helpers.py
Add tests to ensure new_draft metadata is correctly set only for published placeholders and not for archived, unpublished, or draft placeholders.
  • Introduce NewDraftPropertiesTestCase to cover behavior of is_content_editable for different version states (PUBLISHED, ARCHIVED, UNPUBLISHED, DRAFT).
  • Assert that published placeholders receive new_draft, new_draft_method, and new_draft_url attributes with expected values including the edit-redirect URL fragment.
  • Assert that placeholders for archived, unpublished, or draft versions do not receive any new_draft-related attributes, confirming state gating.
  • Reuse existing factories (PageVersionFactory, PlaceholderFactory) and superuser setup from CMSTestCase for consistent test fixtures.
tests/test_checks.py

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
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:

  • In is_content_editable, the draft-state check is now performed twice (early if version.state == DRAFT: return True and the final return version.state == DRAFT), so you can simplify this logic and make the return behavior more explicit around the new side-effectful block that sets new_draft attributes.
  • The four NewDraftPropertiesTestCase methods share very similar setup and assertions; consider extracting a small helper or using parametrization to reduce repetition and make the intent of the different state-dependent behaviors clearer.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `is_content_editable`, the draft-state check is now performed twice (early `if version.state == DRAFT: return True` and the final `return version.state == DRAFT`), so you can simplify this logic and make the return behavior more explicit around the new side-effectful block that sets `new_draft` attributes.
- The four `NewDraftPropertiesTestCase` methods share very similar setup and assertions; consider extracting a small helper or using parametrization to reduce repetition and make the intent of the different state-dependent behaviors clearer.

## Individual Comments

### Comment 1
<location> `djangocms_versioning/helpers.py:258-261` </location>
<code_context>
     """
     try:
-        versionables.for_content(placeholder.source)
+        proxy_model = versionables.for_content(placeholder.source).version_model_proxy
     except KeyError:
         return True
     from .models import Version

     version = Version.objects.get_for_content(placeholder.source)
+    if version.state == DRAFT:
+        return True
+    if version.check_edit_redirect.as_bool(user):
+        placeholder.new_draft = _("Create new draft to edit")
+        placeholder.new_draft_method = "cms-form-post-method"
+        placeholder.new_draft_url = reverse(
+            f"admin:{proxy_model._meta.app_label}_{proxy_model.__name__.lower()}_edit_redirect",
+            args=(version.pk,),
</code_context>

<issue_to_address>
**suggestion:** Prefer using `_meta.model_name` instead of `__name__.lower()` when building the admin URL.

Using `proxy_model._meta.model_name` is more robust, especially if the model customizes `object_name` or related internals, and it better reflects Django’s conventions. For example:

```python
placeholder.new_draft_url = reverse(
    f"admin:{proxy_model._meta.app_label}_{proxy_model._meta.model_name}_edit_redirect",
    args=(version.pk,),
)
```

```suggestion
        placeholder.new_draft_url = reverse(
            f"admin:{proxy_model._meta.app_label}_{proxy_model._meta.model_name}_edit_redirect",
            args=(version.pk,),
        )
```
</issue_to_address>

### Comment 2
<location> `tests/test_checks.py:55-64` </location>
<code_context>
+
+
+class NewDraftPropertiesTestCase(CMSTestCase):
+    def test_new_draft_properties_set_for_published_placeholder(self):
+        """Test that new_draft properties are set on published placeholders"""
+        user = self.get_superuser()
+        version = PageVersionFactory(state=PUBLISHED)
+        placeholder = PlaceholderFactory(source=version.content)
+
+        # Call is_content_editable which should set the new_draft properties
+        is_content_editable(placeholder, user)
+
+        # Check that new_draft properties are set
+        self.assertTrue(hasattr(placeholder, "new_draft"))
+        self.assertTrue(hasattr(placeholder, "new_draft_method"))
+        self.assertTrue(hasattr(placeholder, "new_draft_url"))
+        self.assertEqual(placeholder.new_draft_method, "cms-form-post-method")
+        self.assertIn("edit-redirect", placeholder.new_draft_url)
+
+    def test_new_draft_properties_not_set_for_archived_placeholder(self):
</code_context>

<issue_to_address>
**suggestion (testing):** Add a test case for when `check_edit_redirect.as_bool(user)` returns `False` to ensure no `new_draft` attributes are set in that branch.

Right now we only exercise the `True` branch of `check_edit_redirect.as_bool(user)` via the published case. Please add a test where it returns `False` for a published version and assert that:

- `is_content_editable` returns the expected value for that scenario, and
- `new_draft`, `new_draft_method`, and `new_draft_url` are not set on the placeholder.

This will cover both sides of the conditional and protect against regressions where `new_draft` attributes are incorrectly added when the redirect check fails.

Suggested implementation:

```python
class NewDraftPropertiesTestCase(CMSTestCase):
    def test_new_draft_properties_set_for_published_placeholder(self):
        """Test that new_draft properties are set on published placeholders"""
        user = self.get_superuser()
        version = PageVersionFactory(state=PUBLISHED)
        placeholder = PlaceholderFactory(source=version.content)

        # Call is_content_editable which should set the new_draft properties
        is_content_editable(placeholder, user)

        # Check that new_draft properties are set
        self.assertTrue(hasattr(placeholder, "new_draft"))
        self.assertTrue(hasattr(placeholder, "new_draft_method"))
        self.assertTrue(hasattr(placeholder, "new_draft_url"))
        self.assertEqual(placeholder.new_draft_method, "cms-form-post-method")
        self.assertIn("edit-redirect", placeholder.new_draft_url)

    def test_new_draft_properties_not_set_for_published_when_redirect_check_false(self):
        """
        When check_edit_redirect.as_bool(user) returns False for a published version,
        is_content_editable should not inject any new_draft attributes.
        """
        user = self.get_superuser()
        version = PageVersionFactory(state=PUBLISHED)
        placeholder = PlaceholderFactory(source=version.content)

        with patch("djangocms_versioning.checks.check_edit_redirect.as_bool", return_value=False):
            editable = is_content_editable(placeholder, user)

        # Assert the expected is_content_editable result for this scenario
        self.assertFalse(editable)

        # Check that new_draft properties are NOT set when redirect check is False
        self.assertFalse(hasattr(placeholder, "new_draft"))
        self.assertFalse(hasattr(placeholder, "new_draft_method"))
        self.assertFalse(hasattr(placeholder, "new_draft_url"))

    def test_new_draft_properties_not_set_for_archived_placeholder(self):
        """Test that new_draft properties are NOT set on archived placeholders"""
        user = self.get_superuser()
        version = PageVersionFactory(state=ARCHIVED)
        placeholder = PlaceholderFactory(source=version.content)

        # Call is_content_editable which should NOT set the new_draft properties for archived
        is_content_editable(placeholder, user)

        # Check that new_draft properties are NOT set
        self.assertFalse(hasattr(placeholder, "new_draft"))

```

1. Ensure `patch` is imported at the top of `tests/test_checks.py`, for example:
   `from unittest.mock import patch`.
2. The patch target string `"djangocms_versioning.checks.check_edit_redirect.as_bool"` assumes that:
   - `is_content_editable` is defined in `djangocms_versioning.checks`, and
   - `check_edit_redirect` is referenced from that module.
   If the actual module path differs, adjust the patch target to point to the module where
   `is_content_editable` is defined (i.e., patch where it is *used*, not necessarily where
   `check_edit_redirect` is originally defined).
3. If the expected return value of `is_content_editable` in the "redirect check False" scenario
   should be `True` in your implementation (instead of `False` as asserted above), update
   `self.assertFalse(editable)` accordingly to match the existing behavior.
</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 Dec 1, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.65%. Comparing base (a132204) to head (cb6c88a).
⚠️ Report is 30 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #503      +/-   ##
==========================================
+ Coverage   90.55%   93.65%   +3.10%     
==========================================
  Files          72       76       +4     
  Lines        2732     2697      -35     
  Branches      322        0     -322     
==========================================
+ Hits         2474     2526      +52     
+ Misses        182      171      -11     
+ Partials       76        0      -76     

☔ 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.

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
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] UX improvements needed for static_alias integration into structure board

2 participants