From c16c1779b9906143cb925ac161999bc8d38878b5 Mon Sep 17 00:00:00 2001 From: vipulnarang95 <61502917+vipulnarang95@users.noreply.github.com> Date: Sun, 22 Oct 2023 01:19:28 +0530 Subject: [PATCH 01/47] Bugfix/use keyword arguments in admin render change form method (#356) * Update admin.py added keyword arguments in render change form in VersioningAdminMixin * Update CHANGELOG.rst --- CHANGELOG.rst | 1 + djangocms_versioning/admin.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 994c46d0..cdda81be 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,7 @@ Changelog Unreleased ========== +* fix: Add keyword arguments in VersionAdminMixin render_change_form * feat: Reversable generic foreign key lookup from version * fix: formatted files through ruff to fix tests * fix: Remove version check when evaluating CMS PageContent objects diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index d82af111..b8783b76 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -133,7 +133,7 @@ def render_change_form( "versioning_fallback_change_form_template" ] = super().change_form_template - return super().render_change_form(request, context, add, change, form_url, obj) + return super().render_change_form(request, context, add=add, change=change, form_url=form_url, obj=obj) def has_change_permission(self, request, obj=None): # Add additional version checks From dda7f5dce0713e1f31e95f55f72aaac5071926fc Mon Sep 17 00:00:00 2001 From: Mark Walker Date: Thu, 2 Nov 2023 00:25:45 +0000 Subject: [PATCH 02/47] build: Add readthedocs config --- .readthedocs.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .readthedocs.yaml diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 00000000..e1bd672f --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,20 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.11" + +sphinx: + configuration: docs/conf.py + fail_on_warning: false + +formats: + - epub + - pdf + +python: + install: + - requirements: docs/requirements.txt From 97f28014c2e7de1e6a914dad073cf1e0e3b9754f Mon Sep 17 00:00:00 2001 From: Jonathan Date: Wed, 8 Nov 2023 08:40:45 +0100 Subject: [PATCH 03/47] Provide additional information when sending publish/unpublish events (#348) * Fix form validation * feat: Provide additional information when publishing/unpublishing When sending post publishing events, include the list of unpublished versions. When sending post unpublishing events, include the version to be published. * added tests for to_be_published and unpublished attributes in signals * added signal documentation --------- Co-authored-by: Fabian Braun Co-authored-by: Angelo Dini Co-authored-by: Dennis Schwertel --- CHANGELOG.rst | 1 + djangocms_versioning/models.py | 16 +++++++++++----- docs/api/signals.rst | 15 +++++++++++++++ tests/test_signals.py | 25 +++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cdda81be..41938de6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -7,6 +7,7 @@ Unreleased ========== * fix: Add keyword arguments in VersionAdminMixin render_change_form * feat: Reversable generic foreign key lookup from version +* feat: Provide additional information about unpublished/published versions when sending signals * fix: formatted files through ruff to fix tests * fix: Remove version check when evaluating CMS PageContent objects diff --git a/djangocms_versioning/models.py b/djangocms_versioning/models.py index b36cc5bd..4f3c2689 100644 --- a/djangocms_versioning/models.py +++ b/djangocms_versioning/models.py @@ -357,13 +357,16 @@ def publish(self, user): content_type=self.content_type, ) for version in to_unpublish: - version.unpublish(user) + version.unpublish(user, to_be_published=self) on_publish = self.versionable.on_publish if on_publish: on_publish(self) # trigger post operation signal send_post_version_operation( - constants.OPERATION_PUBLISH, version=self, token=action_token + constants.OPERATION_PUBLISH, + version=self, + token=action_token, + unpublished=list(to_unpublish), ) if emit_content_change: emit_content_change(self.content) @@ -391,11 +394,11 @@ def _set_publish(self, user): def can_be_unpublished(self): return can_proceed(self._set_unpublish) - def unpublish(self, user): + def unpublish(self, user, to_be_published=None): """Change state to UNPUBLISHED""" # trigger pre operation signal action_token = send_pre_version_operation( - constants.OPERATION_UNPUBLISH, version=self + constants.OPERATION_UNPUBLISH, version=self, to_be_published=to_be_published ) self._set_unpublish(user) self.modified = timezone.now() @@ -411,7 +414,10 @@ def unpublish(self, user): on_unpublish(self) # trigger post operation signal send_post_version_operation( - constants.OPERATION_UNPUBLISH, version=self, token=action_token + constants.OPERATION_UNPUBLISH, + version=self, + token=action_token, + to_be_published=to_be_published, ) if emit_content_change: emit_content_change(self.content) diff --git a/docs/api/signals.rst b/docs/api/signals.rst index 9adc9074..73eb45f3 100644 --- a/docs/api/signals.rst +++ b/docs/api/signals.rst @@ -32,3 +32,18 @@ The CMS used to provide page publish and unpublish signals which have since been # ... do something +Handling the effect of a (un-)publish to other items via signals +---------------------------------------------------------------- + +Events often times do not happen in isolation. +A publish signal indicates a publish of an item but it also means that potentially other items are unpublished as part of the same action, also triggering unpublish signals. +To be able to react accordingly, information is added to the publish signal which other items were potentially unpublished as part of this action (`unpublished`) and information is also added to the unpublish singal which other items are going to get published (`to_be_published`). +This information allows you to differentiate if an item is published for the first time - because nothing is unpublished - or if it is just a new version of an existing item. + +For example, the differentiation can be benefitial if you integrate with other services like Elasticsearch and you want to update the Elasticsearch index via signals. You can get in the following situations: + - Publish signal with no unpublished item results in a new entry in the index. + - Publish signal with at least one unpublished item results in an update of an existing entry in the index. + - Unpublish singal with no to be published items results in the removal of the entry from the index. + - Unpublish signal with a to be published item results in the update on an existing entry in the index but will be handled in the corresponding publish signal and can be ignored. + +All those situations are distinct, require different information, and can be handled according to requirements. diff --git a/tests/test_signals.py b/tests/test_signals.py index 47b24ec2..3d5d25f9 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -48,6 +48,31 @@ def test_publish_signals_fired(self): ) self.assertEqual(post_call_kwargs["obj"], version) + + def test_publish_signals_fired_with_to_be_published_and_unpublished(self): + poll = factories.PollFactory() + version1 = factories.PollVersionFactory( + state=constants.DRAFT, content__poll=poll + ) + version2 = version1.copy(self.superuser) + + # Here, we just expect the signals for version 1 + with signal_tester(pre_version_operation, post_version_operation) as env: + version1.publish(self.superuser) + self.assertEqual(env.call_count, 2) + + # Here, we expect the signals for the unpublish of version 1 and the + # publish of version 2. + with signal_tester(pre_version_operation, post_version_operation) as env: + version2.publish(self.superuser) + self.assertEqual(env.call_count, 4) + version_1_pre_call_kwargs = env.calls[1][1] + version_2_post_call_kwargs = env.calls[3][1] + + self.assertEqual(version_1_pre_call_kwargs["to_be_published"], version2) + self.assertEqual(version_2_post_call_kwargs["unpublished"], [version1]) + + def test_unpublish_signals_fired(self): """ When a version is changed to unpublished the correct signals are fired! From fd49df6d6744255ce8361ebedf489123e84b2b45 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 16 Nov 2023 17:29:02 +0100 Subject: [PATCH 04/47] fix: Preview link language (#357) * Remove remove_published_where * Fix language issue with preview links * Fix tests * Fix linting issues * Shorter name for github action tests for better overview * Add py12 to tests * Fix is_editable indicators * fix linting issues * Undo unnecessary change and update tests * Add setuptools to requirements * Small DB optimization * Undo, get page from toolbar --- .github/workflows/test.yml | 12 +- djangocms_versioning/admin.py | 19 +-- djangocms_versioning/cms_config.py | 3 +- djangocms_versioning/cms_menus.py | 2 +- djangocms_versioning/helpers.py | 11 +- djangocms_versioning/indicators.py | 9 - .../locale/de/LC_MESSAGES/django.po | 153 +++++++++-------- .../locale/en/LC_MESSAGES/django.po | 144 ++++++++-------- .../locale/fr/LC_MESSAGES/django.po | 161 +++++++++--------- .../locale/nl/LC_MESSAGES/django.po | 155 ++++++++--------- .../locale/sq/LC_MESSAGES/django.po | 146 ++++++++-------- tests/requirements/requirements_base.txt | 4 +- tests/test_admin.py | 37 ++-- tests/test_locking.py | 2 +- tox.ini | 19 +-- 15 files changed, 434 insertions(+), 443 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e5897902..96aa6836 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,12 +7,12 @@ concurrency: cancel-in-progress: true jobs: - sqlite-unit-tests: + sqlite: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: [ 3.9, "3.10", "3.11" ] # latest release minus two + python-version: [ 3.9, "3.10", "3.11", "3.12" ] # latest release minus two requirements-file: [ dj32_cms41.txt, dj40_cms41.txt, @@ -39,12 +39,12 @@ jobs: - name: Upload Coverage to Codecov uses: codecov/codecov-action@v2 - postgres-unit-tests: + postgres: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: [ 3.9, "3.10", "3.11" ] # latest release minus two + python-version: [ 3.9, "3.10", "3.11", "3.12" ] # latest release minus two requirements-file: [ dj32_cms41.txt, dj40_cms41.txt, @@ -85,12 +85,12 @@ jobs: - name: Upload Coverage to Codecov uses: codecov/codecov-action@v2 - mysql-unit-tests: + mysql: runs-on: ubuntu-latest strategy: fail-fast: false matrix: - python-version: [ 3.9, "3.10", "3.11" ] # latest release minus two + python-version: [ 3.9, "3.10", "3.11", "3.12" ] # latest release minus two requirements-file: [ dj32_cms41.txt, dj40_cms41.txt, diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index b8783b76..424fe96a 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -1177,13 +1177,8 @@ def compare_view(self, request, object_id): get_cms_setting("CMS_TOOLBAR_URL__DISABLE"): 1, get_cms_setting("CMS_TOOLBAR_URL__PERSIST"): 0, } - v1_preview_url = add_url_parameters( - reverse( - "admin:cms_placeholder_render_object_preview", - args=(v1.content_type_id, v1.object_id), - ), - **persist_params - ) + v1_preview_url = get_preview_url(v1.content) + v1_preview_url = add_url_parameters(v1_preview_url, **persist_params) # Get the list of versions for the grouper. This is for use # in the dropdown to choose a version. version_list = Version.objects.filter_by_content_grouping_values( @@ -1206,16 +1201,12 @@ def compare_view(self, request, object_id): request, self.model._meta, request.GET["compare_to"] ) else: + v2_preview_url = get_preview_url(v2.content) + v2_preview_url = add_url_parameters(v2_preview_url, **persist_params) context.update( { "v2": v2, - "v2_preview_url": add_url_parameters( - reverse( - "admin:cms_placeholder_render_object_preview", - args=(v2.content_type_id, v2.object_id), - ), - **persist_params - ), + "v2_preview_url": v2_preview_url, } ) return TemplateResponse( diff --git a/djangocms_versioning/cms_config.py b/djangocms_versioning/cms_config.py index bd113ccd..92c66fe4 100644 --- a/djangocms_versioning/cms_config.py +++ b/djangocms_versioning/cms_config.py @@ -32,6 +32,7 @@ from .helpers import ( 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, @@ -408,5 +409,5 @@ class VersioningCMSConfig(CMSAppConfig): ) ] cms_toolbar_mixin = CMSToolbarVersioningMixin - PageContent.add_to_class("is_editable", indicators.is_editable) + PageContent.add_to_class("is_editable", is_editable) PageContent.add_to_class("content_indicator", indicators.content_indicator) diff --git a/djangocms_versioning/cms_menus.py b/djangocms_versioning/cms_menus.py index 7d955384..eba612de 100644 --- a/djangocms_versioning/cms_menus.py +++ b/djangocms_versioning/cms_menus.py @@ -94,7 +94,7 @@ def get_nodes(self, request): # Depending on the toolbar mode, we need to get the correct version. # On edit or preview mode: return DRAFT, - # if DRAFT does not exists then return PUBLISHED. + # if DRAFT does not exist then return PUBLISHED. # On public mode: return PUBLISHED. if edit_or_preview: states = [constants.DRAFT, constants.PUBLISHED] diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index e58770af..04272cdf 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -26,6 +26,13 @@ emit_content_change = None +def is_editable(content_obj, request): + """Check of content_obj is editable""" + from .models import Version + + return Version.objects.get_for_content(content_obj).check_modify.as_bool(request.user) + + def versioning_admin_factory(admin_class, mixin): """A class factory returning admin class with overriden versioning functionality. @@ -147,6 +154,8 @@ def inject_generic_relation_to_version(model): related_query_name = f"{model._meta.app_label}_{model._meta.model_name}" model.add_to_class("versions", GenericRelation( Version, related_query_name=related_query_name)) + if not hasattr(model, "is_editable"): + model.add_to_class("is_editable", is_editable) def _set_default_manager(model, manager): @@ -264,8 +273,8 @@ def get_preview_url(content_obj: models.Model, language: typing.Union[str, None] return versionable.preview_url(content_obj) if is_editable_model(content_obj.__class__): url = get_object_preview_url(content_obj, language=language) - # Or else, the standard change view should be used else: + # Or else, the standard change view should be used url = admin_reverse( f"{content_obj._meta.app_label}_{content_obj._meta.model_name}_change", args=[content_obj.pk], diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index 2e8380fb..0b625d63 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -120,12 +120,3 @@ def content_indicator(content_obj): content_obj._indicator_status = None content_obj._version = [None] return content_obj._indicator_status - - -def is_editable(content_obj, request): - """Check of content_obj is editable""" - if not content_obj.content_indicator(): - # Something's wrong: content indicator not identified. Maybe no version? - return False - versions = content_obj._version - return versions[0].check_modify.as_bool(request.user) diff --git a/djangocms_versioning/locale/de/LC_MESSAGES/django.po b/djangocms_versioning/locale/de/LC_MESSAGES/django.po index 939a0fc6..5390b07a 100644 --- a/djangocms_versioning/locale/de/LC_MESSAGES/django.po +++ b/djangocms_versioning/locale/de/LC_MESSAGES/django.po @@ -11,115 +11,116 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-05 17:59+0200\n" +"POT-Creation-Date: 2023-10-02 09:37+0200\n" "PO-Revision-Date: 2023-01-10 15:29+0000\n" "Last-Translator: Fabian Braun , 2023\n" "Language-Team: German (https://app.transifex.com/divio/teams/58664/de/)\n" +"Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: admin.py:164 admin.py:301 admin.py:383 +#: admin.py:164 admin.py:301 admin.py:377 msgid "State" msgstr "Status" -#: admin.py:192 constants.py:28 +#: admin.py:192 constants.py:27 msgid "Empty" msgstr "Leer" -#: admin.py:315 admin.py:395 +#: admin.py:315 admin.py:387 msgid "Author" msgstr "Autor" -#: admin.py:329 admin.py:406 models.py:89 +#: admin.py:329 admin.py:401 models.py:87 msgid "Modified" msgstr "Geändert" -#: admin.py:433 admin.py:661 +#: admin.py:437 admin.py:667 #: templates/djangocms_versioning/admin/icons/preview.html:3 #: templates/djangocms_versioning/admin/preview.html:3 msgid "Preview" msgstr "Vorschau" -#: admin.py:468 admin.py:760 cms_toolbars.py:121 +#: admin.py:470 admin.py:758 cms_toolbars.py:115 #: templates/djangocms_versioning/admin/icons/edit_icon.html:3 msgid "Edit" msgstr "Bearbeiten" -#: admin.py:480 +#: admin.py:482 #: templates/djangocms_versioning/admin/icons/manage_versions.html:3 msgid "Manage versions" msgstr "Versionen verwalten" -#: admin.py:639 +#: admin.py:631 msgid "Content" msgstr "Inhalt" -#: admin.py:649 +#: admin.py:647 msgid "locked" msgstr "gesperrt" -#: admin.py:679 templates/djangocms_versioning/admin/icons/archive_icon.html:3 +#: admin.py:683 templates/djangocms_versioning/admin/icons/archive_icon.html:3 msgid "Archive" msgstr "Archivieren" -#: admin.py:699 cms_toolbars.py:83 indicators.py:41 +#: admin.py:701 cms_toolbars.py:79 indicators.py:34 #: templates/djangocms_versioning/admin/icons/publish_icon.html:3 msgid "Publish" msgstr "Veröffentlichen" -#: admin.py:721 indicators.py:61 indicators.py:67 +#: admin.py:721 indicators.py:54 indicators.py:60 #: templates/djangocms_versioning/admin/icons/unpublish_icon.html:3 msgid "Unpublish" msgstr "Veröffentlichung aufheben" -#: admin.py:760 cms_toolbars.py:121 +#: admin.py:758 cms_toolbars.py:115 msgid "New Draft" msgstr "Neuer Entwurf" -#: admin.py:783 cms_toolbars.py:188 +#: admin.py:779 cms_toolbars.py:177 #: templates/djangocms_versioning/admin/icons/revert_icon.html:3 msgid "Revert" msgstr "Zurückholen" -#: admin.py:804 templates/djangocms_versioning/admin/icons/discard_icon.html:3 +#: admin.py:798 templates/djangocms_versioning/admin/icons/discard_icon.html:3 msgid "Discard" msgstr "Verwerfen" -#: admin.py:829 cms_toolbars.py:153 +#: admin.py:821 cms_toolbars.py:145 msgid "Unlock" msgstr "Entsperren" #: admin.py:856 -msgid "Exactly two versions need to be selected." -msgstr "Genau zwei Versionen müssen ausgewählt werden." - -#: admin.py:870 msgid "Compare versions" msgstr "Versionen vergleichen" -#: admin.py:897 +#: admin.py:866 +msgid "Exactly two versions need to be selected." +msgstr "Genau zwei Versionen müssen ausgewählt werden." + +#: admin.py:903 msgid "Version cannot be archived" msgstr "Version kann nicht archiviert werden" -#: admin.py:926 +#: admin.py:929 msgid "Version archived" msgstr "Version archiviert" -#: admin.py:937 admin.py:1059 admin.py:1241 +#: admin.py:940 admin.py:1059 admin.py:1235 msgid "This view only supports POST method." msgstr "Dieser View unterstützt nur die POST-Methode." -#: admin.py:948 +#: admin.py:951 msgid "Version cannot be published" msgstr "Version kann nicht veröffentlicht werden" -#: admin.py:959 +#: admin.py:962 msgid "Version published" msgstr "Version veröffentlicht" -#: admin.py:976 +#: admin.py:979 msgid "Version cannot be unpublished" msgstr "Die Veröffentlichung kann nicht aufgehoben werden" @@ -127,19 +128,19 @@ msgstr "Die Veröffentlichung kann nicht aufgehoben werden" msgid "Version unpublished" msgstr "Veröffentlichung aufgehoben" -#: admin.py:1169 +#: admin.py:1163 msgid "The last version has been deleted" msgstr "Die neueste Version wurde gelöscht" -#: admin.py:1255 +#: admin.py:1249 msgid "You do not have permission to remove the version lock" msgstr "Keine Berechtigung vorhanden, um die Sperrung der Version aufzuheben." -#: admin.py:1260 +#: admin.py:1254 msgid "Version unlocked" msgstr "Version entsperrt" -#: admin.py:1309 +#: admin.py:1303 #, python-brace-format msgid "Displaying versions of \"{grouper}\"" msgstr "Zeige Versionen von \"{grouper}\"" @@ -148,180 +149,180 @@ msgstr "Zeige Versionen von \"{grouper}\"" msgid "django CMS Versioning" msgstr "django CMS Versioning" -#: cms_config.py:262 +#: cms_config.py:246 msgid "No available title" msgstr "Kein Titel verfügbar" -#: cms_config.py:264 constants.py:13 constants.py:26 +#: cms_config.py:248 constants.py:12 constants.py:25 msgid "Unpublished" msgstr "Veröffentlichung aufgehoben" -#: cms_config.py:358 +#: cms_config.py:342 msgid "Language must be set to a supported language!" msgstr "Eine unterstützte Sprache muss ausgewählt sein!" -#: cms_config.py:376 +#: cms_config.py:360 msgid "You do not have permission to copy these plugins." msgstr "Keine Erlaubnis, diese Plugins zu kopieren." -#: cms_toolbars.py:218 +#: cms_toolbars.py:207 msgid "Manage Versions" msgstr "Versionen verwalten" -#: cms_toolbars.py:221 +#: cms_toolbars.py:210 #, python-brace-format msgid "Compare to {source}" msgstr "Mit {source} vergleichen" -#: cms_toolbars.py:236 indicators.py:73 +#: cms_toolbars.py:226 indicators.py:66 msgid "Discard Changes" msgstr "Änderungen verwerfen" -#: cms_toolbars.py:271 +#: cms_toolbars.py:262 msgid "View Published" msgstr "Veröffentlichung ansehen" -#: cms_toolbars.py:327 +#: cms_toolbars.py:317 msgid "Language" msgstr "Sprache" -#: cms_toolbars.py:374 +#: cms_toolbars.py:364 msgid "Add Translation" msgstr "Übersetzung hinzufügen" -#: cms_toolbars.py:387 +#: cms_toolbars.py:377 msgid "Copy all plugins" msgstr "Alle Plugins kopieren" -#: cms_toolbars.py:389 +#: cms_toolbars.py:379 #, python-format msgid "from %s" msgstr "von %s" -#: cms_toolbars.py:390 +#: cms_toolbars.py:380 #, python-format msgid "Are you sure you want to copy all plugins from %s?" msgstr "Sind Sie sicher, dass sie alle Plugins von %s kopieren wollen?" -#: cms_toolbars.py:405 +#: cms_toolbars.py:395 msgid "No other language available" msgstr "Keine andere Sprache verfügbar" -#: constants.py:11 constants.py:25 +#: constants.py:10 constants.py:24 msgid "Draft" msgstr "Entwurf" -#: constants.py:12 constants.py:23 +#: constants.py:11 constants.py:22 msgid "Published" msgstr "Veröffentlicht" -#: constants.py:14 constants.py:27 +#: constants.py:13 constants.py:26 msgid "Archived" msgstr "Archiviert" -#: constants.py:24 +#: constants.py:23 msgid "Changed" msgstr "Verändert" -#: emails.py:38 +#: emails.py:39 msgid "Unlocked" msgstr "Entsperrt" -#: indicators.py:35 +#: indicators.py:28 #, python-format msgid "Unlock (%(message)s)" msgstr "Entsperren (%(message)s)" -#: indicators.py:47 +#: indicators.py:40 msgid "Create new draft" msgstr "Neuen Entwurf erstellen" -#: indicators.py:53 +#: indicators.py:46 msgid "Revert from Unpublish" msgstr "Zurückholen" -#: indicators.py:73 +#: indicators.py:66 msgid "Delete Draft" msgstr "Entwurf löschen" -#: indicators.py:79 +#: indicators.py:72 msgid "Compare Draft to Published..." msgstr "Entwurf mit Veröffentlichung vergleichen..." -#: indicators.py:89 +#: indicators.py:82 msgid "Manage Versions..." msgstr "Versionen verwalten..." -#: models.py:31 +#: models.py:29 msgid "Version is not a draft" msgstr "Version ist kein Entwurf" -#: models.py:32 +#: models.py:30 #, python-brace-format msgid "Action Denied. The latest version is locked by {user}" msgstr "Aktion verweigert. Die aktuelle Version ist von {user} gesperrt." -#: models.py:33 +#: models.py:31 #, python-brace-format msgid "Action Denied. The draft version is locked by {user}" msgstr "Aktion verweigert. Der Entwurf ist von {user} gesperrt." -#: models.py:88 +#: models.py:86 msgid "Created" msgstr "Erstellt" -#: models.py:91 +#: models.py:89 msgid "author" msgstr "Autor" -#: models.py:100 +#: models.py:102 msgid "status" msgstr "Status" -#: models.py:108 +#: models.py:110 msgid "locked by" msgstr "gesperrt von" -#: models.py:117 +#: models.py:119 msgid "source" msgstr "Ursprung" -#: models.py:131 +#: models.py:133 #, python-brace-format msgid "Version #{number} ({state} {date})" msgstr "Version #{number} ({state} {date}) " -#: models.py:138 +#: models.py:140 #, python-brace-format msgid "Version #{number} ({state})" msgstr "Version #{number} ({state})" -#: models.py:144 +#: models.py:146 #, python-format msgid "Locked by %(user)s" msgstr "Gesperrt von %(user)s" -#: models.py:276 models.py:325 +#: models.py:278 models.py:327 msgid "Version is not in draft state" msgstr "Version ist kein Entwurf" -#: models.py:385 +#: models.py:387 msgid "Version is not in published state" msgstr "Version ist nicht veröffentlicht" -#: models.py:442 +#: models.py:444 msgid "Version is not in archived or unpublished state" msgstr "Version ist weder archiviert noch eine Veröffentlichung aufgehoben" -#: models.py:457 +#: models.py:459 msgid "Version is not in draft or published state" msgstr "Version ist weder ein Entwurf noch veröffentlicht" -#: models.py:465 +#: models.py:467 msgid "Version is already locked" msgstr "Version bereits gesperrt" -#: models.py:471 +#: models.py:473 msgid "Draft version is not locked" msgstr "Entwurf ist nicht gesperrt" @@ -489,7 +490,8 @@ msgid "" "The following draft version has been unlocked by %(by_user)s for their use.\n" "%(version_link)s\n" "\n" -"Please note you will not be able to further edit this draft. Kindly reach out to %(by_user)s in case of any concerns.\n" +"Please note you will not be able to further edit this draft. Kindly reach " +"out to %(by_user)s in case of any concerns.\n" "\n" "This is an automated notification from Django CMS.\n" msgstr "" @@ -498,6 +500,7 @@ msgstr "" "\n" "%(version_link)s\n" "\n" -"Bitte beachte, dass Du diesen Entwurf nicht mehr ändern kannst. Bitte kontaktiere %(by_user)s für Rückfragen.\n" +"Bitte beachte, dass Du diesen Entwurf nicht mehr ändern kannst. Bitte " +"kontaktiere %(by_user)s für Rückfragen.\n" "\n" "Dies ist eine automatisierte Nachricht von Django CMS.\n" diff --git a/djangocms_versioning/locale/en/LC_MESSAGES/django.po b/djangocms_versioning/locale/en/LC_MESSAGES/django.po index f5352e8b..05666e41 100644 --- a/djangocms_versioning/locale/en/LC_MESSAGES/django.po +++ b/djangocms_versioning/locale/en/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-05 17:59+0200\n" +"POT-Creation-Date: 2023-10-02 09:37+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,106 +18,106 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: admin.py:164 admin.py:301 admin.py:383 +#: admin.py:164 admin.py:301 admin.py:377 msgid "State" msgstr "" -#: admin.py:192 constants.py:28 +#: admin.py:192 constants.py:27 msgid "Empty" msgstr "" -#: admin.py:315 admin.py:395 +#: admin.py:315 admin.py:387 msgid "Author" msgstr "" -#: admin.py:329 admin.py:406 models.py:89 +#: admin.py:329 admin.py:401 models.py:87 msgid "Modified" msgstr "" -#: admin.py:433 admin.py:661 +#: admin.py:437 admin.py:667 #: templates/djangocms_versioning/admin/icons/preview.html:3 #: templates/djangocms_versioning/admin/preview.html:3 msgid "Preview" msgstr "" -#: admin.py:468 admin.py:760 cms_toolbars.py:121 +#: admin.py:470 admin.py:758 cms_toolbars.py:115 #: templates/djangocms_versioning/admin/icons/edit_icon.html:3 msgid "Edit" msgstr "" -#: admin.py:480 +#: admin.py:482 #: templates/djangocms_versioning/admin/icons/manage_versions.html:3 msgid "Manage versions" msgstr "" -#: admin.py:639 +#: admin.py:631 msgid "Content" msgstr "" -#: admin.py:649 +#: admin.py:647 msgid "locked" msgstr "" -#: admin.py:679 templates/djangocms_versioning/admin/icons/archive_icon.html:3 +#: admin.py:683 templates/djangocms_versioning/admin/icons/archive_icon.html:3 msgid "Archive" msgstr "" -#: admin.py:699 cms_toolbars.py:83 indicators.py:41 +#: admin.py:701 cms_toolbars.py:79 indicators.py:34 #: templates/djangocms_versioning/admin/icons/publish_icon.html:3 msgid "Publish" msgstr "" -#: admin.py:721 indicators.py:61 indicators.py:67 +#: admin.py:721 indicators.py:54 indicators.py:60 #: templates/djangocms_versioning/admin/icons/unpublish_icon.html:3 msgid "Unpublish" msgstr "" -#: admin.py:760 cms_toolbars.py:121 +#: admin.py:758 cms_toolbars.py:115 msgid "New Draft" msgstr "" -#: admin.py:783 cms_toolbars.py:188 +#: admin.py:779 cms_toolbars.py:177 #: templates/djangocms_versioning/admin/icons/revert_icon.html:3 msgid "Revert" msgstr "" -#: admin.py:804 templates/djangocms_versioning/admin/icons/discard_icon.html:3 +#: admin.py:798 templates/djangocms_versioning/admin/icons/discard_icon.html:3 msgid "Discard" msgstr "" -#: admin.py:829 cms_toolbars.py:153 +#: admin.py:821 cms_toolbars.py:145 msgid "Unlock" msgstr "" #: admin.py:856 -msgid "Exactly two versions need to be selected." +msgid "Compare versions" msgstr "" -#: admin.py:870 -msgid "Compare versions" +#: admin.py:866 +msgid "Exactly two versions need to be selected." msgstr "" -#: admin.py:897 +#: admin.py:903 msgid "Version cannot be archived" msgstr "" -#: admin.py:926 +#: admin.py:929 msgid "Version archived" msgstr "" -#: admin.py:937 admin.py:1059 admin.py:1241 +#: admin.py:940 admin.py:1059 admin.py:1235 msgid "This view only supports POST method." msgstr "" -#: admin.py:948 +#: admin.py:951 msgid "Version cannot be published" msgstr "" -#: admin.py:959 +#: admin.py:962 msgid "Version published" msgstr "" -#: admin.py:976 +#: admin.py:979 msgid "Version cannot be unpublished" msgstr "" @@ -125,19 +125,19 @@ msgstr "" msgid "Version unpublished" msgstr "" -#: admin.py:1169 +#: admin.py:1163 msgid "The last version has been deleted" msgstr "" -#: admin.py:1255 +#: admin.py:1249 msgid "You do not have permission to remove the version lock" msgstr "" -#: admin.py:1260 +#: admin.py:1254 msgid "Version unlocked" msgstr "" -#: admin.py:1309 +#: admin.py:1303 #, python-brace-format msgid "Displaying versions of \"{grouper}\"" msgstr "" @@ -146,180 +146,180 @@ msgstr "" msgid "django CMS Versioning" msgstr "" -#: cms_config.py:262 +#: cms_config.py:246 msgid "No available title" msgstr "" -#: cms_config.py:264 constants.py:13 constants.py:26 +#: cms_config.py:248 constants.py:12 constants.py:25 msgid "Unpublished" msgstr "" -#: cms_config.py:358 +#: cms_config.py:342 msgid "Language must be set to a supported language!" msgstr "" -#: cms_config.py:376 +#: cms_config.py:360 msgid "You do not have permission to copy these plugins." msgstr "" -#: cms_toolbars.py:218 +#: cms_toolbars.py:207 msgid "Manage Versions" msgstr "" -#: cms_toolbars.py:221 +#: cms_toolbars.py:210 #, python-brace-format msgid "Compare to {source}" msgstr "" -#: cms_toolbars.py:236 indicators.py:73 +#: cms_toolbars.py:226 indicators.py:66 msgid "Discard Changes" msgstr "" -#: cms_toolbars.py:271 +#: cms_toolbars.py:262 msgid "View Published" msgstr "" -#: cms_toolbars.py:327 +#: cms_toolbars.py:317 msgid "Language" msgstr "" -#: cms_toolbars.py:374 +#: cms_toolbars.py:364 msgid "Add Translation" msgstr "" -#: cms_toolbars.py:387 +#: cms_toolbars.py:377 msgid "Copy all plugins" msgstr "" -#: cms_toolbars.py:389 +#: cms_toolbars.py:379 #, python-format msgid "from %s" msgstr "" -#: cms_toolbars.py:390 +#: cms_toolbars.py:380 #, python-format msgid "Are you sure you want to copy all plugins from %s?" msgstr "" -#: cms_toolbars.py:405 +#: cms_toolbars.py:395 msgid "No other language available" msgstr "" -#: constants.py:11 constants.py:25 +#: constants.py:10 constants.py:24 msgid "Draft" msgstr "" -#: constants.py:12 constants.py:23 +#: constants.py:11 constants.py:22 msgid "Published" msgstr "" -#: constants.py:14 constants.py:27 +#: constants.py:13 constants.py:26 msgid "Archived" msgstr "" -#: constants.py:24 +#: constants.py:23 msgid "Changed" msgstr "" -#: emails.py:38 +#: emails.py:39 msgid "Unlocked" msgstr "" -#: indicators.py:35 +#: indicators.py:28 #, python-format msgid "Unlock (%(message)s)" msgstr "" -#: indicators.py:47 +#: indicators.py:40 msgid "Create new draft" msgstr "" -#: indicators.py:53 +#: indicators.py:46 msgid "Revert from Unpublish" msgstr "" -#: indicators.py:73 +#: indicators.py:66 msgid "Delete Draft" msgstr "" -#: indicators.py:79 +#: indicators.py:72 msgid "Compare Draft to Published..." msgstr "" -#: indicators.py:89 +#: indicators.py:82 msgid "Manage Versions..." msgstr "" -#: models.py:31 +#: models.py:29 msgid "Version is not a draft" msgstr "" -#: models.py:32 +#: models.py:30 #, python-brace-format msgid "Action Denied. The latest version is locked by {user}" msgstr "" -#: models.py:33 +#: models.py:31 #, python-brace-format msgid "Action Denied. The draft version is locked by {user}" msgstr "" -#: models.py:88 +#: models.py:86 msgid "Created" msgstr "" -#: models.py:91 +#: models.py:89 msgid "author" msgstr "" -#: models.py:100 +#: models.py:102 msgid "status" msgstr "" -#: models.py:108 +#: models.py:110 msgid "locked by" msgstr "" -#: models.py:117 +#: models.py:119 msgid "source" msgstr "" -#: models.py:131 +#: models.py:133 #, python-brace-format msgid "Version #{number} ({state} {date})" msgstr "" -#: models.py:138 +#: models.py:140 #, python-brace-format msgid "Version #{number} ({state})" msgstr "" -#: models.py:144 +#: models.py:146 #, python-format msgid "Locked by %(user)s" msgstr "" -#: models.py:276 models.py:325 +#: models.py:278 models.py:327 msgid "Version is not in draft state" msgstr "" -#: models.py:385 +#: models.py:387 msgid "Version is not in published state" msgstr "" -#: models.py:442 +#: models.py:444 msgid "Version is not in archived or unpublished state" msgstr "" -#: models.py:457 +#: models.py:459 msgid "Version is not in draft or published state" msgstr "" -#: models.py:465 +#: models.py:467 msgid "Version is already locked" msgstr "" -#: models.py:471 +#: models.py:473 msgid "Draft version is not locked" msgstr "" diff --git a/djangocms_versioning/locale/fr/LC_MESSAGES/django.po b/djangocms_versioning/locale/fr/LC_MESSAGES/django.po index 2fa5d9fa..0316829c 100644 --- a/djangocms_versioning/locale/fr/LC_MESSAGES/django.po +++ b/djangocms_versioning/locale/fr/LC_MESSAGES/django.po @@ -2,126 +2,127 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# # Translators: # François Palmier , 2023 # Frédéric Roland, 2023 -# +# #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-05 17:59+0200\n" +"POT-Creation-Date: 2023-10-02 09:37+0200\n" "PO-Revision-Date: 2023-01-10 15:29+0000\n" "Last-Translator: Frédéric Roland, 2023\n" "Language-Team: French (https://app.transifex.com/divio/teams/58664/fr/)\n" +"Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Language: fr\n" -"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n" +"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % " +"1000000 == 0 ? 1 : 2;\n" -#: admin.py:164 admin.py:301 admin.py:383 +#: admin.py:164 admin.py:301 admin.py:377 msgid "State" msgstr "État" -#: admin.py:192 constants.py:28 +#: admin.py:192 constants.py:27 msgid "Empty" msgstr "Vide" -#: admin.py:315 admin.py:395 +#: admin.py:315 admin.py:387 msgid "Author" msgstr "Auteur" -#: admin.py:329 admin.py:406 models.py:89 +#: admin.py:329 admin.py:401 models.py:87 msgid "Modified" msgstr "Modifié" -#: admin.py:433 admin.py:661 +#: admin.py:437 admin.py:667 #: templates/djangocms_versioning/admin/icons/preview.html:3 #: templates/djangocms_versioning/admin/preview.html:3 msgid "Preview" msgstr "Pré-visualisation" -#: admin.py:468 admin.py:760 cms_toolbars.py:121 +#: admin.py:470 admin.py:758 cms_toolbars.py:115 #: templates/djangocms_versioning/admin/icons/edit_icon.html:3 msgid "Edit" msgstr "Éditer" -#: admin.py:480 +#: admin.py:482 #: templates/djangocms_versioning/admin/icons/manage_versions.html:3 msgid "Manage versions" msgstr "Gérer les versions" -#: admin.py:639 +#: admin.py:631 msgid "Content" msgstr "Contenu" -#: admin.py:649 +#: admin.py:647 msgid "locked" msgstr "verrouillé" -#: admin.py:679 templates/djangocms_versioning/admin/icons/archive_icon.html:3 +#: admin.py:683 templates/djangocms_versioning/admin/icons/archive_icon.html:3 msgid "Archive" msgstr "Archiver" -#: admin.py:699 cms_toolbars.py:83 indicators.py:41 +#: admin.py:701 cms_toolbars.py:79 indicators.py:34 #: templates/djangocms_versioning/admin/icons/publish_icon.html:3 msgid "Publish" msgstr "Publier" -#: admin.py:721 indicators.py:61 indicators.py:67 +#: admin.py:721 indicators.py:54 indicators.py:60 #: templates/djangocms_versioning/admin/icons/unpublish_icon.html:3 msgid "Unpublish" msgstr "Dépublier" -#: admin.py:760 cms_toolbars.py:121 +#: admin.py:758 cms_toolbars.py:115 msgid "New Draft" msgstr "Nouveau Brouillon" -#: admin.py:783 cms_toolbars.py:188 +#: admin.py:779 cms_toolbars.py:177 #: templates/djangocms_versioning/admin/icons/revert_icon.html:3 msgid "Revert" msgstr "Rétablir" -#: admin.py:804 templates/djangocms_versioning/admin/icons/discard_icon.html:3 +#: admin.py:798 templates/djangocms_versioning/admin/icons/discard_icon.html:3 msgid "Discard" msgstr "Rejeter" -#: admin.py:829 cms_toolbars.py:153 +#: admin.py:821 cms_toolbars.py:145 msgid "Unlock" msgstr "Déverrouiller" #: admin.py:856 -msgid "Exactly two versions need to be selected." -msgstr "Il faut sélectionner exactement deux versions." - -#: admin.py:870 msgid "Compare versions" msgstr "Comparer les versions" -#: admin.py:897 +#: admin.py:866 +msgid "Exactly two versions need to be selected." +msgstr "Il faut sélectionner exactement deux versions." + +#: admin.py:903 msgid "Version cannot be archived" msgstr "La version ne peut pas être archivée" -#: admin.py:926 +#: admin.py:929 msgid "Version archived" msgstr "Version archivée" -#: admin.py:937 admin.py:1059 admin.py:1241 +#: admin.py:940 admin.py:1059 admin.py:1235 msgid "This view only supports POST method." msgstr "Cette vue ne prend en charge que la méthode POST." -#: admin.py:948 +#: admin.py:951 msgid "Version cannot be published" msgstr "La version ne peut pas être publiée" -#: admin.py:959 +#: admin.py:962 msgid "Version published" msgstr "Version publiée" -#: admin.py:976 +#: admin.py:979 msgid "Version cannot be unpublished" msgstr "La version ne peut pas être dépubliée" @@ -129,19 +130,19 @@ msgstr "La version ne peut pas être dépubliée" msgid "Version unpublished" msgstr "Version non publiée" -#: admin.py:1169 +#: admin.py:1163 msgid "The last version has been deleted" msgstr "La dernière version a été supprimée" -#: admin.py:1255 +#: admin.py:1249 msgid "You do not have permission to remove the version lock" msgstr "Vous n'avez pas la permission de retirer le verrouillage de version" -#: admin.py:1260 +#: admin.py:1254 msgid "Version unlocked" msgstr "Version déverrouillée" -#: admin.py:1309 +#: admin.py:1303 #, python-brace-format msgid "Displaying versions of \"{grouper}\"" msgstr "Afficher les versions de \"{grouper}\"" @@ -150,180 +151,180 @@ msgstr "Afficher les versions de \"{grouper}\"" msgid "django CMS Versioning" msgstr "django CMS Versioning" -#: cms_config.py:262 +#: cms_config.py:246 msgid "No available title" msgstr "Aucun titre disponible" -#: cms_config.py:264 constants.py:13 constants.py:26 +#: cms_config.py:248 constants.py:12 constants.py:25 msgid "Unpublished" msgstr "Non publié" -#: cms_config.py:358 +#: cms_config.py:342 msgid "Language must be set to a supported language!" msgstr "La langue doit être définie comme une langue prise en charge !" -#: cms_config.py:376 +#: cms_config.py:360 msgid "You do not have permission to copy these plugins." msgstr "Vous n'avez pas la permission de copier ces plugins." -#: cms_toolbars.py:218 +#: cms_toolbars.py:207 msgid "Manage Versions" msgstr "Gérer les versions" -#: cms_toolbars.py:221 +#: cms_toolbars.py:210 #, python-brace-format msgid "Compare to {source}" msgstr "Comparer à {source}" -#: cms_toolbars.py:236 indicators.py:73 +#: cms_toolbars.py:226 indicators.py:66 msgid "Discard Changes" msgstr "Abandonner les modifications" -#: cms_toolbars.py:271 +#: cms_toolbars.py:262 msgid "View Published" msgstr "Vue publiée" -#: cms_toolbars.py:327 +#: cms_toolbars.py:317 msgid "Language" msgstr "Langue" -#: cms_toolbars.py:374 +#: cms_toolbars.py:364 msgid "Add Translation" msgstr "Ajouter une traduction" -#: cms_toolbars.py:387 +#: cms_toolbars.py:377 msgid "Copy all plugins" msgstr "Copier tous les plugins" -#: cms_toolbars.py:389 +#: cms_toolbars.py:379 #, python-format msgid "from %s" msgstr "de %s" -#: cms_toolbars.py:390 +#: cms_toolbars.py:380 #, python-format msgid "Are you sure you want to copy all plugins from %s?" msgstr "Êtes-vous sûr de vouloir copier tous les plugins de %s?" -#: cms_toolbars.py:405 +#: cms_toolbars.py:395 msgid "No other language available" msgstr "Aucune autre langue disponible" -#: constants.py:11 constants.py:25 +#: constants.py:10 constants.py:24 msgid "Draft" msgstr "Brouillon" -#: constants.py:12 constants.py:23 +#: constants.py:11 constants.py:22 msgid "Published" msgstr "Publié" -#: constants.py:14 constants.py:27 +#: constants.py:13 constants.py:26 msgid "Archived" msgstr "Archivé" -#: constants.py:24 +#: constants.py:23 msgid "Changed" msgstr "Modifié" -#: emails.py:38 +#: emails.py:39 msgid "Unlocked" msgstr "Déverrouillée" -#: indicators.py:35 +#: indicators.py:28 #, python-format msgid "Unlock (%(message)s)" msgstr "Déverrouiller (%(message)s)" -#: indicators.py:47 +#: indicators.py:40 msgid "Create new draft" msgstr "Créer un brouillon" -#: indicators.py:53 +#: indicators.py:46 msgid "Revert from Unpublish" msgstr "Annulation de la publication" -#: indicators.py:73 +#: indicators.py:66 msgid "Delete Draft" msgstr "Supprimer le brouillon" -#: indicators.py:79 +#: indicators.py:72 msgid "Compare Draft to Published..." msgstr "Comparer le brouillon à la version publiée..." -#: indicators.py:89 +#: indicators.py:82 msgid "Manage Versions..." msgstr "Gérer les versions..." -#: models.py:31 +#: models.py:29 msgid "Version is not a draft" msgstr "La version n'est pas un brouillon" -#: models.py:32 +#: models.py:30 #, python-brace-format msgid "Action Denied. The latest version is locked by {user}" msgstr "Action Refusée. La dernière version est verrouillée par {user}" -#: models.py:33 +#: models.py:31 #, python-brace-format msgid "Action Denied. The draft version is locked by {user}" msgstr "Action Refusée. Le brouillon est verrouillé par {user}" -#: models.py:88 +#: models.py:86 msgid "Created" msgstr "Créée" -#: models.py:91 +#: models.py:89 msgid "author" msgstr "auteur" -#: models.py:100 +#: models.py:102 msgid "status" msgstr "statut" -#: models.py:108 +#: models.py:110 msgid "locked by" msgstr "verrouillée par" -#: models.py:117 +#: models.py:119 msgid "source" msgstr "source" -#: models.py:131 +#: models.py:133 #, python-brace-format msgid "Version #{number} ({state} {date})" msgstr "Version #{number} ({state} {date})" -#: models.py:138 +#: models.py:140 #, python-brace-format msgid "Version #{number} ({state})" msgstr "Version #{number} ({state})" -#: models.py:144 +#: models.py:146 #, python-format msgid "Locked by %(user)s" msgstr "Verrouillée par %(user)s" -#: models.py:276 models.py:325 +#: models.py:278 models.py:327 msgid "Version is not in draft state" msgstr "La version n'est pas à l'état de brouillon" -#: models.py:385 +#: models.py:387 msgid "Version is not in published state" msgstr "La version n'est pas dans l'état publié" -#: models.py:442 +#: models.py:444 msgid "Version is not in archived or unpublished state" msgstr "La version n'est pas archivé ou non publié" -#: models.py:457 +#: models.py:459 msgid "Version is not in draft or published state" msgstr "La version n'est pas en brouillon ou publiée" -#: models.py:465 +#: models.py:467 msgid "Version is already locked" msgstr "La version est déjà verrouillée" -#: models.py:471 +#: models.py:473 msgid "Draft version is not locked" msgstr "Le brouillon n'est pas verrouillé" @@ -492,7 +493,8 @@ msgid "" "The following draft version has been unlocked by %(by_user)s for their use.\n" "%(version_link)s\n" "\n" -"Please note you will not be able to further edit this draft. Kindly reach out to %(by_user)s in case of any concerns.\n" +"Please note you will not be able to further edit this draft. Kindly reach " +"out to %(by_user)s in case of any concerns.\n" "\n" "This is an automated notification from Django CMS.\n" msgstr "" @@ -500,6 +502,7 @@ msgstr "" "Le brouillon suivant a été déverrouillé par %(by_user)s pour son usage.\n" "%(version_link)s\n" "\n" -"Notez que vous ne pourrez pas continuer a modifier ce brouillon. Vous êtes prié de contacter %(by_user)s en cas de soucis.\n" +"Notez que vous ne pourrez pas continuer a modifier ce brouillon. Vous êtes " +"prié de contacter %(by_user)s en cas de soucis.\n" "\n" "C'est une notification automatique de Django CMS.\n" diff --git a/djangocms_versioning/locale/nl/LC_MESSAGES/django.po b/djangocms_versioning/locale/nl/LC_MESSAGES/django.po index fec58ac3..ccc036a8 100644 --- a/djangocms_versioning/locale/nl/LC_MESSAGES/django.po +++ b/djangocms_versioning/locale/nl/LC_MESSAGES/django.po @@ -12,7 +12,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-05 17:59+0200\n" +"POT-Creation-Date: 2023-10-02 09:37+0200\n" "PO-Revision-Date: 2023-01-10 15:29+0000\n" "Last-Translator: Stefan van den Eertwegh , 2023\n" "Language-Team: Dutch (https://app.transifex.com/divio/teams/58664/nl/)\n" @@ -22,106 +22,106 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: admin.py:164 admin.py:301 admin.py:383 +#: admin.py:164 admin.py:301 admin.py:377 msgid "State" msgstr "Status" -#: admin.py:192 constants.py:28 +#: admin.py:192 constants.py:27 msgid "Empty" msgstr "Leeg" -#: admin.py:315 admin.py:395 +#: admin.py:315 admin.py:387 msgid "Author" msgstr "Auteur" -#: admin.py:329 admin.py:406 models.py:89 +#: admin.py:329 admin.py:401 models.py:87 msgid "Modified" msgstr "Gewijzigd" -#: admin.py:433 admin.py:661 +#: admin.py:437 admin.py:667 #: templates/djangocms_versioning/admin/icons/preview.html:3 #: templates/djangocms_versioning/admin/preview.html:3 msgid "Preview" msgstr "Voorbeeld" -#: admin.py:468 admin.py:760 cms_toolbars.py:121 +#: admin.py:470 admin.py:758 cms_toolbars.py:115 #: templates/djangocms_versioning/admin/icons/edit_icon.html:3 msgid "Edit" msgstr "Bewerk" -#: admin.py:480 +#: admin.py:482 #: templates/djangocms_versioning/admin/icons/manage_versions.html:3 msgid "Manage versions" msgstr "Beheer versies" -#: admin.py:639 +#: admin.py:631 msgid "Content" msgstr "Content" -#: admin.py:649 +#: admin.py:647 msgid "locked" msgstr "gesloten" -#: admin.py:679 templates/djangocms_versioning/admin/icons/archive_icon.html:3 +#: admin.py:683 templates/djangocms_versioning/admin/icons/archive_icon.html:3 msgid "Archive" msgstr "Archiveer" -#: admin.py:699 cms_toolbars.py:83 indicators.py:41 +#: admin.py:701 cms_toolbars.py:79 indicators.py:34 #: templates/djangocms_versioning/admin/icons/publish_icon.html:3 msgid "Publish" msgstr "Publiceer" -#: admin.py:721 indicators.py:61 indicators.py:67 +#: admin.py:721 indicators.py:54 indicators.py:60 #: templates/djangocms_versioning/admin/icons/unpublish_icon.html:3 msgid "Unpublish" msgstr "Gedepubliceerd" -#: admin.py:760 cms_toolbars.py:121 +#: admin.py:758 cms_toolbars.py:115 msgid "New Draft" msgstr "Nieuw concept" -#: admin.py:783 cms_toolbars.py:188 +#: admin.py:779 cms_toolbars.py:177 #: templates/djangocms_versioning/admin/icons/revert_icon.html:3 msgid "Revert" msgstr "Terugdraaien" -#: admin.py:804 templates/djangocms_versioning/admin/icons/discard_icon.html:3 +#: admin.py:798 templates/djangocms_versioning/admin/icons/discard_icon.html:3 msgid "Discard" msgstr "Annuleer" -#: admin.py:829 cms_toolbars.py:153 +#: admin.py:821 cms_toolbars.py:145 msgid "Unlock" msgstr "Ongesloten" #: admin.py:856 -msgid "Exactly two versions need to be selected." -msgstr "Precies twee versies moeten zijn geselecteerd." - -#: admin.py:870 msgid "Compare versions" msgstr "Vergelijk versies" -#: admin.py:897 +#: admin.py:866 +msgid "Exactly two versions need to be selected." +msgstr "Precies twee versies moeten zijn geselecteerd." + +#: admin.py:903 msgid "Version cannot be archived" msgstr "Versie kan niet worden gearchiveerd" -#: admin.py:926 +#: admin.py:929 msgid "Version archived" msgstr "Versie gearchiveerd" -#: admin.py:937 admin.py:1059 admin.py:1241 +#: admin.py:940 admin.py:1059 admin.py:1235 msgid "This view only supports POST method." msgstr "Deze weergave ondersteunt alleen de POST methode" -#: admin.py:948 +#: admin.py:951 msgid "Version cannot be published" msgstr "Versie kan niet worden gepubliceerd" -#: admin.py:959 +#: admin.py:962 msgid "Version published" msgstr "Versie gepubliceerd" -#: admin.py:976 +#: admin.py:979 msgid "Version cannot be unpublished" msgstr "Versie kan niet worden gedepubliceerd" @@ -129,19 +129,19 @@ msgstr "Versie kan niet worden gedepubliceerd" msgid "Version unpublished" msgstr "Versie ongepubliceerd" -#: admin.py:1169 +#: admin.py:1163 msgid "The last version has been deleted" msgstr "De laatste versie is verwijderd" -#: admin.py:1255 +#: admin.py:1249 msgid "You do not have permission to remove the version lock" msgstr "Je hebt geen rechten om de versie van t slot te halen" -#: admin.py:1260 +#: admin.py:1254 msgid "Version unlocked" msgstr "Versie ongesloten" -#: admin.py:1309 +#: admin.py:1303 #, python-brace-format msgid "Displaying versions of \"{grouper}\"" msgstr "Weergave versies van \"{grouper}\"" @@ -150,180 +150,180 @@ msgstr "Weergave versies van \"{grouper}\"" msgid "django CMS Versioning" msgstr "django CMS Versionering" -#: cms_config.py:262 +#: cms_config.py:246 msgid "No available title" msgstr "Geen beschikbare titel" -#: cms_config.py:264 constants.py:13 constants.py:26 +#: cms_config.py:248 constants.py:12 constants.py:25 msgid "Unpublished" msgstr "Ongepubliceerd" -#: cms_config.py:358 +#: cms_config.py:342 msgid "Language must be set to a supported language!" msgstr "Taal moet gespecificeerd worden binnen de ondersteunde talen!" -#: cms_config.py:376 +#: cms_config.py:360 msgid "You do not have permission to copy these plugins." msgstr "Je hebt geen rechten om deze plugin te kopieëren." -#: cms_toolbars.py:218 +#: cms_toolbars.py:207 msgid "Manage Versions" msgstr "Beheer versies" -#: cms_toolbars.py:221 +#: cms_toolbars.py:210 #, python-brace-format msgid "Compare to {source}" msgstr "Vergelijk met {source}" -#: cms_toolbars.py:236 indicators.py:73 +#: cms_toolbars.py:226 indicators.py:66 msgid "Discard Changes" msgstr "Annuleer wijzigingen" -#: cms_toolbars.py:271 +#: cms_toolbars.py:262 msgid "View Published" msgstr "Bekijk live versie" -#: cms_toolbars.py:327 +#: cms_toolbars.py:317 msgid "Language" msgstr "Taal" -#: cms_toolbars.py:374 +#: cms_toolbars.py:364 msgid "Add Translation" msgstr "Voeg vertaling toe" -#: cms_toolbars.py:387 +#: cms_toolbars.py:377 msgid "Copy all plugins" msgstr "Kopieer alle plugins" -#: cms_toolbars.py:389 +#: cms_toolbars.py:379 #, python-format msgid "from %s" msgstr "van %s" -#: cms_toolbars.py:390 +#: cms_toolbars.py:380 #, python-format msgid "Are you sure you want to copy all plugins from %s?" msgstr "Ben je er zeker van om alle plugins te kopiëren van %s?" -#: cms_toolbars.py:405 +#: cms_toolbars.py:395 msgid "No other language available" msgstr "Geen andere taal beschikbaar" -#: constants.py:11 constants.py:25 +#: constants.py:10 constants.py:24 msgid "Draft" msgstr "Concept" -#: constants.py:12 constants.py:23 +#: constants.py:11 constants.py:22 msgid "Published" msgstr "Gepubliceerd" -#: constants.py:14 constants.py:27 +#: constants.py:13 constants.py:26 msgid "Archived" msgstr "Gearchiveerd" -#: constants.py:24 +#: constants.py:23 msgid "Changed" msgstr "Gewijzigd" -#: emails.py:38 +#: emails.py:39 msgid "Unlocked" msgstr "Ongesloten" -#: indicators.py:35 +#: indicators.py:28 #, python-format msgid "Unlock (%(message)s)" msgstr "Ongesloten (%(message)s)" -#: indicators.py:47 +#: indicators.py:40 msgid "Create new draft" msgstr "Maakt een nieuw concept" -#: indicators.py:53 +#: indicators.py:46 msgid "Revert from Unpublish" msgstr "Gedepubliceerde terugdraaien" -#: indicators.py:73 +#: indicators.py:66 msgid "Delete Draft" msgstr "Verwijder concept" -#: indicators.py:79 +#: indicators.py:72 msgid "Compare Draft to Published..." msgstr "Vergelijk Concept met Gepubliceerde..." -#: indicators.py:89 +#: indicators.py:82 msgid "Manage Versions..." msgstr "Beheer versies..." -#: models.py:31 +#: models.py:29 msgid "Version is not a draft" msgstr "Versie is niet een concept" -#: models.py:32 +#: models.py:30 #, python-brace-format msgid "Action Denied. The latest version is locked by {user}" msgstr "Actie niet geldig. De laatste versie is gesloten door {user}" -#: models.py:33 +#: models.py:31 #, python-brace-format msgid "Action Denied. The draft version is locked by {user}" msgstr "Actie niet geldig. De concept versie is gesloten door {user}" -#: models.py:88 +#: models.py:86 msgid "Created" msgstr "Aangemaakt" -#: models.py:91 +#: models.py:89 msgid "author" msgstr "auteur" -#: models.py:100 +#: models.py:102 msgid "status" msgstr "status" -#: models.py:108 +#: models.py:110 msgid "locked by" msgstr "gesloten door" -#: models.py:117 +#: models.py:119 msgid "source" msgstr "bron" -#: models.py:131 +#: models.py:133 #, python-brace-format msgid "Version #{number} ({state} {date})" msgstr "Versie #{number} ({state} {date})" -#: models.py:138 +#: models.py:140 #, python-brace-format msgid "Version #{number} ({state})" msgstr "Versie #{number} ({state})" -#: models.py:144 +#: models.py:146 #, python-format msgid "Locked by %(user)s" msgstr "Gesloten door %(user)s" -#: models.py:276 models.py:325 +#: models.py:278 models.py:327 msgid "Version is not in draft state" msgstr "Versie is niet in concept staat" -#: models.py:385 +#: models.py:387 msgid "Version is not in published state" msgstr "Versie is niet in gepubliceerde staat" -#: models.py:442 +#: models.py:444 msgid "Version is not in archived or unpublished state" msgstr "Versie is niet gearchiveerd en niet gepubliceerd" -#: models.py:457 +#: models.py:459 msgid "Version is not in draft or published state" msgstr "Versie is niet een concept of gepubliceerde staat" -#: models.py:465 +#: models.py:467 msgid "Version is already locked" msgstr "Versie is al gesloten" -#: models.py:471 +#: models.py:473 msgid "Draft version is not locked" msgstr "Concept versie is niet gesloten" @@ -492,13 +492,16 @@ msgid "" "The following draft version has been unlocked by %(by_user)s for their use.\n" "%(version_link)s\n" "\n" -"Please note you will not be able to further edit this draft. Kindly reach out to %(by_user)s in case of any concerns.\n" +"Please note you will not be able to further edit this draft. Kindly reach " +"out to %(by_user)s in case of any concerns.\n" "\n" "This is an automated notification from Django CMS.\n" msgstr "" "\n" -"De volgende concept versie is van het slot af door%(by_user)svoor hun gebruik.\n" +"De volgende concept versie is van het slot af door%(by_user)svoor hun " +"gebruik.\n" " %(version_link)s\n" -"Let op: je kunt niet verder bewerken in dit concept. Neem asjeblieft contact op met %(by_user)s in geval van enige zorgen. \n" +"Let op: je kunt niet verder bewerken in dit concept. Neem asjeblieft contact " +"op met %(by_user)s in geval van enige zorgen. \n" "\n" "Dit is een geautomatiseerde notificatie van Django CMS.\n" diff --git a/djangocms_versioning/locale/sq/LC_MESSAGES/django.po b/djangocms_versioning/locale/sq/LC_MESSAGES/django.po index 2c740ce4..263ae0c5 100644 --- a/djangocms_versioning/locale/sq/LC_MESSAGES/django.po +++ b/djangocms_versioning/locale/sq/LC_MESSAGES/django.po @@ -11,7 +11,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2023-05-05 17:59+0200\n" +"POT-Creation-Date: 2023-10-02 09:37+0200\n" "PO-Revision-Date: 2023-01-10 15:29+0000\n" "Last-Translator: Besnik Bleta , 2023\n" "Language-Team: Albanian (https://www.transifex.com/divio/teams/58664/sq/)\n" @@ -21,108 +21,108 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: admin.py:164 admin.py:301 admin.py:383 +#: admin.py:164 admin.py:301 admin.py:377 msgid "State" msgstr "Gjendje" -#: admin.py:192 constants.py:28 +#: admin.py:192 constants.py:27 msgid "Empty" msgstr "I zbrazët" -#: admin.py:315 admin.py:395 +#: admin.py:315 admin.py:387 msgid "Author" msgstr "Autor" -#: admin.py:329 admin.py:406 models.py:89 +#: admin.py:329 admin.py:401 models.py:87 msgid "Modified" msgstr "E ndryshuar" -#: admin.py:433 admin.py:661 +#: admin.py:437 admin.py:667 #: templates/djangocms_versioning/admin/icons/preview.html:3 #: templates/djangocms_versioning/admin/preview.html:3 msgid "Preview" msgstr "Paraparje" -#: admin.py:468 admin.py:760 cms_toolbars.py:121 +#: admin.py:470 admin.py:758 cms_toolbars.py:115 #: templates/djangocms_versioning/admin/icons/edit_icon.html:3 msgid "Edit" msgstr "Përpunojeni" -#: admin.py:480 +#: admin.py:482 #: templates/djangocms_versioning/admin/icons/manage_versions.html:3 msgid "Manage versions" msgstr "Administroni versione" -#: admin.py:639 +#: admin.py:631 msgid "Content" msgstr "Lëndë" -#: admin.py:649 +#: admin.py:647 msgid "locked" msgstr "" -#: admin.py:679 templates/djangocms_versioning/admin/icons/archive_icon.html:3 +#: admin.py:683 templates/djangocms_versioning/admin/icons/archive_icon.html:3 msgid "Archive" msgstr "Arkiv" -#: admin.py:699 cms_toolbars.py:83 indicators.py:41 +#: admin.py:701 cms_toolbars.py:79 indicators.py:34 #: templates/djangocms_versioning/admin/icons/publish_icon.html:3 msgid "Publish" msgstr "Botoje" -#: admin.py:721 indicators.py:61 indicators.py:67 +#: admin.py:721 indicators.py:54 indicators.py:60 #: templates/djangocms_versioning/admin/icons/unpublish_icon.html:3 msgid "Unpublish" msgstr "Hiqe nga të botuar" -#: admin.py:760 cms_toolbars.py:121 +#: admin.py:758 cms_toolbars.py:115 #, fuzzy #| msgid "Draft" msgid "New Draft" msgstr "Skicë" -#: admin.py:783 cms_toolbars.py:188 +#: admin.py:779 cms_toolbars.py:177 #: templates/djangocms_versioning/admin/icons/revert_icon.html:3 msgid "Revert" msgstr "Riktheje" -#: admin.py:804 templates/djangocms_versioning/admin/icons/discard_icon.html:3 +#: admin.py:798 templates/djangocms_versioning/admin/icons/discard_icon.html:3 msgid "Discard" msgstr "Hidhe tej" -#: admin.py:829 cms_toolbars.py:153 +#: admin.py:821 cms_toolbars.py:145 msgid "Unlock" msgstr "" #: admin.py:856 -msgid "Exactly two versions need to be selected." -msgstr "Lypset të përzgjidhen saktësisht dy versione." - -#: admin.py:870 msgid "Compare versions" msgstr "Krahasoni versione" -#: admin.py:897 +#: admin.py:866 +msgid "Exactly two versions need to be selected." +msgstr "Lypset të përzgjidhen saktësisht dy versione." + +#: admin.py:903 msgid "Version cannot be archived" msgstr "Versioni s’mund të arkivohet" -#: admin.py:926 +#: admin.py:929 msgid "Version archived" msgstr "Versioni u arkivua" -#: admin.py:937 admin.py:1059 admin.py:1241 +#: admin.py:940 admin.py:1059 admin.py:1235 msgid "This view only supports POST method." msgstr "Kjo pamje mbulon vetëm metodën POST." -#: admin.py:948 +#: admin.py:951 msgid "Version cannot be published" msgstr "Versioni s’mund të botohet" -#: admin.py:959 +#: admin.py:962 msgid "Version published" msgstr "Versioni u botua" -#: admin.py:976 +#: admin.py:979 msgid "Version cannot be unpublished" msgstr "Versioni s’mund të shbotohet" @@ -130,23 +130,23 @@ msgstr "Versioni s’mund të shbotohet" msgid "Version unpublished" msgstr "Versioni u shbotua" -#: admin.py:1169 +#: admin.py:1163 msgid "The last version has been deleted" msgstr "Versioni i fundit është fshirë" -#: admin.py:1255 +#: admin.py:1249 #, fuzzy #| msgid "You do not have permission to copy these plugins." msgid "You do not have permission to remove the version lock" msgstr "S’keni leje të kopjoni këto shtojca." -#: admin.py:1260 +#: admin.py:1254 #, fuzzy #| msgid "Version unpublished" msgid "Version unlocked" msgstr "Versioni u shbotua" -#: admin.py:1309 +#: admin.py:1303 #, python-brace-format msgid "Displaying versions of \"{grouper}\"" msgstr "Po shfaqen versione të “{grouper}”" @@ -155,187 +155,187 @@ msgstr "Po shfaqen versione të “{grouper}”" msgid "django CMS Versioning" msgstr "Versione në django CMS" -#: cms_config.py:262 +#: cms_config.py:246 msgid "No available title" msgstr "S’ka titull" -#: cms_config.py:264 constants.py:13 constants.py:26 +#: cms_config.py:248 constants.py:12 constants.py:25 msgid "Unpublished" msgstr "I pabotuar" -#: cms_config.py:358 +#: cms_config.py:342 msgid "Language must be set to a supported language!" msgstr "Si gjuhë duhet të caktoni një gjuhë të mbuluar!" -#: cms_config.py:376 +#: cms_config.py:360 msgid "You do not have permission to copy these plugins." msgstr "S’keni leje të kopjoni këto shtojca." -#: cms_toolbars.py:218 +#: cms_toolbars.py:207 msgid "Manage Versions" msgstr "Administroni Versione" -#: cms_toolbars.py:221 +#: cms_toolbars.py:210 #, fuzzy, python-brace-format #| msgid "Compare to {state} source" msgid "Compare to {source}" msgstr "Krahasoje me burimin {state}" -#: cms_toolbars.py:236 indicators.py:73 +#: cms_toolbars.py:226 indicators.py:66 #, fuzzy #| msgid "Discard" msgid "Discard Changes" msgstr "Hidhe tej" -#: cms_toolbars.py:271 +#: cms_toolbars.py:262 msgid "View Published" msgstr "Shihni të Botuarin" -#: cms_toolbars.py:327 +#: cms_toolbars.py:317 msgid "Language" msgstr "Gjuhë" -#: cms_toolbars.py:374 +#: cms_toolbars.py:364 msgid "Add Translation" msgstr "Shtoni Përkthim" -#: cms_toolbars.py:387 +#: cms_toolbars.py:377 msgid "Copy all plugins" msgstr "Kopjo krejt shtojcat" -#: cms_toolbars.py:389 +#: cms_toolbars.py:379 #, python-format msgid "from %s" msgstr "prej %s" -#: cms_toolbars.py:390 +#: cms_toolbars.py:380 #, python-format msgid "Are you sure you want to copy all plugins from %s?" msgstr "Jeni i sigurt se doni të kopjohen krejt shtojcat prej %s?" -#: cms_toolbars.py:405 +#: cms_toolbars.py:395 msgid "No other language available" msgstr "S’ka gjuhë të tjera" -#: constants.py:11 constants.py:25 +#: constants.py:10 constants.py:24 msgid "Draft" msgstr "Skicë" -#: constants.py:12 constants.py:23 +#: constants.py:11 constants.py:22 msgid "Published" msgstr "I botuar" -#: constants.py:14 constants.py:27 +#: constants.py:13 constants.py:26 msgid "Archived" msgstr "I arkivuar" -#: constants.py:24 +#: constants.py:23 msgid "Changed" msgstr "I ndryshur" -#: emails.py:38 +#: emails.py:39 msgid "Unlocked" msgstr "" -#: indicators.py:35 +#: indicators.py:28 #, python-format msgid "Unlock (%(message)s)" msgstr "" -#: indicators.py:47 +#: indicators.py:40 msgid "Create new draft" msgstr "Krijoni një skicë të re" -#: indicators.py:53 +#: indicators.py:46 msgid "Revert from Unpublish" msgstr "Riktheje nga Shbotoje" -#: indicators.py:73 +#: indicators.py:66 msgid "Delete Draft" msgstr "Fshije Skicën" -#: indicators.py:79 +#: indicators.py:72 msgid "Compare Draft to Published..." msgstr "Krahaso Skicë me të Pabotuar…" -#: indicators.py:89 +#: indicators.py:82 msgid "Manage Versions..." msgstr "Administroni Versione…" -#: models.py:31 +#: models.py:29 msgid "Version is not a draft" msgstr "Versioni s’është skicë" -#: models.py:32 +#: models.py:30 #, python-brace-format msgid "Action Denied. The latest version is locked by {user}" msgstr "" -#: models.py:33 +#: models.py:31 #, python-brace-format msgid "Action Denied. The draft version is locked by {user}" msgstr "" -#: models.py:88 +#: models.py:86 #, fuzzy #| msgid "Create new draft" msgid "Created" msgstr "Krijoni një skicë të re" -#: models.py:91 +#: models.py:89 msgid "author" msgstr "autor" -#: models.py:100 +#: models.py:102 msgid "status" msgstr "gjendje" -#: models.py:108 +#: models.py:110 msgid "locked by" msgstr "" -#: models.py:117 +#: models.py:119 msgid "source" msgstr "burim" -#: models.py:131 +#: models.py:133 #, python-brace-format msgid "Version #{number} ({state} {date})" msgstr "Version #{number} ({state} {date})" -#: models.py:138 +#: models.py:140 #, python-brace-format msgid "Version #{number} ({state})" msgstr "Version #{number} ({state})" -#: models.py:144 +#: models.py:146 #, python-format msgid "Locked by %(user)s" msgstr "" -#: models.py:276 models.py:325 +#: models.py:278 models.py:327 msgid "Version is not in draft state" msgstr "Versioni s’është nën gjendjen “skicë”" -#: models.py:385 +#: models.py:387 msgid "Version is not in published state" msgstr "Versioni s’është nën gjendjen “i botuar”" -#: models.py:442 +#: models.py:444 msgid "Version is not in archived or unpublished state" msgstr "Versioni s’është nën gjendjen “i arkivuar” ose “i pabotuar”" -#: models.py:457 +#: models.py:459 msgid "Version is not in draft or published state" msgstr "Versioni s’është nën gjendjen “skicë” ose “i botuar”" -#: models.py:465 +#: models.py:467 #, fuzzy #| msgid "Version archived" msgid "Version is already locked" msgstr "Versioni u arkivua" -#: models.py:471 +#: models.py:473 #, fuzzy #| msgid "Version is not a draft" msgid "Draft version is not locked" diff --git a/tests/requirements/requirements_base.txt b/tests/requirements/requirements_base.txt index 5ffedc51..06dad753 100644 --- a/tests/requirements/requirements_base.txt +++ b/tests/requirements/requirements_base.txt @@ -2,9 +2,8 @@ beautifulsoup4 coverage django-app-helper factory-boy -flake8 +ruff freezegun -isort lxml mock pillow @@ -12,6 +11,7 @@ pyflakes>=2.1.1 python-dateutil mysqlclient==2.0.3 psycopg2 +setuptools djangocms-text-ckeditor>=5.1.2 # Unreleased django-cms 4.0 compatible packages diff --git a/tests/test_admin.py b/tests/test_admin.py index 838ca46e..f2496b58 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -25,6 +25,7 @@ from django.urls import reverse from django.utils.http import urlencode from django.utils.timezone import now +from django.utils.translation import override from freezegun import freeze_time import djangocms_versioning.helpers @@ -388,10 +389,7 @@ def test_content_link_editable_object(self): The link returned is the change url for an editable object """ version = factories.PageVersionFactory(content__title="mypage") - preview_url = admin_reverse( - "cms_placeholder_render_object_preview", - args=(version.content_type_id, version.object_id), - ) + preview_url = helpers.get_preview_url(version.content) self.assertEqual( self.site._registry[Version].content_link(version), f'{version.content}', @@ -429,13 +427,14 @@ 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): - self.assertEqual( - self.site._registry[Version].content_link(version), - '{label}'.format( - url=get_object_preview_url(version.content), - label=version.content - ), - ) + with override(version.content.language): + 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 + ), + ) class VersionAdminActionsTestCase(CMSTestCase): @@ -2019,10 +2018,7 @@ def test_compare_view_has_version_data_in_context_when_no_get_param(self): self.assertIn("v1", context) self.assertEqual(context["v1"], versions[0]) self.assertIn("v1_preview_url", context) - v1_preview_url = reverse( - "admin:cms_placeholder_render_object_preview", - args=(versions[0].content_type_id, versions[0].object_id), - ) + v1_preview_url = helpers.get_preview_url(versions[0].content) parsed = urlparse(context["v1_preview_url"]) self.assertEqual(parsed.path, v1_preview_url) self.assertEqual( @@ -2074,10 +2070,7 @@ def test_compare_view_has_version_data_in_context_when_version2_in_get_param(sel self.assertIn("v1", context) self.assertEqual(context["v1"], versions[0]) self.assertIn("v1_preview_url", context) - v1_preview_url = reverse( - "admin:cms_placeholder_render_object_preview", - args=(versions[0].content_type_id, versions[0].object_id), - ) + v1_preview_url = helpers.get_preview_url(versions[0].content) parsed = urlparse(context["v1_preview_url"]) self.assertEqual(parsed.path, v1_preview_url) self.assertEqual( @@ -2087,10 +2080,7 @@ def test_compare_view_has_version_data_in_context_when_version2_in_get_param(sel self.assertIn("v2", context) self.assertEqual(context["v2"], versions[1]) self.assertIn("v2_preview_url", context) - v2_preview_url = reverse( - "admin:cms_placeholder_render_object_preview", - args=(versions[1].content_type_id, versions[1].object_id), - ) + v2_preview_url = helpers.get_preview_url(versions[1].content) parsed = urlparse(context["v2_preview_url"]) self.assertEqual(parsed.path, v2_preview_url) self.assertEqual( @@ -3084,3 +3074,4 @@ def test_fake_back_link(self): response = self.client.get(changelist + "?back=/hijack_url") self.assertNotContains(response, "hijack_url") self.assertContains(response, version_list_url(version.content)) + diff --git a/tests/test_locking.py b/tests/test_locking.py index 47d5e21d..82e61aeb 100644 --- a/tests/test_locking.py +++ b/tests/test_locking.py @@ -749,7 +749,7 @@ def setUp(self) -> None: self.user_has_change_perms = self._create_user( "user_default_perms", is_staff=True, - permissions=["change_page", "add_page", "publish_page", "delete_page"], + permissions=["change_page", "add_page", "delete_page"], ) # Grant permission (or Unlock button will not be shown) GlobalPagePermission.objects.create( diff --git a/tox.ini b/tox.ini index 36dad075..d7ed0942 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,7 @@ [tox] envlist = - flake8 - isort - py{39.310,311}-dj{32,40,41}-sqlite + ruff + py{39.310,311}-dj{32,40,41,42}-sqlite skip_missing_interpreters=True @@ -13,11 +12,12 @@ deps = dj32: -r{toxinidir}/tests/requirements/dj32_cms41.txt dj40: -r{toxinidir}/tests/requirements/dj40_cms41.txt dj41: -r{toxinidir}/tests/requirements/dj41_cms41.txt + dj42: -r{toxinidir}/tests/requirements/dj42_cms41.txt basepython = py39: python3.9 py310: python3.10 - py311: python3.10 + py311: python3.11 commands = {envpython} --version @@ -25,10 +25,9 @@ commands = {env:COMMAND:coverage} run setup.py test {env:COMMAND:coverage} report -[testenv:flake8] -commands = flake8 -basepython = python3.9 +[testenv:ruff] +commands = + ruff {toxinidir}/djangocms_versioning + ruff {toxinidir}/tests -[testenv:isort] -commands = isort --check-only --diff {toxinidir}/djangocms_versioning -basepython = python3.9 +basepython = python3.11 From 0d5b461506d792f83345085c8225dff18e7e3275 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Wed, 22 Nov 2023 16:04:15 +0100 Subject: [PATCH 05/47] docs: Document version states (#362) * Remove remove_published_where * Fix language issue with preview links * Add setuptools to requirements for * Add explanation of version states. * Add diagram * Revert unwanted changes * Revert changes to test requirements --- docs/basic_concepts.rst | 57 +++++++++++++++++++++++++++++++++ docs/index.rst | 1 + docs/static/version-states.png | Bin 0 -> 51889 bytes 3 files changed, 58 insertions(+) create mode 100644 docs/basic_concepts.rst create mode 100644 docs/static/version-states.png diff --git a/docs/basic_concepts.rst b/docs/basic_concepts.rst new file mode 100644 index 00000000..7519a204 --- /dev/null +++ b/docs/basic_concepts.rst @@ -0,0 +1,57 @@ +Introduction +============ + +djangocms-versioning is a general purpose package that manages versions +for page contents and other models within four categories: **published**, +**draft**, **unpublished**, or **archived**, called "version states". + + +Version states +-------------- + +Each versioned object carries a version number, creation date, modification date, a reference to the user who created the version, and **version state**. The states are: + + * **draft**: This is the version which currently can be edited. Only draft versions can + be edited and only one draft version per language is allowed. Changes made to draft + pages are not visible to the public. + * **published**: This is the version currently visible on the website to the public. Only + one version per language can be public. It cannot be changed. If it needs to be changed + a new draft is created based on a published page and the published page stays unchanged. + * **unpublished**: This is a version which was published at one time but now is not + visible to the public any more. There can be many unpublished versions. + * **archived**: This is a version which has not been published and therefore has never been + visible to the public. It represents a state which is intended to be used for + later work (by reverting it to a draft state). + +Each new draft version will generate a new version number. + +.. image:: /static/version-states.png + :align: center + :alt: Version states + +When an object is published, it changes state to **published** and thereby becomes publicly visible. All other version states are invisible to the public. + +Effect on the model's manager +----------------------------- + +When handling versioned models in code, you'll generally only "see" published objects: + +.. code-block:: + + MyModel.objects.filter(language="en") # get all published English objects of MyModel + +will return a queryset with published objects only. This is to ensure that no draft or unpublished versions leak or become visible to the public. + +Since often draft contents are the ones you interact with in the admin interface, or in draft mode in the CMS frontend, djangocms-versioning introduces an additional model manager for the versioned models **which may only be used on admin sites and admin forms**:: + + MyModel.admin_manager.filter(language="en") + +will retrieve all objects of all versions. Alternativley, to get the current draft version you can to filter the ``Version`` object:: + + from djangocms_versioning.constants import DRAFT + + MyModel.admin_manager.filter(language="en", versions__status==DRAFT) + +Finally, there are instance where you want to access the "current" version of a page. This is either the current draft version or - there is no draft - the published version. You can easily achieve this by using:: + + MyModel.admin_manager.filter(language="en").current_content() diff --git a/docs/index.rst b/docs/index.rst index 6b7bfa31..b6f65499 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -5,6 +5,7 @@ Welcome to "djangocms-versioning"'s documentation! :maxdepth: 2 :caption: Quick Start: + basic_concepts versioning_integration version_locking diff --git a/docs/static/version-states.png b/docs/static/version-states.png new file mode 100644 index 0000000000000000000000000000000000000000..d0b8af84bc4c6201b642c247624bce83de9280b8 GIT binary patch literal 51889 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 QvkXAs>FVdQ&MBb@09UF6F8}}l literal 0 HcmV?d00001 From b099c9a694665deed936ea91a81ec9e70f075a17 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 28 Dec 2023 12:22:29 +0100 Subject: [PATCH 06/47] feat: Add configuration to manage redirect on publish (#358) * Remove remove_published_where * Fix language issue with preview links * Add setuptools to requirements for * feat: Add configurable redirect on publish * fix lint errors * Fix tests * Test redirects * Extend tests to django-cms@develop-4 * Update tests * Update tests.yml * Use Py3.11 for latest cms branch * Redirects for unpublish * Update docs * Update toolbar test * Fix toolbar tests prt 2 --- .github/workflows/test.yml | 40 ++++++++- djangocms_versioning/admin.py | 34 +++++-- djangocms_versioning/cms_menus.py | 2 +- djangocms_versioning/cms_toolbars.py | 2 +- djangocms_versioning/conf.py | 5 ++ djangocms_versioning/helpers.py | 4 + docs/settings.rst | 25 ++++++ tests/requirements/dj32_cms41.txt | 2 + tests/requirements/dj40_cms41.txt | 2 + tests/requirements/dj41_cms41.txt | 2 + tests/requirements/dj42_cms41.txt | 2 + tests/requirements/requirements_base.txt | 3 +- tests/test_admin.py | 110 +++++++++++++++++++++-- tests/test_toolbars.py | 7 +- 14 files changed, 214 insertions(+), 26 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 96aa6836..654f4793 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ 3.9, "3.10", "3.11", "3.12" ] # latest release minus two + python-version: [ 3.9, "3.10", "3.11", ] # latest release minus two requirements-file: [ dj32_cms41.txt, dj40_cms41.txt, @@ -44,7 +44,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ 3.9, "3.10", "3.11", "3.12" ] # latest release minus two + python-version: [ 3.9, "3.10", "3.11", ] # latest release minus two requirements-file: [ dj32_cms41.txt, dj40_cms41.txt, @@ -90,7 +90,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ 3.9, "3.10", "3.11", "3.12" ] # latest release minus two + python-version: [ 3.9, "3.10", "3.11", ] # latest release minus two requirements-file: [ dj32_cms41.txt, dj40_cms41.txt, @@ -128,3 +128,37 @@ jobs: - name: Upload Coverage to Codecov uses: codecov/codecov-action@v2 + + cms-develop-sqlite: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: ['3.11'] + requirements-file: ['dj42_cms41.txt'] + cms-version: [ + 'https://github.com/django-cms/django-cms/archive/develop-4.tar.gz' + ] + os: [ + ubuntu-20.04, + ] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r tests/requirements/${{ matrix.requirements-file }} + pip install ${{ matrix.cms-version }} + python setup.py install + + - name: Run coverage + run: coverage run setup.py test + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v2 diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 424fe96a..16008576 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -947,21 +947,33 @@ def publish_view(self, request, object_id): request, self.model._meta, object_id ) + if conf.ON_PUBLISH_REDIRECT in ("preview", "published"): + redirect_url=get_preview_url(version.content) + else: + redirect_url=version_list_url(version.content) + if not version.can_be_published(): self.message_user(request, _("Version cannot be published"), messages.ERROR) - return redirect(version_list_url(version.content)) + return redirect(redirect_url) try: version.check_publish(request.user) except ConditionFailed as e: self.message_user(request, force_str(e), messages.ERROR) - return redirect(version_list_url(version.content)) + return redirect(redirect_url) # Publish the version version.publish(request.user) + # Display message self.message_user(request, _("Version published")) - # Redirect - return redirect(version_list_url(version.content)) + + # 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 + + return redirect(redirect_url) def unpublish_view(self, request, object_id): """Unpublishes the specified version and redirects back to the @@ -974,16 +986,21 @@ def unpublish_view(self, request, object_id): request, self.model._meta, object_id ) + if conf.ON_PUBLISH_REDIRECT in ("preview", "published"): + redirect_url=get_preview_url(version.content) + else: + redirect_url=version_list_url(version.content) + if not version.can_be_unpublished(): self.message_user( request, _("Version cannot be unpublished"), messages.ERROR ) - return redirect(version_list_url(version.content)) + return redirect(redirect_url) try: version.check_unpublish(request.user) except ConditionFailed as e: self.message_user(request, force_str(e), messages.ERROR) - return redirect(version_list_url(version.content)) + return redirect(redirect_url) if request.method != "POST": context = { @@ -1016,7 +1033,7 @@ def unpublish_view(self, request, object_id): # Display message self.message_user(request, _("Version unpublished")) # Redirect - return redirect(version_list_url(version.content)) + return redirect(redirect_url) def _get_edit_redirect_version(self, request, version): """Helper method to get the latest draft or create one if one does not exist.""" @@ -1202,11 +1219,10 @@ def compare_view(self, request, object_id): ) else: v2_preview_url = get_preview_url(v2.content) - v2_preview_url = add_url_parameters(v2_preview_url, **persist_params) context.update( { "v2": v2, - "v2_preview_url": v2_preview_url, + "v2_preview_url": add_url_parameters(v2_preview_url, **persist_params), } ) return TemplateResponse( diff --git a/djangocms_versioning/cms_menus.py b/djangocms_versioning/cms_menus.py index eba612de..e11cb109 100644 --- a/djangocms_versioning/cms_menus.py +++ b/djangocms_versioning/cms_menus.py @@ -117,7 +117,7 @@ def get_nodes(self, request): if page not in visible_pages_for_user: # The page is restricted for the user. - # Therefore we avoid adding it to the menu. + # Therefore, we avoid adding it to the menu. continue version = page_content.versions.all()[0] diff --git a/djangocms_versioning/cms_toolbars.py b/djangocms_versioning/cms_toolbars.py index d4fed366..c9d3838a 100644 --- a/djangocms_versioning/cms_toolbars.py +++ b/djangocms_versioning/cms_toolbars.py @@ -299,7 +299,7 @@ def get_page_content(self, language=None): return get_latest_admin_viewable_content(self.page, language=language) def populate(self): - self.page = self.request.current_page or getattr(self.toolbar.obj, "page", None) + self.page = self.request.current_page self.title = self.get_page_content() if self.page else None self.permissions_activated = get_cms_setting("PERMISSION") diff --git a/djangocms_versioning/conf.py b/djangocms_versioning/conf.py index 67634898..40030005 100644 --- a/djangocms_versioning/conf.py +++ b/djangocms_versioning/conf.py @@ -27,3 +27,8 @@ EMAIL_NOTIFICATIONS_FAIL_SILENTLY = getattr( settings, "EMAIL_NOTIFICATIONS_FAIL_SILENTLY", False ) + +ON_PUBLISH_REDIRECT = getattr( + settings, "DJANGOCMS_VERISONING_ON_PUBLISH_REDIRECT", "published" +) +# Allowed values: "versions", "published", "preview" diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index 04272cdf..8d2213d8 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -15,6 +15,7 @@ from django.db import models from django.template.loader import render_to_string from django.utils.encoding import force_str +from django.utils.translation import get_language from . import versionables from .conf import EMAIL_NOTIFICATIONS_FAIL_SILENTLY @@ -272,6 +273,9 @@ def get_preview_url(content_obj: models.Model, language: typing.Union[str, None] if versionable.preview_url: return versionable.preview_url(content_obj) if is_editable_model(content_obj.__class__): + if not language: + # Use language field is content object has one to determine the language + language = getattr(content_obj, "language", get_language()) url = get_object_preview_url(content_obj, language=language) else: # Or else, the standard change view should be used diff --git a/docs/settings.rst b/docs/settings.rst index 231c2d4d..7f468aef 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -60,3 +60,28 @@ Settings for djangocms Versioning will fail. +.. py:attribute:: DJANGOCMS_VERSIONING_ON_PUBLISH_REDIRECT + + Defaults to ``"published"`` + + .. versionadded:: 2.0 + + Before version 2.0 the behavior was always ``"versions"``. + + This setting determines what happens after publication/unpublication of a + content object. Three options exist: + + * ``"versions"``: The user will be redirected to a version overview of + the current object. This is particularly useful for advanced users who + need to keep a regular overview on the existing versions. + + * ``"published"``: The user will be redirected to the content object on + the site. Its URL is determined by calling ``.get_absolute_url()`` on + the content object. If does not have an absolute url or the object was + unpublished the user is redirected to the object's preview endpoint. + This is particularly useful if users only want to interact with versions + if necessary. + + * ``"preview"``: The user will be redirected to the content object's + preview endpoint. + diff --git a/tests/requirements/dj32_cms41.txt b/tests/requirements/dj32_cms41.txt index 8e55074c..24060eaf 100644 --- a/tests/requirements/dj32_cms41.txt +++ b/tests/requirements/dj32_cms41.txt @@ -1,5 +1,7 @@ -r requirements_base.txt +django-cms>=4.1.0rc2 + Django>=3.2,<4.0 django-classy-tags django-fsm>=2.6 diff --git a/tests/requirements/dj40_cms41.txt b/tests/requirements/dj40_cms41.txt index 53c58914..7b1ccb33 100644 --- a/tests/requirements/dj40_cms41.txt +++ b/tests/requirements/dj40_cms41.txt @@ -1,5 +1,7 @@ -r requirements_base.txt +django-cms>=4.1.0rc2 + Django>=4.0,<4.1 django-classy-tags django-fsm>=2.6 diff --git a/tests/requirements/dj41_cms41.txt b/tests/requirements/dj41_cms41.txt index a839ca44..5c1aa2b8 100644 --- a/tests/requirements/dj41_cms41.txt +++ b/tests/requirements/dj41_cms41.txt @@ -1,5 +1,7 @@ -r requirements_base.txt +django-cms>=4.1.0rc2 + Django>=4.1,<4.2 django-classy-tags django-fsm>=2.6 diff --git a/tests/requirements/dj42_cms41.txt b/tests/requirements/dj42_cms41.txt index 20ef631b..1e78584a 100644 --- a/tests/requirements/dj42_cms41.txt +++ b/tests/requirements/dj42_cms41.txt @@ -1,5 +1,7 @@ -r requirements_base.txt +django-cms>=4.1.0rc2 + Django>=4.2,<5 django-classy-tags django-fsm>=2.6 diff --git a/tests/requirements/requirements_base.txt b/tests/requirements/requirements_base.txt index 06dad753..1be2a30c 100644 --- a/tests/requirements/requirements_base.txt +++ b/tests/requirements/requirements_base.txt @@ -1,3 +1,4 @@ +setuptools beautifulsoup4 coverage django-app-helper @@ -14,5 +15,3 @@ psycopg2 setuptools djangocms-text-ckeditor>=5.1.2 -# Unreleased django-cms 4.0 compatible packages -django-cms>=4.1.0rc2 diff --git a/tests/test_admin.py b/tests/test_admin.py index f2496b58..14a4ba9e 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -75,6 +75,26 @@ def assertRedirectsToVersionList(self, response, version): }, ) + def assertRedirectsToPreview(self, response, version): + parsed = urlparse(response.url) + self.assertEqual(response.status_code, 302) + self.assertEqual( + parsed.path, + helpers.get_preview_url(version.content), + ) + + def assertRedirectsToPublished(self, response, version): + if hasattr(version.content, "get_absolute_url"): + published_url = version.content.get_absolute_url() + else: + published_url = helpers.get_preview_url(version.content) + parsed = urlparse(response.url) + self.assertEqual(response.status_code, 302) + self.assertEqual( + parsed.path, + published_url, + ) + class AdminVersioningTestCase(CMSTestCase): def test_admin_factory(self): @@ -1320,8 +1340,46 @@ def test_publish_view_sets_state_and_redirects(self, mocked_messages): self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], "Version published") # Redirect happened + self.assertRedirectsToPublished(response, poll_version) + + 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() + + conf.ON_PUBLISH_REDIRECT ="published" + poll_version = factories.PollVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", poll_version.pk + ) + + with self.login_user_context(user): + response = self.client.post(url) + self.assertRedirectsToPublished(response, poll_version) + + conf.ON_PUBLISH_REDIRECT ="preview" + poll_version = factories.PollVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", poll_version.pk + ) + + with self.login_user_context(user): + response = self.client.post(url) + self.assertRedirectsToPreview(response, poll_version) + + conf.ON_PUBLISH_REDIRECT ="versions" + poll_version = factories.PollVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", poll_version.pk + ) + + with self.login_user_context(user): + response = self.client.post(url) self.assertRedirectsToVersionList(response, poll_version) + conf.ON_PUBLISH_REDIRECT = original_setting + def test_published_view_sets_modified_time(self): poll_version = factories.PollVersionFactory(state=constants.DRAFT) url = self.get_admin_url( @@ -1353,7 +1411,7 @@ def test_publish_view_cannot_be_accessed_for_archived_version( with self.login_user_context(self.get_staff_user_with_no_permissions()): response = self.client.post(url) - self.assertRedirectsToVersionList(response, poll_version) + self.assertRedirectsToPreview(response, poll_version) self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) @@ -1377,7 +1435,7 @@ def test_publish_view_cannot_be_accessed_for_published_version( with self.login_user_context(self.get_staff_user_with_no_permissions()): response = self.client.post(url) - self.assertRedirectsToVersionList(response, poll_version) + self.assertRedirectsToPreview(response, poll_version) self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) @@ -1401,7 +1459,7 @@ def test_publish_view_cannot_be_accessed_for_unpublished_version( with self.login_user_context(self.get_staff_user_with_no_permissions()): response = self.client.post(url) - self.assertRedirectsToVersionList(response, poll_version) + self.assertRedirectsToPreview(response, poll_version) self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) @@ -1506,7 +1564,7 @@ def test_unpublish_view_sets_state_and_redirects(self, mocked_messages): self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], "Version unpublished") # Redirect happened - self.assertRedirectsToVersionList(response, poll_version) + self.assertRedirectsToPreview(response, poll_version) def test_unpublish_view_sets_modified_time(self): poll_version = factories.PollVersionFactory(state=constants.PUBLISHED) @@ -1539,7 +1597,7 @@ def test_unpublish_view_cannot_be_accessed_for_archived_version( with self.login_user_context(self.get_staff_user_with_no_permissions()): response = self.client.post(url) - self.assertRedirectsToVersionList(response, poll_version) + self.assertRedirectsToPreview(response, poll_version) self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) @@ -1565,7 +1623,7 @@ def test_unpublish_view_cannot_be_accessed_for_unpublished_version( with self.login_user_context(self.get_staff_user_with_no_permissions()): response = self.client.post(url) - self.assertRedirectsToVersionList(response, poll_version) + self.assertRedirectsToPreview(response, poll_version) self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) @@ -1586,7 +1644,7 @@ def test_unpublish_view_cannot_be_accessed_for_draft_version(self, mocked_messag with self.login_user_context(self.get_staff_user_with_no_permissions()): response = self.client.post(url) - self.assertRedirectsToVersionList(response, poll_version) + self.assertRedirectsToPreview(response, poll_version) self.assertEqual(mocked_messages.call_count, 1) self.assertEqual(mocked_messages.call_args[0][1], messages.ERROR) @@ -1705,6 +1763,44 @@ def test_unpublish_view_doesnt_throw_exception_if_no_app_registered_extra_unpubl self.assertEqual(response.status_code, 200) + def test_unpublish_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() + + conf.ON_PUBLISH_REDIRECT ="published" + poll_version = factories.PollVersionFactory(state=constants.PUBLISHED) + url = self.get_admin_url( + self.versionable.version_model_proxy, "unpublish", poll_version.pk + ) + + with self.login_user_context(user): + response = self.client.post(url) + self.assertRedirectsToPreview(response, poll_version) + + conf.ON_PUBLISH_REDIRECT ="preview" + poll_version = factories.PollVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", poll_version.pk + ) + + with self.login_user_context(user): + response = self.client.post(url) + self.assertRedirectsToPreview(response, poll_version) + + conf.ON_PUBLISH_REDIRECT ="versions" + poll_version = factories.PollVersionFactory(state=constants.DRAFT) + url = self.get_admin_url( + self.versionable.version_model_proxy, "publish", poll_version.pk + ) + + with self.login_user_context(user): + response = self.client.post(url) + self.assertRedirectsToVersionList(response, poll_version) + + conf.ON_PUBLISH_REDIRECT = original_setting + class RevertViewTestCase(BaseStateTestCase): def setUp(self): diff --git a/tests/test_toolbars.py b/tests/test_toolbars.py index a98d7aba..665cdd36 100644 --- a/tests/test_toolbars.py +++ b/tests/test_toolbars.py @@ -211,10 +211,11 @@ def test_default_cms_edit_button_is_replaced_by_versioning_edit_button(self): The versioning edit button is available on the toolbar when versioning is installed and the model is versionable. """ - pagecontent = PageVersionFactory(content__template="") - url = get_object_preview_url(pagecontent.content, language="en") + page = PageVersionFactory(content__template="", content__language="en") + url = get_object_preview_url(page.content) + edit_url = self._get_edit_url( - pagecontent, VersioningCMSConfig.versioning[0] + page, VersioningCMSConfig.versioning[0] ) with self.login_user_context(self.get_superuser()): From 022dff891c1e4ffaa1cb5cac7c2aa7ae245a8226 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 28 Dec 2023 20:20:24 +0100 Subject: [PATCH 07/47] Bump version to 2.0.0 --- djangocms_versioning/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_versioning/__init__.py b/djangocms_versioning/__init__.py index 0cda2d10..8c0d5d5b 100644 --- a/djangocms_versioning/__init__.py +++ b/djangocms_versioning/__init__.py @@ -1 +1 @@ -__version__ = "2.0.0rc1" +__version__ = "2.0.0" From 08c48a5013bf5575a4c28f4cf697ff495c6a26c5 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 29 Dec 2023 10:50:37 +0100 Subject: [PATCH 08/47] Update CHANGELOG.rst --- CHANGELOG.rst | 97 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 66 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 41938de6..f7ab8665 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,37 +3,72 @@ Changelog ========= -Unreleased -========== -* fix: Add keyword arguments in VersionAdminMixin render_change_form -* feat: Reversable generic foreign key lookup from version -* feat: Provide additional information about unpublished/published versions when sending signals -* fix: formatted files through ruff to fix tests -* fix: Remove version check when evaluating CMS PageContent objects - -2.0.0rc1 -======== -* fix: Only try modifying page language menu if it is present -* fix: Added ``related_name`` attribute to the ``content_type`` foreign key of the ``Version`` model. -* fix: burger menu adjusts to the design of django cms core dropdown -* fix: bug that showed an archived version as unpublished in some cases in the state indicator -* add: Dutch and French translations thanks to Stefan van den Eertwegh and François Palmierso -* add: transifex support, German translations -* add: Revert button as replacement for dysfunctional Edit button for unpublished - versions -* add: status indicators and drop down menus for django cms page tree -* fix: only offer languages for plugin copy with available content -* feat: Add support for Django 4.0, 4.1 and Python 3.10 and 3.11 -* fix: migrations for MySql -* ci: Updated isort params in lint workflow to meet current requirements. -* ci: Update actions to v3 where possible, and coverage to v2 due to v1 sunset in Feb -* ci: Remove ``os`` from test workflow matrix because it's unused -* ci: Added concurrency option to cancel in progress runs when new changes occur -* fix: Added setting to make the field to identify a user configurable in ``ExtendedVersionAdminMixin.get_queryset()`` to fix issue for custom user models with no ``username`` -* ci: Run tests on sqlite, mysql and postgres db - -* feat: Compatibility with page content extension changes to django-cms -* ci: Added basic linting pre-commit hooks +2.0.0 (2023-12-29) +================== + +What's Changed +-------------- +* ci: Added concurrency to workflows by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/271 +* ci: Remove ``os`` from test workflow matrix by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/270 +* ci: Update actions to latest versions by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/269 +* ci: Update isort params for v5 by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/268 +* Add CodeQL workflow for GitHub code scanning by @lgtm-com in https://github.com/django-cms/djangocms-versioning/pull/297 +* feat: Django 4.0, 4.1 / Python 3.10/3.11, mysql support, running tests on sqlite, postgres and mysql by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/287 +* feat: Compat with cms page content extension changes by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/291 +* fix: Additional change missed in #291 by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/301 +* Add: Allow simple version management commands from the page tree indicator drop down menus by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/295 +* fix: Adds compatibility for User models with no username field [#292] by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/293 +* feat: Use same icons in page tree state indicators and Manage verisons by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/302 +* fix: Remove patching the django CMS core by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/300 +* fix: test requirements after removing the patching pattern by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/303 +* feat: add localization and transifex support by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/305 +* feat: Add management command to create version objects by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/304 +* feat: add Dutch translations, transifex integration file by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/306 +* feat: French localization by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/307 +* feat: Albanian localization, Transifex integration by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/308 +* Some fixed strings are now translatable by @svandeneertwegh in https://github.com/django-cms/djangocms-versioning/pull/310 +* Translate '/djangocms_versioning/locale/en/LC_MESSAGES/django.po' in 'de' by @transifex-integration in https://github.com/django-cms/djangocms-versioning/pull/311 +* Translate '/djangocms_versioning/locale/en/LC_MESSAGES/django.po' in 'nl' by @transifex-integration in https://github.com/django-cms/djangocms-versioning/pull/312 +* fix: translation inconsistencies by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/313 +* feat: Add preview button to view published mode by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/316 +* feat: Huge performance improvement for admin_manager by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/318 +* fix: Minor usability improvements by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/317 +* fix: update messages by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/321 +* Translate 'djangocms_versioning/locale/en/LC_MESSAGES/django.po' in 'de' by @transifex-integration in https://github.com/django-cms/djangocms-versioning/pull/322 +* fix: deletion of version objects blocked by source fields by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/320 +* feat: allow reuse of status indicators by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/319 +* fix: burger menu to also work with new core icons by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/323 +* Translate 'djangocms_versioning/locale/en/LC_MESSAGES/django.po' in 'nl' by @transifex-integration in https://github.com/django-cms/djangocms-versioning/pull/328 +* ci: Switch flake8 and isort for ruff by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/329 +* fix: Added related_name to version content type field by @marksweb in https://github.com/django-cms/djangocms-versioning/pull/274 +* feat: Django 4.2, Django CMS 4.1.0rc2 compatibility, and version locking by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/326 +* Translations for djangocms_versioning/locale/en/LC_MESSAGES/django.po in de by @transifex-integration in https://github.com/django-cms/djangocms-versioning/pull/330 +* Translations for djangocms_versioning/locale/en/LC_MESSAGES/django.po in nl by @transifex-integration in https://github.com/django-cms/djangocms-versioning/pull/331 +* fix: Modify language menu for pages only if it is present by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/333 +* feat: Add pypi actions by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/335 +* feat: Reversable generic foreign key lookup from version by @Aiky30 in https://github.com/django-cms/djangocms-versioning/pull/241 +* Add caching to PageContent __bool__ by @stefanw in https://github.com/django-cms/djangocms-versioning/pull/346 +* Fix tests by @FinalAngel in https://github.com/django-cms/djangocms-versioning/pull/349 +* Updates for file djangocms_versioning/locale/en/LC_MESSAGES/django.po in fr on branch master by @transifex-integration in https://github.com/django-cms/djangocms-versioning/pull/347 +* docs: List `DJANGOCMS_VERSIONING_LOCK_VERSIONS` in settings by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/350 +* docs: Update documentation by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/351 +* fix: Update templates for better styling w/o djangocms-admin-style by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/352 +* fix: PageContent extension's `copy_relations` method not called by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/344 +* Bugfix/use keyword arguments in admin render change form method by @vipulnarang95 in https://github.com/django-cms/djangocms-versioning/pull/356 +* Provide additional information when sending publish/unpublish events by @GaretJax in https://github.com/django-cms/djangocms-versioning/pull/348 +* fix: Preview link language by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/357 +* docs: Document version states by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/362 +* feat: Add configuration to manage redirect on publish by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/358 + +New Contributors +---------------- +* @marksweb made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/271 +* @fsbraun made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/287 +* @svandeneertwegh made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/310 +* @stefanw made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/346 +* @FinalAngel made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/349 +* @vipulnarang95 made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/356 +* @GaretJax made their first contribution in https://github.com/django-cms/djangocms-versioning/pull/348 1.2.2 (2022-07-20) ================== From ecac82ff4885776f723445dbeb5389dcf1cc10c8 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 4 Jan 2024 00:38:12 +0100 Subject: [PATCH 09/47] fix: Create missing __init__.py in management folder (#366) --- djangocms_versioning/management/__init__.py | 1 + 1 file changed, 1 insertion(+) create mode 100644 djangocms_versioning/management/__init__.py diff --git a/djangocms_versioning/management/__init__.py b/djangocms_versioning/management/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/djangocms_versioning/management/__init__.py @@ -0,0 +1 @@ + From 4ed03c961779e161e6e3af46213984a327ad0599 Mon Sep 17 00:00:00 2001 From: Mark Walker Date: Mon, 15 Jan 2024 12:31:16 +0000 Subject: [PATCH 10/47] ci: Add testing against django main (#353) --- .github/dependabot.yml | 11 +++++++ .github/workflows/test.yml | 58 +++++++++++++++++++++++++++++------- tests/test_admin.py | 14 +++++---- tests/test_datastructures.py | 12 +++++--- tests/test_models.py | 8 +++-- tox.ini | 3 ++ 6 files changed, 84 insertions(+), 22 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..14f01789 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 654f4793..de4d7473 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,7 +12,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ 3.9, "3.10", "3.11", ] # latest release minus two + python-version: [ 3.9, "3.10", "3.11", "3.12" ] # latest release minus two requirements-file: [ dj32_cms41.txt, dj40_cms41.txt, @@ -21,10 +21,10 @@ jobs: ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -44,7 +44,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ 3.9, "3.10", "3.11", ] # latest release minus two + python-version: [ 3.9, "3.10", "3.11", "3.12" ] # latest release minus two requirements-file: [ dj32_cms41.txt, dj40_cms41.txt, @@ -65,10 +65,10 @@ jobs: options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -90,7 +90,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ 3.9, "3.10", "3.11", ] # latest release minus two + python-version: [ 3.9, "3.10", "3.11", "3.12" ] # latest release minus two requirements-file: [ dj32_cms41.txt, dj40_cms41.txt, @@ -109,10 +109,10 @@ jobs: options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -153,8 +153,44 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -r tests/requirements/${{ matrix.requirements-file }} - pip install ${{ matrix.cms-version }} + python -m pip install -r tests/requirements/${{ matrix.requirements-file }} + python -m pip install ${{ matrix.cms-version }} + python setup.py install + + - name: Run coverage + run: coverage run setup.py test + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v2 + + sqlite-django-main: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: [ "3.11" ] + cms-version: [ + 'https://github.com/django-cms/django-cms/archive/develop-4.tar.gz' + ] + django-version: [ + 'https://github.com/django/django/archive/main.tar.gz' + ] + requirements-file: [ + requirements_base.txt, + ] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r tests/requirements/${{ matrix.requirements-file }} + python -m pip install ${{ matrix.cms-version }} ${{ matrix.django-version }} python setup.py install - name: Run coverage diff --git a/tests/test_admin.py b/tests/test_admin.py index 14a4ba9e..8ed65073 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -58,6 +58,10 @@ from djangocms_versioning.test_utils.polls.cms_config import PollsCMSConfig from djangocms_versioning.test_utils.polls.models import Answer, Poll, PollContent +if not hasattr(CMSTestCase, "assertQuerySetEqual"): + # Django < 4.2 + CMSTestCase.assertQuerySetEqual = CMSTestCase.assertQuerysetEqual + class BaseStateTestCase(CMSTestCase): def assertRedirectsToVersionList(self, response, version): @@ -268,7 +272,7 @@ def test_only_fetches_latest_content_records(self): with self.login_user_context(self.get_superuser()): response = self.client.get(self.get_admin_url(PollContent, "changelist")) - self.assertQuerysetEqual( + self.assertQuerySetEqual( response.context["cl"].queryset, [poll_content1.pk, poll_content2.pk, poll_content3.pk], transform=lambda x: x.pk, @@ -291,7 +295,7 @@ def test_records_filtering_is_generic(self): with self.login_user_context(self.get_superuser()): response = self.client.get(self.get_admin_url(BlogContent, "changelist")) - self.assertQuerysetEqual( + self.assertQuerySetEqual( response.context["cl"].queryset, [blog_content1.pk, blog_content2.pk], transform=lambda x: x.pk, @@ -2124,7 +2128,7 @@ def test_compare_view_has_version_data_in_context_when_no_get_param(self): self.assertNotIn("v2", context) self.assertNotIn("v2_preview_url", context) self.assertIn("version_list", context) - self.assertQuerysetEqual( + self.assertQuerySetEqual( context["version_list"], [versions[0].pk, versions[1].pk], transform=lambda o: o.pk, @@ -2184,7 +2188,7 @@ def test_compare_view_has_version_data_in_context_when_version2_in_get_param(sel self.disable_toolbar_params, ) self.assertIn("version_list", context) - self.assertQuerysetEqual( + self.assertQuerySetEqual( context["version_list"], [versions[0].pk, versions[1].pk, versions[2].pk], transform=lambda o: o.pk, @@ -2310,7 +2314,7 @@ def test_grouper_filtering(self): self.assertEqual(response.status_code, 200) self.assertIn("cl", response.context) - self.assertQuerysetEqual( + self.assertQuerySetEqual( response.context["cl"].queryset, [pv.pk], transform=lambda x: x.pk, diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py index cdea40c9..633ec384 100644 --- a/tests/test_datastructures.py +++ b/tests/test_datastructures.py @@ -11,6 +11,10 @@ from djangocms_versioning.test_utils.people.models import PersonContent from djangocms_versioning.test_utils.polls.models import Poll, PollContent +if not hasattr(CMSTestCase, "assertQuerySetEqual"): + # Django < 4.2 + CMSTestCase.assertQuerySetEqual = CMSTestCase.assertQuerysetEqual + class VersionableItemTestCase(CMSTestCase): def setUp(self): @@ -31,7 +35,7 @@ def test_distinct_groupers(self): grouper_field_name="poll", copy_function=default_copy, ) - self.assertQuerysetEqual( + self.assertQuerySetEqual( versionable.distinct_groupers(), [latest_poll1_version.content.pk, latest_poll2_version.content.pk], transform=lambda x: x.pk, @@ -59,7 +63,7 @@ def test_queryset_filter_for_distinct_groupers(self): qs_published_filter = {"versions__state__in": [PUBLISHED]} # Should be one published version - self.assertQuerysetEqual( + self.assertQuerySetEqual( versionable.distinct_groupers(**qs_published_filter), [poll1_published_version.content.pk], transform=lambda x: x.pk, @@ -68,7 +72,7 @@ def test_queryset_filter_for_distinct_groupers(self): qs_archive_filter = {"versions__state__in": [ARCHIVED]} # Should be two archived versions - self.assertQuerysetEqual( + self.assertQuerySetEqual( versionable.distinct_groupers(**qs_archive_filter), [poll1_archived_version.content.pk, poll2_archived_version.content.pk], transform=lambda x: x.pk, @@ -89,7 +93,7 @@ def test_for_grouper(self): copy_function=default_copy, ) - self.assertQuerysetEqual( + self.assertQuerySetEqual( versionable.for_grouper(self.initial_version.content.poll), [self.initial_version.content.pk, poll1_version2.content.pk], transform=lambda x: x.pk, diff --git a/tests/test_models.py b/tests/test_models.py index 98ac5b3e..8485d9ec 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -12,6 +12,10 @@ from djangocms_versioning.test_utils.polls.cms_config import PollsCMSConfig from djangocms_versioning.test_utils.polls.models import Poll, PollContent +if not hasattr(CMSTestCase, "assertQuerySetEqual"): + # Django < 4.2 + CMSTestCase.assertQuerySetEqual = CMSTestCase.assertQuerysetEqual + class CopyTestCase(CMSTestCase): def _create_versionables_mock(self, copy_function): @@ -261,7 +265,7 @@ def test_filter_by_grouper(self): versions_for_grouper = Version.objects.filter_by_grouper(poll) - self.assertQuerysetEqual( + self.assertQuerySetEqual( versions_for_grouper, [versions[0].pk, versions[1].pk], transform=lambda o: o.pk, @@ -278,7 +282,7 @@ def test_filter_by_grouper_doesnt_include_other_content_types(self): versions_for_grouper = Version.objects.filter_by_grouper(pv.content.poll) # Only poll version included - self.assertQuerysetEqual( + self.assertQuerySetEqual( versions_for_grouper, [pv.pk], transform=lambda o: o.pk, ordered=False ) diff --git a/tox.ini b/tox.ini index d7ed0942..52c40c51 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,7 @@ envlist = ruff py{39.310,311}-dj{32,40,41,42}-sqlite + py{311,312}-djmain-cms-develop4-sqlite skip_missing_interpreters=True @@ -13,6 +14,8 @@ deps = dj40: -r{toxinidir}/tests/requirements/dj40_cms41.txt dj41: -r{toxinidir}/tests/requirements/dj41_cms41.txt dj42: -r{toxinidir}/tests/requirements/dj42_cms41.txt + djmain: https://github.com/django/django/archive/main.tar.gz + develop4: https://github.com/django-cms/django-cms/archive/develop-4.tar.gz basepython = py39: python3.9 From c5af26f0332989c2e4f73170b5dd2881c5369a81 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 15:24:48 +0000 Subject: [PATCH 11/47] build(deps): bump actions/checkout from 3 to 4 (#373) Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/checkout 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> --- .github/workflows/codeql.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/publish-to-live-pypi.yml | 2 +- .github/workflows/publish-to-test-pypi.yml | 2 +- .github/workflows/test.yml | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ceac2fc8..c30908d0 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Initialize CodeQL uses: github/codeql-action/init@v2 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8687ed46..cd70ea54 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v4 with: diff --git a/.github/workflows/publish-to-live-pypi.yml b/.github/workflows/publish-to-live-pypi.yml index 2d8272e2..79936d2b 100644 --- a/.github/workflows/publish-to-live-pypi.yml +++ b/.github/workflows/publish-to-live-pypi.yml @@ -15,7 +15,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3.10 uses: actions/setup-python@v4 with: diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml index fd1cf6fb..95548485 100644 --- a/.github/workflows/publish-to-test-pypi.yml +++ b/.github/workflows/publish-to-test-pypi.yml @@ -15,7 +15,7 @@ jobs: permissions: id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python 3.10 uses: actions/setup-python@v4 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index de4d7473..4d289ac1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -144,7 +144,7 @@ jobs: ] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v3 From 38ed838f7ff2c7bccf026aa62f40a67551166b1d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 15:38:03 +0000 Subject: [PATCH 12/47] build(deps): bump actions/cache from 3.3.1 to 3.3.3 (#372) Bumps [actions/cache](https://github.com/actions/cache) from 3.3.1 to 3.3.3. - [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/v3.3.1...v3.3.3) --- 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 6412cc0f..1b7db249 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@v3.3.1 + uses: actions/cache@v3.3.3 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@v3.3.1 + uses: actions/cache@v3.3.3 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('docs/requirements.txt') }} From b51e1f99fc3210bc2daf8ca150153dd43345f6a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 15:44:16 +0000 Subject: [PATCH 13/47] build(deps): bump codecov/codecov-action from 2 to 3 (#370) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 2 to 3. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v2...v3) --- updated-dependencies: - dependency-name: codecov/codecov-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> --- .github/workflows/test.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4d289ac1..29f43b29 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,7 +37,7 @@ jobs: run: coverage run setup.py test - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 postgres: runs-on: ubuntu-latest @@ -83,7 +83,7 @@ jobs: DATABASE_URL: postgres://postgres:postgres@127.0.0.1/postgres - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 mysql: runs-on: ubuntu-latest @@ -127,7 +127,7 @@ jobs: DATABASE_URL: mysql://root@127.0.0.1/djangocms_test - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 cms-develop-sqlite: runs-on: ${{ matrix.os }} @@ -161,7 +161,7 @@ jobs: run: coverage run setup.py test - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 sqlite-django-main: runs-on: ubuntu-latest @@ -197,4 +197,4 @@ jobs: run: coverage run setup.py test - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v3 From fc786a75072008333fa9f4739433a6641db20c62 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Jan 2024 16:12:13 +0000 Subject: [PATCH 14/47] build(deps): bump actions/setup-python from 3 to 5 (#369) Bumps [actions/setup-python](https://github.com/actions/setup-python) from 3 to 5. - [Release notes](https://github.com/actions/setup-python/releases) - [Commits](https://github.com/actions/setup-python/compare/v3...v5) --- updated-dependencies: - dependency-name: actions/setup-python 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> --- .github/workflows/docs.yml | 4 ++-- .github/workflows/lint.yml | 2 +- .github/workflows/publish-to-live-pypi.yml | 2 +- .github/workflows/publish-to-test-pypi.yml | 2 +- .github/workflows/test.yml | 10 +++++----- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 1b7db249..7c1ec0a7 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' cache: 'pip' @@ -39,7 +39,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' cache: 'pip' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cd70ea54..6d465993 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,7 @@ jobs: - name: Checkout uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.11" cache: 'pip' diff --git a/.github/workflows/publish-to-live-pypi.yml b/.github/workflows/publish-to-live-pypi.yml index 79936d2b..3208e6bb 100644 --- a/.github/workflows/publish-to-live-pypi.yml +++ b/.github/workflows/publish-to-live-pypi.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.10' diff --git a/.github/workflows/publish-to-test-pypi.yml b/.github/workflows/publish-to-test-pypi.yml index 95548485..32ddf41a 100644 --- a/.github/workflows/publish-to-test-pypi.yml +++ b/.github/workflows/publish-to-test-pypi.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python 3.10 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.10' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 29f43b29..8c69a445 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -68,7 +68,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -112,7 +112,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -147,7 +147,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -183,7 +183,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies From f90c5b25810f209cfdc14f83964338793c8d0c7c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 Jan 2024 11:54:11 +0000 Subject: [PATCH 15/47] build(deps): bump actions/cache from 3.3.3 to 4.0.0 (#375) Bumps [actions/cache](https://github.com/actions/cache) from 3.3.3 to 4.0.0. - [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/v3.3.3...v4.0.0) --- updated-dependencies: - dependency-name: actions/cache 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> --- .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 7c1ec0a7..8fa38e54 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@v3.3.3 + uses: actions/cache@v4.0.0 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@v3.3.3 + uses: actions/cache@v4.0.0 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('docs/requirements.txt') }} From cf7c3de562355a9ca07b4698cce914086c045caa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Feb 2024 00:44:55 +0000 Subject: [PATCH 16/47] build(deps): bump codecov/codecov-action from 3 to 4 (#377) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 3 to 4. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v3...v4) --- updated-dependencies: - dependency-name: codecov/codecov-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> --- .github/workflows/test.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8c69a445..ff49a8aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,7 +37,7 @@ jobs: run: coverage run setup.py test - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 postgres: runs-on: ubuntu-latest @@ -83,7 +83,7 @@ jobs: DATABASE_URL: postgres://postgres:postgres@127.0.0.1/postgres - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 mysql: runs-on: ubuntu-latest @@ -127,7 +127,7 @@ jobs: DATABASE_URL: mysql://root@127.0.0.1/djangocms_test - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 cms-develop-sqlite: runs-on: ${{ matrix.os }} @@ -161,7 +161,7 @@ jobs: run: coverage run setup.py test - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 sqlite-django-main: runs-on: ubuntu-latest @@ -197,4 +197,4 @@ jobs: run: coverage run setup.py test - name: Upload Coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 From 8f5c0aef531dec37d191ff2642e95db46815fa53 Mon Sep 17 00:00:00 2001 From: Mark Walker Date: Tue, 6 Feb 2024 20:06:15 +0000 Subject: [PATCH 17/47] ci: Improve efficiency of ruff workflow (#378) --- .github/workflows/lint.yml | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6d465993..da24e88e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,26 +1,17 @@ -name: Lint +name: Ruff -on: [push, pull_request] - -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true +on: + push: + pull_request: jobs: ruff: - name: ruff runs-on: ubuntu-latest + steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - cache: 'pip' - - run: | - python -m pip install --upgrade pip - pip install ruff - - name: Run Ruff - run: | - ruff djangocms_versioning tests + - uses: actions/checkout@v4 + + - run: python -Im pip install --user ruff + + - name: Run ruff + run: ruff --output-format=github djangocms_versioning tests From a70c194c27c5aadb58f07e42309613ca7952daa8 Mon Sep 17 00:00:00 2001 From: Raffaella <45825990+raffaellasuardini@users.noreply.github.com> Date: Sun, 18 Feb 2024 17:10:43 +0100 Subject: [PATCH 18/47] Chore: update ruff and pre-commit hook (#381) * chore: updated pyproject.toml to solve warnings * ci: updated ruff-pre-commit version hook --- .pre-commit-config.yaml | 4 ++-- pyproject.toml | 31 ++++++++++++++++--------------- 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ed8a6403..6922db3d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,8 +14,8 @@ repos: - id: check-merge-conflict - id: mixed-line-ending - - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.0.264" + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.2.1 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] diff --git a/pyproject.toml b/pyproject.toml index c6a1005d..43360176 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,19 @@ [tool.ruff] -# https://beta.ruff.rs/docs/configuration/ +exclude = [ + ".eggs", + ".git", + ".mypy_cache", + ".ruff_cache", + ".env", + ".venv", + "**migrations/**", + "node_modules", + "venv", +] line-length = 120 + +[tool.ruff.lint] +# https://beta.ruff.rs/docs/configuration/ select = [ "E", # pycodestyle errors "W", # pycodestyle warnings @@ -15,18 +28,6 @@ select = [ "UP", # pyupgrade ] -exclude = [ - ".eggs", - ".git", - ".mypy_cache", - ".ruff_cache", - ".env", - ".venv", - "**migrations/**", - "node_modules", - "venv", -] - ignore = [ "B006", # Do not use mutable data structures for argument defaults "B011", # tests use assert False @@ -43,12 +44,12 @@ ignore = [ "UP007", # Use `X | Y` for type annotations ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "__init__.py" = [ "F401" # unused-import ] -[tool.ruff.isort] +[tool.ruff.lint.isort] combine-as-imports = true known-first-party = [ "djangocms_versioning", From 8a4e6c99e1cd2540172abed12b20e1d6e1e90dc8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Mar 2024 23:38:01 +0000 Subject: [PATCH 19/47] build(deps): bump actions/cache from 4.0.0 to 4.0.1 (#385) Bumps [actions/cache](https://github.com/actions/cache) from 4.0.0 to 4.0.1. - [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.0...v4.0.1) --- 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 8fa38e54..c393f081 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.0 + uses: actions/cache@v4.0.1 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.0 + uses: actions/cache@v4.0.1 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('docs/requirements.txt') }} From 93a3e52c3435e40882f69835b599b509e0b275dc Mon Sep 17 00:00:00 2001 From: Jacob Rief Date: Tue, 5 Mar 2024 16:30:28 +0100 Subject: [PATCH 20/47] 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 21/47] 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 22/47] 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 23/47] 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 24/47] 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 25/47] 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 26/47] 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 27/47] 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 28/47] 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 29/47] 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" From 4c80aa52e5ebd2b9fbf33b83b093f4d31e1f873a Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 3 May 2024 16:05:53 +0200 Subject: [PATCH 30/47] fix: Do not show edit action for version objects where editing is not possible (#405) * Fix: Do not show edit action for version objects where editing is not possible * fix ruff errors * Fix tests * Fix linting --- .github/workflows/lint.yml | 2 +- djangocms_versioning/admin.py | 8 ++++++-- tests/test_admin.py | 12 ++++++------ tests/test_extensions.py | 6 +++--- tests/test_handlers.py | 9 ++++++++- tests/test_locking.py | 4 ++-- tests/test_toolbars.py | 14 ++++++++++++++ 7 files changed, 40 insertions(+), 15 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index da24e88e..8878fd89 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,4 +14,4 @@ jobs: - run: python -Im pip install --user ruff - name: Run ruff - run: ruff --output-format=github djangocms_versioning tests + run: ruff check --output-format=github djangocms_versioning tests diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 00898e44..0641bcb3 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -491,7 +491,7 @@ def get_actions_list(self): actions = [ self._get_preview_link, self._get_edit_link, - ] + ] if "state_indicator" not in self.versioning_list_display: # State indicator mixin loaded? actions.append(self._get_manage_versions_link) @@ -729,6 +729,10 @@ def _get_unpublish_link(self, obj, request, disabled=False): 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( @@ -758,7 +762,7 @@ def _get_edit_link(self, obj, request, disabled=False): title=_("Edit") if icon == "pencil" else _("New Draft"), name="edit", action="post", - disabled=not obj.check_edit_redirect.as_bool(request.user) or disabled, + disabled=disabled, keepsideframe=keepsideframe, ) diff --git a/tests/test_admin.py b/tests/test_admin.py index cb59fc33..56560c9e 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -528,9 +528,9 @@ def test_revert_action_link_enable_state(self): 'cms-action-revert ' 'js-action ' 'js-keep-sideframe" ' - 'href="%s" ' + f'href="{draft_revert_url}" ' 'title="Revert">' - ) % draft_revert_url + ) self.assertIn(expected_enabled_state, actual_enabled_control.replace("\n", "")) def test_revert_action_link_for_draft_state(self): @@ -599,9 +599,9 @@ def test_discard_action_link_enabled_state(self): 'cms-action-discard ' 'js-action ' 'js-keep-sideframe" ' - 'href="%s" ' + f'href="{draft_discard_url}" ' 'title="Discard">' - ) % draft_discard_url + ) self.assertIn(expected_enabled_state, actual_enabled_control.replace("\n", "")) def test_discard_action_link_for_archive_state(self): @@ -664,11 +664,11 @@ def test_revert_action_link_for_archive_state(self): 'cms-action-revert ' 'js-action ' 'js-keep-sideframe" ' - 'href="%s" ' + f'href="{draft_revert_url}" ' 'title="Revert">' '' '' - ) % draft_revert_url + ) self.assertIn( expected_disabled_control, actual_disabled_control.replace("\n", "") diff --git a/tests/test_extensions.py b/tests/test_extensions.py index bcb6c35e..9d1b1a01 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -117,7 +117,7 @@ def test_title_extension_admin_monkey_patch_save(self): poll_extension = PollTitleExtensionFactory(extended_object=self.version.content) model_site = PollExtensionAdmin(admin_site=admin.AdminSite(), model=PollPageContentExtension) test_url = admin_reverse("extended_polls_pollpagecontentextension_change", args=(poll_extension.pk,)) - test_url += "?extended_object=%s" % self.version.content.pk + test_url += f"?extended_object={self.version.content.pk}" request = RequestFactory().post(path=test_url) request.user = self.get_superuser() @@ -137,7 +137,7 @@ def test_title_extension_admin_monkey_patch_save_date_modified_updated(self): model_site = PollExtensionAdmin(admin_site=admin.AdminSite(), model=PollPageContentExtension) pre_changes_date_modified = Version.objects.get(id=self.version.pk).modified test_url = admin_reverse("extended_polls_pollpagecontentextension_change", args=(poll_extension.pk,)) - test_url += "?extended_object=%s" % self.version.content.pk + test_url += f"?extended_object={self.version.content.pk}" request = RequestFactory().post(path=test_url) request.user = self.get_superuser() @@ -155,7 +155,7 @@ def test_title_extension_admin_monkeypatch_add_view(self): with self.login_user_context(self.get_superuser()): response = self.client.get( admin_reverse("extended_polls_pollpagecontentextension_add") + - "?extended_object=%s" % self.version.content.pk, + f"?extended_object={self.version.content.pk}", follow=True ) self.assertEqual(response.status_code, 200) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index c9cf0a90..06d426b3 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -21,6 +21,7 @@ def test_modified_date(self): def test_add_plugin(self): version = factories.PageVersionFactory() placeholder = factories.PlaceholderFactory(source=version.content) + placeholder.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" # Fake URL needed for URI poll = factories.PollFactory() dt = datetime(2016, 6, 6) with freeze_time(dt): @@ -45,6 +46,7 @@ def test_change_plugin(self): plugin = add_plugin( placeholder, "PollPlugin", version.content.language, poll=poll ) + plugin.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" # Fake URL needed for URI dt = datetime(2016, 6, 6) with freeze_time(dt): @@ -61,6 +63,7 @@ def test_change_plugin(self): def test_clear_placeholder(self): version = factories.PageVersionFactory() placeholder = factories.PlaceholderFactory(source=version.content) + placeholder.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" # Fake URL needed for URI dt = datetime(2016, 6, 6) with freeze_time(dt): @@ -81,6 +84,7 @@ def test_delete_plugin(self): plugin = add_plugin( placeholder, "PollPlugin", version.content.language, poll=poll ) + plugin.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" # Fake URL needed for URI dt = datetime(2016, 6, 6) with freeze_time(dt): @@ -103,6 +107,7 @@ def test_add_plugins_from_placeholder(self): plugin = add_plugin( source_placeholder, "PollPlugin", version.content.language, poll=poll ) + plugin.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" # Fake URL needed for URI dt = datetime(2016, 6, 6) with freeze_time(dt): @@ -165,7 +170,7 @@ def test_paste_plugin(self): plugin = add_plugin( source_placeholder, "PollPlugin", version.content.language, poll=poll ) - + plugin.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" dt = datetime(2016, 6, 6) with freeze_time(dt): endpoint = self.get_move_plugin_uri(plugin) @@ -197,6 +202,7 @@ def test_cut_plugin(self): plugin = add_plugin( placeholder, "PollPlugin", version.content.language, poll=poll ) + plugin.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" # Fake URL needed for URI dt = datetime(2016, 6, 6) with freeze_time(dt): @@ -223,6 +229,7 @@ def test_move_plugin(self): plugin = add_plugin( source_placeholder, "PollPlugin", version.content.language, poll=poll ) + plugin.page.get_absolute_url = lambda *args, **kwargs: "/test_page/" # Fake URL needed for URI dt = datetime(2016, 6, 6) with freeze_time(dt): diff --git a/tests/test_locking.py b/tests/test_locking.py index bbc72d94..7b016778 100644 --- a/tests/test_locking.py +++ b/tests/test_locking.py @@ -394,12 +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" + expected_disabled_state = "" actual_disabled_state = self.version_admin._get_edit_link(version, otheruser_request) self.assertFalse(version.check_edit_redirect.as_bool(self.superuser)) - self.assertIn(expected_disabled_state, actual_disabled_state) + self.assertEqual(expected_disabled_state, actual_disabled_state) @override_settings(DJANGOCMS_VERSIONING_LOCK_VERSIONS=True) diff --git a/tests/test_toolbars.py b/tests/test_toolbars.py index deb1f038..66339249 100644 --- a/tests/test_toolbars.py +++ b/tests/test_toolbars.py @@ -341,6 +341,13 @@ def test_view_published_in_toolbar_in_edit_mode_for_published_page(self): are published """ published_version = PageVersionFactory(content__language="en", state=PUBLISHED) + # Create URL + PageUrlFactory( + page=published_version.content.page, + language=published_version.content.language, + path=slugify("test_page"), + slug=slugify("test_page"), + ) toolbar = get_toolbar(published_version.content, edit_mode=True) toolbar.post_template_populate() @@ -353,6 +360,13 @@ def test_view_published_in_toolbar_in_preview_mode_for_published_page(self): are published """ published_version = PageVersionFactory(content__language="en", state=PUBLISHED) + # Create URL + PageUrlFactory( + page=published_version.content.page, + language=published_version.content.language, + path=slugify("test_page"), + slug=slugify("test_page"), + ) toolbar = get_toolbar(published_version.content, preview_mode=True) toolbar.post_template_populate() From ec3e5b9e41ca937039ad1dc9a6fb90af1c93b2a1 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 3 May 2024 22:00:57 +0200 Subject: [PATCH 31/47] fix: Avoid unnecessary loading of `actions.js` asset into the toolbar (#403) * fix: as of django-cms 4.1.1, the `actions.js` asset needs not be loaded for the toolbar * Fix test --- djangocms_versioning/cms_toolbars.py | 10 +++------- setup.py | 4 ++-- tests/test_locking.py | 2 +- tests/test_toolbars.py | 4 ++-- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/djangocms_versioning/cms_toolbars.py b/djangocms_versioning/cms_toolbars.py index 153f43ba..62a5231a 100644 --- a/djangocms_versioning/cms_toolbars.py +++ b/djangocms_versioning/cms_toolbars.py @@ -35,9 +35,6 @@ class VersioningToolbar(PlaceholderToolbar): - class Media: - js = ("cms/js/admin/actions.js",) - def _get_versionable(self): """Helper method to get the versionable for the content type of the version @@ -79,7 +76,7 @@ def _add_publish_button(self): _("Publish"), url=publish_url, disabled=False, - extra_classes=["cms-btn-action", "js-action", "cms-form-post-method", "cms-versioning-js-publish-btn"], + extra_classes=["cms-btn-action", "cms-form-post-method", "cms-versioning-js-publish-btn"], ) self.toolbar.add_item(item) @@ -115,7 +112,7 @@ def _add_edit_button(self, disabled=False): _("Edit") if draft_exists else _("New Draft"), url=edit_url, disabled=disabled, - extra_classes=["cms-btn-action", "js-action", "cms-form-post-method", "cms-versioning-js-edit-btn"], + extra_classes=["cms-btn-action", "cms-form-post-method", "cms-versioning-js-edit-btn"], ) self.toolbar.add_item(item) @@ -135,7 +132,6 @@ def _add_unlock_button(self): if can_unlock: extra_classes = [ "cms-btn-action", - "js-action", "cms-form-post-method", "cms-versioning-js-unlock-btn", ] @@ -316,7 +312,7 @@ def override_language_menu(self): # Only override the menu if it exists and a page can be found language_menu = self.toolbar.get_menu(LANGUAGE_MENU_IDENTIFIER, _("Language")) if settings.USE_I18N and language_menu and self.page: - # remove_item uses `items` attribute so we have to copy object + # remove_item uses `items` attribute, so we have to copy object for _item in copy(language_menu.items): language_menu.remove_item(item=_item) diff --git a/setup.py b/setup.py index 9ddc378b..a1f934f3 100644 --- a/setup.py +++ b/setup.py @@ -3,8 +3,8 @@ import djangocms_versioning INSTALL_REQUIREMENTS = [ - "Django>=1.11", - "django-cms", + "Django>=3.2", + "django-cms>=4.1.1", "django-fsm" ] diff --git a/tests/test_locking.py b/tests/test_locking.py index 7b016778..f8199f57 100644 --- a/tests/test_locking.py +++ b/tests/test_locking.py @@ -839,7 +839,7 @@ def test_enable_edit_button_when_content_is_locked(self): self.assertFalse(edit_button.disabled) self.assertListEqual( edit_button.extra_classes, - ["cms-btn-action", "js-action", "cms-form-post-method", "cms-versioning-js-edit-btn"] + ["cms-btn-action", "cms-form-post-method", "cms-versioning-js-edit-btn"] ) def test_lock_message_when_content_is_locked(self): diff --git a/tests/test_toolbars.py b/tests/test_toolbars.py index 66339249..1c51d838 100644 --- a/tests/test_toolbars.py +++ b/tests/test_toolbars.py @@ -65,7 +65,7 @@ def test_publish_in_toolbar_in_edit_mode(self): self.assertFalse(publish_button.disabled) self.assertListEqual( publish_button.extra_classes, - ["cms-btn-action", "js-action", "cms-form-post-method", "cms-versioning-js-publish-btn"], + ["cms-btn-action", "cms-form-post-method", "cms-versioning-js-publish-btn"], ) def test_revert_in_toolbar_in_preview_mode(self): @@ -150,7 +150,7 @@ def test_edit_in_toolbar_in_preview_mode(self): self.assertFalse(edit_button.disabled) self.assertListEqual( edit_button.extra_classes, - ["cms-btn-action", "js-action", "cms-form-post-method", "cms-versioning-js-edit-btn"] + ["cms-btn-action", "cms-form-post-method", "cms-versioning-js-edit-btn"] ) def test_edit_not_in_toolbar_in_edit_mode(self): From 5b07387b31bce7dcbe41863aeeb6ea2c3849ec33 Mon Sep 17 00:00:00 2001 From: "transifex-integration[bot]" <43880903+transifex-integration[bot]@users.noreply.github.com> Date: Sat, 4 May 2024 04:47:16 +0200 Subject: [PATCH 32/47] Translate django.po in ar (#407) 100% translated source file: 'django.po' on 'ar'. Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com> --- .../locale/ar/LC_MESSAGES/django.po | 502 ++++++++++++++++++ 1 file changed, 502 insertions(+) create mode 100644 djangocms_versioning/locale/ar/LC_MESSAGES/django.po diff --git a/djangocms_versioning/locale/ar/LC_MESSAGES/django.po b/djangocms_versioning/locale/ar/LC_MESSAGES/django.po new file mode 100644 index 00000000..7de35d10 --- /dev/null +++ b/djangocms_versioning/locale/ar/LC_MESSAGES/django.po @@ -0,0 +1,502 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +# Translators: +# Seraj Adden Baltu, 2024 +# Fabian Braun , 2024 +# Mohammad Alsakhawy, 2024 +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-10-02 09:37+0200\n" +"PO-Revision-Date: 2023-01-10 15:29+0000\n" +"Last-Translator: Mohammad Alsakhawy, 2024\n" +"Language-Team: Arabic (https://app.transifex.com/divio/teams/58664/ar/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Language: ar\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" + +#: admin.py:164 admin.py:301 admin.py:377 +msgid "State" +msgstr "الحالة" + +#: admin.py:192 constants.py:27 +msgid "Empty" +msgstr "فارغ" + +#: admin.py:315 admin.py:387 +msgid "Author" +msgstr "المؤلف" + +#: admin.py:329 admin.py:401 models.py:87 +msgid "Modified" +msgstr "تعديل" + +#: admin.py:437 admin.py:667 +#: templates/djangocms_versioning/admin/icons/preview.html:3 +#: templates/djangocms_versioning/admin/preview.html:3 +msgid "Preview" +msgstr "معاينة" + +#: admin.py:470 admin.py:758 cms_toolbars.py:115 +#: templates/djangocms_versioning/admin/icons/edit_icon.html:3 +msgid "Edit" +msgstr "تحرير" + +#: admin.py:482 +#: templates/djangocms_versioning/admin/icons/manage_versions.html:3 +msgid "Manage versions" +msgstr "إدارة الإصدارات " + +#: admin.py:631 +msgid "Content" +msgstr "المحتوى" + +#: admin.py:647 +msgid "locked" +msgstr "مقفول" + +#: admin.py:683 templates/djangocms_versioning/admin/icons/archive_icon.html:3 +msgid "Archive" +msgstr "أرشيف" + +#: admin.py:701 cms_toolbars.py:79 indicators.py:34 +#: templates/djangocms_versioning/admin/icons/publish_icon.html:3 +msgid "Publish" +msgstr "نشر" + +#: admin.py:721 indicators.py:54 indicators.py:60 +#: templates/djangocms_versioning/admin/icons/unpublish_icon.html:3 +msgid "Unpublish" +msgstr "الغاء النشر " + +#: admin.py:758 cms_toolbars.py:115 +msgid "New Draft" +msgstr "مسودة جديدة " + +#: admin.py:779 cms_toolbars.py:177 +#: templates/djangocms_versioning/admin/icons/revert_icon.html:3 +msgid "Revert" +msgstr "استرجاع" + +#: admin.py:798 templates/djangocms_versioning/admin/icons/discard_icon.html:3 +msgid "Discard" +msgstr "تجاهل" + +#: admin.py:821 cms_toolbars.py:145 +msgid "Unlock" +msgstr "إلغاء القفل " + +#: admin.py:856 +msgid "Compare versions" +msgstr "مقارنة الإصدارات " + +#: admin.py:866 +msgid "Exactly two versions need to be selected." +msgstr "يجب تحديد إثنين من الإصدارات بالضبط" + +#: admin.py:903 +msgid "Version cannot be archived" +msgstr "لا يمكن أرشفة الإصدار " + +#: admin.py:929 +msgid "Version archived" +msgstr "تمت أرشفة الإصدار " + +#: admin.py:940 admin.py:1059 admin.py:1235 +msgid "This view only supports POST method." +msgstr "هذا العرض يدعم فقط طريقة POST" + +#: admin.py:951 +msgid "Version cannot be published" +msgstr "لا يمكن نشر الإصدار " + +#: admin.py:962 +msgid "Version published" +msgstr "تم نشر الإصدار " + +#: admin.py:979 +msgid "Version cannot be unpublished" +msgstr "لا يمكن إلغاء نشر الإصدار" + +#: admin.py:1017 +msgid "Version unpublished" +msgstr "تم إلغاء نشر الإصدار" + +#: admin.py:1163 +msgid "The last version has been deleted" +msgstr "تم حذف الإصدار السابق" + +#: admin.py:1249 +msgid "You do not have permission to remove the version lock" +msgstr "ليس لديك صلاحيات لحذف قفل الإصدار" + +#: admin.py:1254 +msgid "Version unlocked" +msgstr "تم إلغاء قفل الإصدار" + +#: admin.py:1303 +#, python-brace-format +msgid "Displaying versions of \"{grouper}\"" +msgstr "عرض إصدارات \"{grouper}\"" + +#: apps.py:8 +msgid "django CMS Versioning" +msgstr "إصدارات ن.إ.م. چانجو" + +#: cms_config.py:246 +msgid "No available title" +msgstr "بدون عنوان" + +#: cms_config.py:248 constants.py:12 constants.py:25 +msgid "Unpublished" +msgstr "غير منشور" + +#: cms_config.py:342 +msgid "Language must be set to a supported language!" +msgstr "يجب تحديد لغة ضمن اللغات المدعومة!" + +#: cms_config.py:360 +msgid "You do not have permission to copy these plugins." +msgstr "ليس لديك صلاحيات لنسخ هذه الملحقات." + +#: cms_toolbars.py:207 +msgid "Manage Versions" +msgstr "إدارة الإصدارات" + +#: cms_toolbars.py:210 +#, python-brace-format +msgid "Compare to {source}" +msgstr "قارن ب {source}" + +#: cms_toolbars.py:226 indicators.py:66 +msgid "Discard Changes" +msgstr " تجاهل التغييرات" + +#: cms_toolbars.py:262 +msgid "View Published" +msgstr "عرض المنشور" + +#: cms_toolbars.py:317 +msgid "Language" +msgstr "اللغة" + +#: cms_toolbars.py:364 +msgid "Add Translation" +msgstr "إضافة ترجمة" + +#: cms_toolbars.py:377 +msgid "Copy all plugins" +msgstr "نسخ كل الملحقات" + +#: cms_toolbars.py:379 +#, python-format +msgid "from %s" +msgstr "من %s" + +#: cms_toolbars.py:380 +#, python-format +msgid "Are you sure you want to copy all plugins from %s?" +msgstr "هل أنت متأكد من أنك تريد نسخ كل الملحقات من %s؟" + +#: cms_toolbars.py:395 +msgid "No other language available" +msgstr "لا توجد لغة أخرى متوفرة" + +#: constants.py:10 constants.py:24 +msgid "Draft" +msgstr "مسودة" + +#: constants.py:11 constants.py:22 +msgid "Published" +msgstr "منشور" + +#: constants.py:13 constants.py:26 +msgid "Archived" +msgstr "مُؤرشَف" + +#: constants.py:23 +msgid "Changed" +msgstr "مُعدّل" + +#: emails.py:39 +msgid "Unlocked" +msgstr "أُلغيَ القفل" + +#: indicators.py:28 +#, python-format +msgid "Unlock (%(message)s)" +msgstr "إلغاء قفل (%(message)s)" + +#: indicators.py:40 +msgid "Create new draft" +msgstr "إنشاء مسودة جديدة" + +#: indicators.py:46 +msgid "Revert from Unpublish" +msgstr "تراجع عن إلغاء النشر" + +#: indicators.py:66 +msgid "Delete Draft" +msgstr "حذف المسودة" + +#: indicators.py:72 +msgid "Compare Draft to Published..." +msgstr "قارن المسودة بالمنشور..." + +#: indicators.py:82 +msgid "Manage Versions..." +msgstr "إدارة الإصدارات..." + +#: models.py:29 +msgid "Version is not a draft" +msgstr "الإصدار ليس مسودة" + +#: models.py:30 +#, python-brace-format +msgid "Action Denied. The latest version is locked by {user}" +msgstr "تم رفض الإجراء. أحدث إصدار مقفول بواسطة {user}" + +#: models.py:31 +#, python-brace-format +msgid "Action Denied. The draft version is locked by {user}" +msgstr "تم رفض الإجراء. إصدار المسودة مقفول بواسطة {user}" + +#: models.py:86 +msgid "Created" +msgstr "أُنشئ" + +#: models.py:89 +msgid "author" +msgstr "المؤلف" + +#: models.py:102 +msgid "status" +msgstr "الحالة" + +#: models.py:110 +msgid "locked by" +msgstr "مقفول بواسطة" + +#: models.py:119 +msgid "source" +msgstr "المصدر" + +#: models.py:133 +#, python-brace-format +msgid "Version #{number} ({state} {date})" +msgstr "الإصدار #{number} ({state} {date})" + +#: models.py:140 +#, python-brace-format +msgid "Version #{number} ({state})" +msgstr "الإصدار #{number} ({state})" + +#: models.py:146 +#, python-format +msgid "Locked by %(user)s" +msgstr "مقفول بواسطة %(user)s" + +#: models.py:278 models.py:327 +msgid "Version is not in draft state" +msgstr "الإصدار ليس في حالة مسودة" + +#: models.py:387 +msgid "Version is not in published state" +msgstr "الإصدار ليس في حالة منشور" + +#: models.py:444 +msgid "Version is not in archived or unpublished state" +msgstr "الإصدار ليس في حالة أرشفة أو حالة غير منشور" + +#: models.py:459 +msgid "Version is not in draft or published state" +msgstr "الإصدار ليس في حالة مسودة أو حالة منشور" + +#: models.py:467 +msgid "Version is already locked" +msgstr "تم قفل الإصدار بالفعل" + +#: models.py:473 +msgid "Draft version is not locked" +msgstr "إصدار المسودة غير مقفول" + +#: templates/admin/djangocms_versioning/versioning_breadcrumbs.html:3 +#: templates/djangocms_versioning/admin/grouper_form.html:9 +msgid "Home" +msgstr "الرئيسية" + +#: templates/admin/djangocms_versioning/versioning_breadcrumbs.html:7 +#: templates/djangocms_versioning/admin/mixin/change_form.html:7 +msgid "Versions" +msgstr "الإصدارات" + +#: templates/djangocms_versioning/admin/archive_confirmation.html:3 +msgid "Archive Confirmation" +msgstr "تأكيد الأرشفة" + +#: templates/djangocms_versioning/admin/archive_confirmation.html:15 +msgid "Are you sure you want to archive the following version?" +msgstr "هل أنت متأكد أنك تريد أرشفة الإصدار التالي؟" + +#: templates/djangocms_versioning/admin/archive_confirmation.html:17 +#: templates/djangocms_versioning/admin/unpublish_confirmation.html:17 +#, python-format +msgid " Version number: %(version_number)s" +msgstr "إصدار رقم: %(version_number)s" + +#: templates/djangocms_versioning/admin/archive_confirmation.html:22 +#: templates/djangocms_versioning/admin/discard_confirmation.html:23 +#: templates/djangocms_versioning/admin/revert_confirmation.html:40 +#: templates/djangocms_versioning/admin/unpublish_confirmation.html:27 +msgid "Yes, I'm sure" +msgstr "نعم، أنا متأكد" + +#: templates/djangocms_versioning/admin/archive_confirmation.html:26 +#: templates/djangocms_versioning/admin/discard_confirmation.html:27 +#: templates/djangocms_versioning/admin/revert_confirmation.html:45 +#: templates/djangocms_versioning/admin/unpublish_confirmation.html:31 +msgid "No, take me back" +msgstr "لا، تراجع للخلف" + +#: templates/djangocms_versioning/admin/compare.html:8 +#, python-format +msgid "" +"\n" +" Compare %(left)s to %(right)s\n" +" " +msgstr "" +"\n" +"قارن %(left)s بـ %(right)s" + +#: templates/djangocms_versioning/admin/compare.html:12 +#, python-format +msgid "" +"\n" +" Compare %(left)s\n" +" " +msgstr "" +"\n" +"قارن %(left)s" + +#: templates/djangocms_versioning/admin/compare.html:16 +#, python-format +msgid "" +"\n" +" Compare %(right)s\n" +" " +msgstr "" +"\n" +"قارن %(right)s" + +#: templates/djangocms_versioning/admin/compare.html:37 +msgid "Back" +msgstr "رجوع" + +#: templates/djangocms_versioning/admin/compare.html:40 +#, python-format +msgid "" +"\n" +" Comparing %(left)s with\n" +" " +msgstr "" +"\n" +"مقارنة %(left)s بـ" + +#: templates/djangocms_versioning/admin/compare.html:45 +msgid "Pick a version to compare to" +msgstr "اختر إصدار للمقارنة" + +#: templates/djangocms_versioning/admin/compare.html:56 +msgid "Visual" +msgstr "مرئي" + +#: templates/djangocms_versioning/admin/compare.html:59 +msgid "Source" +msgstr "مصدر" + +#: templates/djangocms_versioning/admin/discard_confirmation.html:3 +msgid "Discard Confirmation" +msgstr "تأكيد التجاهل" + +#: templates/djangocms_versioning/admin/discard_confirmation.html:15 +msgid "Are you sure you want to discard following version?" +msgstr "هل أنت متأكد أنك تريد تجاهل الإصدار التالي؟" + +#: templates/djangocms_versioning/admin/discard_confirmation.html:17 +#: templates/djangocms_versioning/admin/revert_confirmation.html:24 +#, python-format +msgid "Version number: %(version_number)s" +msgstr "إصدار رقم: %(version_number)s" + +#: templates/djangocms_versioning/admin/grouper_form.html:27 +#, python-format +msgid "Add %(name)s" +msgstr "إضافة %(name)s" + +#: templates/djangocms_versioning/admin/grouper_form.html:37 +msgid "Submit" +msgstr "تأكيد" + +#: templates/djangocms_versioning/admin/icons/view.html:3 +msgid "View on site" +msgstr "عرض على الموقع" + +#: templates/djangocms_versioning/admin/revert_confirmation.html:3 +#: templates/djangocms_versioning/admin/unpublish_confirmation.html:3 +msgid "Revert Confirmation" +msgstr "تأكيد الاسترجاع" + +#: templates/djangocms_versioning/admin/revert_confirmation.html:18 +msgid "" +"Reverting to this version may cause loss of an existing draft version. " +"Please select an option to continue" +msgstr "" +"الاسترجاع لهذا الإصدار قد يتسبب في خسارة إصدار مسودة متواجدة. يرجى تحديد " +"اختيار للإستمرار" + +#: templates/djangocms_versioning/admin/revert_confirmation.html:20 +msgid "Are you sure you want to revert to the following version?" +msgstr "هل أنت متأكد أنك تريد استرجاع الإصدار التالي؟" + +#: templates/djangocms_versioning/admin/revert_confirmation.html:31 +msgid "Discard existing draft and Revert" +msgstr "تجاهل المسودة المتواجدة واسترجع" + +#: templates/djangocms_versioning/admin/revert_confirmation.html:35 +msgid "Archive existing draft and Revert" +msgstr "أرشف المسودة المتواجدة واسترجع" + +#: templates/djangocms_versioning/admin/unpublish_confirmation.html:15 +msgid "" +"Unpublishing will remove this version from live. Are you sure you want to " +"unpublish?" +msgstr "" +"إلغاء النشر سيؤدي إلى حذف هذا الإصدار من مباشر الموقع. هل أنت متأكد أنك تريد" +" إلغاء النشر؟" + +#: templates/djangocms_versioning/emails/unlock-notification.txt:2 +#, python-format +msgid "" +"\n" +"The following draft version has been unlocked by %(by_user)s for their use.\n" +"%(version_link)s\n" +"\n" +"Please note you will not be able to further edit this draft. Kindly reach out to %(by_user)s in case of any concerns.\n" +"\n" +"This is an automated notification from Django CMS.\n" +msgstr "" +"\n" +"تم إلغاء قفل المسودة التالية بواسطة %(by_user)s لاستخدامهم.\n" +"%(version_link)s\n" +"\n" +"برجاء العلم أنك لن تستطيع إجراء المزيد من التعديلات على هذه المسودة. يرجى التواصل مع %(by_user)s في حالة وجود أي استفسارات.\n" +"\n" +"هذا إشعار تلقائي من ن.إ.م. چانجو.\n" From 7860a6d4a79068899defa42f227ed9b25f517494 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sat, 4 May 2024 05:26:43 +0200 Subject: [PATCH 33/47] feat: Update locales (#406) * feat: Update locales * Update mo files --- CHANGELOG.rst | 6 ++ djangocms_versioning/__init__.py | 2 +- .../locale/ar/LC_MESSAGES/django.mo | Bin 0 -> 9074 bytes .../locale/de/LC_MESSAGES/django.mo | Bin 8257 -> 8270 bytes .../locale/de/LC_MESSAGES/django.po | 12 ++-- .../locale/fr/LC_MESSAGES/django.mo | Bin 6571 -> 8298 bytes .../locale/fr/LC_MESSAGES/django.po | 15 ++--- .../locale/nl/LC_MESSAGES/django.mo | Bin 8121 -> 8121 bytes .../locale/nl/LC_MESSAGES/django.po | 15 ++--- .../locale/sq/LC_MESSAGES/django.mo | Bin 6454 -> 6454 bytes .../locale/sq/LC_MESSAGES/django.po | 53 +++++------------- 11 files changed, 38 insertions(+), 65 deletions(-) create mode 100644 djangocms_versioning/locale/ar/LC_MESSAGES/django.mo diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f21c5790..1b54acd2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,12 @@ Changelog ========= +2.0.2 (2024-05-03) +================== + +* fix: Do not show edit action for version objects where editing is not possible by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/405 +* feat: Add Arabic locale + 2.0.1 (2024-03-29) ================== diff --git a/djangocms_versioning/__init__.py b/djangocms_versioning/__init__.py index 159d48b8..0309ae29 100644 --- a/djangocms_versioning/__init__.py +++ b/djangocms_versioning/__init__.py @@ -1 +1 @@ -__version__ = "2.0.1" +__version__ = "2.0.2" diff --git a/djangocms_versioning/locale/ar/LC_MESSAGES/django.mo b/djangocms_versioning/locale/ar/LC_MESSAGES/django.mo new file mode 100644 index 0000000000000000000000000000000000000000..15abc8cde9a88dc3bf492c31f4c2932c9fca2bb5 GIT binary patch literal 9074 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, YEAR. -# +# # Translators: # Fabian Braun , 2023 -# +# #, fuzzy msgid "" msgstr "" @@ -15,10 +15,10 @@ msgstr "" "PO-Revision-Date: 2023-01-10 15:29+0000\n" "Last-Translator: Fabian Braun , 2023\n" "Language-Team: German (https://app.transifex.com/divio/teams/58664/de/)\n" -"Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Language: de\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: admin.py:164 admin.py:301 admin.py:377 @@ -490,8 +490,7 @@ msgid "" "The following draft version has been unlocked by %(by_user)s for their use.\n" "%(version_link)s\n" "\n" -"Please note you will not be able to further edit this draft. Kindly reach " -"out to %(by_user)s in case of any concerns.\n" +"Please note you will not be able to further edit this draft. Kindly reach out to %(by_user)s in case of any concerns.\n" "\n" "This is an automated notification from Django CMS.\n" msgstr "" @@ -500,7 +499,6 @@ msgstr "" "\n" "%(version_link)s\n" "\n" -"Bitte beachte, dass Du diesen Entwurf nicht mehr ändern kannst. Bitte " -"kontaktiere %(by_user)s für Rückfragen.\n" +"Bitte beachte, dass Du diesen Entwurf nicht mehr ändern kannst. Bitte kontaktiere %(by_user)s für Rückfragen.\n" "\n" "Dies ist eine automatisierte Nachricht von Django CMS.\n" diff --git a/djangocms_versioning/locale/fr/LC_MESSAGES/django.mo b/djangocms_versioning/locale/fr/LC_MESSAGES/django.mo index 92cd88bbaf4bc329314c166d2a40475d32837532..1315279b0a49e4ff899380ca5d80be430380c508 100644 GIT binary patch literal 8298 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 RRxRAOzGrtqTcwt${{mq3EhGQ{ delta 2033 zcmXxldu)?c7{~Eb#vG2WyOhCNMq4f$!%dX66Nc=DtALws>Vgn3qnd)-*b2L_WD*-u zgNZINIWuU)#6J*BG{)@x10}}9U?OO|MvM?68gGd)3;rWUqw)J|&rbUE^Sh7O# z&Y%){)jgj@C2|gx$Y;2U@y#DxsMsaN`GLZy`zWr%8rR>0lIEHXyl}1gi2@3}O;1aW@WP#y!7+O7sVJ|0}Ap zIb4Hfl&{}wLb^;FYMHxGOZf_BmDzbNl-X4b<26+O;*$JSuf?U@CsAv^7jMF6-Se}k zfiIzc_&SzjIFK)(0W~A-$jj{DjUmjg0Q0X(({%71a}YP+6srFXROKFOhAz7PFPz`t zYd#XkHl9CC721>+@K*dCH4`hyrVh6vU1kVX(9v?{Uzu4tRPjk{!naZVbEr*u9knUz zS!iw6H0pCNpc4Jmc?tEsZ&5S#Gpc|})=>r2<1&mRFVmIfU77TwK5zu}g~u>~GpGtK zqSkT_^|`-Lr$Sp=0~MnBn@|b0U^{jqA2m;*#(B}*zlBOP`+JoqS{%k2oWVLghuVZcVgnXd<&SA2>V6-t!)a6kXR%D@|0)+0ZLXoF zq%f4l^}p;qh3rwYjhJs*Qng;4f32gYSV`#k zsohW9Luf!%sWrZj(6Q4F-$dL?Bni!iDpXU2^R3Tas0huFPRM-Q!NqNa#?Q4<-Zg!9 z5n2vSxmrOUbEi`*Aas72Fmne%fw|+>gB^t0dLl|_k8~1hnx!s6v!^9sRig!YCun*L6xbragKYWfEiCpHiw;zh;`|R|hcWiHQjrV==q|XldqkJzIXHFmb*$w+GC{F%(aSgbd7aCjg+8r!n}Q2S8o(D?e{%tHgM zHW{ofn4FxnBf(mKcWUIoMCw3#b8P>pwZT2!Kf#dC7qkCH>bwg}J)eE9s>5EcirUK1 wl${Mt+uh;qo)`Yo=e=Lu=1iP4ACW^6n)J~3|o2QH<|g8%>k diff --git a/djangocms_versioning/locale/fr/LC_MESSAGES/django.po b/djangocms_versioning/locale/fr/LC_MESSAGES/django.po index 0316829c..02035cba 100644 --- a/djangocms_versioning/locale/fr/LC_MESSAGES/django.po +++ b/djangocms_versioning/locale/fr/LC_MESSAGES/django.po @@ -2,11 +2,11 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# # Translators: # François Palmier , 2023 # Frédéric Roland, 2023 -# +# #, fuzzy msgid "" msgstr "" @@ -16,12 +16,11 @@ msgstr "" "PO-Revision-Date: 2023-01-10 15:29+0000\n" "Last-Translator: Frédéric Roland, 2023\n" "Language-Team: French (https://app.transifex.com/divio/teams/58664/fr/)\n" -"Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % " -"1000000 == 0 ? 1 : 2;\n" +"Language: fr\n" +"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n" #: admin.py:164 admin.py:301 admin.py:377 msgid "State" @@ -493,8 +492,7 @@ msgid "" "The following draft version has been unlocked by %(by_user)s for their use.\n" "%(version_link)s\n" "\n" -"Please note you will not be able to further edit this draft. Kindly reach " -"out to %(by_user)s in case of any concerns.\n" +"Please note you will not be able to further edit this draft. Kindly reach out to %(by_user)s in case of any concerns.\n" "\n" "This is an automated notification from Django CMS.\n" msgstr "" @@ -502,7 +500,6 @@ msgstr "" "Le brouillon suivant a été déverrouillé par %(by_user)s pour son usage.\n" "%(version_link)s\n" "\n" -"Notez que vous ne pourrez pas continuer a modifier ce brouillon. Vous êtes " -"prié de contacter %(by_user)s en cas de soucis.\n" +"Notez que vous ne pourrez pas continuer a modifier ce brouillon. Vous êtes prié de contacter %(by_user)s en cas de soucis.\n" "\n" "C'est une notification automatique de Django CMS.\n" diff --git a/djangocms_versioning/locale/nl/LC_MESSAGES/django.mo b/djangocms_versioning/locale/nl/LC_MESSAGES/django.mo index a1ffd899b3435bb042b75fdd2779b2c8899e994c..7bc611d5d6dba6ad16c521e3951d8b2084995a32 100644 GIT binary patch delta 29 lcmdmKzteuhI^oH?g`;?U67$ka6Vp?z6!LO5i-;`c1^~7X3k(1N delta 29 lcmdmKzteuhI$<84#Ju#<#Pn1vg}j`}YlWjXi;67a1^}?Q3jY8A diff --git a/djangocms_versioning/locale/nl/LC_MESSAGES/django.po b/djangocms_versioning/locale/nl/LC_MESSAGES/django.po index ccc036a8..fd9c6b65 100644 --- a/djangocms_versioning/locale/nl/LC_MESSAGES/django.po +++ b/djangocms_versioning/locale/nl/LC_MESSAGES/django.po @@ -2,11 +2,11 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# # Translators: # Fabian Braun , 2023 # Stefan van den Eertwegh , 2023 -# +# #, fuzzy msgid "" msgstr "" @@ -16,10 +16,10 @@ msgstr "" "PO-Revision-Date: 2023-01-10 15:29+0000\n" "Last-Translator: Stefan van den Eertwegh , 2023\n" "Language-Team: Dutch (https://app.transifex.com/divio/teams/58664/nl/)\n" -"Language: nl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Language: nl\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: admin.py:164 admin.py:301 admin.py:377 @@ -492,16 +492,13 @@ msgid "" "The following draft version has been unlocked by %(by_user)s for their use.\n" "%(version_link)s\n" "\n" -"Please note you will not be able to further edit this draft. Kindly reach " -"out to %(by_user)s in case of any concerns.\n" +"Please note you will not be able to further edit this draft. Kindly reach out to %(by_user)s in case of any concerns.\n" "\n" "This is an automated notification from Django CMS.\n" msgstr "" "\n" -"De volgende concept versie is van het slot af door%(by_user)svoor hun " -"gebruik.\n" +"De volgende concept versie is van het slot af door%(by_user)svoor hun gebruik.\n" " %(version_link)s\n" -"Let op: je kunt niet verder bewerken in dit concept. Neem asjeblieft contact " -"op met %(by_user)s in geval van enige zorgen. \n" +"Let op: je kunt niet verder bewerken in dit concept. Neem asjeblieft contact op met %(by_user)s in geval van enige zorgen. \n" "\n" "Dit is een geautomatiseerde notificatie van Django CMS.\n" diff --git a/djangocms_versioning/locale/sq/LC_MESSAGES/django.mo b/djangocms_versioning/locale/sq/LC_MESSAGES/django.mo index 625df0675edc9842df3f3171f3276238351b5ef2..1929104de5013565f25686c9476cc736211674da 100644 GIT binary patch delta 37 tcmdmHw9RP4B7Ww?f`Z9Q`PC-h;E&?*Nz6+xO-xU=s_4JQBq delta 37 tcmdmHw9RP4B7Wxb^76?``PF!Q67$ka6Vp?z6p9NcpW=_+Y$U+K2>=lj4L$$> diff --git a/djangocms_versioning/locale/sq/LC_MESSAGES/django.po b/djangocms_versioning/locale/sq/LC_MESSAGES/django.po index 263ae0c5..9a638674 100644 --- a/djangocms_versioning/locale/sq/LC_MESSAGES/django.po +++ b/djangocms_versioning/locale/sq/LC_MESSAGES/django.po @@ -2,10 +2,10 @@ # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. -# +# # Translators: # Besnik Bleta , 2023 -# +# #, fuzzy msgid "" msgstr "" @@ -14,11 +14,11 @@ msgstr "" "POT-Creation-Date: 2023-10-02 09:37+0200\n" "PO-Revision-Date: 2023-01-10 15:29+0000\n" "Last-Translator: Besnik Bleta , 2023\n" -"Language-Team: Albanian (https://www.transifex.com/divio/teams/58664/sq/)\n" -"Language: sq\n" +"Language-Team: Albanian (https://app.transifex.com/divio/teams/58664/sq/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" +"Language: sq\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: admin.py:164 admin.py:301 admin.py:377 @@ -76,10 +76,8 @@ msgid "Unpublish" msgstr "Hiqe nga të botuar" #: admin.py:758 cms_toolbars.py:115 -#, fuzzy -#| msgid "Draft" msgid "New Draft" -msgstr "Skicë" +msgstr "" #: admin.py:779 cms_toolbars.py:177 #: templates/djangocms_versioning/admin/icons/revert_icon.html:3 @@ -135,16 +133,12 @@ msgid "The last version has been deleted" msgstr "Versioni i fundit është fshirë" #: admin.py:1249 -#, fuzzy -#| msgid "You do not have permission to copy these plugins." msgid "You do not have permission to remove the version lock" -msgstr "S’keni leje të kopjoni këto shtojca." +msgstr "" #: admin.py:1254 -#, fuzzy -#| msgid "Version unpublished" msgid "Version unlocked" -msgstr "Versioni u shbotua" +msgstr "" #: admin.py:1303 #, python-brace-format @@ -176,16 +170,13 @@ msgid "Manage Versions" msgstr "Administroni Versione" #: cms_toolbars.py:210 -#, fuzzy, python-brace-format -#| msgid "Compare to {state} source" +#, python-brace-format msgid "Compare to {source}" -msgstr "Krahasoje me burimin {state}" +msgstr "" #: cms_toolbars.py:226 indicators.py:66 -#, fuzzy -#| msgid "Discard" msgid "Discard Changes" -msgstr "Hidhe tej" +msgstr "" #: cms_toolbars.py:262 msgid "View Published" @@ -277,10 +268,8 @@ msgid "Action Denied. The draft version is locked by {user}" msgstr "" #: models.py:86 -#, fuzzy -#| msgid "Create new draft" msgid "Created" -msgstr "Krijoni një skicë të re" +msgstr "" #: models.py:89 msgid "author" @@ -330,16 +319,12 @@ msgid "Version is not in draft or published state" msgstr "Versioni s’është nën gjendjen “skicë” ose “i botuar”" #: models.py:467 -#, fuzzy -#| msgid "Version archived" msgid "Version is already locked" -msgstr "Versioni u arkivua" +msgstr "" #: models.py:473 -#, fuzzy -#| msgid "Version is not a draft" msgid "Draft version is not locked" -msgstr "Versioni s’është skicë" +msgstr "" #: templates/admin/djangocms_versioning/versioning_breadcrumbs.html:3 #: templates/djangocms_versioning/admin/grouper_form.html:9 @@ -506,17 +491,7 @@ msgid "" "The following draft version has been unlocked by %(by_user)s for their use.\n" "%(version_link)s\n" "\n" -"Please note you will not be able to further edit this draft. Kindly reach " -"out to %(by_user)s in case of any concerns.\n" +"Please note you will not be able to further edit this draft. Kindly reach out to %(by_user)s in case of any concerns.\n" "\n" "This is an automated notification from Django CMS.\n" msgstr "" - -#~ msgid "actions" -#~ msgstr "veprime" - -#~ msgid "version number" -#~ msgstr "numër versioni" - -#~ msgid "Delete Changes" -#~ msgstr "Fshiji Ndryshimet" From adce88065509c84212e432c41c05a121c246ed1f Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sat, 4 May 2024 13:50:20 +0200 Subject: [PATCH 34/47] fix: pin django fsm to < 3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index a1f934f3..aa90ca36 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ INSTALL_REQUIREMENTS = [ "Django>=3.2", "django-cms>=4.1.1", - "django-fsm" + "django-fsm<3" ] setup( From 34577f055a14f1a434e1884320d6f692c80a67f8 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 24 May 2024 00:02:04 +0200 Subject: [PATCH 35/47] feat: Add versioning actions to settings (admin change view) of versioned objects (#408) * fix: Add versioning actions to change forms * Fix ruff errors * Fix settings button * Add tests * Only offer publish button publishing technically is possible * fix: Unify edit icons * Fix: Only offer settings button if the admin change view exists. * Fix ruff issue * Improve DRY in tests --- djangocms_versioning/admin.py | 61 +++++++++-- djangocms_versioning/cms_config.py | 11 +- djangocms_versioning/cms_toolbars.py | 3 + djangocms_versioning/helpers.py | 6 +- .../djangocms_versioning/css/object-tools.css | 8 ++ .../djangocms_versioning/js/object-tools.js | 13 +++ .../page/change_form.html | 100 ++++++++++++++++++ .../versioning_buttons.html | 30 ++++++ .../admin/mixin/change_form.html | 18 ++-- .../templatetags/djangocms_versioning.py | 38 +++++++ tests/test_admin.py | 74 +++++++++++++ 11 files changed, 336 insertions(+), 26 deletions(-) create mode 100644 djangocms_versioning/static/djangocms_versioning/css/object-tools.css create mode 100644 djangocms_versioning/static/djangocms_versioning/js/object-tools.js create mode 100644 djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html create mode 100644 djangocms_versioning/templates/admin/djangocms_versioning/versioning_buttons.html diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 0641bcb3..77b1748b 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -8,6 +8,7 @@ from cms.models import PageContent from cms.utils import get_language_from_request from cms.utils.conf import get_cms_setting +from cms.utils.helpers import is_editable_model from cms.utils.urlutils import add_url_parameters, static_with_version from django.conf import settings from django.contrib import admin, messages @@ -464,10 +465,26 @@ def _get_edit_link(self, obj, request, disabled=False): f"admin:{version._meta.app_label}_{version._meta.model_name}_edit_redirect", args=(version.pk,), ) + # Only show if no draft exists + if version.state == PUBLISHED: + pks_for_grouper = version.versionable.for_content_grouping_values( + obj + ).values_list("pk", flat=True) + drafts = Version.objects.filter( + object_id__in=pks_for_grouper, + content_type=version.content_type, + state=DRAFT, + ) + if drafts.exists(): + return "" + icon = "edit-new" + else: + icon = "edit" + return self.admin_action_button( url, - icon="pencil", - title=_("Edit"), + icon=icon, + title=_("Edit") if icon == "edit" else _("New Draft"), name="edit", disabled=disabled, action="post", @@ -747,7 +764,7 @@ def _get_edit_link(self, obj, request, disabled=False): return "" icon = "edit-new" else: - icon = "pencil" + icon = "edit" # Don't open in the sideframe if the item is not sideframe compatible keepsideframe = obj.versionable.content_model_is_sideframe_editable @@ -759,7 +776,7 @@ def _get_edit_link(self, obj, request, disabled=False): return self.admin_action_button( edit_url, icon=icon, - title=_("Edit") if icon == "pencil" else _("New Draft"), + title=_("Edit") if icon == "edit" else _("New Draft"), name="edit", action="post", disabled=disabled, @@ -822,6 +839,32 @@ def _get_unlock_link(self, obj, request): disabled=not obj.check_unlock.as_bool(request.user), ) + def _get_settings_link(self, obj, request): + """ + Generate a settings button for the Versioning Admin + """ + + # If the content object is not registered for frontend editing no action should be present + # Also, the content object must be registered with the admin site + content_model = obj.versionable.content_model + if not is_editable_model(content_model): + return "" + + try: + settings_url = reverse( + f"admin:{content_model._meta.app_label}_{content_model._meta.model_name}_change", + args=(obj.content.pk,) + ) + except Resolver404: + return "" + + return self.admin_action_button( + settings_url, + icon="settings", + title=_("Settings"), + name="settings", + ) + def get_actions_list(self): """Returns all action links as a list""" return self.get_state_actions() @@ -848,6 +891,7 @@ def get_state_actions(self): self._get_revert_link, self._get_discard_link, self._get_unlock_link, + self._get_settings_link, ] @admin.action( @@ -945,6 +989,7 @@ def publish_view(self, request, object_id): request, self.model._meta, object_id ) + requested_redirect = request.GET.get("next", None) if conf.ON_PUBLISH_REDIRECT in ("preview", "published"): redirect_url=get_preview_url(version.content) else: @@ -952,12 +997,12 @@ def publish_view(self, request, object_id): if not version.can_be_published(): self.message_user(request, _("Version cannot be published"), messages.ERROR) - return redirect(redirect_url) + return redirect(requested_redirect or redirect_url) try: version.check_publish(request.user) except ConditionFailed as e: self.message_user(request, force_str(e), messages.ERROR) - return redirect(redirect_url) + return redirect(requested_redirect or redirect_url) # Publish the version version.publish(request.user) @@ -970,7 +1015,7 @@ def publish_view(self, request, object_id): if hasattr(version.content, "get_absolute_url"): redirect_url = version.content.get_absolute_url() or redirect_url - return redirect(redirect_url) + return redirect(requested_redirect or redirect_url) def unpublish_view(self, request, object_id): """Unpublishes the specified version and redirects back to the @@ -1085,7 +1130,7 @@ def edit_redirect_view(self, request, object_id): return redirect(version_list_url(version.content)) # Redirect - return redirect(get_editable_url(target.content)) + return redirect(get_editable_url(target.content, request.GET.get("force_admin"))) def revert_view(self, request, object_id): """Reverts to the specified version i.e. creates a draft from it.""" diff --git a/djangocms_versioning/cms_config.py b/djangocms_versioning/cms_config.py index e2894084..39dbc70a 100644 --- a/djangocms_versioning/cms_config.py +++ b/djangocms_versioning/cms_config.py @@ -268,6 +268,8 @@ def on_page_content_archive(version): class VersioningCMSPageAdminMixin(VersioningAdminMixin): + change_form_template = "admin/djangocms_versioning/page/change_form.html" + def get_readonly_fields(self, request, obj=None): fields = super().get_readonly_fields(request, obj) if obj: @@ -281,15 +283,6 @@ def get_readonly_fields(self, request, obj=None): fields.remove(f_name) return fields - def get_form(self, request, obj=None, **kwargs): - form = super().get_form(request, obj, **kwargs) - if obj: - version = Version.objects.get_for_content(obj) - if not version.check_modify.as_bool(request.user): - for f_name in ["slug", "overwrite_url"]: - form.declared_fields[f_name].widget.attrs["readonly"] = True - return form - def get_queryset(self, request): urls = ("cms_pagecontent_get_tree",) queryset = super().get_queryset(request) diff --git a/djangocms_versioning/cms_toolbars.py b/djangocms_versioning/cms_toolbars.py index 62a5231a..73d18594 100644 --- a/djangocms_versioning/cms_toolbars.py +++ b/djangocms_versioning/cms_toolbars.py @@ -292,6 +292,9 @@ def get_page_content(self, language=None): if not language: language = self.current_lang + toolbar_obj = self.toolbar.get_object() + if toolbar_obj and toolbar_obj.language == language: + return self.toolbar.get_object() return get_latest_admin_viewable_content(self.page, language=language) def populate(self): diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index 8d2213d8..76636e14 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -233,11 +233,11 @@ def is_content_editable(placeholder, user): return version.state == DRAFT -def get_editable_url(content_obj): +def get_editable_url(content_obj, force_admin=False): """If the object is editable the cms editable view should be used, with the toolbar. - This method is provides the URL for it. + This method provides the URL for it. """ - if is_editable_model(content_obj.__class__): + if is_editable_model(content_obj.__class__) and not force_admin: language = getattr(content_obj, "language", None) url = get_object_edit_url(content_obj, language) # Or else, the standard edit view should be used diff --git a/djangocms_versioning/static/djangocms_versioning/css/object-tools.css b/djangocms_versioning/static/djangocms_versioning/css/object-tools.css new file mode 100644 index 00000000..6b7b671e --- /dev/null +++ b/djangocms_versioning/static/djangocms_versioning/css/object-tools.css @@ -0,0 +1,8 @@ +.object-tools a.accent { + background-color: var(--accent) !important; +} +.object-tools a.accent:hover, +.object-tools a.accent:active, +.object-tools a.accent:hover:active { + background-color: color-mix(in srgb, var(--accent) 90%, var(--dca-black)) !important; +} diff --git a/djangocms_versioning/static/djangocms_versioning/js/object-tools.js b/djangocms_versioning/static/djangocms_versioning/js/object-tools.js new file mode 100644 index 00000000..ca7e1e44 --- /dev/null +++ b/djangocms_versioning/static/djangocms_versioning/js/object-tools.js @@ -0,0 +1,13 @@ +(function($) { + $(document).ready(function() { + $('.cms-form-post-method').on('click', function(e) { + e.preventDefault(); + var csrf_token = document.querySelector('form input[name="csrfmiddlewaretoken"]').value; + var url = this.href; + var $form = $(''); + var $csrf = $(``); + $form.append($csrf); + $form.appendTo('body').submit(); + }); + }); +})(django.jQuery); diff --git a/djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html b/djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html new file mode 100644 index 00000000..92d00d0e --- /dev/null +++ b/djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html @@ -0,0 +1,100 @@ +{% extends "admin/cms/page/change_form.html" %} +{% load static admin_urls admin_modify djangocms_versioning i18n cms_admin %} + +{% block extrahead %} + {{ block.super }} + +{% endblock %} +{% block extrastyle %} + {{ block.super }} + +{% endblock %} + +{% block content_title %} + {% if title %}

{{ title }}{% if original %} - {{ original.versions.first.short_name }}{% endif %}

{% endif %} + {% block object-tools %} + {% if not popup and not add %} +
    + {% block object-tools-items %} + {% include "admin/djangocms_versioning/versioning_buttons.html" %} +
  • + {% get_preview_url original as admin_url %} + {% trans "Preview" %} +
  • + {% endblock %} +
+ {% endif %} + {% endblock %} +{% endblock %} + +{% block content %} +
+ + + +
+{% csrf_token %} +{% block form_top %}{% endblock %} + +{% if show_language_tabs and not show_permissions %} +
+ {% for lang_code, lang_name in language_tabs %} + + {% endfor %} +
+
+{% endif %} + +
+{% if is_popup %}{% endif %} +{% if save_on_top %}{% submit_row %}{% endif %} +{% if errors %} +

+{% blocktrans count errors|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %} +

+
    {% for error in adminform.form.non_field_errors %}
  • {{ error }}
  • {% endfor %}
+{% endif %} + +{% for fieldset in adminform %} + {% include "admin/cms/page/includes/fieldset.html" %} +{% endfor %} + +{% for inline_admin_formset in inline_admin_formsets %} + {% include inline_admin_formset.opts.template %} +{% endfor %} + +{% if show_permissions %} +
+ +
+{% endif %} + +{% block after_related_objects %}{% endblock %} + +{% if add %} +
+ + +
+{% else %} + {% page_submit_row %} +{% endif %} +
+
+
+ +{% block admin_change_form_document_ready %} +{{ block.super }} +{% endblock %} + +{# JavaScript for prepopulated fields #} +{% prepopulated_fields_js %} + +{% endblock %} diff --git a/djangocms_versioning/templates/admin/djangocms_versioning/versioning_buttons.html b/djangocms_versioning/templates/admin/djangocms_versioning/versioning_buttons.html new file mode 100644 index 00000000..f08c1d12 --- /dev/null +++ b/djangocms_versioning/templates/admin/djangocms_versioning/versioning_buttons.html @@ -0,0 +1,30 @@ +{% load djangocms_versioning i18n %} +{% with url=original|url_publish_version:request.user %} + {% if url %} +
  • + {% trans "Publish" %} +
  • + {% endif %} +{% endwith %} +{% with url=original|url_new_draft:request.user %} + {% if url %} +
  • + {% trans "New Draft" %} +
  • + {% endif %} +{% endwith %} +{% with url=original|url_revert_version:request.user %} + {% if url %} +
  • + {% trans "Revert" %} +
  • + {% endif %} +{% endwith %} + +{% with url=original|url_version_list %} + {% if url %} +
  • + {% trans "Versions" %} +
  • + {% endif %} +{% endwith %} diff --git a/djangocms_versioning/templates/djangocms_versioning/admin/mixin/change_form.html b/djangocms_versioning/templates/djangocms_versioning/admin/mixin/change_form.html index 6fab19b2..32908d92 100644 --- a/djangocms_versioning/templates/djangocms_versioning/admin/mixin/change_form.html +++ b/djangocms_versioning/templates/djangocms_versioning/admin/mixin/change_form.html @@ -1,12 +1,18 @@ {% extends versioning_fallback_change_form_template|default:"admin/change_form.html" %} -{% load i18n admin_urls djangocms_versioning %} +{% load static %} + +{% block extrahead %} + {{ block.super }} + +{% endblock %} + +{% block extrastyle %} + {{ block.super }} + +{% endblock %} {% block object-tools-items %} -
  • - - {% translate "Versions" %} - -
  • + {% include "admin/djangocms_versioning/versioning_buttons.html" %} {{ block.super }} {% endblock %} diff --git a/djangocms_versioning/templatetags/djangocms_versioning.py b/djangocms_versioning/templatetags/djangocms_versioning.py index e6dae706..2c07cd12 100644 --- a/djangocms_versioning/templatetags/djangocms_versioning.py +++ b/djangocms_versioning/templatetags/djangocms_versioning.py @@ -1,5 +1,7 @@ from django import template +from django.urls import reverse +from .. import constants, versionables from ..helpers import version_list_url register = template.Library() @@ -8,3 +10,39 @@ @register.filter def url_version_list(content): return version_list_url(content) + +@register.filter +def url_publish_version(content, user): + version = content.versions.first() + if version: + if version.check_publish.as_bool(user) and version.can_be_published(): + proxy_model = versionables.for_content(content).version_model_proxy + return reverse( + f"admin:{proxy_model._meta.app_label}_{proxy_model.__name__.lower()}_publish", + args=(version.pk,), + ) + return "" + +@register.filter +def url_new_draft(content, user): + version = content.versions.first() + if version: + if version.state == constants.PUBLISHED: + proxy_model = versionables.for_content(content).version_model_proxy + return reverse( + f"admin:{proxy_model._meta.app_label}_{proxy_model.__name__.lower()}_edit_redirect", + args=(version.pk,), + ) + return "" + +@register.filter +def url_revert_version(content, user): + version = content.versions.first() + if version: + if version.check_revert.as_bool(user): + proxy_model = versionables.for_content(content).version_model_proxy + return reverse( + f"admin:{proxy_model._meta.app_label}_{proxy_model.__name__.lower()}_revert", + args=(version.pk,), + ) + return "" diff --git a/tests/test_admin.py b/tests/test_admin.py index 56560c9e..2244e460 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -51,6 +51,7 @@ BlogContentFactory, BlogPostFactory, BlogPostVersionFactory, + PollVersionFactory, ) from djangocms_versioning.test_utils.incorrectly_configured_blogpost.models import ( IncorrectBlogContent, @@ -3171,3 +3172,76 @@ def test_fake_back_link(self): self.assertNotContains(response, "hijack_url") self.assertContains(response, version_list_url(version.content)) +class VersioningAdminButtonsTestCase(CMSTestCase): + def _get_versioning_url(self, version, action, versionable=PollsCMSConfig.versioning[0]): + """Helper method to return the expected action url + """ + admin_url = self.get_admin_url( + versionable.version_model_proxy, action, version.pk + ) + return admin_url + + def get_change_view_url(self, content): + return self.get_admin_url( + content.__class__, + "change", + content.pk, + ) + + def test_buttons_in_draft_changeview(self): + """Only publish button should be visible in draft mode""" + version = PollVersionFactory(state=constants.DRAFT) + action_url = self._get_versioning_url(version, "publish") + next_url = self.get_change_view_url(version.content) + expected_button = ('Publish') + + with self.login_user_context(self.get_superuser()): + response = self.client.get(self.get_change_view_url(version.content)) + + self.assertContains(response, expected_button) + self.assertNotContains(response, "Revert") + self.assertNotContains(response, "New Draft") + + def test_buttons_in_published_changeview(self): + """Only revert button should be visible in published mode""" + version = PollVersionFactory(state=constants.PUBLISHED) + action_url = self._get_versioning_url(version, "edit_redirect") + expected_button = ('New Draft') + + with self.login_user_context(self.get_superuser()): + response = self.client.get(self.get_change_view_url(version.content)) + + self.assertContains(response, expected_button) + self.assertNotContains(response, "Revert") + self.assertNotContains(response, "Publish") + + def test_buttons_in_unpublished_changeview(self): + """Only revert button should be visible in unpublished mode""" + version = PollVersionFactory(state=constants.UNPUBLISHED) + action_url = self._get_versioning_url(version, "revert") + next_url = self.get_change_view_url(version.content) + expected_button = f'Revert' + + with self.login_user_context(self.get_superuser()): + response = self.client.get(self.get_change_view_url(version.content)) + + self.assertContains(response, expected_button) + self.assertNotContains(response, "New Draft") + self.assertNotContains(response, "Publish") + + def test_buttons_in_archived_changeview(self): + """Only revert button should be visible in archived mode""" + version = PollVersionFactory(state=constants.ARCHIVED) + action_url = self._get_versioning_url(version, "revert") + next_url = self.get_change_view_url(version.content) + expected_button = f'Revert' + + with self.login_user_context(self.get_superuser()): + response = self.client.get(self.get_change_view_url(version.content)) + + self.assertContains(response, expected_button) + self.assertNotContains(response, "New Draft") + self.assertNotContains(response, "Publish") + From 1c8b14b3dc8e61d4ad3863c62665bc14136e7ebb Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 30 May 2024 10:11:09 +0200 Subject: [PATCH 36/47] fix: Remove workaround for page-specific rendering (#411) --- djangocms_versioning/plugin_rendering.py | 46 +++++++++++++----------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/djangocms_versioning/plugin_rendering.py b/djangocms_versioning/plugin_rendering.py index 7571bbaf..cad0be24 100644 --- a/djangocms_versioning/plugin_rendering.py +++ b/djangocms_versioning/plugin_rendering.py @@ -1,5 +1,6 @@ from functools import lru_cache +from cms import __version__ as cms_version from cms.plugin_rendering import ContentRenderer, StructureRenderer from cms.utils.placeholder import rescan_placeholders_for_obj @@ -41,29 +42,32 @@ def render_plugin(self, instance, context, placeholder=None, editable=False): prefetch_versioned_related_objects(instance, self.toolbar) return super().render_plugin(instance, context, placeholder, editable) - def render_obj_placeholder( - self, slot, context, inherit, nodelist=None, editable=True - ): - # FIXME This is an ad-hoc solution for page-specific rendering - # code, which by default doesn't work well with versioning. - # Remove this method once the issue is fixed. - from cms.models import Placeholder + if cms_version in ("4.1.0", "4.1.1"): + # Only needed for CMS 4.1.0 and 4.1.1 which have fix #7952 not merged + # With #7952, page-specific rendering works well with versioning. + def render_obj_placeholder( + self, slot, context, inherit, nodelist=None, editable=True + ): + # FIXME This is an ad-hoc solution for page-specific rendering + # code, which by default doesn't work well with versioning. + # Remove this method once the issue is fixed. + from cms.models import Placeholder - current_obj = self.toolbar.get_object() + current_obj = self.toolbar.get_object() - # Not page, therefore we will use toolbar object as - # the current object and render the placeholder - rescan_placeholders_for_obj(current_obj) - placeholder = Placeholder.objects.get_for_obj(current_obj).get(slot=slot) - content = self.render_placeholder( - placeholder, - context=context, - page=current_obj, - editable=editable, - use_cache=True, - nodelist=None, - ) - return content + # Not page, therefore we will use toolbar object as + # the current object and render the placeholder + rescan_placeholders_for_obj(current_obj) + placeholder = Placeholder.objects.get_for_obj(current_obj).get(slot=slot) + content = self.render_placeholder( + placeholder, + context=context, + page=current_obj, + editable=editable, + use_cache=True, + nodelist=None, + ) + return content class VersionStructureRenderer(StructureRenderer): From f31b5e0fb89ccccb486df4014eec53d6322aec5b Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 30 May 2024 12:28:38 +0200 Subject: [PATCH 37/47] fix: Compare versions' back button sometimes returns to invalid URL (#413) --- djangocms_versioning/cms_toolbars.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_versioning/cms_toolbars.py b/djangocms_versioning/cms_toolbars.py index 73d18594..007cac53 100644 --- a/djangocms_versioning/cms_toolbars.py +++ b/djangocms_versioning/cms_toolbars.py @@ -212,7 +212,7 @@ def _add_versioning_menu(self): url += "?" + urlencode({ "compare_to": version.pk, - "back": self.request.get_full_path(), + "back": self.toolbar.request_path, }) versioning_menu.add_link_item(name, url=url) # Discard changes menu entry (wrt to source) From 778254f03efc49c7b1d2f8ebcc86804f584af1b6 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sun, 14 Jul 2024 14:08:12 +0200 Subject: [PATCH 38/47] feat: Optimize db evaluation (#416) * Cache `page_content` in toolbar * Avoid repeated db hits * Fix signature evaluation * Avoid double asignment * Add test for number of queries! * Fix ruff issue --- djangocms_versioning/cms_toolbars.py | 18 +++++++++++++++--- djangocms_versioning/helpers.py | 19 ++++++++++--------- djangocms_versioning/indicators.py | 21 +++++++++++---------- tests/test_indicators.py | 10 ++++++++++ 4 files changed, 46 insertions(+), 22 deletions(-) diff --git a/djangocms_versioning/cms_toolbars.py b/djangocms_versioning/cms_toolbars.py index 007cac53..f7da4b5c 100644 --- a/djangocms_versioning/cms_toolbars.py +++ b/djangocms_versioning/cms_toolbars.py @@ -1,5 +1,6 @@ from collections import OrderedDict from copy import copy +from typing import Optional from cms.cms_toolbars import ( ADD_PAGE_LANGUAGE_BREAK, @@ -288,18 +289,29 @@ class VersioningPageToolbar(PageToolbar): Overriding the original Page toolbar to ensure that draft and published pages can be accessed and to allow full control over the Page toolbar for versioned pages. """ - def get_page_content(self, language=None): + + def __init__(self, *args, **kwargs): + self.page_content: Optional[PageContent] = None + super().__init__(*args, **kwargs) + + def get_page_content(self, language: Optional[str] = None) -> PageContent: if not language: language = self.current_lang + if self.page_content and self.page_content.language == language: + # Already known - no need to query it again + return self.page_content toolbar_obj = self.toolbar.get_object() if toolbar_obj and toolbar_obj.language == language: + # Already in the toolbar, then use it! return self.toolbar.get_object() - return get_latest_admin_viewable_content(self.page, language=language) + else: + # Get it from the DB + return get_latest_admin_viewable_content(self.page, language=language) def populate(self): self.page = self.request.current_page - self.title = self.get_page_content() if self.page else None + self.page_content = self.get_page_content() if self.page else None self.permissions_activated = get_cms_setting("PERMISSION") self.override_language_menu() diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index 76636e14..19abd78d 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -304,7 +304,7 @@ def remove_published_where(queryset): def get_latest_admin_viewable_content( - grouper: type, + grouper: models.Model, include_unpublished_archived: bool = False, **extra_grouping_fields, ) -> models.Model: @@ -425,15 +425,16 @@ def send_email( def get_latest_draft_version(version): - """Get latest draft version of version object + """Get latest draft version of version object and caches it """ from djangocms_versioning.constants import DRAFT from djangocms_versioning.models import Version - drafts = ( - Version.objects - .filter_by_content_grouping_values(version.content) - .filter(state=DRAFT) - ) - - return drafts.first() + if not hasattr(version, "_latest_draft_version"): + drafts = ( + Version.objects + .filter_by_content_grouping_values(version.content) + .filter(state=DRAFT) + ) + version._latest_draft_version = drafts.first() + return version._latest_draft_version diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index 0b625d63..7424d97c 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -97,25 +97,26 @@ def content_indicator(content_obj): versions = Version.objects.filter_by_content_grouping_values( content_obj ).order_by("-pk") + version_states = dict(VERSION_STATES) signature = { - state: versions.filter(state=state) - for state, name in VERSION_STATES + version.state: version + for version in versions if version.state in version_states } - if signature[DRAFT] and not signature[PUBLISHED]: + if DRAFT in signature and PUBLISHED not in signature: content_obj._indicator_status = "draft" - content_obj._version = signature[DRAFT] - elif signature[DRAFT] and signature[PUBLISHED]: + content_obj._version = signature[DRAFT], + elif DRAFT in signature and PUBLISHED in signature: content_obj._indicator_status = "dirty" - content_obj._version = (signature[DRAFT][0], signature[PUBLISHED][0]) - elif signature[PUBLISHED]: + content_obj._version = (signature[DRAFT], signature[PUBLISHED]) + elif PUBLISHED in signature: content_obj._indicator_status = "published" - content_obj._version = signature[PUBLISHED] + content_obj._version = signature[PUBLISHED], elif versions[0].state == UNPUBLISHED: content_obj._indicator_status = "unpublished" - content_obj._version = signature[UNPUBLISHED] + content_obj._version = signature[UNPUBLISHED], elif versions[0].state == ARCHIVED: content_obj._indicator_status = "archived" - content_obj._version = signature[ARCHIVED] + content_obj._version = signature[ARCHIVED], else: # pragma: no cover content_obj._indicator_status = None content_obj._version = [None] diff --git a/tests/test_indicators.py b/tests/test_indicators.py index dabce266..551e3316 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -219,3 +219,13 @@ def test_mixin_factory(self): self.assertContains(response, "cms.pagetree.css"), # JS loadeD? self.assertContains(response, "indicators.js") + + def test_page_indicator_db_queries(self): + """Only one query should be executed to get the indicator""" + version = PageVersionFactory( + content__language="en", + ) + with self.assertNumQueries(1): + from djangocms_versioning.indicators import content_indicator + + content_indicator(version.content) From 3af4fc00223b9d459e284b19f8779457b70e6ec5 Mon Sep 17 00:00:00 2001 From: Jacob Rief Date: Wed, 24 Jul 2024 16:21:17 +0200 Subject: [PATCH 39/47] fix-permissions-adding-page (#419) * fix-permissions-adding-page * Update djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html Co-authored-by: Fabian Braun --------- Co-authored-by: Fabian Braun --- .../templates/admin/djangocms_versioning/page/change_form.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html b/djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html index 92d00d0e..925c1845 100644 --- a/djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html +++ b/djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html @@ -32,7 +32,7 @@ -
    + {% csrf_token %} {% block form_top %}{% endblock %} From ad0024c728898200bf91ce8c103a112a73dda4fd Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 26 Jul 2024 11:27:13 +0200 Subject: [PATCH 40/47] chore: Prepare release 2.1.0 (#415) * Prepare release 2.1.0 * Update tests for django-cms@develop-4 * Fix ruff issues * Add warning to `CMSMenu` class --- CHANGELOG.rst | 10 +++ djangocms_versioning/__init__.py | 2 +- djangocms_versioning/cms_menus.py | 14 +++- djangocms_versioning/plugin_rendering.py | 4 +- djangocms_versioning/test_utils/factories.py | 37 +++++++---- tests/test_indicators.py | 3 +- tests/test_integration_with_core.py | 3 +- tests/test_menus.py | 67 ++++++++------------ 8 files changed, 78 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 1b54acd2..3b6d6f82 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,16 @@ Changelog ========= +2.1.0 (2024-07-12) +================== + +* feat: Add versioning actions to settings (admin change view) of versioned objects by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/408 +* fix: Remove workaround for page-specific rendering by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/411 +* fix: Compare versions' back button sometimes returns to invalid URL by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/413 + + +**Full Changelog**: https://github.com/django-cms/djangocms-versioning/compare/2.0.2...2.1.0 + 2.0.2 (2024-05-03) ================== diff --git a/djangocms_versioning/__init__.py b/djangocms_versioning/__init__.py index 0309ae29..9aa3f903 100644 --- a/djangocms_versioning/__init__.py +++ b/djangocms_versioning/__init__.py @@ -1 +1 @@ -__version__ = "2.0.2" +__version__ = "2.1.0" diff --git a/djangocms_versioning/cms_menus.py b/djangocms_versioning/cms_menus.py index e11cb109..51fd54ec 100644 --- a/djangocms_versioning/cms_menus.py +++ b/djangocms_versioning/cms_menus.py @@ -2,6 +2,11 @@ from cms.apphook_pool import apphook_pool from cms.cms_menus import CMSMenu as OriginalCMSMenu, get_visible_nodes from cms.models import Page + +try: + from cms.models import TreeNode +except ImportError: + TreeNode = None from cms.toolbar.utils import get_object_preview_url, get_toolbar_from_request from cms.utils.page import get_page_queryset from django.apps import apps @@ -76,6 +81,11 @@ def _get_attrs_for_node(renderer, page_content): class CMSMenu(Menu): + """This is a legacy class used by django CMS 4.0 and django CMS 4.1.0 only. Its language + fallback mechanism does not comply with django CMS' core's. Also, it is by far slower + than django CMS core's. As of django CMS 4.1.1, this class is by default deactivated. + + See https://discord.com/channels/800813886689247262/1204047551570120755 for more information.""" def get_nodes(self, request): site = self.renderer.site language = self.renderer.request_language @@ -106,8 +116,8 @@ def get_nodes(self, request): versionable_item.content_model._base_manager.filter( language=language, page__in=pages_qs, versions__state__in=states ) - .order_by("page__node__path", "versions__state") - .select_related("page", "page__node") + .order_by("page__node__path" if TreeNode else "page__path", "versions__state") + .select_related("page", "page__node" if TreeNode else "page") .prefetch_related("versions") ) added_pages = [] diff --git a/djangocms_versioning/plugin_rendering.py b/djangocms_versioning/plugin_rendering.py index cad0be24..2ed05652 100644 --- a/djangocms_versioning/plugin_rendering.py +++ b/djangocms_versioning/plugin_rendering.py @@ -43,8 +43,8 @@ def render_plugin(self, instance, context, placeholder=None, editable=False): return super().render_plugin(instance, context, placeholder, editable) if cms_version in ("4.1.0", "4.1.1"): - # Only needed for CMS 4.1.0 and 4.1.1 which have fix #7952 not merged - # With #7952, page-specific rendering works well with versioning. + # Only needed for CMS 4.1.0 and 4.1.1 which have fix #7924 not merged + # With #7924, page-specific rendering works well with versioning. def render_obj_placeholder( self, slot, context, inherit, nodelist=None, editable=True ): diff --git a/djangocms_versioning/test_utils/factories.py b/djangocms_versioning/test_utils/factories.py index 15a8a364..0b62dc59 100644 --- a/djangocms_versioning/test_utils/factories.py +++ b/djangocms_versioning/test_utils/factories.py @@ -2,7 +2,12 @@ import factory from cms import constants -from cms.models import Page, PageContent, PageUrl, Placeholder, TreeNode +from cms.models import Page, PageContent, PageUrl, Placeholder + +try: + from cms.models import TreeNode +except ImportError: + TreeNode = None from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.contrib.sites.models import Site @@ -170,18 +175,19 @@ def version(self, create, extracted, **kwargs): IncorrectBlogPostVersionFactory(content=self, **kwargs) -class TreeNodeFactory(factory.django.DjangoModelFactory): - site = factory.fuzzy.FuzzyChoice(Site.objects.all()) - depth = 0 - # NOTE: Generating path this way is probably not a good way of - # doing it, but seems to work for our present tests which only - # really need a tree node to exist and not throw unique constraint - # errors on this field. If the data in this model starts mattering - # in our tests then something more will need to be done here. - path = FuzzyText(length=8, chars=string.digits) +if TreeNode: + class TreeNodeFactory(factory.django.DjangoModelFactory): + site = factory.fuzzy.FuzzyChoice(Site.objects.all()) + depth = 0 + # NOTE: Generating path this way is probably not a good way of + # doing it, but seems to work for our present tests which only + # really need a tree node to exist and not throw unique constraint + # errors on this field. If the data in this model starts mattering + # in our tests then something more will need to be done here. + path = FuzzyText(length=8, chars=string.digits) - class Meta: - model = TreeNode + class Meta: + model = TreeNode class PageUrlFactory(factory.django.DjangoModelFactory): @@ -195,7 +201,12 @@ class Meta: class PageFactory(factory.django.DjangoModelFactory): - node = factory.SubFactory(TreeNodeFactory) + if TreeNode: + node = factory.SubFactory(TreeNodeFactory) + else: + site = factory.fuzzy.FuzzyChoice(Site.objects.all()) + depth = 0 + path = FuzzyText(length=8, chars=string.digits) class Meta: model = Page diff --git a/tests/test_indicators.py b/tests/test_indicators.py index 551e3316..f3601bed 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -11,6 +11,7 @@ BlogPostVersionFactory, PageFactory, PageVersionFactory, + TreeNode, ) @@ -86,7 +87,7 @@ def test_latest_admin_viewable_archive_on_top_of_published(self): class TestVersionState(CMSTestCase): def test_page_indicators(self): """The page content indicators render correctly""" - page = PageFactory(node__depth=1) + page = PageFactory(node__depth=1) if TreeNode else PageFactory(depth=1) version1 = PageVersionFactory( content__page=page, content__language="en", diff --git a/tests/test_integration_with_core.py b/tests/test_integration_with_core.py index a4b8a8bd..8dd9cc1b 100644 --- a/tests/test_integration_with_core.py +++ b/tests/test_integration_with_core.py @@ -9,6 +9,7 @@ PlaceholderFactory, PollVersionFactory, TextPluginFactory, + TreeNode, ) @@ -190,7 +191,7 @@ def test_default_cms_page_changelist_view_language_with_multi_language_content(s language filters / additional grouping values are set using the default CMS PageContent view """ - page = PageFactory(node__depth=1) + page = PageFactory(node__depth=1) if TreeNode else PageFactory(depth=1) en_version1 = PageVersionFactory( content__page=page, content__language="en", diff --git a/tests/test_menus.py b/tests/test_menus.py index a8c02b1a..5340d82d 100644 --- a/tests/test_menus.py +++ b/tests/test_menus.py @@ -19,48 +19,31 @@ class CMSVersionedMenuTestCase(CMSTestCase): def setUp(self): super().setUp() - self._page_1 = PageVersionFactory( - content__title="page_content_1", - content__menu_title="", - content__in_navigation=True, - content__limit_visibility_in_menu=None, - content__language="en", - content__page__node__path="0001", - ) - self._page_2 = PageVersionFactory( - content__title="page_content_2", - content__menu_title="", - content__in_navigation=True, - content__limit_visibility_in_menu=None, - content__language="en", - content__page__node__path="0002", - ) - self._page_2_1 = PageVersionFactory( - content__title="page_content_2_1", - content__menu_title="", - content__in_navigation=True, - content__limit_visibility_in_menu=None, - content__language="en", - content__page__node__path="00020001", - content__page__node__parent=self._page_2.content.page.node, - ) - self._page_2_2 = PageVersionFactory( - content__title="page_content_2_2", - content__menu_title="", - content__in_navigation=True, - content__limit_visibility_in_menu=None, - content__language="en", - content__page__node__path="00020002", - content__page__node__parent=self._page_2.content.page.node, - ) - self._page_3 = PageVersionFactory( - content__title="page_content_3", - content__menu_title="", - content__in_navigation=True, - content__limit_visibility_in_menu=None, - content__language="en", - content__page__node__path="0003", - ) + from djangocms_versioning.test_utils.factories import TreeNode + + def get_page(title, path, parent=None): + return { + "content__title": title, + "content__menu_title": "", + "content__in_navigation": True, + "content__limit_visibility_in_menu": None, + "content__language": "en", + "content__page__node__path" if TreeNode else "content__page__path": path, + "content__page__node__parent" if TreeNode else "content__page__parent": parent, + } + self._page_1 = PageVersionFactory(**get_page("page_content_1", "0001")) + self._page_2 = PageVersionFactory(**get_page("page_content_2", "0002")) + self._page_2_1 = PageVersionFactory(**get_page( + "page_content_2_1", + "00020001", + self._page_2.content.page.node if TreeNode else self._page_2.content.page, + )) + self._page_2_2 = PageVersionFactory(**get_page( + "page_content_2_2", + "00020002", + self._page_2.content.page.node if TreeNode else self._page_2.content.page, + )) + self._page_3 = PageVersionFactory(**get_page("page_content_3", "0003")) def _render_menu(self, user=None, **kwargs): request = RequestFactory().get("/") From 5bc66e5bbff42e7ece33c6afe3c90251c3c6e929 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 29 Jul 2024 18:48:23 +0200 Subject: [PATCH 41/47] fix: Run tests without setup tools (#420) * Replace `setup.py test` in github actions * Update test requirements * Undo some unnecessary changes --- .github/workflows/test.yml | 10 +++++----- tests/requirements/dj32_cms41.txt | 2 +- tests/requirements/dj40_cms41.txt | 2 +- tests/requirements/dj41_cms41.txt | 2 +- tests/requirements/dj42_cms41.txt | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ff49a8aa..f2d50066 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: python setup.py install - name: Run coverage - run: coverage run setup.py test + run: coverage run ./test_settings.py - name: Upload Coverage to Codecov uses: codecov/codecov-action@v4 @@ -78,7 +78,7 @@ jobs: python setup.py install - name: Run coverage - run: coverage run setup.py test + run: coverage run ./test_settings.py env: DATABASE_URL: postgres://postgres:postgres@127.0.0.1/postgres @@ -122,7 +122,7 @@ jobs: python setup.py install - name: Run coverage - run: coverage run setup.py test + run: coverage run ./test_settings.py env: DATABASE_URL: mysql://root@127.0.0.1/djangocms_test @@ -158,7 +158,7 @@ jobs: python setup.py install - name: Run coverage - run: coverage run setup.py test + run: coverage run ./test_settings.py - name: Upload Coverage to Codecov uses: codecov/codecov-action@v4 @@ -194,7 +194,7 @@ jobs: python setup.py install - name: Run coverage - run: coverage run setup.py test + run: coverage run ./test_settings.py - name: Upload Coverage to Codecov uses: codecov/codecov-action@v4 diff --git a/tests/requirements/dj32_cms41.txt b/tests/requirements/dj32_cms41.txt index 24060eaf..b81e33fd 100644 --- a/tests/requirements/dj32_cms41.txt +++ b/tests/requirements/dj32_cms41.txt @@ -1,6 +1,6 @@ -r requirements_base.txt -django-cms>=4.1.0rc2 +django-cms>=4.1,<4.2 Django>=3.2,<4.0 django-classy-tags diff --git a/tests/requirements/dj40_cms41.txt b/tests/requirements/dj40_cms41.txt index 7b1ccb33..08f469ca 100644 --- a/tests/requirements/dj40_cms41.txt +++ b/tests/requirements/dj40_cms41.txt @@ -1,6 +1,6 @@ -r requirements_base.txt -django-cms>=4.1.0rc2 +django-cms>=4.1,<4.2 Django>=4.0,<4.1 django-classy-tags diff --git a/tests/requirements/dj41_cms41.txt b/tests/requirements/dj41_cms41.txt index 5c1aa2b8..08e4d41b 100644 --- a/tests/requirements/dj41_cms41.txt +++ b/tests/requirements/dj41_cms41.txt @@ -1,6 +1,6 @@ -r requirements_base.txt -django-cms>=4.1.0rc2 +django-cms>=4.1,<4.2 Django>=4.1,<4.2 django-classy-tags diff --git a/tests/requirements/dj42_cms41.txt b/tests/requirements/dj42_cms41.txt index 1e78584a..3546934e 100644 --- a/tests/requirements/dj42_cms41.txt +++ b/tests/requirements/dj42_cms41.txt @@ -1,6 +1,6 @@ -r requirements_base.txt -django-cms>=4.1.0rc2 +django-cms>=4.1,<4.2 Django>=4.2,<5 django-classy-tags From 9b8abd5d786770b746a7732e06738c70aecf4608 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 30 Jul 2024 14:27:32 +0200 Subject: [PATCH 42/47] fix: Unnecessary complexity in `current_content` query set (#417) * Fix: Linear in stead of quadratic complexity in `current_content` queryset method. * Update tests * Add test for latest_content issue in core --- djangocms_versioning/managers.py | 15 +++------- tests/test_content_models.py | 10 +++---- tests/test_integration_with_core.py | 45 +++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/djangocms_versioning/managers.py b/djangocms_versioning/managers.py index 55d615e8..5d323b76 100644 --- a/djangocms_versioning/managers.py +++ b/djangocms_versioning/managers.py @@ -60,23 +60,16 @@ def _chain(self): clone._group_by_key = self._group_by_key return clone - def current_content_iterator(self, **kwargs): - """Returns generator (not a queryset) over current content versions. Current versions are either draft - versions or published versions (in that order)""" - warnings.warn("current_content_iterator is deprecated in favour of current_conent", - DeprecationWarning, stacklevel=2) - return iter(self.current_content(**kwargs)) - def current_content(self, **kwargs): """Returns a queryset current content versions. Current versions are either draft versions or published versions (in that order). This optimized query assumes that draft versions always have a higher pk than any other version type. This is true as long as no other version type can be converted to draft without creating a new version.""" - qs = self.filter(versions__state__in=(constants.DRAFT, constants.PUBLISHED), **kwargs) - pk_filter = qs.values(*self._group_by_key)\ + pk_filter = self.filter(versions__state__in=(constants.DRAFT, constants.PUBLISHED))\ + .values(*self._group_by_key)\ .annotate(vers_pk=models.Max("versions__pk"))\ - .values_list("vers_pk", flat=True) - return qs.filter(versions__pk__in=pk_filter) + .values("vers_pk") + return self.filter(versions__pk__in=pk_filter, **kwargs) def latest_content(self, **kwargs): """Returns the "latest" content object which is in this order diff --git a/tests/test_content_models.py b/tests/test_content_models.py index 0a4d07a1..4117446c 100644 --- a/tests/test_content_models.py +++ b/tests/test_content_models.py @@ -68,7 +68,7 @@ def setUp(self) -> None: self.create_page_content(page, "it", constants.ARCHIVED) self.create_page_content(page, "it", constants.PUBLISHED) - def test_current_content_iterator(self): + def test_current_content(self): # 12 PageContent versions in total self.assertEqual(len(list( PageContent.admin_manager.all() @@ -79,11 +79,11 @@ def test_current_content_iterator(self): self.assertEqual(len(qs), 4) self.assertEqual(qs._group_by_key, ["page", "language"]) self.assertEqual(len(list( - PageContent.admin_manager.filter(page__in=self.pages1).current_content_iterator() - )), 4, f"{list(PageContent.admin_manager.filter(page__in=self.pages1).current_content_iterator())}") + PageContent.admin_manager.filter(page__in=self.pages1).current_content() + )), 4, f"{list(PageContent.admin_manager.filter(page__in=self.pages1).current_content())}") # 2 current PageContent versions for self.pages2 self.assertEqual(len(list( - PageContent.admin_manager.filter(page__in=self.pages2).current_content_iterator() + PageContent.admin_manager.filter(page__in=self.pages2).current_content() )), 4) # Now unpublish all published in pages2 @@ -93,5 +93,5 @@ def test_current_content_iterator(self): # 2 current PageContent versions for self.pages2 self.assertEqual(len(list( - PageContent.admin_manager.filter(page__in=self.pages2).current_content_iterator() + PageContent.admin_manager.filter(page__in=self.pages2).current_content() )), 2) diff --git a/tests/test_integration_with_core.py b/tests/test_integration_with_core.py index 8dd9cc1b..d4afcfb4 100644 --- a/tests/test_integration_with_core.py +++ b/tests/test_integration_with_core.py @@ -1,7 +1,12 @@ +from unittest import skipIf + +from cms import __version__ as cms_version from cms.test_utils.testcases import CMSTestCase from cms.toolbar.toolbar import CMSToolbar from cms.utils.urlutils import admin_reverse +from django.template import Context +from djangocms_versioning import constants from djangocms_versioning.plugin_rendering import VersionContentRenderer from djangocms_versioning.test_utils.factories import ( PageFactory, @@ -256,3 +261,43 @@ def test_success_url_for_cms_wizard(self): poll_wizard.get_success_url(version.content), version.content.get_absolute_url(), ) + + +class AdminManagerIntegrationTestCase(CMSTestCase): + def setUp(self): + self.page = PageFactory(node__depth=1) if TreeNode else PageFactory(depth=1) + self.en_version = PageVersionFactory( + content__page=self.page, + content__language="en", + state=constants.UNPUBLISHED, + ) + self.fr_version = PageVersionFactory( + content__page=self.page, + content__language="fr", + state=constants.ARCHIVED, + ) + self.page.languages = "en,fr" + self.page.save() + + + @skipIf(cms_version < "4.1.3", "Bug only fixed in django CMS 4.1.3") + def test_get_admin_url_for_language(self): + """Regression fixed that made unpublished and archived versions invisivle to get_admin_url_for_language + template tag. See: https://github.com/django-cms/django-cms/pull/7967""" + from django.template import Template + + # Test English page with unpublished version + context = Context({"page": self.page}) + template = Template("{% load cms_admin %}{% get_admin_url_for_language page 'en' %}") + + result = template.render(context) + + self.assertIn(f"/admin/cms/pagecontent/{self.en_version.content.pk}/", result) + + # Test French page with archived version + template = Template("{% load cms_admin %}{% get_admin_url_for_language page 'fr' %}") + + result = template.render(context) + + self.assertIn(f"/admin/cms/pagecontent/{self.fr_version.content.pk}/", result) + From ac763d9843aa075b01caf2de1ad52fea99dc5f3e Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Wed, 4 Sep 2024 07:44:18 +0200 Subject: [PATCH 43/47] Allow prefetched version objects for page contents (#418) --- djangocms_versioning/cms_config.py | 31 ++++++++++--------- djangocms_versioning/helpers.py | 16 ++++++---- djangocms_versioning/indicators.py | 16 +++++++--- djangocms_versioning/models.py | 4 +-- .../templatetags/djangocms_versioning.py | 15 +++++++-- 5 files changed, 52 insertions(+), 30 deletions(-) diff --git a/djangocms_versioning/cms_config.py b/djangocms_versioning/cms_config.py index 39dbc70a..ae864f2b 100644 --- a/djangocms_versioning/cms_config.py +++ b/djangocms_versioning/cms_config.py @@ -3,7 +3,6 @@ from cms.app_base import CMSAppConfig, CMSAppExtension from cms.extensions.models import BaseExtension from cms.models import PageContent, Placeholder -from cms.utils import get_language_from_request from cms.utils.i18n import get_language_list, get_language_tuple from cms.utils.plugins import copy_plugins_to_placeholder from cms.utils.urlutils import admin_reverse @@ -14,6 +13,7 @@ ObjectDoesNotExist, PermissionDenied, ) +from django.db.models import Prefetch from django.http import ( HttpResponse, HttpResponseBadRequest, @@ -23,7 +23,7 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from . import indicators, versionables +from . import indicators from .admin import VersioningAdminMixin from .constants import INDICATOR_DESCRIPTIONS from .datastructures import BaseVersionableItem, VersionableItem @@ -284,18 +284,8 @@ def get_readonly_fields(self, request, obj=None): return fields def get_queryset(self, request): - urls = ("cms_pagecontent_get_tree",) - queryset = super().get_queryset(request) - if request.resolver_match.url_name in urls: - versionable = versionables.for_content(queryset.model) - - # TODO: Improve the grouping filters to use anything defined in the - # apps versioning config extra_grouping_fields - grouping_filters = {} - if "language" in versionable.extra_grouping_fields: - grouping_filters["language"] = get_language_from_request(request) - - return queryset.filter(pk__in=versionable.distinct_groupers(**grouping_filters)) + queryset = super().get_queryset(request)\ + .prefetch_related(Prefetch("versions", to_attr="prefetched_versions")) return queryset # CAVEAT: @@ -361,7 +351,18 @@ def get_indicator_menu(cls, request, page_content): """Get the indicator menu for PageContent object taking into account the currently available versions""" menu_template = "admin/cms/page/tree/indicator_menu.html" - status = page_content.content_indicator() + if hasattr(page_content.page, "filtered_translations") and hasattr(page_content, "prefetched_versions"): + # get_tree has prefetched versions + versions = sorted( + [content.prefetched_versions[0] for content in page_content.page.filtered_translations], + key=lambda version: -version.pk, + ) + for content in page_content.page.filtered_translations: + content.__dict__["content"] = content + status = page_content.content_indicator(versions) + else: + # No prefetched versions available, get them ourselves + status = page_content.content_indicator() if not status or status == "empty": # pragma: no cover return super().get_indicator_menu(request, page_content) versions = page_content._version # Cache from .content_indicator() diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index 19abd78d..9afa7140 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -387,7 +387,11 @@ def content_is_unlocked_for_user(content: models.Model, user: settings.AUTH_USER """Check if lock doesn't exist or object is locked to provided user. """ try: - return version_is_unlocked_for_user(content.versions.first(), user) + if hasattr(content, "prefetched_versions"): + version = content.prefetched_versions[0] + else: + version = content.versions.first() + return version_is_unlocked_for_user(version, user) except AttributeError: return True @@ -425,16 +429,16 @@ def send_email( def get_latest_draft_version(version): - """Get latest draft version of version object and caches it - """ + """Get latest draft version of version object and caches it in the + content object""" from djangocms_versioning.constants import DRAFT from djangocms_versioning.models import Version - if not hasattr(version, "_latest_draft_version"): + if not hasattr(version.content, "_latest_draft_version"): drafts = ( Version.objects .filter_by_content_grouping_values(version.content) .filter(state=DRAFT) ) - version._latest_draft_version = drafts.first() - return version._latest_draft_version + version.content._latest_draft_version = drafts.first() + return version.content._latest_draft_version diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index 7424d97c..a23ebd13 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -1,5 +1,8 @@ +import typing + from cms.utils.urlutils import admin_reverse from django.contrib.auth import get_permission_codename +from django.db import models from django.utils.http import urlencode from django.utils.translation import gettext_lazy as _ @@ -87,16 +90,21 @@ def content_indicator_menu(request, status, versions, back=""): return menu -def content_indicator(content_obj): +def content_indicator( + content_obj: models.Model, + versions: typing.Optional[list[Version]] = None +) -> typing.Optional[str]: """Translates available versions into status to be reflected by the indicator. Function caches the result in the page_content object""" if not content_obj: return None # pragma: no cover elif not hasattr(content_obj, "_indicator_status"): - versions = Version.objects.filter_by_content_grouping_values( - content_obj - ).order_by("-pk") + if versions is None: + # Get all versions for the content object if not available + versions = Version.objects.filter_by_content_grouping_values( + content_obj + ).order_by("-pk") version_states = dict(VERSION_STATES) signature = { version.state: version diff --git a/djangocms_versioning/models.py b/djangocms_versioning/models.py index 0f4a3956..08ac8079 100644 --- a/djangocms_versioning/models.py +++ b/djangocms_versioning/models.py @@ -74,8 +74,8 @@ def filter_by_grouping_values(self, versionable, **kwargs): def filter_by_content_grouping_values(self, content): """Returns a list of Version objects for grouping values taken - from provided content object. In other words: - it uses the content instance property values as filter parameters + from provided content object. In other words: + it uses the content instance property values as filter parameters """ versionable = versionables.for_content(content) content_objects = versionable.for_content_grouping_values(content) diff --git a/djangocms_versioning/templatetags/djangocms_versioning.py b/djangocms_versioning/templatetags/djangocms_versioning.py index 2c07cd12..6b9bbcfe 100644 --- a/djangocms_versioning/templatetags/djangocms_versioning.py +++ b/djangocms_versioning/templatetags/djangocms_versioning.py @@ -13,7 +13,10 @@ def url_version_list(content): @register.filter def url_publish_version(content, user): - version = content.versions.first() + if hasattr(content, "prefetched_versions"): + version = content.prefetched_versions[0] + else: + version = content.versions.first() if version: if version.check_publish.as_bool(user) and version.can_be_published(): proxy_model = versionables.for_content(content).version_model_proxy @@ -25,7 +28,10 @@ def url_publish_version(content, user): @register.filter def url_new_draft(content, user): - version = content.versions.first() + if hasattr(content, "prefetched_versions"): + version = content.prefetched_versions[0] + else: + version = content.versions.first() if version: if version.state == constants.PUBLISHED: proxy_model = versionables.for_content(content).version_model_proxy @@ -37,7 +43,10 @@ def url_new_draft(content, user): @register.filter def url_revert_version(content, user): - version = content.versions.first() + if hasattr(content, "prefetched_versions"): + version = content.prefetched_versions[0] + else: + version = content.versions.first() if version: if version.check_revert.as_bool(user): proxy_model = versionables.for_content(content).version_model_proxy From 07222a40ba50661808ebdb720d9606c6c81d97c5 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sun, 22 Sep 2024 12:30:38 +0200 Subject: [PATCH 44/47] fix: get_page_content retrieved non page-content objects from the toolbar (#423) * fix: get_page_content retrieved non page-content objects from the toolbar * Fix: Lint error * Add regression test * Same test but simpler --- djangocms_versioning/cms_toolbars.py | 8 +++++--- djangocms_versioning/test_utils/test_helpers.py | 2 +- tests/test_toolbars.py | 17 +++++++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/djangocms_versioning/cms_toolbars.py b/djangocms_versioning/cms_toolbars.py index f7da4b5c..fc635d00 100644 --- a/djangocms_versioning/cms_toolbars.py +++ b/djangocms_versioning/cms_toolbars.py @@ -295,16 +295,18 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def get_page_content(self, language: Optional[str] = None) -> PageContent: + # This method overwrites the method in django CMS core. Not necessary + # for django CMS 4.2+ if not language: language = self.current_lang - if self.page_content and self.page_content.language == language: + if isinstance(self.page_content, PageContent) and self.page_content.language == language: # Already known - no need to query it again return self.page_content toolbar_obj = self.toolbar.get_object() - if toolbar_obj and toolbar_obj.language == language: + if isinstance(toolbar_obj, PageContent) and toolbar_obj.language == language: # Already in the toolbar, then use it! - return self.toolbar.get_object() + return toolbar_obj else: # Get it from the DB return get_latest_admin_viewable_content(self.page, language=language) diff --git a/djangocms_versioning/test_utils/test_helpers.py b/djangocms_versioning/test_utils/test_helpers.py index 51db206f..bca487fc 100644 --- a/djangocms_versioning/test_utils/test_helpers.py +++ b/djangocms_versioning/test_utils/test_helpers.py @@ -20,7 +20,7 @@ def get_toolbar(content_obj, user=None, **kwargs): request = kwargs.get("request", RequestFactory().get("/")) request.user = user request.session = kwargs.get("session", {}) - request.current_page = getattr(content_obj, "page", None) + request.current_page = kwargs.get("current_page", getattr(content_obj, "page", None)) request.toolbar = CMSToolbar(request) # Set the toolbar class if kwargs.get("toolbar_class", False): diff --git a/tests/test_toolbars.py b/tests/test_toolbars.py index 1c51d838..e4674560 100644 --- a/tests/test_toolbars.py +++ b/tests/test_toolbars.py @@ -6,12 +6,14 @@ from django.utils.text import slugify from djangocms_versioning.cms_config import VersioningCMSConfig +from djangocms_versioning.cms_toolbars import VersioningPageToolbar from djangocms_versioning.constants import ARCHIVED, DRAFT, PUBLISHED from djangocms_versioning.helpers import version_list_url from djangocms_versioning.test_utils.factories import ( BlogPostVersionFactory, FancyPollFactory, PageContentWithVersionFactory, + PageFactory, PageUrlFactory, PageVersionFactory, PollVersionFactory, @@ -615,3 +617,18 @@ def test_page_toolbar_wo_language_menu(self): language_menu = request.toolbar.get_menu(LANGUAGE_MENU_IDENTIFIER, _("Language")) self.assertIsNone(language_menu) + + def test_toolbar_only_catches_page_content_objects(self): + """Regression test to ensure that the toolbar only catches PageContent objects and not + other toolbar objects.""" + + version = PollVersionFactory() # Not a page content model + page = PageFactory() # Get a page, e.g. where an apphook is configured + toolbar = get_toolbar(version.content, edit_mode=True, toolbar_class=VersioningPageToolbar, current_page=page) + + # Did page get detected? Otherwise, page_content never will be detected + self.assertIs(toolbar.page, page) + # Check regression does not happen + self.assertNotIsInstance(toolbar.page_content, version.content.__class__) + # Check for correct result + self.assertIsNone(toolbar.page_content) From 85b0182a50234b423e681d757c66f82cb3535e7a Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 1 Oct 2024 20:24:37 +0200 Subject: [PATCH 45/47] Update README.rst (#424) * Update README.rst * Update README.rst --------- Co-authored-by: Vinit Kumar --- README.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index e4c7bb1e..1556a22e 100644 --- a/README.rst +++ b/README.rst @@ -32,9 +32,11 @@ Add ``djangocms_versioning`` to your project's ``INSTALLED_APPS``. Run:: - python manage.py migrate djangocms_versioning + python -m manage migrate djangocms_versioning + python -m manage create_versions --user-id -to perform the application's database migrations. +to perform the application's database migrations and (only if you have an existing database) add version objects +needed to mark existing versions as draft. ===== From 07f9ccbcbdb6f665df466c3ad24eac82a15c5b95 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 28 Oct 2024 22:00:43 +0100 Subject: [PATCH 46/47] feat: add support for Django 5.0 and 5.1 (#429) * Replace `setup.py test` in github actions * fix: added `exclude_parameters` to `ChangeList.get_queryset` * Delay test since tested fix has not been ported back to django-cms 4.1.3 * Update tests for django 5.0, 5.1 * Update test.yml for all database setups * Update to postgres 13 for django 5.x tests * Update Changelog * Update page content factory to deliver valid x frame options * fix: Close sideframe when clicking preview button * Fix: `FuzzyInteger`'s higher limit is inclusive it turns out --- .github/workflows/test.yml | 31 ++++++++++++++----- CHANGELOG.rst | 11 +++++++ djangocms_versioning/admin.py | 18 ++++++++--- .../page/change_form.html | 4 ++- djangocms_versioning/test_utils/factories.py | 2 +- tests/requirements/dj32_cms41.txt | 2 +- tests/requirements/dj42_cms41.txt | 2 +- .../{dj40_cms41.txt => dj50_cms41.txt} | 4 +-- .../{dj41_cms41.txt => dj51_cms41.txt} | 4 +-- tests/test_cms_config.py | 2 +- tests/test_integration_with_core.py | 4 +-- 11 files changed, 61 insertions(+), 23 deletions(-) rename tests/requirements/{dj40_cms41.txt => dj50_cms41.txt} (69%) rename tests/requirements/{dj41_cms41.txt => dj51_cms41.txt} (69%) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f2d50066..c4315c7a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,10 +15,15 @@ jobs: python-version: [ 3.9, "3.10", "3.11", "3.12" ] # latest release minus two requirements-file: [ dj32_cms41.txt, - dj40_cms41.txt, - dj41_cms41.txt, dj42_cms41.txt, + dj50_cms41.txt, + dj51_cms41.txt, ] + exclude: + - requirements-file: dj50_cms41.txt + python-version: 3.9 + - requirements-file: dj51_cms41.txt + python-version: 3.9 steps: - uses: actions/checkout@v4 @@ -47,14 +52,19 @@ jobs: python-version: [ 3.9, "3.10", "3.11", "3.12" ] # latest release minus two requirements-file: [ dj32_cms41.txt, - dj40_cms41.txt, - dj41_cms41.txt, dj42_cms41.txt, + dj50_cms41.txt, + dj51_cms41.txt, ] + exclude: + - requirements-file: dj50_cms41.txt + python-version: 3.9 + - requirements-file: dj51_cms41.txt + python-version: 3.9 services: postgres: - image: postgres:12 + image: postgres:13 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -93,10 +103,15 @@ jobs: python-version: [ 3.9, "3.10", "3.11", "3.12" ] # latest release minus two requirements-file: [ dj32_cms41.txt, - dj40_cms41.txt, - dj41_cms41.txt, dj42_cms41.txt, + dj50_cms41.txt, + dj51_cms41.txt, ] + exclude: + - requirements-file: dj50_cms41.txt + python-version: 3.9 + - requirements-file: dj51_cms41.txt + python-version: 3.9 services: mysql: @@ -135,7 +150,7 @@ jobs: fail-fast: false matrix: python-version: ['3.11'] - requirements-file: ['dj42_cms41.txt'] + requirements-file: ['dj51_cms41.txt'] cms-version: [ 'https://github.com/django-cms/django-cms/archive/develop-4.tar.gz' ] diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3b6d6f82..92f7272c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,11 +5,22 @@ Changelog 2.1.0 (2024-07-12) ================== +* feat: add support for Django 5.0 and 5.1 (#429) by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/429 * feat: Add versioning actions to settings (admin change view) of versioned objects by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/408 * fix: Remove workaround for page-specific rendering by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/411 * fix: Compare versions' back button sometimes returns to invalid URL by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/413 +* feat: Add versioning actions to settings (admin change view) of versioned objects by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/408 +* feat: Optimize db evaluation by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/416 +* feat: Prefetch page content version objects for faster page tree by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/418 +* fix: Remove workaround for page-specific rendering by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/411 +* fix: Compare versions' back button sometimes returns to invalid URL by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/413 +* fix: Preparation for changes in django CMS 4.2 by @jrief in https://github.com/django-cms/djangocms-versioning/pull/419 +* fix: Unnecessary complexity in ``current_content`` query set by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/417 +* fix: get_page_content retrieved non page-content objects from the toolbar by @fsbraun in https://github.com/django-cms/djangocms-versioning/pull/423 + + **Full Changelog**: https://github.com/django-cms/djangocms-versioning/compare/2.0.2...2.1.0 2.0.2 (2024-05-03) diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 77b1748b..84e89ce3 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -61,9 +61,14 @@ class VersioningChangeListMixin: """Mixin used for ChangeList classes of content models.""" - def get_queryset(self, request): + def get_queryset(self, request, exclude_parameters=None): """Limit the content model queryset to the latest versions only.""" - queryset = super().get_queryset(request) + if exclude_parameters: + # Django 5.0+ (facet support) + queryset = super().get_queryset(request, exclude_parameters) + else: + # Django 4.2 compatible get_queryset + queryset = super().get_queryset(request) versionable = versionables.for_content(queryset.model) """Check if there is a method "self.get__from_request" for each extra grouping field. @@ -557,7 +562,7 @@ def get_grouping_field_filters(self, request): if value is not None: yield field, value - def get_queryset(self, request): + def get_queryset(self, request, exclude_parameters=None): """Adds support for querying the version model by grouping fields. Filters by the value of grouping fields (specified in VersionableItem @@ -567,7 +572,12 @@ def get_queryset(self, request): for specifying filters that work without being shown in the UI along with filter choices. """ - queryset = super().get_queryset(request) + if exclude_parameters: + # Django 5.0+ (facet support) + queryset = super().get_queryset(request, exclude_parameters) + else: + # Django 4.2 compatible get_queryset + queryset = super().get_queryset(request) content_model = self.model_admin.model._source_model versionable = versionables.for_content(content_model) filters = dict(self.get_grouping_field_filters(request)) diff --git a/djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html b/djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html index 925c1845..43039a22 100644 --- a/djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html +++ b/djangocms_versioning/templates/admin/djangocms_versioning/page/change_form.html @@ -19,7 +19,9 @@ {% include "admin/djangocms_versioning/versioning_buttons.html" %}
  • {% get_preview_url original as admin_url %} - {% trans "Preview" %} + + {% trans "Preview" %} +
  • {% endblock %} diff --git a/djangocms_versioning/test_utils/factories.py b/djangocms_versioning/test_utils/factories.py index 0b62dc59..2eb97417 100644 --- a/djangocms_versioning/test_utils/factories.py +++ b/djangocms_versioning/test_utils/factories.py @@ -226,7 +226,7 @@ class PageContentFactory(AbstractContentFactory): soft_root = FuzzyChoice([True, False]) limit_visibility_in_menu = constants.VISIBILITY_USERS template = "page.html" - xframe_options = FuzzyInteger(0, 25) + xframe_options = FuzzyInteger(0, 3) class Meta: model = PageContent diff --git a/tests/requirements/dj32_cms41.txt b/tests/requirements/dj32_cms41.txt index b81e33fd..aaafdfa5 100644 --- a/tests/requirements/dj32_cms41.txt +++ b/tests/requirements/dj32_cms41.txt @@ -4,5 +4,5 @@ django-cms>=4.1,<4.2 Django>=3.2,<4.0 django-classy-tags -django-fsm>=2.6 +django-fsm>=2.6,<3 django-sekizai diff --git a/tests/requirements/dj42_cms41.txt b/tests/requirements/dj42_cms41.txt index 3546934e..bf600a57 100644 --- a/tests/requirements/dj42_cms41.txt +++ b/tests/requirements/dj42_cms41.txt @@ -4,5 +4,5 @@ django-cms>=4.1,<4.2 Django>=4.2,<5 django-classy-tags -django-fsm>=2.6 +django-fsm>=2.6,<3 django-sekizai diff --git a/tests/requirements/dj40_cms41.txt b/tests/requirements/dj50_cms41.txt similarity index 69% rename from tests/requirements/dj40_cms41.txt rename to tests/requirements/dj50_cms41.txt index 08f469ca..4326bfd7 100644 --- a/tests/requirements/dj40_cms41.txt +++ b/tests/requirements/dj50_cms41.txt @@ -2,7 +2,7 @@ django-cms>=4.1,<4.2 -Django>=4.0,<4.1 +Django>=5.0,<5.1 django-classy-tags -django-fsm>=2.6 +django-fsm>=2.6,<3 django-sekizai diff --git a/tests/requirements/dj41_cms41.txt b/tests/requirements/dj51_cms41.txt similarity index 69% rename from tests/requirements/dj41_cms41.txt rename to tests/requirements/dj51_cms41.txt index 08e4d41b..14b5770e 100644 --- a/tests/requirements/dj41_cms41.txt +++ b/tests/requirements/dj51_cms41.txt @@ -2,7 +2,7 @@ django-cms>=4.1,<4.2 -Django>=4.1,<4.2 +Django>=5.1,<5.2 django-classy-tags -django-fsm>=2.6 +django-fsm>=2.6,<3 django-sekizai diff --git a/tests/test_cms_config.py b/tests/test_cms_config.py index 13153e12..001f4520 100644 --- a/tests/test_cms_config.py +++ b/tests/test_cms_config.py @@ -119,7 +119,7 @@ def test_changing_slug_changes_page_url(self): form = ChangePageForm(data, instance=self.content) form._request = request form._site = self.site - self.assertEqual(form.is_valid(), True) + self.assertTrue(form.is_valid(), f"Form errors: {form.errors}") form.save() page = Page.objects.get(pk=self.page.pk) diff --git a/tests/test_integration_with_core.py b/tests/test_integration_with_core.py index d4afcfb4..ff66110e 100644 --- a/tests/test_integration_with_core.py +++ b/tests/test_integration_with_core.py @@ -280,9 +280,9 @@ def setUp(self): self.page.save() - @skipIf(cms_version < "4.1.3", "Bug only fixed in django CMS 4.1.3") + @skipIf(cms_version < "4.1.4", "Bug only fixed in django CMS 4.1.4") def test_get_admin_url_for_language(self): - """Regression fixed that made unpublished and archived versions invisivle to get_admin_url_for_language + """Regression fixed that made unpublished and archived versions invisible to get_admin_url_for_language template tag. See: https://github.com/django-cms/django-cms/pull/7967""" from django.template import Template From 76a7cc402a857f29d1b454dda2169500dee0326f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 29 Oct 2024 08:22:25 +0100 Subject: [PATCH 47/47] build(deps): bump actions/cache from 4.0.2 to 4.1.2 (#431) Bumps [actions/cache](https://github.com/actions/cache) from 4.0.2 to 4.1.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.2...v4.1.2) --- updated-dependencies: - dependency-name: actions/cache dependency-type: direct:production update-type: version-update:semver-minor ... 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 2fceee19..f2dee668 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.2 + uses: actions/cache@v4.1.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.2 + uses: actions/cache@v4.1.2 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('docs/requirements.txt') }}