Skip to content

feat: Setting to block deletion of public versions#501

Open
fsbraun wants to merge 8 commits intomasterfrom
feat/protect-published
Open

feat: Setting to block deletion of public versions#501
fsbraun wants to merge 8 commits intomasterfrom
feat/protect-published

Conversation

@fsbraun
Copy link
Member

@fsbraun fsbraun commented Nov 17, 2025

Description

Add configurable deletion modes for versioned content to allow any deletions, block all deletions, or block only public version deletions; update deletion handlers, version string output, tests, and documentation accordingly.

New Features:

  • Add DJANGOCMS_VERSIONING_ALLOW_DELETING_VERSIONS options: none, any, and non-public-only deletion modes

Enhancements:

  • Implement PROTECT_IF_PUBLIC_VERSION handler and extend allow_deleting_versions to enforce the new deletion modes
  • Enhance Version.__str__ to include version state and associated content for clearer representation

Documentation:

  • Document the new deletion mode settings in the API configuration guide

Tests:

  • Add tests for non-public-only deletion mode to allow non-public deletions and protect public versions
  • Update existing deletion tests to use the new deletion mode constants

This is a first step to prevent accidental deletion of large page trees (see #490). Caveat: You still can delete an object that has a single version - independently if it's public or not.

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.

Summary by Sourcery

Introduce configurable deletion modes for versioned content, including protection for public versions, and improve version string representation.

New Features:

  • Add configurable deletion mode constants for version deletion, including a non-public-only mode that allows deleting only non-public versions.

Enhancements:

  • Route version deletion through a new handler that protects public versions while allowing safe deletions based on the configured mode.
  • Enhance the Version string representation to include its state and associated content when available, with a safe fallback if content cannot be stringified.

Documentation:

  • Document the new version deletion mode settings in the API configuration guide.

Tests:

  • Extend deletion tests to cover the non-public-only mode and update existing tests to use the new deletion mode constants.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Nov 17, 2025

Reviewer's Guide

Introduce configurable deletion modes for versioned content (none, any, non-public-only), wire them into the deletion handler and version string representation, and update tests and docs accordingly.

Class diagram for version deletion configuration and handlers

classDiagram
    class Version {
        +pk
        +state
        +object_id
        +__str__() str
        +verbose_name() str
    }

    class Constants {
        +VERSION_STATES
        +DELETE_NON_PUBLIC_ONLY
        +DELETE_ANY
        +DELETE_NONE
    }

    class DeletionHandlers {
        +PROTECT_IF_PUBLIC_VERSION(collector, field, sub_objs, using)
        +allow_deleting_versions(collector, field, sub_objs, using)
    }

    Version --> Constants : uses
    DeletionHandlers --> Constants : uses
    DeletionHandlers ..> Version : acts_on_versions
Loading

Flow diagram for allow_deleting_versions deletion modes

flowchart TD
    A[Start deletion of related versions] --> B[Read ALLOW_DELETING_VERSIONS setting]

    B -->|DELETE_NON_PUBLIC_ONLY| C[Filter sub_objs where state == PUBLISHED]
    B -->|DELETE_ANY or True| E[Set foreign key to NULL via SET_NULL]
    B -->|DELETE_NONE or other| F[Protect all via PROTECT]

    C -->|Public versions exist| D[Raise ProtectedError and block deletion]
    C -->|No public versions| E

    E --> G[Deletion of parent object continues]
    F --> H[Deletion of parent object blocked]
    D --> H
Loading

File-Level Changes

Change Details Files
Add configurable deletion modes for version deletions and wire them into the deletion handler logic.
  • Introduce DELETE_NON_PUBLIC_ONLY, DELETE_ANY, and DELETE_NONE constants to represent deletion modes.
  • Update allow_deleting_versions to interpret the new constants, including backwards-compatible handling of the legacy True value as DELETE_ANY.
  • Add PROTECT_IF_PUBLIC_VERSION handler that raises ProtectedError when any public versions exist and otherwise nulls references via SET_NULL.
djangocms_versioning/constants.py
djangocms_versioning/models.py
Enhance Version.str representation for better debugging and display resilience.
  • Extend Version.str to include the version state and associated content when possible.
  • Wrap content stringification in a try/except so failures fall back to a simpler representation without the content object.
  • Add tests covering normal and failing content str behavior to assert the new output format.
djangocms_versioning/models.py
tests/test_models.py
Align tests and documentation with the new deletion-mode constants and non-public-only behavior.
  • Replace boolean DJANGOCMS_VERSIONING_ALLOW_DELETING_VERSIONS overrides with constant-based overrides for DELETE_NONE and DELETE_ANY.
  • Add tests verifying that in DELETE_NON_PUBLIC_ONLY mode non-public versions can be deleted while public versions are protected via ProtectedError.
  • Stub or update README and settings documentation files to describe the new configuration options.
tests/test_settings.py
README.rst
docs/api/settings.rst

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

@codecov
Copy link

codecov bot commented Nov 17, 2025

Codecov Report

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

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #501      +/-   ##
==========================================
+ Coverage   90.55%   93.68%   +3.12%     
==========================================
  Files          72       76       +4     
  Lines        2732     2706      -26     
  Branches      322        0     -322     
==========================================
+ Hits         2474     2535      +61     
+ 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.

@fsbraun fsbraun marked this pull request as ready for review February 3, 2026 20:35
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 - I've found 4 issues, and left some high level feedback:

  • In PROTECT_IF_PUBLIC_VERSION, relying on sub_objs[0] inside the error message is a bit brittle and can diverge from public_exists (or even raise if sub_objs is empty); consider using public_exists.model / public_exists.first() or guarding against empty collections to make the error construction more robust.
  • The new Version.__str__ implementation catches a bare Exception, which can mask unexpected issues in content stringification; consider narrowing this to TypeError/AttributeError (or similar) and/or logging the failure before falling back to the simpler representation.
  • In test_version_str_failing, the manual monkeypatch of __str__ on the content class is restored only on the happy path; wrapping the patch/restore in a try/finally or using a context manager/fixture would avoid leaving the class in a modified state if the assertion fails.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `PROTECT_IF_PUBLIC_VERSION`, relying on `sub_objs[0]` inside the error message is a bit brittle and can diverge from `public_exists` (or even raise if `sub_objs` is empty); consider using `public_exists.model` / `public_exists.first()` or guarding against empty collections to make the error construction more robust.
- The new `Version.__str__` implementation catches a bare `Exception`, which can mask unexpected issues in content stringification; consider narrowing this to `TypeError`/`AttributeError` (or similar) and/or logging the failure before falling back to the simpler representation.
- In `test_version_str_failing`, the manual monkeypatch of `__str__` on the content class is restored only on the happy path; wrapping the patch/restore in a `try/finally` or using a context manager/fixture would avoid leaving the class in a modified state if the assertion fails.

## Individual Comments

### Comment 1
<location> `djangocms_versioning/models.py:39-40` </location>
<code_context>


+def PROTECT_IF_PUBLIC_VERSION(collector, field, sub_objs, using):
+    public_exists = sub_objs.filter(state=constants.PUBLISHED)
+    if public_exists:
+        # Any public objects?
+        raise models.ProtectedError(
</code_context>

<issue_to_address>
**issue (bug_risk):** The queryset truthiness check will raise in Django; use `.exists()` (or similar) instead.

Evaluating a QuerySet in a boolean context (`if public_exists:`) raises `ValueError: The truth value of a QuerySet is ambiguous`. Instead, assign the filtered queryset and call `.exists()` before raising:

```python
def PROTECT_IF_PUBLIC_VERSION(collector, field, sub_objs, using):
    public_qs = sub_objs.filter(state=constants.PUBLISHED)
    if public_qs.exists():
        raise models.ProtectedError(..., public_qs)
```

This keeps the behavior but avoids a runtime error on delete.
</issue_to_address>

### Comment 2
<location> `tests/test_settings.py:59-55` </location>
<code_context>
+    @override_settings(DJANGOCMS_VERSIONING_ALLOW_DELETING_VERSIONS=constants.DELETE_NON_PUBLIC_ONLY)
</code_context>

<issue_to_address>
**issue (testing):** This test deletes a published version while the setting name and PR description suggest only non-public deletions should be allowed.

In `test_deletion_non_public_possible`, `version1` is set to `PUBLISHED` before calling `version1.content.delete()`. This makes the test assert that deleting a *public* version is allowed under `DELETE_NON_PUBLIC_ONLY`, which conflicts with both the setting name and the PR description (“allow non-public deletions and protect public versions”). Please confirm the intent: if we want to verify that only non‑public versions can be deleted, the test should instead delete a non‑public object (e.g. `version2.content`, which remains a draft) or otherwise make clear that the deleted object is not public.
</issue_to_address>

### Comment 3
<location> `tests/test_models.py:246-257` </location>
<code_context>
+        expected_str = f"Version #{version.pk} (Draft) of {version.content}"
+        self.assertEqual(str(version), expected_str)
+
+    def test_version_str_failing(self):
+        def failing_str_method(self):
+            raise Exception("Cannot stringify")
+
+        version = factories.PollVersionFactory()
+        original_str_method = version.content.__class__.__str__
+        version.content.__class__.__str__ = failing_str_method
+
+        expected_str = f"Version #{version.pk} (Draft)"
+        self.assertEqual(str(version), expected_str)
+
+        version.content.__class__.__str__ = original_str_method
+

</code_context>

<issue_to_address>
**suggestion (bug_risk):** Use a try/finally or context manager to restore the patched __str__ method even if the test assertion fails.

In `test_version_str_failing`, if the assertion (or any earlier line) raises, the patched `__str__` is never restored and can affect other tests. Wrap the patch and assertion in a `try/finally` so the original method is always restored, or use a monkeypatch fixture that handles cleanup automatically:

```python
original_str_method = version.content.__class__.__str__
try:
    version.content.__class__.__str__ = failing_str_method
    expected_str = f"Version #{version.pk} (Draft)"
    self.assertEqual(str(version), expected_str)
finally:
    version.content.__class__.__str__ = original_str_method
```

```suggestion
    def test_version_str_failing(self):
        def failing_str_method(self):
            raise Exception("Cannot stringify")

        version = factories.PollVersionFactory()
        original_str_method = version.content.__class__.__str__
        try:
            version.content.__class__.__str__ = failing_str_method

            expected_str = f"Version #{version.pk} (Draft)"
            self.assertEqual(str(version), expected_str)
        finally:
            version.content.__class__.__str__ = original_str_method
```
</issue_to_address>

### Comment 4
<location> `docs/api/settings.rst:17` </location>
<code_context>
-    rights. Set this to ``True`` if you want users to be able to delete versioned
-    objects and you do not need a full history of versions, e.g. for documentation
-    purposes.
+    If set to ``constants.DELETE_ANY`` (``"any"``)) users can delete version objects
+    if the have the appropriate rights. Set this to ``"any"`` if you want users to be
+    able to delete versioned objects and you do not need a full history of versions,
</code_context>

<issue_to_address>
**issue (typo):** Remove the extra closing parenthesis after the "any" constant label.

It should read: `If set to ``constants.DELETE_ANY`` (``"any"``) users can...` with only one closing parenthesis.

```suggestion
    If set to ``constants.DELETE_ANY`` (``"any"``) users can delete version objects
```
</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.

@@ -55,3 +55,52 @@ def test_deletion_possible(self):
# try deleting and see if error is raised
Copy link
Contributor

Choose a reason for hiding this comment

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

issue (testing): This test deletes a published version while the setting name and PR description suggest only non-public deletions should be allowed.

In test_deletion_non_public_possible, version1 is set to PUBLISHED before calling version1.content.delete(). This makes the test assert that deleting a public version is allowed under DELETE_NON_PUBLIC_ONLY, which conflicts with both the setting name and the PR description (“allow non-public deletions and protect public versions”). Please confirm the intent: if we want to verify that only non‑public versions can be deleted, the test should instead delete a non‑public object (e.g. version2.content, which remains a draft) or otherwise make clear that the deleted object is not public.

fsbraun and others added 2 commits February 3, 2026 21:38
Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
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.

1 participant