From 93a3e52c3435e40882f69835b599b509e0b275dc Mon Sep 17 00:00:00 2001 From: Jacob Rief Date: Tue, 5 Mar 2024 16:30:28 +0100 Subject: [PATCH 01/10] fix #363: Better UX in versioning listview (#364) Co-authored-by: Fabian Braun --- djangocms_versioning/admin.py | 3 ++ .../djangocms_versioning/js/versioning.js | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 djangocms_versioning/static/djangocms_versioning/js/versioning.js diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 16008576..bc73d4dc 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -608,6 +608,9 @@ class VersionAdmin(ChangeListActionsMixin, admin.ModelAdmin, metaclass=MediaDefi # def get_queryset(self, request): # return super().get_queryset(request).prefetch_related('content') + class Media: + js = ["djangocms_versioning/js/versioning.js"] + def get_changelist(self, request, **kwargs): return VersionChangeList diff --git a/djangocms_versioning/static/djangocms_versioning/js/versioning.js b/djangocms_versioning/static/djangocms_versioning/js/versioning.js new file mode 100644 index 00000000..0704f537 --- /dev/null +++ b/djangocms_versioning/static/djangocms_versioning/js/versioning.js @@ -0,0 +1,37 @@ +(function() { + var firstChecked, lastChecked; + + function handleVersionSelection(event) { + if (firstChecked instanceof HTMLInputElement && firstChecked.checked) { + firstChecked.checked = false; + firstChecked.closest('tr').classList.remove('selected'); + firstChecked = lastChecked; + } + if (event.target instanceof HTMLInputElement) { + if (event.target.checked) { + firstChecked = lastChecked; + lastChecked = event.target; + } else if (firstChecked === event.target) { + firstChecked = null; + } else { + lastChecked = null; + } + } + } + + document.addEventListener('DOMContentLoaded', function(){ + var selectedVersions = document.querySelectorAll('#result_list input[type="checkbox"].action-select'); + var selectElement = document.querySelector('#changelist-form select[name="action"]'); + if (selectElement instanceof HTMLSelectElement) { + for (var i = 0; i < selectElement.options.length; i++) { + if (selectElement.options[i].value && selectElement.options[i].value !== 'compare_versions') { + // for future safety: do not restrict on two selected versions, since there might be other actions + return; + } + } + } + selectedVersions.forEach(function(selectedVersion){ + selectedVersion.addEventListener('change', handleVersionSelection); + }); + }); + })(); From 2d22c0d0f981d0680c6c8ca8f504079ec5b6886f Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 5 Mar 2024 16:41:27 +0100 Subject: [PATCH 02/10] fix: Several fixes for the versioning forms: #382, #383, #384 (#386) * fix #384: Unlock button in toolbar points onto DRAFT version * Fix #382, #383 --------- Co-authored-by: Jacob Rief --- djangocms_versioning/admin.py | 1 - djangocms_versioning/cms_toolbars.py | 4 ++-- .../djangocms_versioning/css/versioning.css | 3 --- .../js/admin/versioning-actions.js | 21 +++++++++++++++++++ .../admin/archive_confirmation.html | 21 +++++++++++-------- .../admin/revert_confirmation.html | 9 ++++---- .../admin/unpublish_confirmation.html | 5 +++-- 7 files changed, 43 insertions(+), 21 deletions(-) create mode 100644 djangocms_versioning/static/djangocms_versioning/js/admin/versioning-actions.js diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index bc73d4dc..834a247d 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -972,7 +972,6 @@ def publish_view(self, request, object_id): # Redirect to published? if conf.ON_PUBLISH_REDIRECT == "published": - redirect_url = None if hasattr(version.content, "get_absolute_url"): redirect_url = version.content.get_absolute_url() or redirect_url diff --git a/djangocms_versioning/cms_toolbars.py b/djangocms_versioning/cms_toolbars.py index c9d3838a..153f43ba 100644 --- a/djangocms_versioning/cms_toolbars.py +++ b/djangocms_versioning/cms_toolbars.py @@ -125,8 +125,8 @@ def _add_unlock_button(self): if LOCK_VERSIONS and self._is_versioned(): item = ButtonList(side=self.toolbar.RIGHT) proxy_model = self._get_proxy_model() - version = Version.objects.get_for_content(self.toolbar.obj) - if version.check_unlock.as_bool(self.request.user): + version = Version.objects.filter_by_content_grouping_values(self.toolbar.obj).filter(state=DRAFT).first() + if version and version.check_unlock.as_bool(self.request.user): unlock_url = reverse( f"admin:{proxy_model._meta.app_label}_{proxy_model.__name__.lower()}_unlock", args=(version.pk,), diff --git a/djangocms_versioning/static/djangocms_versioning/css/versioning.css b/djangocms_versioning/static/djangocms_versioning/css/versioning.css index f66b94a0..bc19b68b 100644 --- a/djangocms_versioning/static/djangocms_versioning/css/versioning.css +++ b/djangocms_versioning/static/djangocms_versioning/css/versioning.css @@ -207,6 +207,3 @@ ins.cms-diff img { .cms-select::-ms-expand { opacity: 0; } -input.button.revert-button { - margin: 5px; -} diff --git a/djangocms_versioning/static/djangocms_versioning/js/admin/versioning-actions.js b/djangocms_versioning/static/djangocms_versioning/js/admin/versioning-actions.js new file mode 100644 index 00000000..50edfa4c --- /dev/null +++ b/djangocms_versioning/static/djangocms_versioning/js/admin/versioning-actions.js @@ -0,0 +1,21 @@ +(function () { + "use strict"; + + function closeSideFrame() { + try { + window.top.CMS.API.Sideframe.close(); + } catch (err) {} + } + + document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('form.js-close-sideframe').forEach(el => { + el.addEventListener("submit", (ev) => { + ev.preventDefault(); + closeSideFrame(); + const form = window.top.document.body.appendChild(ev.target); + form.style.display = 'none'; + form.submit(); + }); + }); + }); +})(); diff --git a/djangocms_versioning/templates/djangocms_versioning/admin/archive_confirmation.html b/djangocms_versioning/templates/djangocms_versioning/admin/archive_confirmation.html index 40ab5cd3..4a70749c 100644 --- a/djangocms_versioning/templates/djangocms_versioning/admin/archive_confirmation.html +++ b/djangocms_versioning/templates/djangocms_versioning/admin/archive_confirmation.html @@ -6,6 +6,7 @@ {{ block.super }} {{ media }} + {% endblock %} {% block breadcrumbs %}{% endblock %} @@ -15,15 +16,17 @@

{% translate "Are you sure you want to archive the following version?" %}

{{ object_name }}

{% blocktrans %} Version number: {{ version_number }}{% endblocktrans %}

-
+ {% csrf_token %} - - - - +
+ + + + +
{% endblock %} diff --git a/djangocms_versioning/templates/djangocms_versioning/admin/revert_confirmation.html b/djangocms_versioning/templates/djangocms_versioning/admin/revert_confirmation.html index 3de7d687..7bbbb168 100644 --- a/djangocms_versioning/templates/djangocms_versioning/admin/revert_confirmation.html +++ b/djangocms_versioning/templates/djangocms_versioning/admin/revert_confirmation.html @@ -6,6 +6,7 @@ {{ media }} + {% endblock %} {% block breadcrumbs %}{% endblock %} @@ -22,20 +23,20 @@

{% block title %}{% translate "Revert Confirmation" %}{% endblock %}

{{ object_name }}

{% blocktrans %}Version number: {{ version_number }}{% endblocktrans %}

-
+ {% csrf_token %}
{% if draft_version %} - - {% else %} - diff --git a/djangocms_versioning/templates/djangocms_versioning/admin/unpublish_confirmation.html b/djangocms_versioning/templates/djangocms_versioning/admin/unpublish_confirmation.html index 7a87ab53..047dd547 100644 --- a/djangocms_versioning/templates/djangocms_versioning/admin/unpublish_confirmation.html +++ b/djangocms_versioning/templates/djangocms_versioning/admin/unpublish_confirmation.html @@ -5,6 +5,7 @@ {{ block.super }} {{ media }} + {% endblock %} {% block breadcrumbs %}{% endblock %} @@ -20,10 +21,10 @@

{% blocktrans %} Version number: {{ version_number }}{% endblocktrans %}

{{thing}}

{% endfor %}
- + {% csrf_token %}
- From 0f6a587ff3c957a1896c9b6dab307aaf887eb6e3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 7 Mar 2024 11:45:41 +0100 Subject: [PATCH 03/10] build(deps): bump github/codeql-action from 2 to 3 (#371) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 2 to 3. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v2...v3) --- updated-dependencies: - dependency-name: github/codeql-action dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Fabian Braun --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c30908d0..9f05d488 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -27,16 +27,16 @@ jobs: uses: actions/checkout@v4 - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v3 if: ${{ matrix.language == 'javascript' || matrix.language == 'python' }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 with: category: "/language:${{ matrix.language }}" From 9071ace3a943323e5ede538443f781dacd62b70a Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 7 Mar 2024 20:34:44 +0100 Subject: [PATCH 04/10] fix: For Django CMS 4.1.1 and later do not automatically register versioned CMS Menu (#388) * fix #384: Unlock button in toolbar points onto DRAFT version * Only by default register the versioning CMS Menu for django CMS <= 4.1.0 * Respect new ruff rule UP032 * Typos... * Ensure tests are covering djangocms-versioning's menu --------- Co-authored-by: Jacob Rief --- djangocms_versioning/admin.py | 5 +---- djangocms_versioning/conf.py | 3 ++- docs/settings.rst | 6 ++++-- test_settings.py | 1 + tests/test_admin.py | 32 ++++++++++++++------------------ 5 files changed, 22 insertions(+), 25 deletions(-) diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 834a247d..de47bc1a 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -1316,10 +1316,7 @@ def changelist_view(self, request, extra_context=None): # Check if custom breadcrumb template defined, otherwise # fallback on default breadcrumb_templates = [ - "admin/djangocms_versioning/{app_label}/{model_name}/versioning_breadcrumbs.html".format( - app_label=breadcrumb_opts.app_label, - model_name=breadcrumb_opts.model_name, - ), + f"admin/djangocms_versioning/{breadcrumb_opts.app_label}/{breadcrumb_opts.model_name}/versioning_breadcrumbs.html", "admin/djangocms_versioning/versioning_breadcrumbs.html", ] extra_context["breadcrumb_template"] = select_template(breadcrumb_templates) diff --git a/djangocms_versioning/conf.py b/djangocms_versioning/conf.py index 40030005..3f4728b6 100644 --- a/djangocms_versioning/conf.py +++ b/djangocms_versioning/conf.py @@ -1,7 +1,8 @@ +from cms import __version__ as CMS_VERSION from django.conf import settings ENABLE_MENU_REGISTRATION = getattr( - settings, "DJANGOCMS_VERSIONING_ENABLE_MENU_REGISTRATION", True + settings, "DJANGOCMS_VERSIONING_ENABLE_MENU_REGISTRATION", CMS_VERSION <= "4.1.0" ) USERNAME_FIELD = getattr( diff --git a/docs/settings.rst b/docs/settings.rst index 7f468aef..7269e21e 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -25,10 +25,12 @@ Settings for djangocms Versioning .. py:attribute:: DJANGOCMS_VERSIONING_ENABLE_MENU_REGISTRATION - Defaults to ``True`` + Defaults to ``True`` (for django CMS <= 4.1.0) and ``False`` + (for django CMS > 4.1.0) This settings specifies if djangocms-versioning should register its own - versioned CMS menu. + versioned CMS menu. This is necessary for CMS <= 4.1.0. For CMS > 4.1.0, the + django CMS core comes with a version-ready menu. The versioned CMS menu also shows draft content in edit and preview mode. diff --git a/test_settings.py b/test_settings.py index b7ba9a75..65950588 100644 --- a/test_settings.py +++ b/test_settings.py @@ -44,6 +44,7 @@ "PARLER_ENABLE_CACHING": False, "LANGUAGE_CODE": "en", "DEFAULT_AUTO_FIELD": "django.db.models.AutoField", + "DJANGOCMS_VERSIONING_ENABLE_MENU_REGISTRATION": True, "CMS_CONFIRM_VERSION4": True, } diff --git a/tests/test_admin.py b/tests/test_admin.py index 8ed65073..ead5a634 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -452,12 +452,11 @@ def test_content_link_for_editable_object_with_no_preview_url(self): version = factories.PageVersionFactory(content__title="test5") with patch.object(helpers, "is_editable_model", return_value=True): with override(version.content.language): + url = get_object_preview_url(version.content, language=version.content.language) + label = version.content self.assertEqual( self.site._registry[Version].content_link(version), - '{label}'.format( - url=get_object_preview_url(version.content, language=version.content.language), - label=version.content - ), + f'{label}', ) @@ -2371,9 +2370,7 @@ def test_changelist_view_displays_correct_breadcrumbs(self): expected = """""" self.assertEqual(str(breadcrumb_html), expected) @@ -2422,12 +2419,11 @@ def test_changelist_view_displays_correct_breadcrumbs_for_extra_grouping_values( breadcrumb_html = soup.find("div", class_="breadcrumbs") # Assert the breadcrumbs - we should have ignored the French one # and put the English one in the breadcrumbs + pk = page_content_en.pk expected = """""" self.assertEqual(str(breadcrumb_html), expected) @@ -2673,10 +2669,10 @@ def test_extended_version_change_list_display_renders_from_provided_list_display self.assertEqual(200, response.status_code) # Check list_display item is rendered - self.assertContains(response, '[TEST]{}'.format( - content.id, - content.text - )) + self.assertContains( + response, + f'[TEST]{content.text}' + ) # Check list_action links are rendered self.assertContains(response, "cms-action-btn") self.assertContains(response, "cms-action-preview") @@ -2901,10 +2897,10 @@ def test_extended_grouper_change_list_display_renders_from_provided_list_display # Check response is valid self.assertEqual(200, response.status_code) # Check list_display item is rendered - self.assertContains(response, '[TEST]{}'.format( - content.poll.id, - content.text - )) + self.assertContains( + response, + f'[TEST]{content.text}', + ) # Check list_action links are rendered self.assertContains(response, "cms-action-btn") self.assertContains(response, "cms-action-view") From 9c7ad78d75d251484be6954e904abbc0e0ffa19d Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 12 Mar 2024 15:32:41 +0100 Subject: [PATCH 05/10] feat: Add content object level publish permissions (#390) * Add permission check for publish and unpublish - delegate to content model if possible * Fix linting * Fix syntax error * Fix tests - still needs tests for version checking * Fix linting * Add docs. * Docs fixes * Make explicit that superusers must also be given permissions * Add change permission for archive and revert * Fix ruff * Fix: mess-up created by ide * Add tests for permissions including low-level permissions * fix linting issues --- djangocms_versioning/conditions.py | 14 + djangocms_versioning/conf.py | 2 +- djangocms_versioning/models.py | 58 +++- .../test_utils/blogpost/models.py | 12 + docs/index.rst | 1 + docs/permissions.rst | 94 +++++++ docs/settings.rst | 6 +- docs/static/blog-new.jpg | Bin 0 -> 27362 bytes docs/static/blog-original.jpg | Bin 0 -> 13122 bytes docs/versioning_integration.rst | 25 +- tests/test_admin.py | 40 +-- tests/test_integration_with_core.py | 17 +- tests/test_locking.py | 2 +- tests/test_permissions.py | 259 ++++++++++++++++++ tests/test_toolbars.py | 2 +- 15 files changed, 476 insertions(+), 56 deletions(-) create mode 100644 docs/permissions.rst create mode 100644 docs/static/blog-new.jpg create mode 100644 docs/static/blog-original.jpg create mode 100644 tests/test_permissions.py diff --git a/djangocms_versioning/conditions.py b/djangocms_versioning/conditions.py index c73a8c14..fe9c9012 100644 --- a/djangocms_versioning/conditions.py +++ b/djangocms_versioning/conditions.py @@ -76,3 +76,17 @@ def inner(version, user): else: raise ConditionFailed(message) return inner + +def user_can_publish(message: str) -> callable: + def inner(version, user): + if not version.has_publish_permission(user): + raise ConditionFailed(message) + return inner + + +def user_can_change(message: str) -> callable: + def inner(version, user): + if not version.has_change_permission(user): + raise ConditionFailed(message) + return inner + diff --git a/djangocms_versioning/conf.py b/djangocms_versioning/conf.py index 3f4728b6..1188780e 100644 --- a/djangocms_versioning/conf.py +++ b/djangocms_versioning/conf.py @@ -32,4 +32,4 @@ ON_PUBLISH_REDIRECT = getattr( settings, "DJANGOCMS_VERISONING_ON_PUBLISH_REDIRECT", "published" ) -# Allowed values: "versions", "published", "preview" +#: Allowed values: "versions", "published", "preview" diff --git a/djangocms_versioning/models.py b/djangocms_versioning/models.py index 4f3c2689..f6347008 100644 --- a/djangocms_versioning/models.py +++ b/djangocms_versioning/models.py @@ -16,6 +16,8 @@ draft_is_not_locked, in_state, is_not_locked, + user_can_change, + user_can_publish, ) from .conf import ALLOW_DELETING_VERSIONS, LOCK_VERSIONS from .operations import send_post_version_operation, send_pre_version_operation @@ -29,7 +31,7 @@ not_draft_error = _("Version is not a draft") lock_error_message = _("Action Denied. The latest version is locked by {user}") lock_draft_error_message = _("Action Denied. The draft version is locked by {user}") - +permission_error_message = _("You do not have permission to perform this action") def allow_deleting_versions(collector, field, sub_objs, using): if ALLOW_DELETING_VERSIONS: @@ -257,7 +259,7 @@ def copy(self, created_by): Allows customization of how the content object will be copied when specified in cms_config.py - This method needs to be ran in a transaction due to the fact that if + This method needs to be run in a transaction due to the fact that if models are partially created in the copy method a version is not attached. It needs to be that if anything goes wrong we should roll back the entire task. We shouldn't leave this to package developers to know to add this feature @@ -275,6 +277,7 @@ def copy(self, created_by): check_archive = Conditions( [ + user_can_change(permission_error_message), in_state([constants.DRAFT], _("Version is not in draft state")), is_not_locked(lock_error_message), ] @@ -324,7 +327,10 @@ def _set_archive(self, user): pass check_publish = Conditions( - [in_state([constants.DRAFT], _("Version is not in draft state"))] + [ + user_can_publish(permission_error_message), + in_state([constants.DRAFT], _("Version is not in draft state")), + ] ) def can_be_published(self): @@ -387,6 +393,7 @@ def _set_publish(self, user): pass check_unpublish = Conditions([ + user_can_publish(permission_error_message), in_state([constants.PUBLISHED], _("Version is not in published state")), draft_is_not_locked(lock_draft_error_message), ]) @@ -437,6 +444,50 @@ def _set_unpublish(self, user): possible to be left with inconsistent data)""" pass + def has_publish_permission(self, user) -> bool: + """ + Check if the given user has permission to publish. + + Args: + user (User): The user to check for permission. + + Returns: + bool: True if the user has publish permission, False otherwise. + """ + return self._has_permission("publish", user) + + def has_change_permission(self, user) -> bool: + """ + Check whether the given user has permission to change the object. + + Parameters: + user (User): The user for which permission needs to be checked. + + Returns: + bool: True if the user has permission to change the object, False otherwise. + """ + return self._has_permission("change", user) + + def _has_permission(self, perm: str, user) -> bool: + """ + Check if the user has the specified permission for the content by + checking the content's has_publish_permission, has_placeholder_change_permission, + or has_change_permission methods. + + Falls back to Djangos change permission for the content object. + """ + if perm == "publish" and hasattr(self.content, "has_publish_permission"): + # First try explicit publish permission + return self.content.has_publish_permission(user) + if hasattr(self.content, "has_change_permission"): + # First fallback: change permissions + return self.content.has_change_permission(user) + if hasattr(self.content, "has_placeholder_change_permission"): + # Second fallback: placeholder change permissions - works for PageContent + return self.content.has_placeholder_change_permission(user) + # final fallback: Django perms + return user.has_perm(f"{self.content_type.app_label}.change_{self.content_type.model}") + check_modify = Conditions( [ in_state([constants.DRAFT], not_draft_error), @@ -445,6 +496,7 @@ def _set_unpublish(self, user): ) check_revert = Conditions( [ + user_can_change(permission_error_message), in_state( [constants.ARCHIVED, constants.UNPUBLISHED], _("Version is not in archived or unpublished state"), diff --git a/djangocms_versioning/test_utils/blogpost/models.py b/djangocms_versioning/test_utils/blogpost/models.py index 282d84c9..7634e5ad 100644 --- a/djangocms_versioning/test_utils/blogpost/models.py +++ b/djangocms_versioning/test_utils/blogpost/models.py @@ -14,6 +14,18 @@ class BlogContent(models.Model): language = models.TextField() text = models.TextField() + def has_publish_permission(self, user): + if user.is_superuser: + return True + # Fake a simple object-dependent permission + return user.username in self.text + + def has_change_permission(self, user): + if user.is_superuser: + return True + # Fake a simple object-dependent permission + return f"<{user.username}>" in self.text + def __str__(self): return self.text diff --git a/docs/index.rst b/docs/index.rst index b6f65499..d09c98f9 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,6 +7,7 @@ Welcome to "djangocms-versioning"'s documentation! basic_concepts versioning_integration + permissions version_locking .. toctree:: diff --git a/docs/permissions.rst b/docs/permissions.rst new file mode 100644 index 00000000..ab404720 --- /dev/null +++ b/docs/permissions.rst @@ -0,0 +1,94 @@ +##################################### + Permissions in djangocms-versioning +##################################### + +This documentation covers the permissions system introduced for +publishing and unpublishing content in djangocms-versioning. This system +allows for fine-grained control over who can publish and unpublish or otherwise +manage versions of content. + +*************************** + Understanding Permissions +*************************** + +Permissions are set at the content object level, allowing for detailed +access control based on the user's roles and permissions. The system +checks for specific methods within the **content object**, e.g. +``PageContent`` to determine if a user has the necessary permissions. + +- **Specific publish permission** (only for publish/unpublish action): + To check if a user has the + permission to publish content, the system looks for a method named + ``has_publish_permission`` on the content object. If this method is + present, it will be called to determine whether the user is allowed + to publish the content. + + Example: + + .. code:: python + + def has_publish_permission(self, user): + if user.is_superuser: + # Superusers typically have permission to publish + return True + # Custom logic to determine if the user can publish + return user_has_permission + +- **Change Permission** (and first fallback for ``has_publish_permission``): + If the content object has a + method named ``has_change_permission``, this method will be called to + assess if a user has the permission to change the content. This is a + general permission check that is not specific to publishing or + unpublishing actions. + + Example: + + .. code:: python + + def has_change_permission(self, user): + if user.is_superuser: + # Superusers typically have permission to publish + return True + # Custom logic to determine if the user can change the content + return user_has_permission + +- **First Fallback Placeholder Change Permission**: For content + objects that involve placeholders, such as PageContent objects, a + method named ``has_placeholder_change_permission`` is checked. This + method should determine if the user has the permission to change + placeholders within the content. + + Example: + + .. code:: python + + def has_placeholder_change_permission(self, user): + if user.is_superuser: + # Superusers typically have permission to publish + return True + # Custom logic to determine if the user can change placeholders + return user_has_permission + +- **Last resort Django permissions:** If none of the above methods are + present on the content object, the system falls back to checking if + the user has a generic Django permission to change ``Version`` + objects. This ensures that there is always a permission check in + place, even if specific methods are not implemented for the content + object. By default, the Django permissions are set on a user or group + level and include all instances of the content object. + + .. note:: + + It is highly recommended to implement the specific permission + methods on your content objects for more granular control over + user actions. + +************ + Conclusion +************ + +The permissions system introduced in djangocms-versioning for publishing +and unpublishing content provides a flexible and powerful way to manage +access to content. By defining custom permission logic within your +content objects, you can ensure that only authorized users are able to +perform these actions. diff --git a/docs/settings.rst b/docs/settings.rst index 7269e21e..7747f40a 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -39,6 +39,9 @@ Settings for djangocms Versioning Defaults to ``False`` + .. versionadded:: 2.0 + Before version 2.0 version locking was part of a separate package. + This setting controls if draft versions are locked. If they are, only the user who created the draft can change the draft. See :ref:`Locking versions ` for more details. @@ -67,8 +70,7 @@ Settings for djangocms Versioning Defaults to ``"published"`` .. versionadded:: 2.0 - - Before version 2.0 the behavior was always ``"versions"``. + Before version 2.0 the behavior was always ``"versions"``. This setting determines what happens after publication/unpublication of a content object. Three options exist: diff --git a/docs/static/blog-new.jpg b/docs/static/blog-new.jpg new file mode 100644 index 0000000000000000000000000000000000000000..2df4f311f786cf342e788e47380002efad473932 GIT binary patch literal 27362 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 literal 0 HcmV?d00001 diff --git a/docs/versioning_integration.rst b/docs/versioning_integration.rst index 45e513e9..21892791 100644 --- a/docs/versioning_integration.rst +++ b/docs/versioning_integration.rst @@ -17,30 +17,13 @@ Change the model structure ---------------------------- Assuming that our `blog` app has one db table: -.. graphviz:: - - digraph ERD1 { - graph [ rankdir = "LR" ]; - ranksep=2; - - "Post" [ label=" Post|id \l |site \l title \l text \l " shape = "record" ]; - - "Post":"PK_GROUPER_ID" [arrowhead = crow]; - } +.. image:: /static/blog-original.jpg + :width: 75px This would have to change to a db structure like this: -.. graphviz:: - - digraph ERD2 { - graph [ rankdir = "LR" ]; - ranksep=2; - - "Post" [ label=" Post|id \l |site \l " shape = "record" ]; - "PostContent" [ label=" PostContent|id \l |post \l |title \l text \l " shape = "record" ]; - - "Post":"PK_GROUPER_ID"->"PostContent":"FK_POST" [arrowhead = crow]; - } +.. image:: /static/blog-new.jpg + :width: 377px Or in python code, `models.py` would need to change from: diff --git a/tests/test_admin.py b/tests/test_admin.py index ead5a634..7751b329 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -515,7 +515,7 @@ def test_revert_action_link_enable_state(self): The revert action is active """ version = factories.PollVersionFactory(state=constants.ARCHIVED) - user = factories.UserFactory() + user = self.get_superuser() request = RequestFactory().get("/admin/polls/pollcontent/") version.created_by = request.user = user actual_enabled_control = self.version_admin._get_revert_link(version, request) @@ -568,7 +568,7 @@ def test_discard_version_through_post_action(self): self.versionable.version_model_proxy, "discard", version.pk ) request = RequestFactory().post(draft_discard_url, {"discard": "1"}) - request.user = factories.UserFactory() + request.user = self.get_superuser() request.session = "session" messages = FallbackStorage(request) @@ -586,7 +586,7 @@ def test_discard_action_link_enabled_state(self): """ version = factories.PollVersionFactory(state=constants.DRAFT) request = RequestFactory().get("/admin/polls/pollcontent/") - request.user = factories.UserFactory() + request.user = self.get_superuser() draft_discard_url = self.get_admin_url( self.versionable.version_model_proxy, "discard", version.pk ) @@ -648,7 +648,7 @@ def test_revert_action_link_for_archive_state(self): The revert url should be null for unpublished state """ version = factories.PollVersionFactory(state=constants.UNPUBLISHED) - user = factories.UserFactory() + user = self.get_superuser() archive_version = version.copy(user) archive_version.archive(user) request = RequestFactory().get("/admin/polls/pollcontent/") @@ -805,7 +805,7 @@ def test_archive_not_in_state_actions_for_unpublished_version(self): def test_publish_in_state_actions_for_draft_version(self): version = factories.PollVersionFactory(state=constants.DRAFT) request = RequestFactory().get("/admin/polls/pollcontent/") - request.user = factories.UserFactory() + request.user = self.get_superuser() # Get the version model proxy from the main admin site # Trying to test this on the plain Version model throws exceptions version_model_proxy = [ @@ -881,7 +881,7 @@ def test_publish_not_in_state_actions_for_unpublished_version(self): def test_unpublish_in_state_actions_for_published_version(self): version = factories.PollVersionFactory(state=constants.PUBLISHED) request = RequestFactory().get("/admin/polls/pollcontent/") - request.user = factories.UserFactory() + request.user = self.get_superuser() # Get the version model proxy from the main admin site # Trying to test this on the plain Version model throws exceptions version_model_proxy = [ @@ -961,7 +961,7 @@ def test_unpublish_not_in_state_actions_for_draft_version(self): def test_edit_in_state_actions_for_draft_version(self): version = factories.PollVersionFactory(state=constants.DRAFT) request = RequestFactory().get("/admin/polls/pollcontent/") - request.user = factories.UserFactory() + request.user = self.get_superuser() # Get the version model proxy from the main admin site # Trying to test this on the plain Version model throws exceptions version_model_proxy = [ @@ -1001,7 +1001,7 @@ def test_edit_not_in_state_actions_for_archived_version(self): def test_edit_in_state_actions_for_published_version(self): version = factories.PollVersionFactory(state=constants.PUBLISHED) request = RequestFactory().get("/admin/polls/pollcontent/") - request.user = factories.UserFactory() + request.user = self.get_superuser() # Get the version model proxy from the main admin site # Trying to test this on the plain Version model throws exceptions version_model_proxy = [ @@ -1128,7 +1128,7 @@ def test_archive_view_sets_modified_time(self): url = self.get_admin_url( self.versionable.version_model_proxy, "archive", poll_version.pk ) - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() with freeze_time("2999-01-11 00:00:00", tz_offset=0), self.login_user_context( user ): @@ -1164,7 +1164,7 @@ def test_archive_view_sets_state_and_redirects(self, mocked_messages): url = self.get_admin_url( self.versionable.version_model_proxy, "archive", poll_version.pk ) - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() with self.login_user_context(user): response = self.client.post(url) @@ -1287,7 +1287,7 @@ def test_archive_view_can_be_accessed_by_get_request(self): self.versionable.version_model_proxy, "archive", poll_version.pk ) - with self.login_user_context(self.get_staff_user_with_no_permissions()): + with self.login_user_context(self.get_superuser()): response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -1324,7 +1324,7 @@ def test_publish_view_sets_state_and_redirects(self, mocked_messages): url = self.get_admin_url( self.versionable.version_model_proxy, "publish", poll_version.pk ) - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() with self.login_user_context(user): response = self.client.post(url) @@ -1349,7 +1349,7 @@ def test_publish_view_redirects_according_to_settings(self): from djangocms_versioning import conf original_setting = conf.ON_PUBLISH_REDIRECT - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() conf.ON_PUBLISH_REDIRECT ="published" poll_version = factories.PollVersionFactory(state=constants.DRAFT) @@ -1388,7 +1388,7 @@ def test_published_view_sets_modified_time(self): url = self.get_admin_url( self.versionable.version_model_proxy, "publish", poll_version.pk ) - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() with freeze_time("2999-01-11 00:00:00", tz_offset=0), self.login_user_context( user ): @@ -1548,7 +1548,7 @@ def test_unpublish_view_sets_state_and_redirects(self, mocked_messages): url = self.get_admin_url( self.versionable.version_model_proxy, "unpublish", poll_version.pk ) - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() with self.login_user_context(user): response = self.client.post(url) @@ -1574,7 +1574,7 @@ def test_unpublish_view_sets_modified_time(self): url = self.get_admin_url( self.versionable.version_model_proxy, "unpublish", poll_version.pk ) - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() with freeze_time("2999-01-11 00:00:00", tz_offset=0), self.login_user_context( user ): @@ -1691,7 +1691,7 @@ def test_unpublish_view_can_be_accessed_by_get_request(self): self.versionable.version_model_proxy, "unpublish", poll_version.pk ) - with self.login_user_context(self.get_staff_user_with_no_permissions()): + with self.login_user_context(self.get_superuser()): response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -1728,7 +1728,7 @@ def publish_context(request, version, *args, **kwargs): } with patch.object(versioning_ext, "add_to_context", extra_context_setting): - with self.login_user_context(self.get_staff_user_with_no_permissions()): + with self.login_user_context(self.get_superuser()): response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -1761,7 +1761,7 @@ def test_unpublish_view_doesnt_throw_exception_if_no_app_registered_extra_unpubl versioning_ext = apps.get_app_config("djangocms_versioning").cms_extension with patch.object(versioning_ext, "add_to_context", {}): - with self.login_user_context(self.get_staff_user_with_no_permissions()): + with self.login_user_context(self.get_superuser()): response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -1814,7 +1814,7 @@ def test_revert_view_sets_modified_time(self): url = self.get_admin_url( self.versionable.version_model_proxy, "revert", poll_version.pk ) - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() with freeze_time("2999-01-11 00:00:00", tz_offset=0), self.login_user_context( user ): diff --git a/tests/test_integration_with_core.py b/tests/test_integration_with_core.py index f414e6b8..a4b8a8bd 100644 --- a/tests/test_integration_with_core.py +++ b/tests/test_integration_with_core.py @@ -221,7 +221,7 @@ class WizzardTestCase(CMSTestCase): def test_success_url_for_cms_wizard(self): from cms.cms_wizards import cms_page_wizard, cms_subpage_wizard - from cms.toolbar.utils import get_object_preview_url + from cms.toolbar.utils import get_object_edit_url, get_object_preview_url from djangocms_versioning.test_utils.polls.cms_wizards import ( poll_wizard, @@ -229,21 +229,24 @@ def test_success_url_for_cms_wizard(self): # Test against page creations in different languages. version = PageVersionFactory(content__language="en") - self.assertEqual( + self.assertIn( cms_page_wizard.get_success_url(version.content.page, language="en"), - get_object_preview_url(version.content), + [get_object_preview_url(version.content), get_object_edit_url(version.content)], ) version = PageVersionFactory(content__language="en") - self.assertEqual( + self.assertIn( cms_subpage_wizard.get_success_url(version.content.page, language="en"), - get_object_preview_url(version.content), + [get_object_preview_url(version.content), get_object_edit_url(version.content)], ) version = PageVersionFactory(content__language="de") - self.assertEqual( + self.assertIn( cms_page_wizard.get_success_url(version.content.page, language="de"), - get_object_preview_url(version.content, language="de"), + [ + get_object_preview_url(version.content, language="de"), + get_object_edit_url(version.content, language="de") + ], ) # Test against a model that doesn't have a PlaceholderRelationField diff --git a/tests/test_locking.py b/tests/test_locking.py index 82e61aeb..08860c00 100644 --- a/tests/test_locking.py +++ b/tests/test_locking.py @@ -647,7 +647,7 @@ def test_version_is_unlocked_for_publishing(self): """ A version lock is not present when a content version is in a published or unpublished state """ - user = self.get_staff_user_with_no_permissions() + user = self.get_superuser() poll_version = factories.PollVersionFactory(state=DRAFT, created_by=user, locked_by=user) publish_url = self.get_admin_url(self.versionable.version_model_proxy, "publish", poll_version.pk) unpublish_url = self.get_admin_url(self.versionable.version_model_proxy, "unpublish", poll_version.pk) diff --git a/tests/test_permissions.py b/tests/test_permissions.py new file mode 100644 index 00000000..5dc74a35 --- /dev/null +++ b/tests/test_permissions.py @@ -0,0 +1,259 @@ +from unittest.mock import patch + +from django.core.checks import messages + +from djangocms_versioning import constants +from djangocms_versioning.models import StateTracking, Version +from djangocms_versioning.test_utils import factories +from djangocms_versioning.test_utils.blogpost.cms_config import BlogpostCMSConfig +from djangocms_versioning.test_utils.polls.cms_config import PollsCMSConfig +from tests.test_admin import BaseStateTestCase + + +class PermissionTestCase(BaseStateTestCase): + def setUp(self): + self.versionable = BlogpostCMSConfig.versioning[0] + self.poll_versionable = PollsCMSConfig.versioning[0] + + def get_user(self, username, is_staff=True): + user = factories.UserFactory(username=username, is_staff=is_staff) + user.set_password(username) + user.save() + return user + + @patch("django.contrib.messages.add_message") + def test_publish_view_cannot_be_accessed_without_permission( + self, mocked_messages + ): + post_version = factories.BlogPostVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", post_version.pk + ) + + with self.login_user_context(self.get_staff_user_with_no_permissions()): + response = self.client.post(url) + + self.assertRedirectsToPreview(response, post_version) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) + self.assertEqual(mocked_messages.call_args[0][2], "You do not have permission to perform this action") + + # status hasn't changed + post_version = Version.objects.get(pk=post_version.pk) + self.assertEqual(post_version.state, constants.DRAFT) + # no status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 0) + + @patch("django.contrib.messages.add_message") + def test_unpublish_view_cannot_be_accessed_without_permission( + self, mocked_messages + ): + post_version = factories.BlogPostVersionFactory(state=constants.PUBLISHED) + url = self.get_admin_url( + self.versionable.version_model_proxy, "unpublish", post_version.pk + ) + + with self.login_user_context(self.get_staff_user_with_no_permissions()): + response = self.client.post(url) + + self.assertRedirectsToPreview(response, post_version) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) + self.assertEqual(mocked_messages.call_args[0][2], "You do not have permission to perform this action") + + # status hasn't changed + post_version = Version.objects.get(pk=post_version.pk) + self.assertEqual(post_version.state, constants.PUBLISHED) + # no status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 0) + + + @patch("django.contrib.messages.add_message") + def test_publish_view_can_be_accessed_with_low_level_permission( + self, mocked_messages + ): + # alice has no permission to publish bob's post + post_version = factories.BlogPostVersionFactory(state=constants.DRAFT, content__text="bob's post") + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", post_version.pk + ) + + with self.login_user_context(self.get_user("bob")): + self.client.post(url) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.INFO) + self.assertEqual(mocked_messages.call_args[0][2], "Version published") + + # status has changed + post_version = Version.objects.get(pk=post_version.pk) + self.assertEqual(post_version.state, constants.PUBLISHED) + # status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 1) + + @patch("django.contrib.messages.add_message") + def test_publish_view_cannot_be_accessed_wo_low_level_permission( + self, mocked_messages + ): + # alice has no permission to publish bob's post + post_version = factories.BlogPostVersionFactory(state=constants.DRAFT, content__text="bob's post") + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", post_version.pk + ) + + with self.login_user_context(self.get_user("alice")): + response = self.client.post(url) + + self.assertRedirectsToPreview(response, post_version) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) + self.assertEqual(mocked_messages.call_args[0][2], "You do not have permission to perform this action") + + # status hasn't changed + post_version = Version.objects.get(pk=post_version.pk) + self.assertEqual(post_version.state, constants.DRAFT) + # no status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 0) + + @patch("django.contrib.messages.add_message") + def test_unpublish_view_can_be_accessed_with_low_level_permission( + self, mocked_messages + ): + # bob has permission to unpublish bob's post + post_version = factories.BlogPostVersionFactory(state=constants.PUBLISHED, content__text="bob's post") + url = self.get_admin_url( + self.versionable.version_model_proxy, "unpublish", post_version.pk + ) + + with self.login_user_context(self.get_user("bob")): + self.client.post(url) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.INFO) + self.assertEqual(mocked_messages.call_args[0][2], "Version unpublished") + + # status has changed + post_version = Version.objects.get(pk=post_version.pk) + self.assertEqual(post_version.state, constants.UNPUBLISHED) + # status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 1) + + @patch("django.contrib.messages.add_message") + def test_unpublish_view_cannot_be_accessed_wo_low_level_permission( + self, mocked_messages + ): + # alice has no permission to unpublish bob's post + post_version = factories.BlogPostVersionFactory(state=constants.PUBLISHED, content__text="bob's post") + url = self.get_admin_url( + self.versionable.version_model_proxy, "unpublish", post_version.pk + ) + + with self.login_user_context(self.get_user("alice")): + response = self.client.post(url) + + self.assertRedirectsToPreview(response, post_version) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) + self.assertEqual(mocked_messages.call_args[0][2], "You do not have permission to perform this action") + + # status hasn't changed + post_version = Version.objects.get(pk=post_version.pk) + self.assertEqual(post_version.state, constants.PUBLISHED) + # no status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 0) + + @patch("django.contrib.messages.add_message") + def test_archive_view_cannot_be_accessed_without_permission( + self, mocked_messages + ): + post_version = factories.BlogPostVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.versionable.version_model_proxy, "archive", post_version.pk + ) + user = self.get_staff_user_with_no_permissions() + + with self.login_user_context(user): + self.client.post(url) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) + self.assertEqual(mocked_messages.call_args[0][2], "You do not have permission to perform this action") + + # status hasn't changed + post_version = Version.objects.get(pk=post_version.pk) + self.assertEqual(post_version.state, constants.DRAFT) + # no status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 0) + + @patch("django.contrib.messages.add_message") + def test_archive_view_can_be_accessed_with_permission( + self, mocked_messages + ): + poll_version = factories.PollVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.poll_versionable.version_model_proxy, "archive", poll_version.pk + ) + user = self.get_staff_user_with_no_permissions() + user.user_permissions.add(self.get_permission("change_pollcontent")) + + with self.login_user_context(user): + self.client.post(url) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.INFO) + self.assertEqual(mocked_messages.call_args[0][2], "Version archived") + + # status has changed + poll_version_ = Version.objects.get(pk=poll_version.pk) + self.assertEqual(poll_version_.state, constants.ARCHIVED) + # status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 1) + + @patch("django.contrib.messages.add_message") + def test_revert_view_cannot_be_accessed_without_permission( + self, mocked_messages + ): + post_version = factories.BlogPostVersionFactory(state=constants.ARCHIVED) + url = self.get_admin_url( + self.versionable.version_model_proxy, "revert", post_version.pk + ) + user = self.get_staff_user_with_no_permissions() + + with self.login_user_context(user): + self.client.post(url) + + self.assertEqual(mocked_messages.call_count, 1) + self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) + self.assertEqual(mocked_messages.call_args[0][2], "You do not have permission to perform this action") + + # status hasn't changed + poll_version_ = Version.objects.get(pk=post_version.pk) + self.assertEqual(poll_version_.state, constants.ARCHIVED) + # no status change has been tracked + self.assertEqual(StateTracking.objects.all().count(), 0) + + @patch("django.contrib.messages.add_message") + def test_revert_view_can_be_accessed_with_low_level_permission( + self, mocked_messages + ): + post_version = factories.BlogPostVersionFactory(state=constants.ARCHIVED, content__text="post ") + url = self.get_admin_url( + self.versionable.version_model_proxy, "revert", post_version.pk + ) + user = self.get_user("alice", is_staff=True) + with self.login_user_context(user): + self.client.post(url) + + # new draft has been created + post_version_ = Version.objects.filter( + content_type=post_version.content_type, + object_id__gt=post_version.object_id, + pk__gt=post_version.pk + ).first() + self.assertIsNotNone(post_version_) + self.assertEqual(post_version_.state, constants.DRAFT) + self.assertTrue(post_version_.content.has_change_permission(user)) # Content was copied diff --git a/tests/test_toolbars.py b/tests/test_toolbars.py index 665cdd36..deb1f038 100644 --- a/tests/test_toolbars.py +++ b/tests/test_toolbars.py @@ -73,7 +73,7 @@ def test_revert_in_toolbar_in_preview_mode(self): version = PollVersionFactory() version.archive(self.get_superuser()) - toolbar = get_toolbar(version.content, edit_mode=False) + toolbar = get_toolbar(version.content, edit_mode=False, user=self.get_superuser()) toolbar.post_template_populate() publish_button = find_toolbar_buttons("Publish", toolbar.toolbar) From 76cda87b369c216657606ec05b9f11462f7d61a1 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Wed, 20 Mar 2024 22:00:54 +0100 Subject: [PATCH 06/10] fix: Post requests from the side frame were sent to wrong URL (#396) * fix #384: Unlock button in toolbar points onto DRAFT version * Fix side frame regression sending post request to currect --------- Co-authored-by: Jacob Rief --- .../djangocms_versioning/js/admin/versioning-actions.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/djangocms_versioning/static/djangocms_versioning/js/admin/versioning-actions.js b/djangocms_versioning/static/djangocms_versioning/js/admin/versioning-actions.js index 50edfa4c..632032fa 100644 --- a/djangocms_versioning/static/djangocms_versioning/js/admin/versioning-actions.js +++ b/djangocms_versioning/static/djangocms_versioning/js/admin/versioning-actions.js @@ -11,10 +11,11 @@ document.querySelectorAll('form.js-close-sideframe').forEach(el => { el.addEventListener("submit", (ev) => { ev.preventDefault(); + ev.target.action = ev.target.action; // save action url closeSideFrame(); - const form = window.top.document.body.appendChild(ev.target); + const form = window.top.document.body.appendChild(ev.target); // move to top window form.style.display = 'none'; - form.submit(); + form.submit(); // submit form }); }); }); From 2094542cf33b232d7de2810a6906636788e56078 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 Mar 2024 09:18:10 +0100 Subject: [PATCH 07/10] build(deps): bump actions/cache from 4.0.1 to 4.0.2 (#397) Bumps [actions/cache](https://github.com/actions/cache) from 4.0.1 to 4.0.2. - [Release notes](https://github.com/actions/cache/releases) - [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md) - [Commits](https://github.com/actions/cache/compare/v4.0.1...v4.0.2) --- updated-dependencies: - dependency-name: actions/cache dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index c393f081..2fceee19 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,7 +19,7 @@ jobs: python-version: '3.11' cache: 'pip' - name: Cache dependencies - uses: actions/cache@v4.0.1 + uses: actions/cache@v4.0.2 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('docs/requirements.txt') }} @@ -44,7 +44,7 @@ jobs: python-version: '3.11' cache: 'pip' - name: Cache dependencies - uses: actions/cache@v4.0.1 + uses: actions/cache@v4.0.2 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('docs/requirements.txt') }} From f43c9235346a456a22a1e1931b851c1a0470ea43 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 28 Mar 2024 09:27:08 +0100 Subject: [PATCH 08/10] fix: Consistent use of action buttons (#392) * fix #384: Unlock button in toolbar points onto DRAFT version * fix: consistent logic for action buttons (availability & enabled/disabled) * fix linting * Remove debug statement * Add missing unlock condition --------- Co-authored-by: Jacob Rief --- djangocms_versioning/admin.py | 31 +++++++++++------------------- djangocms_versioning/conditions.py | 7 ++++++- djangocms_versioning/models.py | 3 +++ tests/test_admin.py | 2 +- tests/test_locking.py | 11 +++++++---- 5 files changed, 28 insertions(+), 26 deletions(-) diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index de47bc1a..00898e44 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -685,13 +685,13 @@ def _get_archive_link(self, obj, request, disabled=False): icon="archive", title=_("Archive"), name="archive", - disabled=not obj.can_be_archived(), + disabled=not obj.check_archive.as_bool(request.user), ) def _get_publish_link(self, obj, request): """Helper function to get the html link to the publish action """ - if not obj.check_publish.as_bool(request.user): + if not obj.can_be_published(): # Don't display the link if it can't be published return "" publish_url = reverse( @@ -704,14 +704,14 @@ def _get_publish_link(self, obj, request): title=_("Publish"), name="publish", action="post", - disabled=not obj.can_be_published(), + disabled=not obj.check_publish.as_bool(request.user), keepsideframe=False, ) def _get_unpublish_link(self, obj, request, disabled=False): """Helper function to get the html link to the unpublish action """ - if not obj.check_unpublish.as_bool(request.user): + if not obj.can_be_unpublished(): # Don't display the link if it can't be unpublished return "" unpublish_url = reverse( @@ -723,15 +723,12 @@ def _get_unpublish_link(self, obj, request, disabled=False): icon="unpublish", title=_("Unpublish"), name="unpublish", - disabled=not obj.can_be_unpublished(), + disabled=not obj.check_unpublish.as_bool(request.user), ) def _get_edit_link(self, obj, request, disabled=False): """Helper function to get the html link to the edit action """ - if not obj.check_edit_redirect.as_bool(request.user): - return "" - # Only show if no draft exists if obj.state == PUBLISHED: pks_for_grouper = obj.versionable.for_content_grouping_values( @@ -761,14 +758,14 @@ def _get_edit_link(self, obj, request, disabled=False): title=_("Edit") if icon == "pencil" else _("New Draft"), name="edit", action="post", - disabled=disabled, + disabled=not obj.check_edit_redirect.as_bool(request.user) or disabled, keepsideframe=keepsideframe, ) def _get_revert_link(self, obj, request, disabled=False): """Helper function to get the html link to the revert action """ - if not obj.check_revert.as_bool(request.user): + if obj.state in (PUBLISHED, DRAFT): # Don't display the link if it's a draft or published return "" @@ -781,13 +778,13 @@ def _get_revert_link(self, obj, request, disabled=False): icon="undo", title=_("Revert"), name="revert", - disabled=disabled, + disabled=not obj.check_revert.as_bool(request.user) or disabled, ) def _get_discard_link(self, obj, request, disabled=False): """Helper function to get the html link to the discard action """ - if not obj.check_discard.as_bool(request.user): + if obj.state != DRAFT: # Don't display the link if it's not a draft return "" @@ -800,7 +797,7 @@ def _get_discard_link(self, obj, request, disabled=False): icon="bin", title=_("Discard"), name="discard", - disabled=disabled, + disabled=not obj.check_discard.as_bool(request.user) or disabled, ) def _get_unlock_link(self, obj, request): @@ -811,12 +808,6 @@ def _get_unlock_link(self, obj, request): if not conf.LOCK_VERSIONS or obj.state != DRAFT or not version_is_locked(obj): return "" - disabled = True - # Check whether the lock can be removed - # Check that the user has unlock permission - if request.user.has_perm("djangocms_versioning.delete_versionlock"): - disabled = False - unlock_url = reverse(f"admin:{obj._meta.app_label}_{self.model._meta.model_name}_unlock", args=(obj.pk,)) return self.admin_action_button( unlock_url, @@ -824,7 +815,7 @@ def _get_unlock_link(self, obj, request): title=_("Unlock"), name="unlock", action="post", - disabled=disabled, + disabled=not obj.check_unlock.as_bool(request.user), ) def get_actions_list(self): diff --git a/djangocms_versioning/conditions.py b/djangocms_versioning/conditions.py index fe9c9012..fd76a007 100644 --- a/djangocms_versioning/conditions.py +++ b/djangocms_versioning/conditions.py @@ -77,6 +77,12 @@ def inner(version, user): raise ConditionFailed(message) return inner +def user_can_unlock(message: str) -> callable: + def inner(version, user): + if not user.has_perm("djangocms_versioning.delete_versionlock"): + raise ConditionFailed(message) + return inner + def user_can_publish(message: str) -> callable: def inner(version, user): if not version.has_publish_permission(user): @@ -89,4 +95,3 @@ def inner(version, user): if not version.has_change_permission(user): raise ConditionFailed(message) return inner - diff --git a/djangocms_versioning/models.py b/djangocms_versioning/models.py index f6347008..0f4a3956 100644 --- a/djangocms_versioning/models.py +++ b/djangocms_versioning/models.py @@ -18,6 +18,7 @@ is_not_locked, user_can_change, user_can_publish, + user_can_unlock, ) from .conf import ALLOW_DELETING_VERSIONS, LOCK_VERSIONS from .operations import send_post_version_operation, send_pre_version_operation @@ -492,6 +493,7 @@ def _has_permission(self, perm: str, user) -> bool: [ in_state([constants.DRAFT], not_draft_error), draft_is_not_locked(lock_draft_error_message), + user_can_unlock(permission_error_message), ] ) check_revert = Conditions( @@ -529,6 +531,7 @@ def _has_permission(self, perm: str, user) -> bool: [ in_state([constants.DRAFT, constants.PUBLISHED], not_draft_error), draft_is_locked(_("Draft version is not locked")) + ] ) diff --git a/tests/test_admin.py b/tests/test_admin.py index 7751b329..cb59fc33 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -729,7 +729,7 @@ class StateActionsTestCase(CMSTestCase): def test_archive_in_state_actions_for_draft_version(self): version = factories.PollVersionFactory(state=constants.DRAFT) request = RequestFactory().get("/admin/polls/pollcontent/") - request.user = factories.UserFactory() + request.user = self.get_superuser() # Get the version model proxy from the main admin site # Trying to test this on the plain Version model throws exceptions version_model_proxy = [ diff --git a/tests/test_locking.py b/tests/test_locking.py index 08860c00..bbc72d94 100644 --- a/tests/test_locking.py +++ b/tests/test_locking.py @@ -270,11 +270,13 @@ def test_unlock_link_not_present_for_user_with_no_unlock_privileges(self): locked_by=self.user_author) changelist_url = version_list_url(poll_version.content) unlock_url = self.get_admin_url(self.versionable.version_model_proxy, "unlock", poll_version.pk) - + exprected_disabled_button = ( + f'' + ) with self.login_user_context(self.user_has_no_unlock_perms): response = self.client.post(changelist_url) - - self.assertNotContains(response, unlock_url) + self.assertInHTML(exprected_disabled_button, response.content.decode("utf-8")) def test_unlock_link_present_for_user_with_privileges(self): poll_version = factories.PollVersionFactory( @@ -392,11 +394,12 @@ def test_edit_action_link_disabled_state(self): author_request.user = self.user_author otheruser_request = RequestFactory() otheruser_request.user = self.superuser + expected_disabled_state = "inactive" actual_disabled_state = self.version_admin._get_edit_link(version, otheruser_request) self.assertFalse(version.check_edit_redirect.as_bool(self.superuser)) - self.assertEqual("", actual_disabled_state) + self.assertIn(expected_disabled_state, actual_disabled_state) @override_settings(DJANGOCMS_VERSIONING_LOCK_VERSIONS=True) From b2d46f6893a20c4698e4eb70cd4fa51443d822d0 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 28 Mar 2024 19:26:18 +0100 Subject: [PATCH 09/10] fix: Avoid duplication of placeholder checks for locked versions (#393) * fix #384: Unlock button in toolbar points onto DRAFT version * fix: placeholder checks only need added once * fix linting --------- Co-authored-by: Jacob Rief --- djangocms_versioning/apps.py | 5 ++++- djangocms_versioning/cms_config.py | 9 --------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/djangocms_versioning/apps.py b/djangocms_versioning/apps.py index c1ebdcfd..a5019d4c 100644 --- a/djangocms_versioning/apps.py +++ b/djangocms_versioning/apps.py @@ -12,15 +12,18 @@ def ready(self): from cms.models import contentmodels, fields from cms.signals import post_obj_operation, post_placeholder_operation + from .conf import LOCK_VERSIONS from .handlers import ( update_modified_date, update_modified_date_for_pagecontent, update_modified_date_for_placeholder_source, ) - from .helpers import is_content_editable + from .helpers import is_content_editable, placeholder_content_is_unlocked_for_user # Add check to PlaceholderRelationField fields.PlaceholderRelationField.default_checks += [is_content_editable] + if LOCK_VERSIONS: + fields.PlaceholderRelationField.default_checks += [placeholder_content_is_unlocked_for_user] # Remove uniqueness constraint from PageContent model to allow for different versions pagecontent_unique_together = tuple( diff --git a/djangocms_versioning/cms_config.py b/djangocms_versioning/cms_config.py index 92c66fe4..e2894084 100644 --- a/djangocms_versioning/cms_config.py +++ b/djangocms_versioning/cms_config.py @@ -25,7 +25,6 @@ from . import indicators, versionables from .admin import VersioningAdminMixin -from .conf import LOCK_VERSIONS from .constants import INDICATOR_DESCRIPTIONS from .datastructures import BaseVersionableItem, VersionableItem from .exceptions import ConditionFailed @@ -33,7 +32,6 @@ get_latest_admin_viewable_content, inject_generic_relation_to_version, is_editable, - placeholder_content_is_unlocked_for_user, register_versionadmin_proxy, replace_admin_for_models, replace_manager, @@ -160,12 +158,6 @@ def handle_admin_field_modifiers(self, cms_config): for key in modifier.keys(): self.add_to_field_extension[key] = modifier[key] - def handle_locking(self): - if LOCK_VERSIONS: - from cms.models import fields - - fields.PlaceholderRelationField.default_checks += [placeholder_content_is_unlocked_for_user] - def configure_app(self, cms_config): if hasattr(cms_config, "extended_admin_field_modifiers"): self.handle_admin_field_modifiers(cms_config) @@ -188,7 +180,6 @@ def configure_app(self, cms_config): self.handle_version_admin(cms_config) self.handle_content_model_generic_relation(cms_config) self.handle_content_model_manager(cms_config) - self.handle_locking() def copy_page_content(original_content): From f726bc203c06ccbb8ba79dc9defb98d1b4e06a9d Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 29 Mar 2024 14:30:09 +0100 Subject: [PATCH 10/10] chore: bump version (#398) * chore: bump version * Fix md format in CHANGELOG.rst --- CHANGELOG.rst | 20 ++++++++++++++++++++ djangocms_versioning/__init__.py | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f7ab8665..f21c5790 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,26 @@ Changelog ========= +2.0.1 (2024-03-29) +================== + +* feat: Add content object level publish permissions by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/390 +* fix: Create missing __init__.py in management folder by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/366 +* fix #363: Better UX in versioning listview by @jrief in https://github.com/django-cms/djangocms-versioning/pull/364 +* fix: Several fixes for the versioning forms: #382, #383, #384 by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/386 +* fix: For Django CMS 4.1.1 and later do not automatically register versioned CMS Menu by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/388 +* fix: Post requests from the side frame were sent to wrong URL by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/396 +* fix: Consistent use of action buttons by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/392 +* fix: Avoid duplication of placeholder checks for locked versions by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/393 +* ci: Add testing against django main by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/353 +* ci: Improve efficiency of ruff workflow by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/378 +* Chore: update ruff and pre-commit hook by @raffaellasuardini in https://github.com/django-cms/djangocms-versioning/pull/381 +* build(deps): bump actions/cache from 4.0.1 to 4.0.2 by @dependabot in https://github.com/django-cms/djangocms-versioning/pull/397 + +New Contributors + +* @raffaellasuardini made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/381 +* @jrief made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/364 2.0.0 (2023-12-29) ================== diff --git a/djangocms_versioning/__init__.py b/djangocms_versioning/__init__.py index 8c0d5d5b..159d48b8 100644 --- a/djangocms_versioning/__init__.py +++ b/djangocms_versioning/__init__.py @@ -1 +1 @@ -__version__ = "2.0.0" +__version__ = "2.0.1"