From aa97881a100c09fb7938ec98247ee592026cd2ba Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 9 Feb 2023 12:40:50 +0100 Subject: [PATCH 01/48] Allow page indicators for any versioned model --- djangocms_versioning/admin.py | 49 +++-- djangocms_versioning/datastructures.py | 35 ++-- djangocms_versioning/indicators.py | 183 ++++++++++-------- djangocms_versioning/managers.py | 28 ++- .../djangocms_versioning/css/actions.css | 21 ++ .../djangocms_versioning/js/indicators.js | 134 +++++++++++++ .../admin/djangocms_versioning/indicator.html | 4 + docs/admin_architecture.rst | 11 +- docs/versioning_integration.rst | 2 + 9 files changed, 337 insertions(+), 130 deletions(-) create mode 100644 djangocms_versioning/static/djangocms_versioning/js/indicators.js create mode 100644 djangocms_versioning/templates/admin/djangocms_versioning/indicator.html diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 1facf652..7b92d465 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -42,16 +42,23 @@ class VersioningChangeListMixin: """Mixin used for ChangeList classes of content models.""" def get_queryset(self, request): - """Limit the content model queryset to latest versions only.""" + """Limit the content model queryset to the latest versions only.""" queryset = super().get_queryset(request) 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) + """Check if there is a method "self.get__from_request" for each extra grouping field. + If so call it to retrieve the appropriate filter. If no method is found (except for "language") + no filter is applied. For "language" the fallback is versioning's "get_language_frmo_request". + Admins requiring extra grouping field beside "language" need to implement the "get__from_request" + method themselves. A common way to select the field might be GET or POST parameters or user-related settings. + """ + grouping_filters = {} + for field in versionable.extra_grouping_fields: + if hasattr(self, f"get_{field}_from_request"): + grouping_filters[field] = getattr(self, f"get_{field}_from_request")(request) + elif field == "language": + grouping_filters[field] = get_language_from_request(request) return queryset.filter(pk__in=versionable.distinct_groupers(**grouping_filters)) @@ -697,7 +704,7 @@ def archive_view(self, request, object_id): ), args=(version.content.pk,), ), - back_url=version_list_url(version.content), + back_url=self.back_link(request) or version_list_url(version.content), ) return render( request, "djangocms_versioning/admin/archive_confirmation.html", context @@ -777,7 +784,7 @@ def unpublish_view(self, request, object_id): ), args=(version.content.pk,), ), - back_url=version_list_url(version.content), + back_url=self.back_link(request) or version_list_url(version.content), ) extra_context = OrderedDict( [ @@ -891,7 +898,7 @@ def revert_view(self, request, object_id): ), args=(version.content.pk,), ), - back_url=version_list_url(version.content), + back_url=self.back_link(request) or version_list_url(version.content), ) return render( request, "djangocms_versioning/admin/revert_confirmation.html", context @@ -933,7 +940,7 @@ def discard_view(self, request, object_id): ), args=(version.content.pk,), ), - back_url=version_list_url(version.content), + back_url=self.back_link(request) or version_list_url(version.content), ) return render( request, "djangocms_versioning/admin/discard_confirmation.html", context @@ -969,14 +976,6 @@ def compare_view(self, request, object_id): ), **persist_params ) - return_url = request.GET.get("back", version_list_url(v1.content)) - try: - # Is return url a valid url? - resolve(urlparse(return_url)[2]) - except Resolver404: - # If not ignore - return_url = None - # 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( @@ -987,7 +986,7 @@ def compare_view(self, request, object_id): "version_list": version_list, "v1": v1, "v1_preview_url": v1_preview_url, - "return_url": return_url, + "return_url": self.back_link(request) or version_list_url(v1.content), } # Now check if version 2 has been specified and add to context @@ -1015,6 +1014,18 @@ def compare_view(self, request, object_id): request, "djangocms_versioning/admin/compare.html", context ) + @staticmethod + def back_link(request): + back_url = request.GET.get("back", None) + if back_url: + try: + # Is return url a valid url? + resolve(urlparse(back_url)[2]) + except Resolver404: + # If not ignore + back_url = None + return back_url + def changelist_view(self, request, extra_context=None): """Handle grouper filtering on the changelist""" if not request.GET: diff --git a/djangocms_versioning/datastructures.py b/djangocms_versioning/datastructures.py index 3597312e..447b61f4 100644 --- a/djangocms_versioning/datastructures.py +++ b/djangocms_versioning/datastructures.py @@ -153,23 +153,24 @@ def suffix(field, allow=True): def grouper_choices_queryset(self): """Returns a queryset of all the available groupers instances of the registered type""" - inner = ( - self.content_model._base_manager.annotate( - order=Case( - When(versions__state=PUBLISHED, then=2), - When(versions__state=DRAFT, then=1), - default=0, - output_field=models.IntegerField(), - ) - ) - .filter(**{self.grouper_field_name: OuterRef("pk")}) - .order_by("-order") - ) - content_objects = self.content_model._base_manager.filter( - pk__in=self.grouper_model._base_manager.annotate( - content=Subquery(inner.values_list("pk")[:1]) - ).values_list("content") - ) + # inner = ( + # self.content_model._base_manager.annotate( + # order=Case( + # When(versions__state=PUBLISHED, then=2), + # When(versions__state=DRAFT, then=1), + # default=0, + # output_field=models.IntegerField(), + # ) + # ) + # .filter(**{self.grouper_field_name: OuterRef("pk")}) + # .order_by("-order") + # ) + # content_objects = self.content_model._base_manager.filter( + # pk__in=self.grouper_model._base_manager.annotate( + # content=Subquery(inner.values_list("pk")[:1]) + # ).values_list("content") + # ) + content_objects = self.content_model.admin_manager.all().latest() cache_name = self.grouper_field.remote_field.get_accessor_name() return self.grouper_model._base_manager.prefetch_related( Prefetch(cache_name, queryset=content_objects) diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index bdb4b9f9..69adc268 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -13,19 +13,21 @@ from djangocms_versioning.helpers import version_list_url from djangocms_versioning.models import Version +indicator_description = { + "published": _("Published"), + "dirty": _("Changed"), + "draft": _("Draft"), + "unpublished": _("Unpublished"), + "archived": _("Archived"), + "empty": _("Empty"), +} + class IndicatorStatusMixin: # Step 1: The legend @property def indicator_descriptions(self): - return { - "published": _("Published"), - "dirty": _("Changed"), - "draft": _("Draft"), - "unpublished": _("Unpublished"), - "archived": _("Archived"), - "empty": _("Empty"), - } + return indicator_description @classmethod def get_indicator_menu(cls, request, page_content): @@ -34,104 +36,117 @@ def get_indicator_menu(cls, request, page_content): if not status or status == "empty": return super().get_indicator_menu(request, page_content) versions = page_content._version # Cache from .content_indicator() (see mixin above) - user = request.user - menu = [] - if user.has_perm(f"cms.{get_permission_codename('change', versions[0]._meta)}"): - if versions[0].check_publish.as_bool(user): - menu.append(( - _("Publish"), "cms-icon-publish", - reverse("admin:djangocms_versioning_pagecontentversion_publish", args=(versions[0].pk,)), - "js-cms-tree-lang-trigger", # Triggers POST from the frontend - )) - if versions[0].check_edit_redirect.as_bool(user) and versions[0].state == PUBLISHED: - menu.append(( - _("Create new draft"), "cms-icon-edit-new", - reverse("admin:djangocms_versioning_pagecontentversion_edit_redirect", args=(versions[0].pk,)), - "js-cms-tree-lang-trigger js-cms-pagetree-page-view", # Triggers POST from the frontend - )) - if versions[0].check_revert.as_bool(user) and versions[0].state == UNPUBLISHED: - # Do not offer revert from unpublish -> archived versions to be managed in version admin - label = _("Revert from Unpublish") - menu.append(( - label, "cms-icon-undo", - reverse("admin:djangocms_versioning_pagecontentversion_revert", args=(versions[0].pk,)), - "js-cms-tree-lang-trigger", # Triggers POST from the frontend - )) - if versions[0].check_unpublish.as_bool(user): - menu.append(( - _("Unpublish"), "cms-icon-unpublish", - reverse("admin:djangocms_versioning_pagecontentversion_unpublish", args=(versions[0].pk,)), - "js-cms-tree-lang-trigger", - )) - if len(versions) > 1 and versions[1].check_unpublish.as_bool(user): - menu.append(( - _("Unpublish"), "cms-icon-unpublish", - reverse("admin:djangocms_versioning_pagecontentversion_unpublish", args=(versions[1].pk,)), - "js-cms-tree-lang-trigger", - )) - if versions[0].check_discard.as_bool(user): - menu.append(( - _("Delete Draft") if status == DRAFT else _("Delete Changes"), "cms-icon-bin", - reverse("admin:djangocms_versioning_pagecontentversion_discard", args=(versions[0].pk,)), - "", # Let view ask for confirmation - )) - if len(versions) >= 2 and versions[0].state == DRAFT and versions[1].state == PUBLISHED: - menu.append(( - _("Compare Draft to Published..."), "cms-icon-layers", - reverse("admin:djangocms_versioning_pagecontentversion_compare", args=(versions[1].pk,)) + - "?" + urlencode(dict( - compare_to=versions[0].pk, - back=reverse("admin:cms_page_changelist"), - )), - "", - )) - menu.append( - ( - _("Manage Versions..."), "cms-icon-copy", - version_list_url(versions[0].content), + menu = content_indicator_menu(request, status, versions) + return menu_template if menu else "", menu + + +def _reverse_action(version, action, back=None): + get_params = f"?{urlencode(dict(back=back))}" if back else "" + return reverse( + f"admin:{version._meta.app_label}_{version.versionable.version_model_proxy._meta.model_name}_{action}", + args=(version.pk,) + ) + get_params + +def content_indicator_menu(request, status, versions): + menu = [] + if request.user.has_perm(f"cms.{get_permission_codename('change', versions[0]._meta)}"): + if versions[0].check_publish.as_bool(request.user): + menu.append(( + _("Publish"), "cms-icon-publish", + _reverse_action(versions[0], "publish"), + "js-cms-tree-lang-trigger", # Triggers POST from the frontend + )) + if versions[0].check_edit_redirect.as_bool(request.user) and versions[0].state == PUBLISHED: + menu.append(( + _("Create new draft"), "cms-icon-edit-new", + _reverse_action(versions[0], "edit_redirect"), + "js-cms-tree-lang-trigger js-cms-pagetree-page-view", # Triggers POST from the frontend + )) + if versions[0].check_revert.as_bool(request.user) and versions[0].state == UNPUBLISHED: + # Do not offer revert from unpublish -> archived versions to be managed in version admin + label = _("Revert from Unpublish") + menu.append(( + label, "cms-icon-undo", + _reverse_action(versions[0], "revert"), + "js-cms-tree-lang-trigger", # Triggers POST from the frontend + )) + if versions[0].check_unpublish.as_bool(request.user): + menu.append(( + _("Unpublish"), "cms-icon-unpublish", + _reverse_action(versions[0], "unpublish"), + "js-cms-tree-lang-trigger", + )) + if len(versions) > 1 and versions[1].check_unpublish.as_bool(request.user): + menu.append(( + _("Unpublish"), "cms-icon-unpublish", + _reverse_action(versions[1], "unpublish"), + "js-cms-tree-lang-trigger", + )) + if versions[0].check_discard.as_bool(request.user): + menu.append(( + _("Delete Draft") if status == DRAFT else _("Delete Changes"), "cms-icon-bin", + _reverse_action(versions[0], "discard", back=request.path_info), + "", # Let view ask for confirmation + )) + if len(versions) >= 2 and versions[0].state == DRAFT and versions[1].state == PUBLISHED: + menu.append(( + _("Compare Draft to Published..."), "cms-icon-layers", + _reverse_action(versions[1], "compare") + + "?" + urlencode(dict( + compare_to=versions[0].pk, + back=request.path_info, + )), "", - ) + )) + menu.append( + ( + _("Manage Versions..."), "cms-icon-copy", + version_list_url(versions[0].content), + "", ) - return menu_template if menu else "", menu + ) + return menu -def content_indicator(page_content): +def content_indicator(content_obj): """Translates available versions into status to be reflected by the indicator. Function caches the result in the page_content object""" - if not hasattr(page_content, "_indicator_status"): + if not content_obj: + return None + elif not hasattr(content_obj, "_indicator_status"): versions = Version.objects.filter_by_content_grouping_values( - page_content + content_obj ).order_by("-pk") signature = { state: versions.filter(state=state) for state, name in VERSION_STATES } if signature[DRAFT] and not signature[PUBLISHED]: - page_content._indicator_status = "draft" - page_content._version = signature[DRAFT] + content_obj._indicator_status = "draft" + content_obj._version = signature[DRAFT] elif signature[DRAFT] and signature[PUBLISHED]: - page_content._indicator_status = "dirty" - page_content._version = (signature[DRAFT][0], signature[PUBLISHED][0]) + content_obj._indicator_status = "dirty" + content_obj._version = (signature[DRAFT][0], signature[PUBLISHED][0]) elif signature[PUBLISHED]: - page_content._indicator_status = "published" - page_content._version = signature[PUBLISHED] + content_obj._indicator_status = "published" + content_obj._version = signature[PUBLISHED] elif signature[UNPUBLISHED]: - page_content._indicator_status = "unpublished" - page_content._version = signature[UNPUBLISHED] + content_obj._indicator_status = "unpublished" + content_obj._version = signature[UNPUBLISHED] elif signature[ARCHIVED]: - page_content._indicator_status = "archived" - page_content._version = signature[ARCHIVED] + content_obj._indicator_status = "archived" + content_obj._version = signature[ARCHIVED] else: - page_content._indicator_status = None - page_content._version = [None] - return page_content._indicator_status + content_obj._indicator_status = None + content_obj._version = [None] + return content_obj._indicator_status # Step 4: Check if current version is editable -def is_editable(page_content, request): - if not page_content.content_indicator(): +def is_editable(content_obj, request): + if not content_obj.content_indicator(): # Something's wrong: content indicator not identified. Maybe no version? return False - versions = page_content._version + versions = content_obj._version return versions[0].check_modify.as_bool(request.user) diff --git a/djangocms_versioning/managers.py b/djangocms_versioning/managers.py index c95903df..6f66030d 100644 --- a/djangocms_versioning/managers.py +++ b/djangocms_versioning/managers.py @@ -53,7 +53,7 @@ def with_user(self, user): return new_manager -class AdminQuerySet(models.QuerySet): +class AdminQuerySetMixin: def _chain(self): # Also clone group by key when chaining querysets! clone = super()._chain() @@ -78,12 +78,34 @@ def current_content(self, **kwargs): .values_list("vers_pk", flat=True) return qs.filter(versions__pk__in=pk_filter) + def latest(self): + inner = ( + self.annotate( + order=models.Case( + models.When(versions__state=constants.PUBLISHED, then=2), + models.When(versions__state=constants.DRAFT, then=1), + default = 0, + output_field = models.IntegerField(), + ), + modified = models.Max("versions__modified"), + ) + .filter(**{ + self._group_by_key[0]: models.OuterRef(self._group_by_key[0]) + }) + .order_by("-order", "-modified") + ) + return self.filter(pk__in=models.Subquery(inner[:1].values("pk"))) + class AdminManagerMixin: versioning_enabled = True _group_by_key = [] def get_queryset(self): - qs = AdminQuerySet(self.model, using=self._db) - qs._group_by_key = self._group_by_key + qs_class = super().get_queryset().__class__ + qs = type( + f"Admin{qs_class.__name__}", + (AdminQuerySetMixin, qs_class), + {"_group_by_key": self._group_by_key} + )(self.model, using=self._db) return qs diff --git a/djangocms_versioning/static/djangocms_versioning/css/actions.css b/djangocms_versioning/static/djangocms_versioning/css/actions.css index 53df29e9..c884e7b9 100644 --- a/djangocms_versioning/static/djangocms_versioning/css/actions.css +++ b/djangocms_versioning/static/djangocms_versioning/css/actions.css @@ -176,3 +176,24 @@ a.cms-actions-dropdown-menu-item-anchor.inactive { opacity: 0.3; filter: alpha(opacity=30); } + +/* Finally center indicators in django changelist view */ + +.change-list table tbody td .cms-pagetree-node-state, +.change-list table tbody td .cms-pagetree-dropdown-trigger { + vertical-align: middle; +} + +.change-list table tbody .field-indicator, +.change-list table thead .column-indicator + { + text-align: center; +} + +/* Bugfix for indicator menus when using djangocms-admin-style */ +body.djangocms-admin-style .cms-pagetree-dropdown-menu a:link, +body.djangocms-admin-style .cms-pagetree-dropdown-menu a:visited, +body.djangocms-admin-style .cms-pagetree-dropdown-menu a:link:visited, +body.djangocms-admin-style .cms-pagetree-dropdown-menu a:link:hover { + color: unset !important; +} diff --git a/djangocms_versioning/static/djangocms_versioning/js/indicators.js b/djangocms_versioning/static/djangocms_versioning/js/indicators.js new file mode 100644 index 00000000..02d19ac7 --- /dev/null +++ b/djangocms_versioning/static/djangocms_versioning/js/indicators.js @@ -0,0 +1,134 @@ +(function ($) { + var container; + + function ajax_post(event) { + event.preventDefault(); + var element = $(this); + if (element.closest('.cms-pagetree-dropdown-item-disabled').length) { + return; + } + var csrfToken = document.cookie.match(/csrftoken=([^;]*);?/)[1]; + + if (element.attr('target') === '_top') { + // Post to target="_top" requires to create a form and submit it + var parent = window; + + if (window.parent) { + parent = window.parent; + } + $('
' + + '
') + .appendTo($(parent.document.body)) + .submit(); + return; + } + try { + window.top.CMS.API.Toolbar.showLoader(); + } catch (err) {} + + $.ajax({ + method: 'post', + url: $(this).attr('href'), + data: {csrfmiddlewaretoken: csrfToken } + }) + .done(function() { + try { + window.top.CMS.API.Toolbar.hideLoader(); + } catch (err) {} + + if (window.self === window.top) { + // simply reload the page + _reloadHelper(); + } else { + window.top.CMS.API.Helpers.reloadBrowser('REFRESH_PAGE'); + } + }) + .fail(function(error) { + try { + window.top.CMS.API.Toolbar.hideLoader(); + } catch (err) {} + showError(error.responseText ? error.responseText : error.statusText); + }); + } + + /** + * Displays an error within the django UI. + * + * @method showError + * @param {String} message string message to display + */ + function showError(message) { + var messages = $('.messagelist'); + var breadcrumb = $('.breadcrumbs'); + var reload = "Reload"; + var tpl = + '' + + ''; + var msg = tpl.replace('{msg}', '' + window.top.CMS.config.lang.error + ' ' + message); + + if (messages.length) { + messages.replaceWith(msg); + } else { + breadcrumb.after(msg); + } + $("a.cms-tree-reload").click(function (e) { + e.preventDefault(); + _reloadHelper(); + }); + } + + /** + * Checks if we should reload the iframe or entire window. For this we + * need to skip `CMS.API.Helpers.reloadBrowser();`. + * + * @method _reloadHelper + * @private + */ + function _reloadHelper() { + if (window.self === window.top) { + CMS.API.Helpers.reloadBrowser(); + } else { + window.location.reload(); + } + } + + function close_menu() { + if (container) { + container.find(".menu-cover").remove(); + container = false; + } + } + + function open_menu(menu) { + var menu; + close_menu(); + container = $("body"); // first parent with position: relative + container.append(''); + container.find(".menu-cover").html(menu); + menu = container.find(".cms-pagetree-dropdown-menu"); + menu.find('.js-cms-tree-lang-trigger').click( + ajax_post + ); + return menu; + } + $(document).click(close_menu); + $(function() { + $('.js-cms-pagetree-dropdown-trigger').click(function(event) { + event.stopPropagation(); + var menu = JSON.parse(this.dataset.menu); + menu = open_menu(menu); + var offset = $(this).offset(); + menu.css({ + top: offset.top - 10, + right: container.width() - offset.left + 10 + }); + }); + }); +})(django.jQuery); diff --git a/djangocms_versioning/templates/admin/djangocms_versioning/indicator.html b/djangocms_versioning/templates/admin/djangocms_versioning/indicator.html new file mode 100644 index 00000000..dfd67233 --- /dev/null +++ b/djangocms_versioning/templates/admin/djangocms_versioning/indicator.html @@ -0,0 +1,4 @@ +{% if menu %}{% endif %} + +{% if menu %}{% endif %} + diff --git a/docs/admin_architecture.rst b/docs/admin_architecture.rst index e72a2d2f..2fa00b09 100644 --- a/docs/admin_architecture.rst +++ b/docs/admin_architecture.rst @@ -4,16 +4,13 @@ The Admin with Versioning The content model admin ------------------------ -Versioning modifies (monkeypatches) the admin for each :term:`content model `. This is because -versioning duplicates content model records every time a new version is created (since content models hold the version data -that's content type specific). Versioning therefore needs to limit the queryset in the content model admin to -include only the records for the latest versions. +Versioning modifies the admin for each :term:`content model `. This is because versioning duplicates content model records every time a new version is created (since content models hold the version data that's content type specific). Versioning therefore needs to limit the queryset in the content model admin to include only the records for the latest versions. Extended Mixin ++++++++++++++ -The ExtendedVersionAdminMixin provides fields related to versioning (such as author, state, last modified) as well as a number -of actions (preview, edit and versions list) to prevent the need to re-implement on each :term:`content model ` admin. -It is used in the same way as any other admin mixin. +The ExtendedVersionAdminMixin provides fields related to versioning (such as author, state, last modified) as well as a number of actions (preview, edit and versions list) to prevent the need to re-implement on each :term:`content model ` admin. It is used in the same way as any other admin mixin. + + diff --git a/docs/versioning_integration.rst b/docs/versioning_integration.rst index 58a08061..763860b2 100644 --- a/docs/versioning_integration.rst +++ b/docs/versioning_integration.rst @@ -292,6 +292,7 @@ to add the fields: .. code-block:: python + class PostAdmin(ExtendedVersionAdminMixin, admin.ModelAdmin): list_display = "title" @@ -299,6 +300,7 @@ The :term:`ExtendedVersionAdminMixin` also has functionality to alter fields fro in the form of a dictionary of {model_name: {field: method}}, the admin for the model, will alter the field, using the method provided. .. code-block:: python + # cms_config.py def post_modifier(obj, field): return obj.get(field) + " extra field text!" From 5e29905058c96f02bc86403df6ea29a3b6e2849a Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 9 Feb 2023 13:22:07 +0100 Subject: [PATCH 02/48] MySQL compatibility --- djangocms_versioning/managers.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/djangocms_versioning/managers.py b/djangocms_versioning/managers.py index 6f66030d..71dd4c51 100644 --- a/djangocms_versioning/managers.py +++ b/djangocms_versioning/managers.py @@ -79,6 +79,7 @@ def current_content(self, **kwargs): return qs.filter(versions__pk__in=pk_filter) def latest(self): + """While this is probably to most general approach it does not work for MySql inner = ( self.annotate( order=models.Case( @@ -95,6 +96,16 @@ def latest(self): .order_by("-order", "-modified") ) return self.filter(pk__in=models.Subquery(inner[:1].values("pk"))) + """ + current = self.filter(versions__state__in=(constants.DRAFT, constants.PUBLISHED))\ + .values(*self._group_by_key)\ + .annotate(vers_pk=models.Max("versions__pk")) + pk_current = current.values("vers_pk") + pk_other = self.exclude(**{key + "__in": current.values(key) for key in self._group_by_key})\ + .values(*self._group_by_key)\ + .annotate(vers_pk=models.Max("versions__pk"))\ + .values("vers_pk") + return self.filter(versions__pk__in=pk_current | pk_other) class AdminManagerMixin: From 679e2fc976eb4f63f56533bfd11e4e2d13798026 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 9 Feb 2023 13:58:54 +0100 Subject: [PATCH 03/48] Fix queryset initialization --- djangocms_versioning/datastructures.py | 4 +--- djangocms_versioning/indicators.py | 2 ++ djangocms_versioning/managers.py | 8 +++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/djangocms_versioning/datastructures.py b/djangocms_versioning/datastructures.py index 447b61f4..7792fe1f 100644 --- a/djangocms_versioning/datastructures.py +++ b/djangocms_versioning/datastructures.py @@ -1,12 +1,10 @@ from itertools import chain from django.contrib.contenttypes.models import ContentType -from django.db import models -from django.db.models import Case, Max, OuterRef, Prefetch, Subquery, When +from django.db.models import Max, Prefetch from django.utils.functional import cached_property from .admin import VersioningAdminMixin -from .constants import DRAFT, PUBLISHED from .helpers import get_content_types_with_subclasses from .models import Version diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index 69adc268..b86f4731 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -13,6 +13,7 @@ from djangocms_versioning.helpers import version_list_url from djangocms_versioning.models import Version + indicator_description = { "published": _("Published"), "dirty": _("Changed"), @@ -47,6 +48,7 @@ def _reverse_action(version, action, back=None): args=(version.pk,) ) + get_params + def content_indicator_menu(request, status, versions): menu = [] if request.user.has_perm(f"cms.{get_permission_codename('change', versions[0]._meta)}"): diff --git a/djangocms_versioning/managers.py b/djangocms_versioning/managers.py index 71dd4c51..d46f7c33 100644 --- a/djangocms_versioning/managers.py +++ b/djangocms_versioning/managers.py @@ -114,9 +114,15 @@ class AdminManagerMixin: def get_queryset(self): qs_class = super().get_queryset().__class__ + if not self._group_by_key: + # Not initialized (e.g. by using content_set(manager="admin_manager"))? + # Get grouping fields form versionable + from . import versionables + versionable = versionables.for_content(self.model) + self._group_by_key = [versionable.grouper_field_name] + list(versionable.extra_grouping_fields) qs = type( f"Admin{qs_class.__name__}", (AdminQuerySetMixin, qs_class), - {"_group_by_key": self._group_by_key} + {"_group_by_key": self._group_by_key} # Pass grouping fields to queryset )(self.model, using=self._db) return qs From de56ba190120e6b73656fc2887f3b5f054309833 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 9 Feb 2023 21:18:02 +0100 Subject: [PATCH 04/48] fix isort --- djangocms_versioning/datastructures.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_versioning/datastructures.py b/djangocms_versioning/datastructures.py index 7792fe1f..752d8b9c 100644 --- a/djangocms_versioning/datastructures.py +++ b/djangocms_versioning/datastructures.py @@ -1,7 +1,7 @@ from itertools import chain from django.contrib.contenttypes.models import ContentType -from django.db.models import Max, Prefetch +from django.db.models import Max, Prefetch from django.utils.functional import cached_property from .admin import VersioningAdminMixin From 7f18122673cddde975669dd942c2d47d1f8ecca0 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 10 Feb 2023 23:37:21 +0100 Subject: [PATCH 05/48] Generalize get_latest_admin_viewable_page_content --- djangocms_versioning/cms_config.py | 6 +++--- djangocms_versioning/cms_toolbars.py | 4 ++-- djangocms_versioning/datastructures.py | 2 +- djangocms_versioning/helpers.py | 28 +++++++++++++++++++------- djangocms_versioning/managers.py | 17 ++++++++++++---- 5 files changed, 40 insertions(+), 17 deletions(-) diff --git a/djangocms_versioning/cms_config.py b/djangocms_versioning/cms_config.py index 19d42d26..21ca25c5 100644 --- a/djangocms_versioning/cms_config.py +++ b/djangocms_versioning/cms_config.py @@ -24,7 +24,7 @@ from .datastructures import BaseVersionableItem, VersionableItem from .exceptions import ConditionFailed from .helpers import ( - get_latest_admin_viewable_page_content, + get_latest_admin_viewable_content, inject_generic_relation_to_version, register_versionadmin_proxy, replace_admin_for_models, @@ -144,7 +144,7 @@ def handle_content_model_manager(self, cms_config): for versionable in cms_config.versioning: replace_manager(versionable.content_model, "objects", PublishedContentManagerMixin) replace_manager(versionable.content_model, "admin_manager", AdminManagerMixin, - _group_by_key=[versionable.grouper_field_name] + list(versionable.extra_grouping_fields)) + _group_by_key=list(versionable.grouping_fields)) def handle_admin_field_modifiers(self, cms_config): """Allows for the transformation of a given field in the ExtendedVersionAdminMixin @@ -332,7 +332,7 @@ def copy_language(self, request, object_id): if not target_language or target_language not in get_language_list(site_id=page.node.site_id): return HttpResponseBadRequest(force_str(_("Language must be set to a supported language!"))) - target_page_content = get_latest_admin_viewable_page_content(page, target_language) + target_page_content = get_latest_admin_viewable_content(page, language=target_language) # First check that we are able to edit the target if not self.has_change_permission(request, obj=target_page_content): diff --git a/djangocms_versioning/cms_toolbars.py b/djangocms_versioning/cms_toolbars.py index 65c94f0f..ada5e61d 100644 --- a/djangocms_versioning/cms_toolbars.py +++ b/djangocms_versioning/cms_toolbars.py @@ -25,7 +25,7 @@ from djangocms_versioning.constants import PUBLISHED from djangocms_versioning.helpers import ( - get_latest_admin_viewable_page_content, + get_latest_admin_viewable_content, version_list_url, ) from djangocms_versioning.models import Version @@ -242,7 +242,7 @@ def get_page_content(self, language=None): if not language: language = self.current_lang - return get_latest_admin_viewable_page_content(self.page, language) + 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) diff --git a/djangocms_versioning/datastructures.py b/djangocms_versioning/datastructures.py index 752d8b9c..d7dbaced 100644 --- a/djangocms_versioning/datastructures.py +++ b/djangocms_versioning/datastructures.py @@ -168,7 +168,7 @@ def grouper_choices_queryset(self): # content=Subquery(inner.values_list("pk")[:1]) # ).values_list("content") # ) - content_objects = self.content_model.admin_manager.all().latest() + content_objects = self.content_model.admin_manager.all().latest_content() cache_name = self.grouper_field.remote_field.get_accessor_name() return self.grouper_model._base_manager.prefetch_related( Prefetch(cache_name, queryset=content_objects) diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index c7ed7a4c..78c81ea5 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -2,6 +2,7 @@ import warnings from contextlib import contextmanager +from django.core.exceptions import ImproperlyConfigured from django.contrib import admin from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.models import ContentType @@ -300,16 +301,29 @@ def remove_published_where(queryset): return queryset -# FIXME: This should reuse a generic method that uses the groupers defined filters -def get_latest_admin_viewable_page_content(page, language): +def get_latest_admin_viewable_content(grouper, **extra_grouping_fields): """ Return the latest Draft or Published PageContent using the draft where possible """ - return PageContent._original_manager.filter( - page=page, language=language, versions__state__in=[DRAFT, PUBLISHED] - ).order_by( - "versions__state" - ).first() + versionable = versionables.for_grouper(grouper) + grouper_model = grouper.__class__ if isinstance(grouper, models.Model) else grouper + for reverse in grouper_model._meta.related_objects: + if reverse.model == grouper_model: + content_set = reverse.get_accessor_name() + break + else: + raise ImproperlyConfigured( + f"No inverse relation from {versionable.content_model} to {grouper_model} found") + + return getattr(grouper, content_set)(manager="admin_manager")\ + .filter(**extra_grouping_fields).current_content().first() + + +def get_latest_admin_viewable_page_content(page, language): + warnings.warn("get_latst_admin_viewable_page_content has ben deprecated. " + "Use get_latest_admin_viewable_content(page, language=language) instead.", + DeprecationWarning, stacklevel=2) + return get_latest_admin_viewable_content(page, language=language) def proxy_model(obj, content_model): diff --git a/djangocms_versioning/managers.py b/djangocms_versioning/managers.py index d46f7c33..5e9ebaad 100644 --- a/djangocms_versioning/managers.py +++ b/djangocms_versioning/managers.py @@ -78,8 +78,17 @@ def current_content(self, **kwargs): .values_list("vers_pk", flat=True) return qs.filter(versions__pk__in=pk_filter) - def latest(self): - """While this is probably to most general approach it does not work for MySql + def latest_content(self): + """Returns the "latest" content object which is in this order + 1. a draft version (should it exist) + 2. a published version (should it exist) + 3. any other version with the highest pk + + This filter assumes that there can only be one draft created and that the draft as + the highest pk of all versions (should it exist). + """ + """ + While this is probably to most general approach it does not work for MySql :-( inner = ( self.annotate( order=models.Case( @@ -116,10 +125,10 @@ def get_queryset(self): qs_class = super().get_queryset().__class__ if not self._group_by_key: # Not initialized (e.g. by using content_set(manager="admin_manager"))? - # Get grouping fields form versionable + # Get grouping fields from versionable from . import versionables versionable = versionables.for_content(self.model) - self._group_by_key = [versionable.grouper_field_name] + list(versionable.extra_grouping_fields) + self._group_by_key = list(versionable.grouping_fields) qs = type( f"Admin{qs_class.__name__}", (AdminQuerySetMixin, qs_class), From 1de231726a54c3358538dcb4c8cadbcdd8916abe Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 10 Feb 2023 23:39:17 +0100 Subject: [PATCH 06/48] fix flake8 --- djangocms_versioning/helpers.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index 78c81ea5..717c4a14 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -2,15 +2,14 @@ import warnings from contextlib import contextmanager -from django.core.exceptions import ImproperlyConfigured from django.contrib import admin from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ImproperlyConfigured from django.db import models from django.db.models.sql.where import WhereNode from django.urls import reverse -from cms.models import PageContent from cms.toolbar.utils import get_object_edit_url, get_object_preview_url from cms.utils.helpers import is_editable_model from cms.utils.urlutils import add_url_parameters, admin_reverse @@ -307,9 +306,9 @@ def get_latest_admin_viewable_content(grouper, **extra_grouping_fields): """ versionable = versionables.for_grouper(grouper) grouper_model = grouper.__class__ if isinstance(grouper, models.Model) else grouper - for reverse in grouper_model._meta.related_objects: - if reverse.model == grouper_model: - content_set = reverse.get_accessor_name() + for reverse_relation in grouper_model._meta.related_objects: + if reverse_relation.model == grouper_model: + content_set = reverse_relation.get_accessor_name() break else: raise ImproperlyConfigured( From c948e5f7049c835cc1de059d4a2754923dd41530 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 10 Feb 2023 23:49:19 +0100 Subject: [PATCH 07/48] Fix: grouper can be model or instance --- djangocms_versioning/helpers.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index 717c4a14..d18a900d 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -305,17 +305,22 @@ def get_latest_admin_viewable_content(grouper, **extra_grouping_fields): Return the latest Draft or Published PageContent using the draft where possible """ versionable = versionables.for_grouper(grouper) - grouper_model = grouper.__class__ if isinstance(grouper, models.Model) else grouper - for reverse_relation in grouper_model._meta.related_objects: - if reverse_relation.model == grouper_model: - content_set = reverse_relation.get_accessor_name() - break + for field in versionable.extra_grouping_fields: + if field not in extra_grouping_fields: + raise ValueError(f"Grouping field {field} required for {versionable.grouper_model}.") + if isinstance(grouper, models.Model): + # We have an instance? Find reverse relation and utilize the prefetch cache + grouper_model = grouper.__class__ + for reverse_relation in grouper_model._meta.related_objects: + if reverse_relation.model == grouper_model: + content_set = reverse_relation.get_accessor_name() + qs = getattr(grouper, content_set)(manager="admin_manager") + break + else: + qs = versionable.content_model.admin_manager else: - raise ImproperlyConfigured( - f"No inverse relation from {versionable.content_model} to {grouper_model} found") - - return getattr(grouper, content_set)(manager="admin_manager")\ - .filter(**extra_grouping_fields).current_content().first() + qs = versionable.content_model.admin_manager + return qs.filter(**extra_grouping_fields).current_content().first() def get_latest_admin_viewable_page_content(page, language): From 0125c2a19a166565dbc1b627b0e59a4c3620d900 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sat, 11 Feb 2023 00:03:53 +0100 Subject: [PATCH 08/48] fix: identify correct inverse relation --- djangocms_versioning/helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index d18a900d..7f35e435 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -5,7 +5,6 @@ from django.contrib import admin from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ImproperlyConfigured from django.db import models from django.db.models.sql.where import WhereNode from django.urls import reverse @@ -305,6 +304,7 @@ def get_latest_admin_viewable_content(grouper, **extra_grouping_fields): Return the latest Draft or Published PageContent using the draft where possible """ versionable = versionables.for_grouper(grouper) + print(f"{grouper=} {versionable.grouper_model=} {versionable.content_model=}") for field in versionable.extra_grouping_fields: if field not in extra_grouping_fields: raise ValueError(f"Grouping field {field} required for {versionable.grouper_model}.") @@ -312,7 +312,7 @@ def get_latest_admin_viewable_content(grouper, **extra_grouping_fields): # We have an instance? Find reverse relation and utilize the prefetch cache grouper_model = grouper.__class__ for reverse_relation in grouper_model._meta.related_objects: - if reverse_relation.model == grouper_model: + if reverse_relation.related_model == versionable.content_model: content_set = reverse_relation.get_accessor_name() qs = getattr(grouper, content_set)(manager="admin_manager") break From bcf56b21f8e4164a160d078f58232034d7fd9ca2 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sat, 11 Feb 2023 15:03:26 +0100 Subject: [PATCH 09/48] Remove IndicatorMixin --- djangocms_versioning/cms_config.py | 19 ++++++++++++++++++- djangocms_versioning/forms.py | 1 + djangocms_versioning/indicators.py | 19 +------------------ 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/djangocms_versioning/cms_config.py b/djangocms_versioning/cms_config.py index 21ca25c5..c606842f 100644 --- a/djangocms_versioning/cms_config.py +++ b/djangocms_versioning/cms_config.py @@ -271,7 +271,7 @@ def on_page_content_archive(version): page.clear_cache(menu=True) -class VersioningCMSPageAdminMixin(indicators.IndicatorStatusMixin, VersioningAdminMixin): +class VersioningCMSPageAdminMixin(VersioningAdminMixin): def get_readonly_fields(self, request, obj=None): fields = super().get_readonly_fields(request, obj) if obj: @@ -362,6 +362,23 @@ def change_innavigation(self, request, object_id): return HttpResponseForbidden(force_str(e)) return super().change_innavigation(request, object_id) + @property + def indicator_descriptions(self): + """Publish indicator description to CMSPageAdmin""" + return indicators.indicator_description + + @classmethod + 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 not status or status == "empty": + return super().get_indicator_menu(request, page_content) + versions = page_content._version # Cache from .content_indicator() (see mixin above) + menu = indicators.content_indicator_menu(request, status, versions) + return menu_template if menu else "", menu + class VersioningCMSConfig(CMSAppConfig): """Implement versioning for core cms models diff --git a/djangocms_versioning/forms.py b/djangocms_versioning/forms.py index f4a72d0a..282b657b 100644 --- a/djangocms_versioning/forms.py +++ b/djangocms_versioning/forms.py @@ -37,6 +37,7 @@ def grouper_form_factory(content_model, language=None): with available grouper objects for specified content model. :param content_model: Content model class + :param language: Language """ versionable = versionables.for_content(content_model) return type( diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index b86f4731..0d54f4cb 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -24,23 +24,6 @@ } -class IndicatorStatusMixin: - # Step 1: The legend - @property - def indicator_descriptions(self): - return indicator_description - - @classmethod - def get_indicator_menu(cls, request, page_content): - menu_template = "admin/cms/page/tree/indicator_menu.html" - status = page_content.content_indicator() - if not status or status == "empty": - return super().get_indicator_menu(request, page_content) - versions = page_content._version # Cache from .content_indicator() (see mixin above) - menu = content_indicator_menu(request, status, versions) - return menu_template if menu else "", menu - - def _reverse_action(version, action, back=None): get_params = f"?{urlencode(dict(back=back))}" if back else "" return reverse( @@ -145,8 +128,8 @@ def content_indicator(content_obj): return content_obj._indicator_status -# Step 4: Check if current version is editable 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 From 5570b5845ccb2a3fa07c5930a3f61b4992894683 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sat, 11 Feb 2023 18:16:46 +0100 Subject: [PATCH 10/48] Add tests --- djangocms_versioning/helpers.py | 15 +-- djangocms_versioning/indicators.py | 69 +++++++++++- .../test_utils/blogpost/admin.py | 17 ++- docs/static/Status-indicators.png | Bin 0 -> 56551 bytes docs/versioning_integration.rst | 98 ++++++++++++++++++ tests/test_indicators.py | 41 +++++++- 6 files changed, 225 insertions(+), 15 deletions(-) create mode 100644 docs/static/Status-indicators.png diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index 7f35e435..59c583ca 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -299,27 +299,22 @@ def remove_published_where(queryset): return queryset -def get_latest_admin_viewable_content(grouper, **extra_grouping_fields): +def get_latest_admin_viewable_content(grouper, include_unpublished_archived=False, **extra_grouping_fields): """ Return the latest Draft or Published PageContent using the draft where possible """ versionable = versionables.for_grouper(grouper) - print(f"{grouper=} {versionable.grouper_model=} {versionable.content_model=}") for field in versionable.extra_grouping_fields: if field not in extra_grouping_fields: raise ValueError(f"Grouping field {field} required for {versionable.grouper_model}.") if isinstance(grouper, models.Model): # We have an instance? Find reverse relation and utilize the prefetch cache - grouper_model = grouper.__class__ - for reverse_relation in grouper_model._meta.related_objects: - if reverse_relation.related_model == versionable.content_model: - content_set = reverse_relation.get_accessor_name() - qs = getattr(grouper, content_set)(manager="admin_manager") - break - else: - qs = versionable.content_model.admin_manager + content_set = versionable.grouper_field.remote_field.get_accessor_name() + qs = getattr(grouper, content_set)(manager="admin_manager") else: qs = versionable.content_model.admin_manager + if include_unpublished_archived: + return qs.filter(**extra_grouping_fields).latest_content().first() return qs.filter(**extra_grouping_fields).current_content().first() diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index 0d54f4cb..a63caf8e 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -1,8 +1,13 @@ +import json + +from cms.utils.urlutils import static_with_version from django.contrib.auth import get_permission_codename +from django.template.loader import render_to_string from django.urls import reverse from django.utils.http import urlencode from django.utils.translation import gettext_lazy as _ +from djangocms_versioning import versionables from djangocms_versioning.constants import ( ARCHIVED, DRAFT, @@ -10,7 +15,7 @@ UNPUBLISHED, VERSION_STATES, ) -from djangocms_versioning.helpers import version_list_url +from djangocms_versioning.helpers import version_list_url, get_latest_admin_viewable_content from djangocms_versioning.models import Version @@ -135,3 +140,65 @@ def is_editable(content_obj, request): return False versions = content_obj._version return versions[0].check_modify.as_bool(request.user) + + +class _IndicatorMixin: + """Mixin to provide indicator column to the changelist view of a content model admin. Usage:: + + class MyContentModelAdmin(ContenModelAdminMixin, admin.ModelAdmin): + list_display = [...] + + def get_list_display(self, request): + return self.list_display + [ + self.get_indicator_column(request) + ] + """ + class Media: + # js for the context menu + js = ("djangocms_versioning/js/indicators.js",) + # css for indicators and context menu + css = { + "all": (static_with_version("cms/css/cms.pagetree.css"),), + } + + @property + def _extra_grouping_fields(self): + try: + return versionables.for_grouper(self.model).extra_grouping_fields + except KeyError: + return None + + def get_indicator_column(self, request): + def indicator(obj): + if self._extra_grouping_fields is not None: # Grouper Model + content_obj = get_latest_admin_viewable_content(obj, include_unpublished_archived=False, **{ + field: getattr(self, field) for field in self._extra_grouping_fields + }) + else: # Content Model + content_obj = obj + status = content_indicator(content_obj) + menu = content_indicator_menu(request, status, content_obj._version) if status else None + return render_to_string( + "admin/djangocms_versioning/indicator.html", + { + "state": status or "empty", + "description": indicator_description.get(status, _("Empty")), + "menu_template": "admin/cms/page/tree/indicator_menu.html", + "menu": json.dumps(render_to_string("admin/cms/page/tree/indicator_menu.html", + dict(indicator_menu_items=menu))) if menu else None, + } + ) + indicator.description = self._indicator_label + return indicator + + def get_list_display(self, request): + """Default behavior: replaces the text "indicator" by the indicator column""" + return list(super().get_list_display(request)) + [self.get_indicator_column(request)] + + +def indicator_mixin_factory(label=_("State")): + return type( + "IndicatorModelAdminMixin", + (_IndicatorMixin, ), + dict(_indicator_label=label,), + ) diff --git a/djangocms_versioning/test_utils/blogpost/admin.py b/djangocms_versioning/test_utils/blogpost/admin.py index 0886ebf1..5930244d 100644 --- a/djangocms_versioning/test_utils/blogpost/admin.py +++ b/djangocms_versioning/test_utils/blogpost/admin.py @@ -1,12 +1,25 @@ from django.contrib import admin from djangocms_versioning.admin import ExtendedVersionAdminMixin +from djangocms_versioning.indicators import indicator_mixin_factory from djangocms_versioning.test_utils.blogpost import models -class BlogContentAdmin(ExtendedVersionAdminMixin, admin.ModelAdmin): +class BlogContentAdmin( + indicator_mixin_factory(), + ExtendedVersionAdminMixin, + admin.ModelAdmin +): pass -admin.site.register(models.BlogPost) +class BlogPostAdmin( + indicator_mixin_factory(), + ExtendedVersionAdminMixin, + admin.ModelAdmin +): + pass + + +admin.site.register(models.BlogPost, BlogPostAdmin) admin.site.register(models.BlogContent, BlogContentAdmin) diff --git a/docs/static/Status-indicators.png b/docs/static/Status-indicators.png new file mode 100644 index 0000000000000000000000000000000000000000..f1b44898f83dff152b1911756dd6f37b6c2fd614 GIT binary patch literal 56551 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 SGV9*}f273a#BxP+eg6m9wfWfq literal 0 HcmV?d00001 diff --git a/docs/versioning_integration.rst b/docs/versioning_integration.rst index 763860b2..054f8292 100644 --- a/docs/versioning_integration.rst +++ b/docs/versioning_integration.rst @@ -314,6 +314,104 @@ in the form of a dictionary of {model_name: {field: method}}, the admin for the Given the code sample above, "This is how we add" would be displayed as "this is how we add extra field text!" in the changelist of PostAdmin. +Using status indicators for your versioned content models +--------------------------------------------------------- + +djangocms-versioning provides status indicators for django CMS' page content models as you know them from the page tree: + +.. image:: static/Status-indicators.png + :width: 50% + +You can use these on your content model's change view admin by adding the following code to the model's Admin class: + +.. code-block:: python + + import json + from cms.utils.urlutils import static_with_version + from djangocms_versioning import indicators + + + class MyContentModelAdmin(admin.Admin): + class Media: + # js for the context menu + js = ("djangocms_versioning/js/indicators.js",) + # css for indicators and context menu + css = { + "all": (static_with_version("cms/css/cms.pagetree.css"),), + } + + # Indicator column adds "indicator" at the end of list + list_items = [...] + + def get_indicator_column(self, request): + # Name and render column + @admin.display(description=_("State")) + def indicator(self, content_obj): + status = indicators.content_indicator(content_obj) + menu = indicators.content_indicator_menu(request, status, content_obj._version) if status else None + return render_to_string( + "admin/djangocms_versioning/indicator.html", + { + "state": status or "empty", + "description": indicators.indicator_description.get(status, _("Empty")), + "menu_template": "admin/cms/page/tree/indicator_menu.html", + "menu": json.dumps(render_to_string("admin/cms/page/tree/indicator_menu.html", + dict(indicator_menu_items=menu))) if menu else None, + } + ) + return indicator + + def get_list_display(self, request): + return [ + self.get_indicator_column(request) if item == "indicator" else item + for item in super().get_list_display(request) + ] + +If you do not want to tweak details you might also use the ``indicator_mixin_factory``. It will create indicators for both grouper and content models. + +.. code-block:: python + + import json + from cms.utils.urlutils import static_with_version + from djangocms_versioning import indicators + + + class MyContentModelAdmin( + indicators.indicator_mixin_factory(), + admin.Admin, + ): + model = MyContentModel + # Indicator column adds "indicator" at the end of list + list_items = [...] + +.. note:: + + The mixin for grouper models expects that the admin instances has properties defined for each extra grouping field, e.g., ``self.language`` if language is an extra grouping field. + + This is typically set in the ``get_changelist_instance`` method, e.g., by getting the language from the request. The page tree keeps its extra grouping field (language) as a get parameter. + + .. code-block:: python + + def get_changelist_instance(self, request): + """Set language property and remove language from changelist_filter_params""" + if request.method == "GET": + request.GET = request.GET.copy() + for field in versionables.for_grouper(self.model).extra_grouping_fields: + value = request.GET.pop(field, [None])[0] + # Validation is recommended: Add clean_language etc. to your Admin class! + if hasattr(self, f"clean_{field}"): + value = getattr(self, f"clean_{field}")(value): + setattr(self, field) = value + # Grouping field-specific cache needs to be cleared when they are changed + self._content_cache = {} + instance = super().get_changelist_instance(request) + # Remove grouping fields from filters + if request.method == "GET": + for field in versionables.for_grouper(self.model).extra_grouping_fields: + if field in instance.params: + del instance.params[field] + return instance + Additional/advanced configuration ---------------------------------- diff --git a/tests/test_indicators.py b/tests/test_indicators.py index d128e24c..ecf801e5 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -2,11 +2,19 @@ from cms.utils.urlutils import admin_reverse from djangocms_versioning.models import Version -from djangocms_versioning.test_utils.factories import PageFactory, PageVersionFactory +from djangocms_versioning.test_utils.blogpost.admin import BlogContentAdmin +from djangocms_versioning.test_utils.blogpost.models import BlogContent +from djangocms_versioning.test_utils.factories import ( + BlogContentFactory, + BlogPostFactory, + BlogPostVersionFactory, + PageFactory, + PageVersionFactory, +) class TestVersionState(CMSTestCase): - def test_indicators(self): + def test_page_indicators(self): page = PageFactory(node__depth=1) version1 = PageVersionFactory( content__page=page, @@ -90,3 +98,32 @@ def test_indicators(self): response = self.client.get(page_tree, {"language": "en"}) self.assertContains(response, "cms-pagetree-node-state-dirty") self.assertNotContains(response, "cms-pagetree-node-state-published") + + def test_mixin_facory_media(self): + from django.contrib import admin + + admin = BlogContentAdmin(BlogContent, admin.site) + self.assertIn("cms.pagetree.css", str(admin.media)) + self.assertIn("indicators.js", str(admin.media)) + + def test_mixin_factory(self): + blogpost = BlogPostFactory() + content = BlogContentFactory( + blogpost=blogpost + ) + BlogPostVersionFactory( + content=content, + ) + + changelist = admin_reverse("blogpost_blogcontent_changelist") + with self.login_user_context(self.get_superuser()): + # New page ahs draft version, nothing else + response = self.client.get(changelist) + # Status indicator available? + self.assertContains(response, "cms-pagetree-node-state-draft") + self.assertNotContains(response, "cms-pagetree-node-state-published") + self.assertNotContains(response, "cms-pagetree-node-state-dirty") + # CSS loaded? + self.assertContains(response, "cms.pagetree.css"), + # JS loadeD? + self.assertContains(response, "indicators.js") From 5ca7d854fd3d0d9328093cb97d2c1d4cdc286006 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sat, 11 Feb 2023 18:22:18 +0100 Subject: [PATCH 11/48] no message --- djangocms_versioning/indicators.py | 22 +++++++++---------- .../test_utils/blogpost/admin.py | 14 +++--------- docs/versioning_integration.rst | 9 +++----- 3 files changed, 16 insertions(+), 29 deletions(-) diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index a63caf8e..a11761af 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -1,12 +1,13 @@ import json -from cms.utils.urlutils import static_with_version from django.contrib.auth import get_permission_codename from django.template.loader import render_to_string from django.urls import reverse from django.utils.http import urlencode from django.utils.translation import gettext_lazy as _ +from cms.utils.urlutils import static_with_version + from djangocms_versioning import versionables from djangocms_versioning.constants import ( ARCHIVED, @@ -15,7 +16,10 @@ UNPUBLISHED, VERSION_STATES, ) -from djangocms_versioning.helpers import version_list_url, get_latest_admin_viewable_content +from djangocms_versioning.helpers import ( + get_latest_admin_viewable_content, + version_list_url, +) from djangocms_versioning.models import Version @@ -142,7 +146,7 @@ def is_editable(content_obj, request): return versions[0].check_modify.as_bool(request.user) -class _IndicatorMixin: +class IndicatorMixin: """Mixin to provide indicator column to the changelist view of a content model admin. Usage:: class MyContentModelAdmin(ContenModelAdminMixin, admin.ModelAdmin): @@ -161,6 +165,8 @@ class Media: "all": (static_with_version("cms/css/cms.pagetree.css"),), } + indicator_column_label = _("State") + @property def _extra_grouping_fields(self): try: @@ -188,17 +194,9 @@ def indicator(obj): dict(indicator_menu_items=menu))) if menu else None, } ) - indicator.description = self._indicator_label + indicator.description = self.indicator_column_label return indicator def get_list_display(self, request): """Default behavior: replaces the text "indicator" by the indicator column""" return list(super().get_list_display(request)) + [self.get_indicator_column(request)] - - -def indicator_mixin_factory(label=_("State")): - return type( - "IndicatorModelAdminMixin", - (_IndicatorMixin, ), - dict(_indicator_label=label,), - ) diff --git a/djangocms_versioning/test_utils/blogpost/admin.py b/djangocms_versioning/test_utils/blogpost/admin.py index 5930244d..2c95947a 100644 --- a/djangocms_versioning/test_utils/blogpost/admin.py +++ b/djangocms_versioning/test_utils/blogpost/admin.py @@ -1,23 +1,15 @@ from django.contrib import admin from djangocms_versioning.admin import ExtendedVersionAdminMixin -from djangocms_versioning.indicators import indicator_mixin_factory +from djangocms_versioning.indicators import IndicatorMixin from djangocms_versioning.test_utils.blogpost import models -class BlogContentAdmin( - indicator_mixin_factory(), - ExtendedVersionAdminMixin, - admin.ModelAdmin -): +class BlogContentAdmin(IndicatorMixin, ExtendedVersionAdminMixin, admin.ModelAdmin): pass -class BlogPostAdmin( - indicator_mixin_factory(), - ExtendedVersionAdminMixin, - admin.ModelAdmin -): +class BlogPostAdmin(IndicatorMixin, ExtendedVersionAdminMixin, admin.ModelAdmin): pass diff --git a/docs/versioning_integration.rst b/docs/versioning_integration.rst index 054f8292..266984a4 100644 --- a/docs/versioning_integration.rst +++ b/docs/versioning_integration.rst @@ -367,7 +367,7 @@ You can use these on your content model's change view admin by adding the follow for item in super().get_list_display(request) ] -If you do not want to tweak details you might also use the ``indicator_mixin_factory``. It will create indicators for both grouper and content models. +If you do not want to tweak details you might also use the ``IndicatorMixin``. It will create indicators for both grouper and content models. .. code-block:: python @@ -376,17 +376,14 @@ If you do not want to tweak details you might also use the ``indicator_mixin_fac from djangocms_versioning import indicators - class MyContentModelAdmin( - indicators.indicator_mixin_factory(), - admin.Admin, - ): + class MyContentModelAdmin(IndicatorMixin, admin.Admin): model = MyContentModel # Indicator column adds "indicator" at the end of list list_items = [...] .. note:: - The mixin for grouper models expects that the admin instances has properties defined for each extra grouping field, e.g., ``self.language`` if language is an extra grouping field. + For grouper models the mixin expects that the admin instances has properties defined for each extra grouping field, e.g., ``self.language`` if language is an extra grouping field. This is typically set in the ``get_changelist_instance`` method, e.g., by getting the language from the request. The page tree keeps its extra grouping field (language) as a get parameter. From 60fe7f3be23ae0bee17aa64e487f086e9d4ab5ce Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sun, 12 Feb 2023 10:23:03 +0100 Subject: [PATCH 12/48] More flexible list_display option --- djangocms_versioning/indicators.py | 8 +++++++- djangocms_versioning/test_utils/blogpost/admin.py | 4 ++-- docs/versioning_integration.rst | 12 ++++++------ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index a11761af..cddcfa30 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -197,6 +197,12 @@ def indicator(obj): indicator.description = self.indicator_column_label return indicator + def indicator(self, obj): + assert False, "The indicator display list item is a placeholder for version indicators. self.indicator " \ + "should not be called." + def get_list_display(self, request): """Default behavior: replaces the text "indicator" by the indicator column""" - return list(super().get_list_display(request)) + [self.get_indicator_column(request)] + + return [self.get_indicator_column(request) if item == "indicator" else item + for item in super().get_list_display(request)] diff --git a/djangocms_versioning/test_utils/blogpost/admin.py b/djangocms_versioning/test_utils/blogpost/admin.py index 2c95947a..ad880691 100644 --- a/djangocms_versioning/test_utils/blogpost/admin.py +++ b/djangocms_versioning/test_utils/blogpost/admin.py @@ -6,11 +6,11 @@ class BlogContentAdmin(IndicatorMixin, ExtendedVersionAdminMixin, admin.ModelAdmin): - pass + list_display = ("__str__", "indicator") class BlogPostAdmin(IndicatorMixin, ExtendedVersionAdminMixin, admin.ModelAdmin): - pass + list_display = ("__str__", "indicator") admin.site.register(models.BlogPost, BlogPostAdmin) diff --git a/docs/versioning_integration.rst b/docs/versioning_integration.rst index 266984a4..2d7664f0 100644 --- a/docs/versioning_integration.rst +++ b/docs/versioning_integration.rst @@ -341,7 +341,7 @@ You can use these on your content model's change view admin by adding the follow } # Indicator column adds "indicator" at the end of list - list_items = [...] + list_items = [..., "indicator", ...] def get_indicator_column(self, request): # Name and render column @@ -362,10 +362,10 @@ You can use these on your content model's change view admin by adding the follow return indicator def get_list_display(self, request): - return [ - self.get_indicator_column(request) if item == "indicator" else item - for item in super().get_list_display(request) - ] + """Default behavior: replaces the text "indicator" by the indicator column""" + + return [self.get_indicator_column(request) if item == "indicator" else item + for item in super().get_list_display(request)] If you do not want to tweak details you might also use the ``IndicatorMixin``. It will create indicators for both grouper and content models. @@ -379,7 +379,7 @@ If you do not want to tweak details you might also use the ``IndicatorMixin``. I class MyContentModelAdmin(IndicatorMixin, admin.Admin): model = MyContentModel # Indicator column adds "indicator" at the end of list - list_items = [...] + list_items = [..., "indicator", ...] .. note:: From 9939a1b4a0eae9a1720c21a4a5ab7838a8987ad7 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sun, 12 Feb 2023 12:47:36 +0100 Subject: [PATCH 13/48] Improve back functionality of get views --- djangocms_versioning/cms_config.py | 6 ++++-- djangocms_versioning/indicators.py | 31 +++++++++++++++++++--------- djangocms_versioning/versionables.py | 24 +++++++++++++++------ 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/djangocms_versioning/cms_config.py b/djangocms_versioning/cms_config.py index c606842f..55d2680c 100644 --- a/djangocms_versioning/cms_config.py +++ b/djangocms_versioning/cms_config.py @@ -1,5 +1,6 @@ import collections +from cms.utils.urlutils import admin_reverse from django.conf import settings from django.contrib import messages from django.contrib.admin.utils import flatten_fieldsets @@ -375,8 +376,9 @@ def get_indicator_menu(cls, request, page_content): status = page_content.content_indicator() if not status or status == "empty": return super().get_indicator_menu(request, page_content) - versions = page_content._version # Cache from .content_indicator() (see mixin above) - menu = indicators.content_indicator_menu(request, status, versions) + versions = page_content._version # Cache from .content_indicator() + back = admin_reverse("cms_pagecontent_changelist") + f"?language={request.GET.get('language')}" + menu = indicators.content_indicator_menu(request, status, versions, back=back) return menu_template if menu else "", menu diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index cddcfa30..85996e2d 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -41,7 +41,7 @@ def _reverse_action(version, action, back=None): ) + get_params -def content_indicator_menu(request, status, versions): +def content_indicator_menu(request, status, versions, back=""): menu = [] if request.user.has_perm(f"cms.{get_permission_codename('change', versions[0]._meta)}"): if versions[0].check_publish.as_bool(request.user): @@ -79,7 +79,7 @@ def content_indicator_menu(request, status, versions): if versions[0].check_discard.as_bool(request.user): menu.append(( _("Delete Draft") if status == DRAFT else _("Delete Changes"), "cms-icon-bin", - _reverse_action(versions[0], "discard", back=request.path_info), + _reverse_action(versions[0], "discard", back=back), "", # Let view ask for confirmation )) if len(versions) >= 2 and versions[0].state == DRAFT and versions[1].state == PUBLISHED: @@ -88,7 +88,7 @@ def content_indicator_menu(request, status, versions): _reverse_action(versions[1], "compare") + "?" + urlencode(dict( compare_to=versions[0].pk, - back=request.path_info, + back=back, )), "", )) @@ -183,7 +183,12 @@ def indicator(obj): else: # Content Model content_obj = obj status = content_indicator(content_obj) - menu = content_indicator_menu(request, status, content_obj._version) if status else None + menu = content_indicator_menu( + request, + status, + content_obj._version, + back=request.path_info + "?" + request.GET.urlencode(), + ) if status else None return render_to_string( "admin/djangocms_versioning/indicator.html", { @@ -194,15 +199,21 @@ def indicator(obj): dict(indicator_menu_items=menu))) if menu else None, } ) - indicator.description = self.indicator_column_label + indicator.short_description = self.indicator_column_label return indicator def indicator(self, obj): - assert False, "The indicator display list item is a placeholder for version indicators. self.indicator " \ - "should not be called." + raise ValueError( + "ModelAdmin.display_list contains \"indicator\" as a placeholder for status indicators. " + "Status indicators, however, are not loaded. If you implement \"get_list_display\" make " + "sure it calls super().get_list_display." + ) def get_list_display(self, request): """Default behavior: replaces the text "indicator" by the indicator column""" - - return [self.get_indicator_column(request) if item == "indicator" else item - for item in super().get_list_display(request)] + if versionables.exists_for_content(self.model) or versionables.exists_for_grouper(self.model): + return [self.get_indicator_column(request) if item == "indicator" else item + for item in super().get_list_display(request)] + else: + # remove "indicator" entry + return [item for item in super().get_list_display(request) if item != "indicator"] diff --git a/djangocms_versioning/versionables.py b/djangocms_versioning/versionables.py index 70f753a9..a6823029 100644 --- a/djangocms_versioning/versionables.py +++ b/djangocms_versioning/versionables.py @@ -6,15 +6,27 @@ def _cms_extension(): return apps.get_app_config("djangocms_versioning").cms_extension -def for_content(model_or_obj): - """Get the registered VersionableItem instance for a content model or content model instance""" +def _to_model(model_or_obj): if isinstance(model_or_obj, Model): model_or_obj = model_or_obj.__class__ - return _cms_extension().versionables_by_content[model_or_obj] + return model_or_obj + + +def for_content(model_or_obj): + """Get the registered VersionableItem instance for a content model or content model instance""" + return _cms_extension().versionables_by_content[_to_model(model_or_obj)] def for_grouper(model_or_obj): """Get the registered VersionableItem instance for a grouper model or grouper model instance""" - if isinstance(model_or_obj, Model): - model_or_obj = model_or_obj.__class__ - return _cms_extension().versionables_by_grouper[model_or_obj] + return _cms_extension().versionables_by_grouper[_to_model(model_or_obj)] + + +def exists_for_content(model_or_obj): + """Test for registered VersionableItem for a content model or content model instance""" + return _to_model(model_or_obj) in _cms_extension().versionables_by_content + + +def exists_for_grouper(model_or_obj): + """Test for registered VersionableItem for a grouper model or grouper model instance""" + return _to_model(model_or_obj) in _cms_extension().versionables_by_grouper From c0346ab2190fc3535a9061aca80a9bdb84fe3fba Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sun, 12 Feb 2023 12:54:32 +0100 Subject: [PATCH 14/48] Fix isort --- djangocms_versioning/cms_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_versioning/cms_config.py b/djangocms_versioning/cms_config.py index 55d2680c..0d7895bb 100644 --- a/djangocms_versioning/cms_config.py +++ b/djangocms_versioning/cms_config.py @@ -1,6 +1,5 @@ import collections -from cms.utils.urlutils import admin_reverse from django.conf import settings from django.contrib import messages from django.contrib.admin.utils import flatten_fieldsets @@ -19,6 +18,7 @@ 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 from . import indicators, versionables from .admin import VersioningAdminMixin From 43c6fc4ae2a55dde9b732f0f22911ef2daa2bb63 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sun, 12 Feb 2023 14:09:15 +0100 Subject: [PATCH 15/48] Add one more test, fix doc inconsistency --- djangocms_versioning/admin.py | 2 +- djangocms_versioning/helpers.py | 12 +++----- djangocms_versioning/indicators.py | 4 +-- docs/versioning_integration.rst | 6 ++-- tests/test_admin.py | 45 ++++++++++++++++++++++++++++++ tests/test_indicators.py | 3 ++ 6 files changed, 57 insertions(+), 15 deletions(-) diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 7b92d465..95a8f8e6 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -56,7 +56,7 @@ def get_queryset(self, request): grouping_filters = {} for field in versionable.extra_grouping_fields: if hasattr(self, f"get_{field}_from_request"): - grouping_filters[field] = getattr(self, f"get_{field}_from_request")(request) + grouping_filters[field] = getattr(self, f"get_{field}_from_request")(request) # pragma: no cover elif field == "language": grouping_filters[field] = get_language_from_request(request) return queryset.filter(pk__in=versionable.distinct_groupers(**grouping_filters)) diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index 59c583ca..04895bf1 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -306,13 +306,9 @@ def get_latest_admin_viewable_content(grouper, include_unpublished_archived=Fals versionable = versionables.for_grouper(grouper) for field in versionable.extra_grouping_fields: if field not in extra_grouping_fields: - raise ValueError(f"Grouping field {field} required for {versionable.grouper_model}.") - if isinstance(grouper, models.Model): - # We have an instance? Find reverse relation and utilize the prefetch cache - content_set = versionable.grouper_field.remote_field.get_accessor_name() - qs = getattr(grouper, content_set)(manager="admin_manager") - else: - qs = versionable.content_model.admin_manager + raise ValueError(f"Grouping field {field} required for {versionable.grouper_model}.") # pragma no cover + content_set = versionable.grouper_field.remote_field.get_accessor_name() + qs = getattr(grouper, content_set)(manager="admin_manager") if include_unpublished_archived: return qs.filter(**extra_grouping_fields).latest_content().first() return qs.filter(**extra_grouping_fields).current_content().first() @@ -321,7 +317,7 @@ def get_latest_admin_viewable_content(grouper, include_unpublished_archived=Fals def get_latest_admin_viewable_page_content(page, language): warnings.warn("get_latst_admin_viewable_page_content has ben deprecated. " "Use get_latest_admin_viewable_content(page, language=language) instead.", - DeprecationWarning, stacklevel=2) + DeprecationWarning, stacklevel=2) # pragma: no cover return get_latest_admin_viewable_content(page, language=language) diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index 85996e2d..67d7239c 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -107,7 +107,7 @@ def content_indicator(content_obj): Function caches the result in the page_content object""" if not content_obj: - return None + return None # pragma: no cover elif not hasattr(content_obj, "_indicator_status"): versions = Version.objects.filter_by_content_grouping_values( content_obj @@ -207,7 +207,7 @@ def indicator(self, obj): "ModelAdmin.display_list contains \"indicator\" as a placeholder for status indicators. " "Status indicators, however, are not loaded. If you implement \"get_list_display\" make " "sure it calls super().get_list_display." - ) + ) # pragma: no cover def get_list_display(self, request): """Default behavior: replaces the text "indicator" by the indicator column""" diff --git a/docs/versioning_integration.rst b/docs/versioning_integration.rst index 2d7664f0..abae948b 100644 --- a/docs/versioning_integration.rst +++ b/docs/versioning_integration.rst @@ -372,13 +372,11 @@ If you do not want to tweak details you might also use the ``IndicatorMixin``. I .. code-block:: python import json - from cms.utils.urlutils import static_with_version from djangocms_versioning import indicators - class MyContentModelAdmin(IndicatorMixin, admin.Admin): - model = MyContentModel - # Indicator column adds "indicator" at the end of list + class MyContentModelAdmin(indicators.IndicatorMixin, admin.Admin): + # Adds "indicator" to the list_items list_items = [..., "indicator", ...] .. note:: diff --git a/tests/test_admin.py b/tests/test_admin.py index d6064474..c67fd97a 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -17,6 +17,7 @@ from django.test import RequestFactory from django.test.utils import ignore_warnings from django.urls import reverse +from django.utils.http import urlencode from django.utils.timezone import now from cms.test_utils.testcases import CMSTestCase @@ -40,12 +41,18 @@ from djangocms_versioning.helpers import ( register_versionadmin_proxy, replace_admin_for_models, + version_list_url, versioning_admin_factory, ) 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.blogpost.models import BlogContent +from djangocms_versioning.test_utils.factories import ( + BlogContentFactory, + BlogPostFactory, + BlogPostVersionFactory, +) from djangocms_versioning.test_utils.incorrectly_configured_blogpost.models import ( IncorrectBlogContent, ) @@ -2828,3 +2835,41 @@ def test_edit_link_inactive(self): self.assertIn("inactive", response) self.assertIn('title="Edit"', response) self.assertNotIn(edit_endpoint, response) + + def test_valid_back_link(self): + """Test if the discard view upon get request replaces the link for the back button with + a valid link given by back query parameter""" + blogpost = BlogPostFactory() + content = BlogContentFactory( + blogpost=blogpost + ) + version = BlogPostVersionFactory( + content=content, + ) + + changelist = admin_reverse("djangocms_versioning_blogcontentversion_discard", args=(version.pk,)) + valid_url = admin_reverse( + "cms_placeholder_render_object_preview", + args=(version.content_type_id, version.object_id), + ) + with self.login_user_context(self.get_superuser()): + response = self.client.get(changelist + "?" + urlencode(dict(back=valid_url))) + self.assertContains(response, valid_url) + self.assertNotContains(response, version_list_url(version.content)) + + def test_fake_back_link(self): + """Test if the discard view upon get request denies replacing the link for the back button with + an invalid link given by back query parameter""" + blogpost = BlogPostFactory() + content = BlogContentFactory( + blogpost=blogpost + ) + version = BlogPostVersionFactory( + content=content, + ) + + changelist = admin_reverse("djangocms_versioning_blogcontentversion_discard", args=(version.pk, )) + with self.login_user_context(self.get_superuser()): + 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_indicators.py b/tests/test_indicators.py index ecf801e5..d41ddc96 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -15,6 +15,7 @@ class TestVersionState(CMSTestCase): def test_page_indicators(self): + """Tests if the page content indicators render correctly""" page = PageFactory(node__depth=1) version1 = PageVersionFactory( content__page=page, @@ -100,6 +101,7 @@ def test_page_indicators(self): self.assertNotContains(response, "cms-pagetree-node-state-published") def test_mixin_facory_media(self): + """Test if the IndicatorMixin imports required js and css""" from django.contrib import admin admin = BlogContentAdmin(BlogContent, admin.site) @@ -107,6 +109,7 @@ def test_mixin_facory_media(self): self.assertIn("indicators.js", str(admin.media)) def test_mixin_factory(self): + """Test if IndicatorMixin causes the indicators to be rendered""" blogpost = BlogPostFactory() content = BlogContentFactory( blogpost=blogpost From c14bc6dc0327bf9ede05dc0d73edb502f6b0d821 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sun, 12 Feb 2023 14:18:53 +0100 Subject: [PATCH 16/48] fix coverage --- djangocms_versioning/cms_config.py | 2 +- djangocms_versioning/helpers.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/djangocms_versioning/cms_config.py b/djangocms_versioning/cms_config.py index 0d7895bb..bb7cc06d 100644 --- a/djangocms_versioning/cms_config.py +++ b/djangocms_versioning/cms_config.py @@ -374,7 +374,7 @@ def get_indicator_menu(cls, request, page_content): currently available versions""" menu_template = "admin/cms/page/tree/indicator_menu.html" status = page_content.content_indicator() - if not status or status == "empty": + 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() back = admin_reverse("cms_pagecontent_changelist") + f"?language={request.GET.get('language')}" diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index 04895bf1..542683a6 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -305,8 +305,8 @@ def get_latest_admin_viewable_content(grouper, include_unpublished_archived=Fals """ versionable = versionables.for_grouper(grouper) for field in versionable.extra_grouping_fields: - if field not in extra_grouping_fields: - raise ValueError(f"Grouping field {field} required for {versionable.grouper_model}.") # pragma no cover + if field not in extra_grouping_fields: # pragma: no cover + raise ValueError(f"Grouping field {field} required for {versionable.grouper_model}.") content_set = versionable.grouper_field.remote_field.get_accessor_name() qs = getattr(grouper, content_set)(manager="admin_manager") if include_unpublished_archived: @@ -314,10 +314,10 @@ def get_latest_admin_viewable_content(grouper, include_unpublished_archived=Fals return qs.filter(**extra_grouping_fields).current_content().first() -def get_latest_admin_viewable_page_content(page, language): +def get_latest_admin_viewable_page_content(page, language): # pragma: no cover warnings.warn("get_latst_admin_viewable_page_content has ben deprecated. " "Use get_latest_admin_viewable_content(page, language=language) instead.", - DeprecationWarning, stacklevel=2) # pragma: no cover + DeprecationWarning, stacklevel=2) return get_latest_admin_viewable_content(page, language=language) From 9c70b642463e7cba3c69b22e6240006f58761683 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sun, 12 Feb 2023 14:22:04 +0100 Subject: [PATCH 17/48] Let get_list_display return tuple --- djangocms_versioning/admin.py | 2 +- djangocms_versioning/indicators.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 95a8f8e6..bdabfb80 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -317,7 +317,7 @@ def extend_list_display(self, request, modifier_dict, list_display): def get_list_display(self, request): # get configured list_display - list_display = self.list_display + list_display = super().get_list_display(request) # Add versioning information and action fields list_display += ( "get_author", diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index 67d7239c..9ea9e5b8 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -212,8 +212,8 @@ def indicator(self, obj): def get_list_display(self, request): """Default behavior: replaces the text "indicator" by the indicator column""" if versionables.exists_for_content(self.model) or versionables.exists_for_grouper(self.model): - return [self.get_indicator_column(request) if item == "indicator" else item - for item in super().get_list_display(request)] + return tuple(self.get_indicator_column(request) if item == "indicator" else item + for item in super().get_list_display(request)) else: # remove "indicator" entry - return [item for item in super().get_list_display(request) if item != "indicator"] + return tuple(item for item in super().get_list_display(request) if item != "indicator") From 61107fe0eb96a077f03a55202980d78548a1b536 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sun, 12 Feb 2023 14:23:28 +0100 Subject: [PATCH 18/48] Fix isort --- djangocms_versioning/indicators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index 9ea9e5b8..4d1d1949 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -213,7 +213,7 @@ def get_list_display(self, request): """Default behavior: replaces the text "indicator" by the indicator column""" if versionables.exists_for_content(self.model) or versionables.exists_for_grouper(self.model): return tuple(self.get_indicator_column(request) if item == "indicator" else item - for item in super().get_list_display(request)) + for item in super().get_list_display(request)) else: # remove "indicator" entry return tuple(item for item in super().get_list_display(request) if item != "indicator") From 9d5a90b601d259cf62ac9571b7a078972547eb20 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sun, 12 Feb 2023 14:40:53 +0100 Subject: [PATCH 19/48] Fix test bugs --- djangocms_versioning/indicators.py | 2 +- tests/test_indicators.py | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index 4d1d1949..73157d2f 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -131,7 +131,7 @@ def content_indicator(content_obj): elif signature[ARCHIVED]: content_obj._indicator_status = "archived" content_obj._version = signature[ARCHIVED] - else: + else: # pragma: no cover content_obj._indicator_status = None content_obj._version = [None] return content_obj._indicator_status diff --git a/tests/test_indicators.py b/tests/test_indicators.py index d41ddc96..73daac09 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -33,11 +33,31 @@ def test_page_indicators(self): self.assertNotContains(response, "cms-pagetree-node-state-dirty") self.assertNotContains(response, "cms-pagetree-node-state-unpublished") + # Now archive + response = self.client.post(admin_reverse("djangocms_versioning_pagecontentversion_archive", + args=(pk,))) + self.assertEqual(response.status_code, 302) # Sends a redirect + # Is archived indicator? No draft indicator + response = self.client.get(page_tree, {"language": "en"}) + self.assertContains(response, "cms-pagetree-node-state-archived") + self.assertNotContains(response, "cms-pagetree-node-state-draft") + + + # Now revert + response = self.client.post(admin_reverse("djangocms_versioning_pagecontentversion_revert", + args=(pk,))) + self.assertEqual(response.status_code, 302) # Sends a redirect + # Is draft indicator? No archived indicator + response = self.client.get(page_tree, {"language": "en"}) + self.assertContains(response, "cms-pagetree-node-state-draft") + self.assertNotContains(response, "cms-pagetree-node-state-archived") + # New draft was created, get new pk + pk = Version.objects.filter_by_content_grouping_values(version1.content).order_by("-pk")[0].pk + # Now publish response = self.client.post(admin_reverse("djangocms_versioning_pagecontentversion_publish", args=(pk,))) self.assertEqual(response.status_code, 302) # Sends a redirect - # Is published indicator? No draft indicator response = self.client.get(page_tree, {"language": "en"}) self.assertContains(response, "cms-pagetree-node-state-published") From a49a141e586c5d59923d52a611ff0cff0b928727 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sun, 12 Feb 2023 14:54:37 +0100 Subject: [PATCH 20/48] Remove empty line --- tests/test_indicators.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_indicators.py b/tests/test_indicators.py index 73daac09..0907aeb9 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -42,7 +42,6 @@ def test_page_indicators(self): self.assertContains(response, "cms-pagetree-node-state-archived") self.assertNotContains(response, "cms-pagetree-node-state-draft") - # Now revert response = self.client.post(admin_reverse("djangocms_versioning_pagecontentversion_revert", args=(pk,))) From 1d6740fe43470466649ac572ebf82a3f91862544 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 13 Feb 2023 13:14:24 +0100 Subject: [PATCH 21/48] Fix: Add MediaDefiningClass meta classes --- djangocms_versioning/admin.py | 3 ++- djangocms_versioning/indicators.py | 7 ++++--- djangocms_versioning/managers.py | 12 ++++++++++-- .../djangocms_versioning/js/indicators.js | 19 ++----------------- 4 files changed, 18 insertions(+), 23 deletions(-) diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index bdabfb80..528a2e29 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -8,6 +8,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist from django.db.models.functions import Lower +from django.forms import MediaDefiningClass from django.http import Http404, HttpResponseNotAllowed from django.shortcuts import redirect, render from django.template.loader import render_to_string, select_template @@ -123,7 +124,7 @@ def has_change_permission(self, request, obj=None): return super().has_change_permission(request, obj) -class ExtendedVersionAdminMixin(VersioningAdminMixin): +class ExtendedVersionAdminMixin(VersioningAdminMixin, metaclass=MediaDefiningClass): """ Extended VersionAdminMixin for common/generic versioning admin items diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index 73157d2f..d5074bd2 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -1,6 +1,7 @@ import json from django.contrib.auth import get_permission_codename +from django.forms import MediaDefiningClass from django.template.loader import render_to_string from django.urls import reverse from django.utils.http import urlencode @@ -146,7 +147,7 @@ def is_editable(content_obj, request): return versions[0].check_modify.as_bool(request.user) -class IndicatorMixin: +class IndicatorMixin(metaclass=MediaDefiningClass): """Mixin to provide indicator column to the changelist view of a content model admin. Usage:: class MyContentModelAdmin(ContenModelAdminMixin, admin.ModelAdmin): @@ -159,7 +160,7 @@ def get_list_display(self, request): """ class Media: # js for the context menu - js = ("djangocms_versioning/js/indicators.js",) + js = ("admin/js/jquery.init.js", "djangocms_versioning/js/indicators.js",) # css for indicators and context menu css = { "all": (static_with_version("cms/css/cms.pagetree.css"),), @@ -177,7 +178,7 @@ def _extra_grouping_fields(self): def get_indicator_column(self, request): def indicator(obj): if self._extra_grouping_fields is not None: # Grouper Model - content_obj = get_latest_admin_viewable_content(obj, include_unpublished_archived=False, **{ + content_obj = get_latest_admin_viewable_content(obj, include_unpublished_archived=True, **{ field: getattr(self, field) for field in self._extra_grouping_fields }) else: # Content Model diff --git a/djangocms_versioning/managers.py b/djangocms_versioning/managers.py index 5e9ebaad..7e55411d 100644 --- a/djangocms_versioning/managers.py +++ b/djangocms_versioning/managers.py @@ -78,7 +78,7 @@ def current_content(self, **kwargs): .values_list("vers_pk", flat=True) return qs.filter(versions__pk__in=pk_filter) - def latest_content(self): + def latest_content(self, **kwargs): """Returns the "latest" content object which is in this order 1. a draft version (should it exist) 2. a published version (should it exist) @@ -114,7 +114,7 @@ def latest_content(self): .values(*self._group_by_key)\ .annotate(vers_pk=models.Max("versions__pk"))\ .values("vers_pk") - return self.filter(versions__pk__in=pk_current | pk_other) + return self.filter(versions__pk__in=pk_current | pk_other, **kwargs) class AdminManagerMixin: @@ -135,3 +135,11 @@ def get_queryset(self): {"_group_by_key": self._group_by_key} # Pass grouping fields to queryset )(self.model, using=self._db) return qs + + def current_content(self, **kwargs): # pragma: no cover + """Syntactic sugar: admin_manager.current_content()""" + return self.get_queryset().current_content(**kwargs) + + def latest_content(self, **kwargs): # pragma: no cover + """Syntactic sugar: admin_manager.latest_content()""" + return self.get_queryset().latest_content(**kwargs) diff --git a/djangocms_versioning/static/djangocms_versioning/js/indicators.js b/djangocms_versioning/static/djangocms_versioning/js/indicators.js index 02d19ac7..b1d54511 100644 --- a/djangocms_versioning/static/djangocms_versioning/js/indicators.js +++ b/djangocms_versioning/static/djangocms_versioning/js/indicators.js @@ -38,8 +38,8 @@ if (window.self === window.top) { // simply reload the page - _reloadHelper(); - } else { + window.location.reload(); + } else { window.top.CMS.API.Helpers.reloadBrowser('REFRESH_PAGE'); } }) @@ -84,21 +84,6 @@ }); } - /** - * Checks if we should reload the iframe or entire window. For this we - * need to skip `CMS.API.Helpers.reloadBrowser();`. - * - * @method _reloadHelper - * @private - */ - function _reloadHelper() { - if (window.self === window.top) { - CMS.API.Helpers.reloadBrowser(); - } else { - window.location.reload(); - } - } - function close_menu() { if (container) { container.find(".menu-cover").remove(); From 8daf0c9e8a841c412842cf68ba4e759f6ae00aa7 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 13 Feb 2023 15:26:17 +0100 Subject: [PATCH 22/48] Refactor for more consistent api --- djangocms_versioning/admin.py | 101 ++++++++++++++++-- djangocms_versioning/indicators.py | 85 +-------------- .../test_utils/blogpost/admin.py | 7 +- docs/versioning_integration.rst | 72 ++++--------- 4 files changed, 116 insertions(+), 149 deletions(-) diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 528a2e29..8e7c21b2 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -1,3 +1,4 @@ +import json from collections import OrderedDict from urllib.parse import urlparse @@ -23,7 +24,7 @@ from cms.utils.conf import get_cms_setting from cms.utils.urlutils import add_url_parameters, static_with_version -from . import versionables +from . import indicators, versionables from .conf import USERNAME_FIELD from .constants import DRAFT, PUBLISHED from .exceptions import ConditionFailed @@ -31,6 +32,7 @@ from .helpers import ( get_admin_url, get_editable_url, + get_latest_admin_viewable_content, get_preview_url, proxy_model, version_list_url, @@ -124,6 +126,74 @@ def has_change_permission(self, request, obj=None): return super().has_change_permission(request, obj) +class StateIndicatorMixin(metaclass=MediaDefiningClass): + """Mixin to provide indicator column to the changelist view of a content model admin. Usage:: + + class MyContentModelAdmin(StateIndicatorMixin, admin.ModelAdmin): + list_display = [..., "state_indicator", ...] + """ + class Media: + # js for the context menu + js = ("admin/js/jquery.init.js", "djangocms_versioning/js/indicators.js",) + # css for indicators and context menu + css = { + "all": (static_with_version("cms/css/cms.pagetree.css"),), + } + + indicator_column_label = _("State") + + @property + def _extra_grouping_fields(self): + try: + return versionables.for_grouper(self.model).extra_grouping_fields + except KeyError: + return None + + def get_indicator_column(self, request): + def indicator(obj): + if self._extra_grouping_fields is not None: # Grouper Model + content_obj = get_latest_admin_viewable_content(obj, include_unpublished_archived=True, **{ + field: getattr(self, field) for field in self._extra_grouping_fields + }) + else: # Content Model + content_obj = obj + status = indicators.content_indicator(content_obj) + menu = indicators.content_indicator_menu( + request, + status, + content_obj._version, + back=request.path_info + "?" + request.GET.urlencode(), + ) if status else None + return render_to_string( + "admin/djangocms_versioning/indicator.html", + { + "state": status or "empty", + "description": indicators.indicator_description.get(status, _("Empty")), + "menu_template": "admin/cms/page/tree/indicator_menu.html", + "menu": json.dumps(render_to_string("admin/cms/page/tree/indicator_menu.html", + dict(indicator_menu_items=menu))) if menu else None, + } + ) + indicator.short_description = self.indicator_column_label + return indicator + + def indicator(self, obj): + raise ValueError( + "ModelAdmin.display_list contains \"indicator\" as a placeholder for status indicators. " + "Status indicators, however, are not loaded. If you implement \"get_list_display\" make " + "sure it calls super().get_list_display." + ) # pragma: no cover + + def get_list_display(self, request): + """Default behavior: replaces the text "indicator" by the indicator column""" + if versionables.exists_for_content(self.model) or versionables.exists_for_grouper(self.model): + return tuple(self.get_indicator_column(request) if item == "state_indicator" else item + for item in super().get_list_display(request)) + else: + # remove "indicator" entry + return tuple(item for item in super().get_list_display(request) if item != "state_indicator") + + class ExtendedVersionAdminMixin(VersioningAdminMixin, metaclass=MediaDefiningClass): """ Extended VersionAdminMixin for common/generic versioning admin items @@ -133,6 +203,11 @@ class ExtendedVersionAdminMixin(VersioningAdminMixin, metaclass=MediaDefiningCla """ change_list_template = "djangocms_versioning/admin/mixin/change_list.html" + versioning_list_display = ( + "get_author", + "get_modified_date", + "get_versioning_state", + ) class Media: js = ("admin/js/jquery.init.js", "djangocms_versioning/js/actions.js") @@ -277,11 +352,14 @@ def get_list_actions(self): """ Collect rendered actions from implemented methods and return as list """ - return [ + actions = [ self._get_preview_link, self._get_edit_link, - self._get_manage_versions_link, - ] + ] + if "state_indicator" not in self.versioning_list_display: + # State indicator mixin loaded? + actions.append(self._get_manage_versions_link) + return actions def get_preview_link(self, obj): return format_html( @@ -320,12 +398,7 @@ def get_list_display(self, request): # get configured list_display list_display = super().get_list_display(request) # Add versioning information and action fields - list_display += ( - "get_author", - "get_modified_date", - "get_versioning_state", - self._list_actions(request) - ) + list_display += self.versioning_list_display + (self._list_actions(request),) # Get the versioning extension extension = _cms_extension() modifier_dict = extension.add_to_field_extension.get(self.model, None) @@ -334,6 +407,14 @@ def get_list_display(self, request): return list_display +class ExtendedIndicatorVersionAdminMixin(StateIndicatorMixin, ExtendedVersionAdminMixin): + versioning_list_display = ( + "get_author", + "get_modified_date", + "state_indicator", + ) + + class VersionChangeList(ChangeList): def get_filters_params(self, params=None): """Removes the grouper param from the filters as the main grouper diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index d5074bd2..5d43354e 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -1,15 +1,8 @@ -import json - from django.contrib.auth import get_permission_codename -from django.forms import MediaDefiningClass -from django.template.loader import render_to_string from django.urls import reverse from django.utils.http import urlencode from django.utils.translation import gettext_lazy as _ -from cms.utils.urlutils import static_with_version - -from djangocms_versioning import versionables from djangocms_versioning.constants import ( ARCHIVED, DRAFT, @@ -17,10 +10,7 @@ UNPUBLISHED, VERSION_STATES, ) -from djangocms_versioning.helpers import ( - get_latest_admin_viewable_content, - version_list_url, -) +from djangocms_versioning.helpers import version_list_url from djangocms_versioning.models import Version @@ -145,76 +135,3 @@ def is_editable(content_obj, request): return False versions = content_obj._version return versions[0].check_modify.as_bool(request.user) - - -class IndicatorMixin(metaclass=MediaDefiningClass): - """Mixin to provide indicator column to the changelist view of a content model admin. Usage:: - - class MyContentModelAdmin(ContenModelAdminMixin, admin.ModelAdmin): - list_display = [...] - - def get_list_display(self, request): - return self.list_display + [ - self.get_indicator_column(request) - ] - """ - class Media: - # js for the context menu - js = ("admin/js/jquery.init.js", "djangocms_versioning/js/indicators.js",) - # css for indicators and context menu - css = { - "all": (static_with_version("cms/css/cms.pagetree.css"),), - } - - indicator_column_label = _("State") - - @property - def _extra_grouping_fields(self): - try: - return versionables.for_grouper(self.model).extra_grouping_fields - except KeyError: - return None - - def get_indicator_column(self, request): - def indicator(obj): - if self._extra_grouping_fields is not None: # Grouper Model - content_obj = get_latest_admin_viewable_content(obj, include_unpublished_archived=True, **{ - field: getattr(self, field) for field in self._extra_grouping_fields - }) - else: # Content Model - content_obj = obj - status = content_indicator(content_obj) - menu = content_indicator_menu( - request, - status, - content_obj._version, - back=request.path_info + "?" + request.GET.urlencode(), - ) if status else None - return render_to_string( - "admin/djangocms_versioning/indicator.html", - { - "state": status or "empty", - "description": indicator_description.get(status, _("Empty")), - "menu_template": "admin/cms/page/tree/indicator_menu.html", - "menu": json.dumps(render_to_string("admin/cms/page/tree/indicator_menu.html", - dict(indicator_menu_items=menu))) if menu else None, - } - ) - indicator.short_description = self.indicator_column_label - return indicator - - def indicator(self, obj): - raise ValueError( - "ModelAdmin.display_list contains \"indicator\" as a placeholder for status indicators. " - "Status indicators, however, are not loaded. If you implement \"get_list_display\" make " - "sure it calls super().get_list_display." - ) # pragma: no cover - - def get_list_display(self, request): - """Default behavior: replaces the text "indicator" by the indicator column""" - if versionables.exists_for_content(self.model) or versionables.exists_for_grouper(self.model): - return tuple(self.get_indicator_column(request) if item == "indicator" else item - for item in super().get_list_display(request)) - else: - # remove "indicator" entry - return tuple(item for item in super().get_list_display(request) if item != "indicator") diff --git a/djangocms_versioning/test_utils/blogpost/admin.py b/djangocms_versioning/test_utils/blogpost/admin.py index ad880691..1afe6dfa 100644 --- a/djangocms_versioning/test_utils/blogpost/admin.py +++ b/djangocms_versioning/test_utils/blogpost/admin.py @@ -1,15 +1,14 @@ from django.contrib import admin -from djangocms_versioning.admin import ExtendedVersionAdminMixin -from djangocms_versioning.indicators import IndicatorMixin +from djangocms_versioning.admin import ExtendedVersionAdminMixin, StateIndicatorMixin from djangocms_versioning.test_utils.blogpost import models -class BlogContentAdmin(IndicatorMixin, ExtendedVersionAdminMixin, admin.ModelAdmin): +class BlogContentAdmin(StateIndicatorMixin, ExtendedVersionAdminMixin, admin.ModelAdmin): list_display = ("__str__", "indicator") -class BlogPostAdmin(IndicatorMixin, ExtendedVersionAdminMixin, admin.ModelAdmin): +class BlogPostAdmin(StateIndicatorMixin, ExtendedVersionAdminMixin, admin.ModelAdmin): list_display = ("__str__", "indicator") diff --git a/docs/versioning_integration.rst b/docs/versioning_integration.rst index abae948b..cbe5c1c5 100644 --- a/docs/versioning_integration.rst +++ b/docs/versioning_integration.rst @@ -314,70 +314,25 @@ in the form of a dictionary of {model_name: {field: method}}, the admin for the Given the code sample above, "This is how we add" would be displayed as "this is how we add extra field text!" in the changelist of PostAdmin. -Using status indicators for your versioned content models ---------------------------------------------------------- +Adding status indicators to a versioned content model +----------------------------------------------------- djangocms-versioning provides status indicators for django CMS' page content models as you know them from the page tree: .. image:: static/Status-indicators.png :width: 50% -You can use these on your content model's change view admin by adding the following code to the model's Admin class: +You can use these on your content model's changelist view admin by adding the following fixin to the model's Admin class: .. code-block:: python import json - from cms.utils.urlutils import static_with_version - from djangocms_versioning import indicators + from djangocms_versioning.admin import StateIndicatorAdminMixin - class MyContentModelAdmin(admin.Admin): - class Media: - # js for the context menu - js = ("djangocms_versioning/js/indicators.js",) - # css for indicators and context menu - css = { - "all": (static_with_version("cms/css/cms.pagetree.css"),), - } - - # Indicator column adds "indicator" at the end of list - list_items = [..., "indicator", ...] - - def get_indicator_column(self, request): - # Name and render column - @admin.display(description=_("State")) - def indicator(self, content_obj): - status = indicators.content_indicator(content_obj) - menu = indicators.content_indicator_menu(request, status, content_obj._version) if status else None - return render_to_string( - "admin/djangocms_versioning/indicator.html", - { - "state": status or "empty", - "description": indicators.indicator_description.get(status, _("Empty")), - "menu_template": "admin/cms/page/tree/indicator_menu.html", - "menu": json.dumps(render_to_string("admin/cms/page/tree/indicator_menu.html", - dict(indicator_menu_items=menu))) if menu else None, - } - ) - return indicator - - def get_list_display(self, request): - """Default behavior: replaces the text "indicator" by the indicator column""" - - return [self.get_indicator_column(request) if item == "indicator" else item - for item in super().get_list_display(request)] - -If you do not want to tweak details you might also use the ``IndicatorMixin``. It will create indicators for both grouper and content models. - -.. code-block:: python - - import json - from djangocms_versioning import indicators - - - class MyContentModelAdmin(indicators.IndicatorMixin, admin.Admin): + class MyContentModelAdmin(StateIndicatorAdminMixin, admin.Admin): # Adds "indicator" to the list_items - list_items = [..., "indicator", ...] + list_items = [..., "state_indicator", ...] .. note:: @@ -407,6 +362,21 @@ If you do not want to tweak details you might also use the ``IndicatorMixin``. I del instance.params[field] return instance +Adding Status Indicators *and* Versioning Entries to a versioned content model +------------------------------------------------------------------------ + +Both mixins can be easily combined. If you want both, state indicators and the additional author, modified date, preview action, and edit action, you can simpliy use the ``ExtendedIndicatorVersionAdminMixin``: + +.. code-block:: python + + class MyContentModelAdmin(ExtendedIndicatorVersionAdminMixin, admin.Admin): + ... + +The versioning state and version list action are replaced by the status indicator and its context menu, respectively. + +Add additional actions by overwriting the ``self.get_list_actions()`` method and calling ``super()``. + + Additional/advanced configuration ---------------------------------- From f2b4c3e0624c731de149e58fade035b0fa6d8416 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 13 Feb 2023 15:31:32 +0100 Subject: [PATCH 23/48] Update tests --- djangocms_versioning/test_utils/blogpost/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_versioning/test_utils/blogpost/admin.py b/djangocms_versioning/test_utils/blogpost/admin.py index 1afe6dfa..80d7842c 100644 --- a/djangocms_versioning/test_utils/blogpost/admin.py +++ b/djangocms_versioning/test_utils/blogpost/admin.py @@ -9,7 +9,7 @@ class BlogContentAdmin(StateIndicatorMixin, ExtendedVersionAdminMixin, admin.Mod class BlogPostAdmin(StateIndicatorMixin, ExtendedVersionAdminMixin, admin.ModelAdmin): - list_display = ("__str__", "indicator") + list_display = ("__str__", "state_indicator") admin.site.register(models.BlogPost, BlogPostAdmin) From 4f186db1e7010c7424956650c5d23efb1a00a048 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 13 Feb 2023 15:41:05 +0100 Subject: [PATCH 24/48] Fix 2 missing renames --- djangocms_versioning/admin.py | 2 +- djangocms_versioning/test_utils/blogpost/admin.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 8e7c21b2..f6ac07dc 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -177,7 +177,7 @@ def indicator(obj): indicator.short_description = self.indicator_column_label return indicator - def indicator(self, obj): + def state_indicator(self, obj): raise ValueError( "ModelAdmin.display_list contains \"indicator\" as a placeholder for status indicators. " "Status indicators, however, are not loaded. If you implement \"get_list_display\" make " diff --git a/djangocms_versioning/test_utils/blogpost/admin.py b/djangocms_versioning/test_utils/blogpost/admin.py index 80d7842c..bc697b57 100644 --- a/djangocms_versioning/test_utils/blogpost/admin.py +++ b/djangocms_versioning/test_utils/blogpost/admin.py @@ -5,7 +5,7 @@ class BlogContentAdmin(StateIndicatorMixin, ExtendedVersionAdminMixin, admin.ModelAdmin): - list_display = ("__str__", "indicator") + list_display = ("__str__", "state_indicator") class BlogPostAdmin(StateIndicatorMixin, ExtendedVersionAdminMixin, admin.ModelAdmin): From cb9cf6d935df204e760354c2ab20d9a03ac5812d Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 13 Feb 2023 15:54:19 +0100 Subject: [PATCH 25/48] Remove spourious import cycle --- djangocms_versioning/indicators.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index 5d43354e..5debf248 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -1,5 +1,5 @@ +from cms.utils.urlutils import admin_reverse from django.contrib.auth import get_permission_codename -from django.urls import reverse from django.utils.http import urlencode from django.utils.translation import gettext_lazy as _ @@ -10,7 +10,6 @@ UNPUBLISHED, VERSION_STATES, ) -from djangocms_versioning.helpers import version_list_url from djangocms_versioning.models import Version @@ -26,13 +25,15 @@ def _reverse_action(version, action, back=None): get_params = f"?{urlencode(dict(back=back))}" if back else "" - return reverse( - f"admin:{version._meta.app_label}_{version.versionable.version_model_proxy._meta.model_name}_{action}", + return admin_reverse( + f"{version._meta.app_label}_{version.versionable.version_model_proxy._meta.model_name}_{action}", args=(version.pk,) ) + get_params def content_indicator_menu(request, status, versions, back=""): + from djangocms_versioning.helpers import version_list_url + menu = [] if request.user.has_perm(f"cms.{get_permission_codename('change', versions[0]._meta)}"): if versions[0].check_publish.as_bool(request.user): From 6515cb56d1326503046a755b9660f8e48593ab3b Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 13 Feb 2023 16:35:51 +0100 Subject: [PATCH 26/48] fix: isort --- djangocms_versioning/indicators.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index 5debf248..6c1816e0 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -1,8 +1,9 @@ -from cms.utils.urlutils import admin_reverse from django.contrib.auth import get_permission_codename from django.utils.http import urlencode from django.utils.translation import gettext_lazy as _ +from cms.utils.urlutils import admin_reverse + from djangocms_versioning.constants import ( ARCHIVED, DRAFT, From 7070ada9516aacaf55399cc1eacf6a0534c36f36 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 16 Feb 2023 22:12:57 +0100 Subject: [PATCH 27/48] Update djangocms_versioning/helpers.py Co-authored-by: Andrew Aikman --- djangocms_versioning/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index 542683a6..6e505593 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -307,6 +307,7 @@ def get_latest_admin_viewable_content(grouper, include_unpublished_archived=Fals for field in versionable.extra_grouping_fields: if field not in extra_grouping_fields: # pragma: no cover raise ValueError(f"Grouping field {field} required for {versionable.grouper_model}.") + content_set = versionable.grouper_field.remote_field.get_accessor_name() qs = getattr(grouper, content_set)(manager="admin_manager") if include_unpublished_archived: From abdfaef23e353c72557df5fd7777579c728552fe Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 16 Feb 2023 22:13:09 +0100 Subject: [PATCH 28/48] Update djangocms_versioning/helpers.py Co-authored-by: Andrew Aikman --- djangocms_versioning/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index 6e505593..25aa6b9b 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -310,6 +310,7 @@ def get_latest_admin_viewable_content(grouper, include_unpublished_archived=Fals content_set = versionable.grouper_field.remote_field.get_accessor_name() qs = getattr(grouper, content_set)(manager="admin_manager") + if include_unpublished_archived: return qs.filter(**extra_grouping_fields).latest_content().first() return qs.filter(**extra_grouping_fields).current_content().first() From 91c7a468f7f5c486c6adec69b1106ea57960a6e2 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 16 Feb 2023 22:14:59 +0100 Subject: [PATCH 29/48] Update docs/versioning_integration.rst Co-authored-by: Andrew Aikman --- docs/versioning_integration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/versioning_integration.rst b/docs/versioning_integration.rst index cbe5c1c5..14840bc7 100644 --- a/docs/versioning_integration.rst +++ b/docs/versioning_integration.rst @@ -317,7 +317,7 @@ Given the code sample above, "This is how we add" would be displayed as Adding status indicators to a versioned content model ----------------------------------------------------- -djangocms-versioning provides status indicators for django CMS' page content models as you know them from the page tree: +djangocms-versioning provides status indicators for django CMS' content models, you may know them from the page tree in django-cms: .. image:: static/Status-indicators.png :width: 50% From 84e91a650d199c7f4537172933f26cda14d44b59 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 17 Feb 2023 14:50:27 +0100 Subject: [PATCH 30/48] Consistent labels for "discard changes" --- djangocms_versioning/indicators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index 6c1816e0..7e7872e5 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -71,7 +71,7 @@ def content_indicator_menu(request, status, versions, back=""): )) if versions[0].check_discard.as_bool(request.user): menu.append(( - _("Delete Draft") if status == DRAFT else _("Delete Changes"), "cms-icon-bin", + _("Delete Draft") if status == DRAFT else _("Discard Changes"), "cms-icon-bin", _reverse_action(versions[0], "discard", back=back), "", # Let view ask for confirmation )) From edfb62c8202105f3a37c27aa7e2f244567f0d7f9 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 21 Feb 2023 14:32:54 +0100 Subject: [PATCH 31/48] Add more tests --- djangocms_versioning/admin.py | 14 +++--- djangocms_versioning/datastructures.py | 17 ------- djangocms_versioning/helpers.py | 20 +++++--- tests/test_indicators.py | 68 +++++++++++++++++++++++++- 4 files changed, 87 insertions(+), 32 deletions(-) diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index f6ac07dc..8780de06 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -786,7 +786,7 @@ def archive_view(self, request, object_id): ), args=(version.content.pk,), ), - back_url=self.back_link(request) or version_list_url(version.content), + back_url=self.back_link(request, version), ) return render( request, "djangocms_versioning/admin/archive_confirmation.html", context @@ -866,7 +866,7 @@ def unpublish_view(self, request, object_id): ), args=(version.content.pk,), ), - back_url=self.back_link(request) or version_list_url(version.content), + back_url=self.back_link(request, version), ) extra_context = OrderedDict( [ @@ -980,7 +980,7 @@ def revert_view(self, request, object_id): ), args=(version.content.pk,), ), - back_url=self.back_link(request) or version_list_url(version.content), + back_url=self.back_link(request, version), ) return render( request, "djangocms_versioning/admin/revert_confirmation.html", context @@ -1022,7 +1022,7 @@ def discard_view(self, request, object_id): ), args=(version.content.pk,), ), - back_url=self.back_link(request) or version_list_url(version.content), + back_url=self.back_link(request, version), ) return render( request, "djangocms_versioning/admin/discard_confirmation.html", context @@ -1068,7 +1068,7 @@ def compare_view(self, request, object_id): "version_list": version_list, "v1": v1, "v1_preview_url": v1_preview_url, - "return_url": self.back_link(request) or version_list_url(v1.content), + "return_url": self.back_link(request, v1), } # Now check if version 2 has been specified and add to context @@ -1097,7 +1097,7 @@ def compare_view(self, request, object_id): ) @staticmethod - def back_link(request): + def back_link(request, version=None): back_url = request.GET.get("back", None) if back_url: try: @@ -1106,7 +1106,7 @@ def back_link(request): except Resolver404: # If not ignore back_url = None - return back_url + return back_url or (version_list_url(version.content) if version else None) def changelist_view(self, request, extra_context=None): """Handle grouper filtering on the changelist""" diff --git a/djangocms_versioning/datastructures.py b/djangocms_versioning/datastructures.py index d7dbaced..39b2deba 100644 --- a/djangocms_versioning/datastructures.py +++ b/djangocms_versioning/datastructures.py @@ -151,23 +151,6 @@ def suffix(field, allow=True): def grouper_choices_queryset(self): """Returns a queryset of all the available groupers instances of the registered type""" - # inner = ( - # self.content_model._base_manager.annotate( - # order=Case( - # When(versions__state=PUBLISHED, then=2), - # When(versions__state=DRAFT, then=1), - # default=0, - # output_field=models.IntegerField(), - # ) - # ) - # .filter(**{self.grouper_field_name: OuterRef("pk")}) - # .order_by("-order") - # ) - # content_objects = self.content_model._base_manager.filter( - # pk__in=self.grouper_model._base_manager.annotate( - # content=Subquery(inner.values_list("pk")[:1]) - # ).values_list("content") - # ) content_objects = self.content_model.admin_manager.all().latest_content() cache_name = self.grouper_field.remote_field.get_accessor_name() return self.grouper_model._base_manager.prefetch_related( diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index 25aa6b9b..7ca3bab6 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -7,7 +7,6 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django.db.models.sql.where import WhereNode -from django.urls import reverse from cms.toolbar.utils import get_object_edit_url, get_object_preview_url from cms.utils.helpers import is_editable_model @@ -225,8 +224,8 @@ def get_editable_url(content_obj): url = get_object_edit_url(content_obj, language) # Or else, the standard edit view should be used else: - url = reverse( - "admin:{app}_{model}_change".format( + url = admin_reverse( + "{app}_{model}_change".format( app=content_obj._meta.app_label, model=content_obj._meta.model_name ), args=(content_obj.pk,), @@ -261,8 +260,8 @@ def get_preview_url(content_obj): url = get_object_preview_url(content_obj) # Or else, the standard change view should be used else: - url = reverse( - "admin:{app}_{model}_change".format( + url = admin_reverse( + "{app}_{model}_change".format( app=content_obj._meta.app_label, model=content_obj._meta.model_name ), args=[content_obj.pk], @@ -304,15 +303,22 @@ def get_latest_admin_viewable_content(grouper, include_unpublished_archived=Fals Return the latest Draft or Published PageContent using the draft where possible """ versionable = versionables.for_grouper(grouper) + + # Check if all required grouping fields are given to be able to select the latest admin viewable content + # It is essential to for field in versionable.extra_grouping_fields: if field not in extra_grouping_fields: # pragma: no cover raise ValueError(f"Grouping field {field} required for {versionable.grouper_model}.") - + + # Get the name of the content_set (e.g., "pagecontent_set") from the versionable content_set = versionable.grouper_field.remote_field.get_accessor_name() + # Accessing the content set through the grouper preserves prefetches qs = getattr(grouper, content_set)(manager="admin_manager") - + if include_unpublished_archived: + # Relevant for admin to see e.g., the latest unpublished or archived versions return qs.filter(**extra_grouping_fields).latest_content().first() + # Return only active versions, e.g., for copying return qs.filter(**extra_grouping_fields).current_content().first() diff --git a/tests/test_indicators.py b/tests/test_indicators.py index 0907aeb9..5c5170c7 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -1,6 +1,7 @@ from cms.test_utils.testcases import CMSTestCase from cms.utils.urlutils import admin_reverse +from djangocms_versioning.helpers import get_latest_admin_viewable_content from djangocms_versioning.models import Version from djangocms_versioning.test_utils.blogpost.admin import BlogContentAdmin from djangocms_versioning.test_utils.blogpost.models import BlogContent @@ -13,6 +14,71 @@ ) +class TestLatestAdminViewable(CMSTestCase): + def test_extra_grouping_fields(self): + page = PageFactory(node__depth=1) + version = PageVersionFactory( + content__page=page, + content__language="en", + ) + + # Test 1: Try getting content w/o language grouping field => needs to fail + self.assertRaises(ValueError, lambda: get_latest_admin_viewable_content(page)) # no language grouper + + # Test 2: Try getting content w/ langauge grouping field => needs to succeed + content = get_latest_admin_viewable_content(page, language="en") # OK + self.assertEqual(content.versions.first(), version) + + def test_latest_admin_viewable_content(self): + """Tests if the page content indicators render correctly""" + page = PageFactory(node__depth=1) + version1 = PageVersionFactory( + content__page=page, + content__language="en", + ) + + # New page has draft version, nothing else: latest_admin_viewable_content is draft + content = get_latest_admin_viewable_content(page, language="en") + self.assertEqual(content.versions.first(), version1) + + # Now archive + version1.archive(user=self.get_superuser()) + # Archived version, nothing else: latest_admin_viewable_content is empty + content = get_latest_admin_viewable_content(page, include_unpublished_archived=False, language="en") + self.assertIsNone(content) + # Archived version, nothing else: latest_admin_viewable_content is empty + content = get_latest_admin_viewable_content(page, include_unpublished_archived=True, language="en") + self.assertEqual(content.versions.first(), version1) + + # Now revert and publish => latest content is published + version2 = version1.copy(created_by=self.get_superuser()) + version2.publish(user=self.get_superuser()) + # Published version is always viewable + content = get_latest_admin_viewable_content(page, include_unpublished_archived=False, language="en") + self.assertEqual(content.versions.first(), version2) + # Published version is always viewable + content = get_latest_admin_viewable_content(page, include_unpublished_archived=True, language="en") + self.assertEqual(content.versions.first(), version2) + + # Now create a draft on top of published -> latest_admin_viewable content will be draft + version3 = version2.copy(created_by=self.get_superuser()) + # Draft version is shadows published version + content = get_latest_admin_viewable_content(page, include_unpublished_archived=False, language="en") + self.assertEqual(content.versions.first(), version3) + # Draft version is shadows published version + content = get_latest_admin_viewable_content(page, include_unpublished_archived=True, language="en") + self.assertEqual(content.versions.first(), version3) + + # Archive draft, with published version available + version3.archive(user=self.get_superuser()) + # Published version now is the latest version + content = get_latest_admin_viewable_content(page, include_unpublished_archived=False, language="en") + self.assertEqual(content.versions.first(), version2) + # Published version now is the latest version even when including archived + content = get_latest_admin_viewable_content(page, include_unpublished_archived=True, language="en") + self.assertEqual(content.versions.first(), version2) + + class TestVersionState(CMSTestCase): def test_page_indicators(self): """Tests if the page content indicators render correctly""" @@ -25,7 +91,7 @@ def test_page_indicators(self): page_tree = admin_reverse("cms_pagecontent_get_tree") with self.login_user_context(self.get_superuser()): - # New page ahs draft version, nothing else + # New page has draft version, nothing else response = self.client.get(page_tree, {"language": "en"}) self.assertNotContains(response, "cms-pagetree-node-state-empty") self.assertContains(response, "cms-pagetree-node-state-draft") From 6e793e6494c0613f3e709d426dc5f960eb6320f6 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 21 Feb 2023 15:21:00 +0100 Subject: [PATCH 32/48] Update release notes --- djangocms_versioning/managers.py | 19 ------------------- docs/upgrade/2.0.0.rst | 11 +++++++++++ 2 files changed, 11 insertions(+), 19 deletions(-) diff --git a/djangocms_versioning/managers.py b/djangocms_versioning/managers.py index 7e55411d..55d615e8 100644 --- a/djangocms_versioning/managers.py +++ b/djangocms_versioning/managers.py @@ -87,25 +87,6 @@ def latest_content(self, **kwargs): This filter assumes that there can only be one draft created and that the draft as the highest pk of all versions (should it exist). """ - """ - While this is probably to most general approach it does not work for MySql :-( - inner = ( - self.annotate( - order=models.Case( - models.When(versions__state=constants.PUBLISHED, then=2), - models.When(versions__state=constants.DRAFT, then=1), - default = 0, - output_field = models.IntegerField(), - ), - modified = models.Max("versions__modified"), - ) - .filter(**{ - self._group_by_key[0]: models.OuterRef(self._group_by_key[0]) - }) - .order_by("-order", "-modified") - ) - return self.filter(pk__in=models.Subquery(inner[:1].values("pk"))) - """ current = self.filter(versions__state__in=(constants.DRAFT, constants.PUBLISHED))\ .values(*self._group_by_key)\ .annotate(vers_pk=models.Max("versions__pk")) diff --git a/docs/upgrade/2.0.0.rst b/docs/upgrade/2.0.0.rst index 00d08c25..6c509dfc 100644 --- a/docs/upgrade/2.0.0.rst +++ b/docs/upgrade/2.0.0.rst @@ -35,6 +35,17 @@ Status indicators in page tree make sure that at least version 3.2.1 is installed. Older versions contain a bug that interferes with djangocms-versioning's icons. +Status indicators for custom versioned models +--------------------------------------------- + +* The new ``StateIndicatorMixin`` allows to add state indicators to a grouper or + content model's admin changelist view. + +* The new ``ExtendedIndicatorVersionAdminMixin`` combines the + ``ExtendedVersionAdminMixin`` and the ``StateIndicatorMixin``, where the + version state is replaced by the indicator and the versioning actions are + part of the indicator drop down menu. + Backwards incompatible changes in 2.0.0 ======================================= From 6fb42e7eb589731cfeb4b265f92e96d29736bad8 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 21 Feb 2023 15:41:33 +0100 Subject: [PATCH 33/48] Fix: Clarify docs (page tree as example) --- docs/versioning_integration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/versioning_integration.rst b/docs/versioning_integration.rst index 14840bc7..6d7835ba 100644 --- a/docs/versioning_integration.rst +++ b/docs/versioning_integration.rst @@ -338,7 +338,7 @@ You can use these on your content model's changelist view admin by adding the fo For grouper models the mixin expects that the admin instances has properties defined for each extra grouping field, e.g., ``self.language`` if language is an extra grouping field. - This is typically set in the ``get_changelist_instance`` method, e.g., by getting the language from the request. The page tree keeps its extra grouping field (language) as a get parameter. + This is typically set in the ``get_changelist_instance`` method, e.g., by getting the language from the request. The page tree, for example, keeps its extra grouping field (language) as a get parameter. .. code-block:: python From ffe1f4c66b7bd0d3874eadba5e7364618815a75c Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 21 Feb 2023 15:58:01 +0100 Subject: [PATCH 34/48] Update docs --- docs/versioning_integration.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/versioning_integration.rst b/docs/versioning_integration.rst index 6d7835ba..1197a778 100644 --- a/docs/versioning_integration.rst +++ b/docs/versioning_integration.rst @@ -338,7 +338,7 @@ You can use these on your content model's changelist view admin by adding the fo For grouper models the mixin expects that the admin instances has properties defined for each extra grouping field, e.g., ``self.language`` if language is an extra grouping field. - This is typically set in the ``get_changelist_instance`` method, e.g., by getting the language from the request. The page tree, for example, keeps its extra grouping field (language) as a get parameter. + This is typically set in the ``get_changelist_instance`` method, e.g., by getting the language from the request. The page tree, for example, keeps its extra grouping field (language) as a get parameter to avoid mixing language of the user interface and language that is changed. .. code-block:: python From d193a0a153265f85055bf82990c2ac23f2a2a2d7 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 2 Mar 2023 20:36:27 +0100 Subject: [PATCH 35/48] Update djangocms_versioning/helpers.py Co-authored-by: Andrew Aikman --- djangocms_versioning/helpers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index a912124f..e4ab6c99 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -306,7 +306,9 @@ def get_latest_admin_viewable_content(grouper, include_unpublished_archived=Fals # Check if all required grouping fields are given to be able to select the latest admin viewable content # It is essential to - for field in versionable.extra_grouping_fields: + missing_fields = [field for field in versionable.extra_grouping_fields if field not in extra_grouping_fields] + if missing_fields: + raise ValueError(f"Grouping field(s) {missing_fields} required for {versionable.grouper_model}.") if field not in extra_grouping_fields: # pragma: no cover raise ValueError(f"Grouping field {field} required for {versionable.grouper_model}.") From f31d501f2710b7ae03546b72b3b91d83e58526dc Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 2 Mar 2023 20:38:24 +0100 Subject: [PATCH 36/48] Update tests/test_admin.py Co-authored-by: Andrew Aikman --- tests/test_admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_admin.py b/tests/test_admin.py index c67fd97a..40850276 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -2837,7 +2837,7 @@ def test_edit_link_inactive(self): self.assertNotIn(edit_endpoint, response) def test_valid_back_link(self): - """Test if the discard view upon get request replaces the link for the back button with + """The discard view upon get request replaces the link for the back button with a valid link given by back query parameter""" blogpost = BlogPostFactory() content = BlogContentFactory( From 033836be2d32d82edeec7a93d727c2164ca98c59 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 2 Mar 2023 20:38:59 +0100 Subject: [PATCH 37/48] Update tests/test_indicators.py Co-authored-by: Andrew Aikman --- tests/test_indicators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_indicators.py b/tests/test_indicators.py index 5c5170c7..8df6909c 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -30,7 +30,7 @@ def test_extra_grouping_fields(self): self.assertEqual(content.versions.first(), version) def test_latest_admin_viewable_content(self): - """Tests if the page content indicators render correctly""" + """The page content indicators render correctly""" page = PageFactory(node__depth=1) version1 = PageVersionFactory( content__page=page, From 8f5229574bbaa6536f705d39e947861e4a0d65d6 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 2 Mar 2023 20:53:23 +0100 Subject: [PATCH 38/48] fix indentation --- djangocms_versioning/admin.py | 8 ++++---- djangocms_versioning/helpers.py | 4 +--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 49711e9a..91cc9565 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -204,10 +204,10 @@ class ExtendedVersionAdminMixin(VersioningAdminMixin, metaclass=MediaDefiningCla change_list_template = "djangocms_versioning/admin/mixin/change_list.html" versioning_list_display = ( - "get_author", - "get_modified_date", - "get_versioning_state", - ) + "get_author", + "get_modified_date", + "get_versioning_state", + ) class Media: js = ("admin/js/jquery.init.js", "djangocms_versioning/js/actions.js") diff --git a/djangocms_versioning/helpers.py b/djangocms_versioning/helpers.py index e4ab6c99..7e400642 100644 --- a/djangocms_versioning/helpers.py +++ b/djangocms_versioning/helpers.py @@ -305,15 +305,13 @@ def get_latest_admin_viewable_content(grouper, include_unpublished_archived=Fals versionable = versionables.for_grouper(grouper) # Check if all required grouping fields are given to be able to select the latest admin viewable content - # It is essential to missing_fields = [field for field in versionable.extra_grouping_fields if field not in extra_grouping_fields] if missing_fields: raise ValueError(f"Grouping field(s) {missing_fields} required for {versionable.grouper_model}.") - if field not in extra_grouping_fields: # pragma: no cover - raise ValueError(f"Grouping field {field} required for {versionable.grouper_model}.") # Get the name of the content_set (e.g., "pagecontent_set") from the versionable content_set = versionable.grouper_field.remote_field.get_accessor_name() + # Accessing the content set through the grouper preserves prefetches qs = getattr(grouper, content_set)(manager="admin_manager") From 6405b455260f8c02dbc3c43ab0354bd3b9a0e4ad Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 2 Mar 2023 20:59:04 +0100 Subject: [PATCH 39/48] Update tests/test_indicators.py Co-authored-by: Andrew Aikman --- tests/test_indicators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_indicators.py b/tests/test_indicators.py index 8df6909c..4226bba6 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -81,7 +81,7 @@ def test_latest_admin_viewable_content(self): class TestVersionState(CMSTestCase): def test_page_indicators(self): - """Tests if the page content indicators render correctly""" + """The page content indicators render correctly""" page = PageFactory(node__depth=1) version1 = PageVersionFactory( content__page=page, From e7bea5c6044f0eda6e378e86f1b6732f569040c9 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 2 Mar 2023 20:59:25 +0100 Subject: [PATCH 40/48] Update tests/test_indicators.py Co-authored-by: Andrew Aikman --- tests/test_indicators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_indicators.py b/tests/test_indicators.py index 4226bba6..164614b6 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -194,7 +194,7 @@ def test_mixin_facory_media(self): self.assertIn("indicators.js", str(admin.media)) def test_mixin_factory(self): - """Test if IndicatorMixin causes the indicators to be rendered""" + """The IndicatorMixin causes the indicators to be rendered""" blogpost = BlogPostFactory() content = BlogContentFactory( blogpost=blogpost From 62e2cb14ee9d4b62aa1929810ed20666ff6dbc3f Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 2 Mar 2023 20:59:41 +0100 Subject: [PATCH 41/48] Update tests/test_indicators.py Co-authored-by: Andrew Aikman --- tests/test_indicators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_indicators.py b/tests/test_indicators.py index 164614b6..82e7e419 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -205,7 +205,7 @@ def test_mixin_factory(self): changelist = admin_reverse("blogpost_blogcontent_changelist") with self.login_user_context(self.get_superuser()): - # New page ahs draft version, nothing else + # New page has draft version, nothing else response = self.client.get(changelist) # Status indicator available? self.assertContains(response, "cms-pagetree-node-state-draft") From b2ce48c87045eddad9b3e77e6c9252b43e766af2 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 2 Mar 2023 21:34:16 +0100 Subject: [PATCH 42/48] Update tests/test_admin.py Co-authored-by: Andrew Aikman --- tests/test_admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_admin.py b/tests/test_admin.py index 40850276..a4e93624 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -2858,7 +2858,7 @@ def test_valid_back_link(self): self.assertNotContains(response, version_list_url(version.content)) def test_fake_back_link(self): - """Test if the discard view upon get request denies replacing the link for the back button with + """The discard view upon get request denies replacing the link for the back button with an invalid link given by back query parameter""" blogpost = BlogPostFactory() content = BlogContentFactory( From 9eabf144516dfec58e76e77a08efb6c732aef90d Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 2 Mar 2023 21:35:17 +0100 Subject: [PATCH 43/48] Move indicator names to constants, add tests for versionables module --- djangocms_versioning/admin.py | 8 ++--- djangocms_versioning/cms_config.py | 3 +- djangocms_versioning/constants.py | 9 +++++ djangocms_versioning/indicators.py | 10 ------ tests/test_versionables.py | 55 ++++++++++++++++++++++++++++++ 5 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 tests/test_versionables.py diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 91cc9565..51d8a90d 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -26,7 +26,7 @@ from . import indicators, versionables from .conf import USERNAME_FIELD -from .constants import DRAFT, PUBLISHED +from .constants import DRAFT, INDICATOR_DESCRIPTIONS, PUBLISHED from .exceptions import ConditionFailed from .forms import grouper_form_factory from .helpers import ( @@ -168,7 +168,7 @@ def indicator(obj): "admin/djangocms_versioning/indicator.html", { "state": status or "empty", - "description": indicators.indicator_description.get(status, _("Empty")), + "description": INDICATOR_DESCRIPTIONS.get(status, _("Empty")), "menu_template": "admin/cms/page/tree/indicator_menu.html", "menu": json.dumps(render_to_string("admin/cms/page/tree/indicator_menu.html", dict(indicator_menu_items=menu))) if menu else None, @@ -185,12 +185,12 @@ def state_indicator(self, obj): ) # pragma: no cover def get_list_display(self, request): - """Default behavior: replaces the text "indicator" by the indicator column""" + """Default behavior: replaces the text "state_indicator" by the indicator column""" if versionables.exists_for_content(self.model) or versionables.exists_for_grouper(self.model): return tuple(self.get_indicator_column(request) if item == "state_indicator" else item for item in super().get_list_display(request)) else: - # remove "indicator" entry + # remove "state_indicator" entry return tuple(item for item in super().get_list_display(request) if item != "state_indicator") diff --git a/djangocms_versioning/cms_config.py b/djangocms_versioning/cms_config.py index efd5dab6..449ef283 100644 --- a/djangocms_versioning/cms_config.py +++ b/djangocms_versioning/cms_config.py @@ -21,6 +21,7 @@ from . import indicators, versionables from .admin import VersioningAdminMixin +from .constants import INDICATOR_DESCRIPTIONS from .datastructures import BaseVersionableItem, VersionableItem from .exceptions import ConditionFailed from .helpers import ( @@ -365,7 +366,7 @@ def change_innavigation(self, request, object_id): @property def indicator_descriptions(self): """Publish indicator description to CMSPageAdmin""" - return indicators.indicator_description + return INDICATOR_DESCRIPTIONS @classmethod def get_indicator_menu(cls, request, page_content): diff --git a/djangocms_versioning/constants.py b/djangocms_versioning/constants.py index 458bad5d..4b05dab2 100644 --- a/djangocms_versioning/constants.py +++ b/djangocms_versioning/constants.py @@ -18,3 +18,12 @@ OPERATION_DRAFT = "operation_draft" OPERATION_PUBLISH = "operation_publish" OPERATION_UNPUBLISH = "operation_unpublish" + +INDICATOR_DESCRIPTIONS = { + "published": _("Published"), + "dirty": _("Changed"), + "draft": _("Draft"), + "unpublished": _("Unpublished"), + "archived": _("Archived"), + "empty": _("Empty"), +} diff --git a/djangocms_versioning/indicators.py b/djangocms_versioning/indicators.py index 7e7872e5..d9558aa6 100644 --- a/djangocms_versioning/indicators.py +++ b/djangocms_versioning/indicators.py @@ -14,16 +14,6 @@ from djangocms_versioning.models import Version -indicator_description = { - "published": _("Published"), - "dirty": _("Changed"), - "draft": _("Draft"), - "unpublished": _("Unpublished"), - "archived": _("Archived"), - "empty": _("Empty"), -} - - def _reverse_action(version, action, back=None): get_params = f"?{urlencode(dict(back=back))}" if back else "" return admin_reverse( diff --git a/tests/test_versionables.py b/tests/test_versionables.py new file mode 100644 index 00000000..fc34d424 --- /dev/null +++ b/tests/test_versionables.py @@ -0,0 +1,55 @@ +from cms.test_utils.testcases import CMSTestCase + +from djangocms_versioning import versionables + + +class VersionableTestCase(CMSTestCase): + def test_exists_functions_for_models(self): + """With the example of the poll app test if versionables exists for models""" + from djangocms_versioning.test_utils.polls.models import Poll, PollContent + + # Check existence + self.assertTrue(versionables.exists_for_grouper(Poll)) + self.assertTrue(versionables.exists_for_content(PollContent)) + + # Check absence + self.assertFalse(versionables.exists_for_grouper(PollContent)) + self.assertFalse(versionables.exists_for_content(Poll)) + + def test_exists_functions_for_objects(self): + """With the example of the poll app test if versionables exists for objects""" + from djangocms_versioning.test_utils.factories import PollFactory, PollContentFactory + + poll = PollFactory() + poll_content = PollContentFactory(poll=poll) + + # Check existence + self.assertTrue(versionables.exists_for_grouper(poll)) + self.assertTrue(versionables.exists_for_content(poll_content)) + + # Check absence + self.assertFalse(versionables.exists_for_grouper(poll_content)) + self.assertFalse(versionables.exists_for_content(poll)) + + def test_get_versionable(self): + """With the example of the poll app test if versionables for grouper and content models are the same. + The versionable correctly identfies the content model.""" + from djangocms_versioning.test_utils.polls.models import Poll, PollContent + + v1 = versionables.for_grouper(Poll) + v2 = versionables.for_content(PollContent) + + self.assertEqual(v1, v2) # Those are supposed to return the same versionable + self.assertEqual(v1.content_model, PollContent) # PollContent should be the content model + + def test_get_versionable_fails_on_unversioned_models(self): + from djangocms_versioning.test_utils.text.models import Text + + # Versionables do not exists + self.assertFalse(versionables.exists_for_grouper(Text)) + self.assertFalse(versionables.exists_for_content(Text)) + + # Trying to get them raises error + self.assertRaises(KeyError, lambda: versionables.for_grouper(Text)) + self.assertRaises(KeyError, lambda: versionables.for_content(Text)) + From c379d74287d47bea86d4e06fa3bf618ede3d9175 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 2 Mar 2023 21:41:22 +0100 Subject: [PATCH 44/48] fix flake8 --- tests/test_versionables.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_versionables.py b/tests/test_versionables.py index fc34d424..19e8a8cf 100644 --- a/tests/test_versionables.py +++ b/tests/test_versionables.py @@ -52,4 +52,3 @@ def test_get_versionable_fails_on_unversioned_models(self): # Trying to get them raises error self.assertRaises(KeyError, lambda: versionables.for_grouper(Text)) self.assertRaises(KeyError, lambda: versionables.for_content(Text)) - From 50cc9e8c418d1e3ce6c2402fa7987b5366414472 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 2 Mar 2023 21:42:10 +0100 Subject: [PATCH 45/48] fix isort --- tests/test_versionables.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_versionables.py b/tests/test_versionables.py index 19e8a8cf..6ea57aa1 100644 --- a/tests/test_versionables.py +++ b/tests/test_versionables.py @@ -18,7 +18,10 @@ def test_exists_functions_for_models(self): def test_exists_functions_for_objects(self): """With the example of the poll app test if versionables exists for objects""" - from djangocms_versioning.test_utils.factories import PollFactory, PollContentFactory + from djangocms_versioning.test_utils.factories import ( + PollContentFactory, + PollFactory, + ) poll = PollFactory() poll_content = PollContentFactory(poll=poll) From 0c2baa753cd5a8ac5e5817bb241ac2de57e66cb3 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 2 Mar 2023 21:46:50 +0100 Subject: [PATCH 46/48] simpler imports --- djangocms_versioning/admin.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 51d8a90d..193524d5 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -24,7 +24,7 @@ from cms.utils.conf import get_cms_setting from cms.utils.urlutils import add_url_parameters, static_with_version -from . import indicators, versionables +from . import versionables from .conf import USERNAME_FIELD from .constants import DRAFT, INDICATOR_DESCRIPTIONS, PUBLISHED from .exceptions import ConditionFailed @@ -37,6 +37,7 @@ proxy_model, version_list_url, ) +from .indicators import content_indicator, content_indicator_menu from .models import Version from .versionables import _cms_extension @@ -157,8 +158,8 @@ def indicator(obj): }) else: # Content Model content_obj = obj - status = indicators.content_indicator(content_obj) - menu = indicators.content_indicator_menu( + status = content_indicator(content_obj) + menu = content_indicator_menu( request, status, content_obj._version, From ca3094803ad752de5f0752591628db33a183efe7 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 6 Mar 2023 10:39:04 +0100 Subject: [PATCH 47/48] Fix: `get_{field}_from_request` now needs to be present in model admin --- djangocms_versioning/admin.py | 5 ++- tests/test_admin.py | 22 ++++++++++- tests/test_indicators.py | 74 ++++++++++++++++++----------------- 3 files changed, 63 insertions(+), 38 deletions(-) diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index 193524d5..c7b437c7 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -57,10 +57,11 @@ def get_queryset(self, request): Admins requiring extra grouping field beside "language" need to implement the "get__from_request" method themselves. A common way to select the field might be GET or POST parameters or user-related settings. """ + grouping_filters = {} for field in versionable.extra_grouping_fields: - if hasattr(self, f"get_{field}_from_request"): - grouping_filters[field] = getattr(self, f"get_{field}_from_request")(request) # pragma: no cover + if hasattr(self.model_admin, f"get_{field}_from_request"): + grouping_filters[field] = getattr(self.model_admin, f"get_{field}_from_request")(request) elif field == "language": grouping_filters[field] = get_language_from_request(request) return queryset.filter(pk__in=versionable.distinct_groupers(**grouping_filters)) diff --git a/tests/test_admin.py b/tests/test_admin.py index a4e93624..c50ccc4e 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -22,6 +22,7 @@ from cms.test_utils.testcases import CMSTestCase from cms.toolbar.utils import get_object_edit_url, get_object_preview_url +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 admin_reverse @@ -280,7 +281,7 @@ def test_records_filtering_is_generic(self): ) def test_default_changelist_view_language_on_polls_with_language_content(self): - """A multi lingual model shows the correct values when + """A multilingual model shows the correct values when language filters / additional grouping values are set using the default content changelist overriden by VersioningChangeListMixin """ @@ -302,6 +303,25 @@ def test_default_changelist_view_language_on_polls_with_language_content(self): self.assertEqual(1, fr_response.context["cl"].queryset.count()) self.assertEqual(fr_version1.content, fr_response.context["cl"].queryset.first()) + def test_additional_grouping_fields_got_from_admin_method(self): + """If the admin has a method called ``get_{field}_from_request`` this method + is called to get the additional grouping field ``field``""" + + from djangocms_versioning.test_utils.polls.admin import PollContentAdmin + + PollContentAdmin.get_language_from_request = lambda self, request: get_language_from_request(request) + + changelist_url = self.get_admin_url(PollContent, "changelist") + poll = factories.PollFactory() + factories.PollVersionFactory(content__poll=poll, content__language="en") + + patch_string = "djangocms_versioning.test_utils.polls.admin.PollContentAdmin.get_language_from_request" + with patch(patch_string) as mock: + with self.login_user_context(self.get_superuser()): + self.client.get(changelist_url, {"language": "en"}) + + mock.assert_called() + class AdminRegisterVersionTestCase(CMSTestCase): def test_register_version_admin(self): diff --git a/tests/test_indicators.py b/tests/test_indicators.py index 82e7e419..a4b096f1 100644 --- a/tests/test_indicators.py +++ b/tests/test_indicators.py @@ -15,68 +15,72 @@ class TestLatestAdminViewable(CMSTestCase): - def test_extra_grouping_fields(self): - page = PageFactory(node__depth=1) - version = PageVersionFactory( - content__page=page, + + def setUp(self) -> None: + """Creates a page, page content and a version object for the following tests""" + self.page = PageFactory() + self.version = PageVersionFactory( + content__page=self.page, content__language="en", ) + def test_extra_grouping_fields(self): # Test 1: Try getting content w/o language grouping field => needs to fail - self.assertRaises(ValueError, lambda: get_latest_admin_viewable_content(page)) # no language grouper + self.assertRaises(ValueError, lambda: get_latest_admin_viewable_content(self.page)) # no language grouper # Test 2: Try getting content w/ langauge grouping field => needs to succeed - content = get_latest_admin_viewable_content(page, language="en") # OK - self.assertEqual(content.versions.first(), version) - - def test_latest_admin_viewable_content(self): - """The page content indicators render correctly""" - page = PageFactory(node__depth=1) - version1 = PageVersionFactory( - content__page=page, - content__language="en", - ) + content = get_latest_admin_viewable_content(self.page, language="en") # OK + self.assertEqual(content.versions.first(), self.version) + def test_latest_admin_viewable_draft(self): # New page has draft version, nothing else: latest_admin_viewable_content is draft - content = get_latest_admin_viewable_content(page, language="en") - self.assertEqual(content.versions.first(), version1) + content = get_latest_admin_viewable_content(self.page, language="en") + self.assertEqual(content.versions.first(), self.version) - # Now archive - version1.archive(user=self.get_superuser()) + def test_latest_admin_viewable_archive(self): + # First archive draft + self.version.archive(user=self.get_superuser()) # Archived version, nothing else: latest_admin_viewable_content is empty - content = get_latest_admin_viewable_content(page, include_unpublished_archived=False, language="en") + content = get_latest_admin_viewable_content(self.page, include_unpublished_archived=False, language="en") self.assertIsNone(content) # Archived version, nothing else: latest_admin_viewable_content is empty - content = get_latest_admin_viewable_content(page, include_unpublished_archived=True, language="en") - self.assertEqual(content.versions.first(), version1) + content = get_latest_admin_viewable_content(self.page, include_unpublished_archived=True, language="en") + self.assertEqual(content.versions.first(), self.version) + def test_latest_admin_viewable_published(self): # Now revert and publish => latest content is published - version2 = version1.copy(created_by=self.get_superuser()) + self.version.archive(user=self.get_superuser()) + version2 = self.version.copy(created_by=self.get_superuser()) version2.publish(user=self.get_superuser()) # Published version is always viewable - content = get_latest_admin_viewable_content(page, include_unpublished_archived=False, language="en") + content = get_latest_admin_viewable_content(self.page, include_unpublished_archived=False, language="en") self.assertEqual(content.versions.first(), version2) # Published version is always viewable - content = get_latest_admin_viewable_content(page, include_unpublished_archived=True, language="en") + content = get_latest_admin_viewable_content(self.page, include_unpublished_archived=True, language="en") self.assertEqual(content.versions.first(), version2) + def test_latest_admin_viewable_draft_on_top_of_published(self): # Now create a draft on top of published -> latest_admin_viewable content will be draft - version3 = version2.copy(created_by=self.get_superuser()) + self.version.publish(user=self.get_superuser()) + version2 = self.version.copy(created_by=self.get_superuser()) # Draft version is shadows published version - content = get_latest_admin_viewable_content(page, include_unpublished_archived=False, language="en") - self.assertEqual(content.versions.first(), version3) + content = get_latest_admin_viewable_content(self.page, include_unpublished_archived=False, language="en") + self.assertEqual(content.versions.first(), version2) # Draft version is shadows published version - content = get_latest_admin_viewable_content(page, include_unpublished_archived=True, language="en") - self.assertEqual(content.versions.first(), version3) + content = get_latest_admin_viewable_content(self.page, include_unpublished_archived=True, language="en") + self.assertEqual(content.versions.first(), version2) + def test_latest_admin_viewable_archive_on_top_of_published(self): # Archive draft, with published version available - version3.archive(user=self.get_superuser()) + self.version.publish(user=self.get_superuser()) + version2 = self.version.copy(created_by=self.get_superuser()) + version2.archive(user=self.get_superuser()) # Published version now is the latest version - content = get_latest_admin_viewable_content(page, include_unpublished_archived=False, language="en") - self.assertEqual(content.versions.first(), version2) + content = get_latest_admin_viewable_content(self.page, include_unpublished_archived=False, language="en") + self.assertEqual(content.versions.first(), self.version) # Published version now is the latest version even when including archived - content = get_latest_admin_viewable_content(page, include_unpublished_archived=True, language="en") - self.assertEqual(content.versions.first(), version2) + content = get_latest_admin_viewable_content(self.page, include_unpublished_archived=True, language="en") + self.assertEqual(content.versions.first(), self.version) class TestVersionState(CMSTestCase): From f5c5fcbae0618d6d112e87face855ce383d7469e Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Sat, 11 Mar 2023 09:30:49 +0100 Subject: [PATCH 48/48] fix 2 typos --- djangocms_versioning/admin.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/djangocms_versioning/admin.py b/djangocms_versioning/admin.py index c7b437c7..db22c848 100644 --- a/djangocms_versioning/admin.py +++ b/djangocms_versioning/admin.py @@ -129,7 +129,7 @@ def has_change_permission(self, request, obj=None): class StateIndicatorMixin(metaclass=MediaDefiningClass): - """Mixin to provide indicator column to the changelist view of a content model admin. Usage:: + """Mixin to provide state_indicator column to the changelist view of a content model admin. Usage:: class MyContentModelAdmin(StateIndicatorMixin, admin.ModelAdmin): list_display = [..., "state_indicator", ...] @@ -181,7 +181,7 @@ def indicator(obj): def state_indicator(self, obj): raise ValueError( - "ModelAdmin.display_list contains \"indicator\" as a placeholder for status indicators. " + "ModelAdmin.display_list contains \"state_indicator\" as a placeholder for status indicators. " "Status indicators, however, are not loaded. If you implement \"get_list_display\" make " "sure it calls super().get_list_display." ) # pragma: no cover