Skip to content

Commit 1e8a48c

Browse files
fsbraunAiky30
andauthored
feat: allow reuse of status indicators (#319)
* Allow page indicators for any versioned model * MySQL compatibility * Fix queryset initialization * fix isort * Generalize get_latest_admin_viewable_page_content * fix flake8 * Fix: grouper can be model or instance * fix: identify correct inverse relation * Remove IndicatorMixin * Add tests * no message * More flexible list_display option * Improve back functionality of get views * Fix isort * Add one more test, fix doc inconsistency * fix coverage * Let get_list_display return tuple * Fix isort * Fix test bugs * Remove empty line * Fix: Add MediaDefiningClass meta classes * Refactor for more consistent api * Update tests * Fix 2 missing renames * Remove spourious import cycle * fix: isort * Update djangocms_versioning/helpers.py Co-authored-by: Andrew Aikman <[email protected]> * Update djangocms_versioning/helpers.py Co-authored-by: Andrew Aikman <[email protected]> * Update docs/versioning_integration.rst Co-authored-by: Andrew Aikman <[email protected]> * Consistent labels for "discard changes" * Add more tests * Update release notes * Fix: Clarify docs (page tree as example) * Update docs * Update djangocms_versioning/helpers.py Co-authored-by: Andrew Aikman <[email protected]> * Update tests/test_admin.py Co-authored-by: Andrew Aikman <[email protected]> * Update tests/test_indicators.py Co-authored-by: Andrew Aikman <[email protected]> * fix indentation * Update tests/test_indicators.py Co-authored-by: Andrew Aikman <[email protected]> * Update tests/test_indicators.py Co-authored-by: Andrew Aikman <[email protected]> * Update tests/test_indicators.py Co-authored-by: Andrew Aikman <[email protected]> * Update tests/test_admin.py Co-authored-by: Andrew Aikman <[email protected]> * Move indicator names to constants, add tests for versionables module * fix flake8 * fix isort * simpler imports * Fix: `get_{field}_from_request` now needs to be present in model admin * fix 2 typos --------- Co-authored-by: Andrew Aikman <[email protected]>
1 parent 7e41d8a commit 1e8a48c

File tree

21 files changed

+831
-194
lines changed

21 files changed

+831
-194
lines changed

djangocms_versioning/admin.py

Lines changed: 126 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
from collections import OrderedDict
23
from urllib.parse import urlparse
34

@@ -8,6 +9,7 @@
89
from django.contrib.contenttypes.models import ContentType
910
from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist
1011
from django.db.models.functions import Lower
12+
from django.forms import MediaDefiningClass
1113
from django.http import Http404, HttpResponseNotAllowed
1214
from django.shortcuts import redirect, render
1315
from django.template.loader import render_to_string, select_template
@@ -24,16 +26,18 @@
2426

2527
from . import versionables
2628
from .conf import USERNAME_FIELD
27-
from .constants import DRAFT, PUBLISHED
29+
from .constants import DRAFT, INDICATOR_DESCRIPTIONS, PUBLISHED
2830
from .exceptions import ConditionFailed
2931
from .forms import grouper_form_factory
3032
from .helpers import (
3133
get_admin_url,
3234
get_editable_url,
35+
get_latest_admin_viewable_content,
3336
get_preview_url,
3437
proxy_model,
3538
version_list_url,
3639
)
40+
from .indicators import content_indicator, content_indicator_menu
3741
from .models import Version
3842
from .versionables import _cms_extension
3943

@@ -42,16 +46,24 @@ class VersioningChangeListMixin:
4246
"""Mixin used for ChangeList classes of content models."""
4347

4448
def get_queryset(self, request):
45-
"""Limit the content model queryset to latest versions only."""
49+
"""Limit the content model queryset to the latest versions only."""
4650
queryset = super().get_queryset(request)
4751
versionable = versionables.for_content(queryset.model)
4852

49-
# TODO: Improve the grouping filters to use anything defined in the
50-
# apps versioning config extra_grouping_fields
51-
grouping_filters = {}
52-
if 'language' in versionable.extra_grouping_fields:
53-
grouping_filters['language'] = get_language_from_request(request)
53+
"""Check if there is a method "self.get_<field>_from_request" for each extra grouping field.
54+
If so call it to retrieve the appropriate filter. If no method is found (except for "language")
55+
no filter is applied. For "language" the fallback is versioning's "get_language_frmo_request".
56+
57+
Admins requiring extra grouping field beside "language" need to implement the "get_<field>_from_request"
58+
method themselves. A common way to select the field might be GET or POST parameters or user-related settings.
59+
"""
5460

61+
grouping_filters = {}
62+
for field in versionable.extra_grouping_fields:
63+
if hasattr(self.model_admin, f"get_{field}_from_request"):
64+
grouping_filters[field] = getattr(self.model_admin, f"get_{field}_from_request")(request)
65+
elif field == "language":
66+
grouping_filters[field] = get_language_from_request(request)
5567
return queryset.filter(pk__in=versionable.distinct_groupers(**grouping_filters))
5668

5769

@@ -116,7 +128,75 @@ def has_change_permission(self, request, obj=None):
116128
return super().has_change_permission(request, obj)
117129

118130

119-
class ExtendedVersionAdminMixin(VersioningAdminMixin):
131+
class StateIndicatorMixin(metaclass=MediaDefiningClass):
132+
"""Mixin to provide state_indicator column to the changelist view of a content model admin. Usage::
133+
134+
class MyContentModelAdmin(StateIndicatorMixin, admin.ModelAdmin):
135+
list_display = [..., "state_indicator", ...]
136+
"""
137+
class Media:
138+
# js for the context menu
139+
js = ("admin/js/jquery.init.js", "djangocms_versioning/js/indicators.js",)
140+
# css for indicators and context menu
141+
css = {
142+
"all": (static_with_version("cms/css/cms.pagetree.css"),),
143+
}
144+
145+
indicator_column_label = _("State")
146+
147+
@property
148+
def _extra_grouping_fields(self):
149+
try:
150+
return versionables.for_grouper(self.model).extra_grouping_fields
151+
except KeyError:
152+
return None
153+
154+
def get_indicator_column(self, request):
155+
def indicator(obj):
156+
if self._extra_grouping_fields is not None: # Grouper Model
157+
content_obj = get_latest_admin_viewable_content(obj, include_unpublished_archived=True, **{
158+
field: getattr(self, field) for field in self._extra_grouping_fields
159+
})
160+
else: # Content Model
161+
content_obj = obj
162+
status = content_indicator(content_obj)
163+
menu = content_indicator_menu(
164+
request,
165+
status,
166+
content_obj._version,
167+
back=request.path_info + "?" + request.GET.urlencode(),
168+
) if status else None
169+
return render_to_string(
170+
"admin/djangocms_versioning/indicator.html",
171+
{
172+
"state": status or "empty",
173+
"description": INDICATOR_DESCRIPTIONS.get(status, _("Empty")),
174+
"menu_template": "admin/cms/page/tree/indicator_menu.html",
175+
"menu": json.dumps(render_to_string("admin/cms/page/tree/indicator_menu.html",
176+
dict(indicator_menu_items=menu))) if menu else None,
177+
}
178+
)
179+
indicator.short_description = self.indicator_column_label
180+
return indicator
181+
182+
def state_indicator(self, obj):
183+
raise ValueError(
184+
"ModelAdmin.display_list contains \"state_indicator\" as a placeholder for status indicators. "
185+
"Status indicators, however, are not loaded. If you implement \"get_list_display\" make "
186+
"sure it calls super().get_list_display."
187+
) # pragma: no cover
188+
189+
def get_list_display(self, request):
190+
"""Default behavior: replaces the text "state_indicator" by the indicator column"""
191+
if versionables.exists_for_content(self.model) or versionables.exists_for_grouper(self.model):
192+
return tuple(self.get_indicator_column(request) if item == "state_indicator" else item
193+
for item in super().get_list_display(request))
194+
else:
195+
# remove "state_indicator" entry
196+
return tuple(item for item in super().get_list_display(request) if item != "state_indicator")
197+
198+
199+
class ExtendedVersionAdminMixin(VersioningAdminMixin, metaclass=MediaDefiningClass):
120200
"""
121201
Extended VersionAdminMixin for common/generic versioning admin items
122202
@@ -125,6 +205,11 @@ class ExtendedVersionAdminMixin(VersioningAdminMixin):
125205
"""
126206

127207
change_list_template = "djangocms_versioning/admin/mixin/change_list.html"
208+
versioning_list_display = (
209+
"get_author",
210+
"get_modified_date",
211+
"get_versioning_state",
212+
)
128213

129214
class Media:
130215
js = ("admin/js/jquery.init.js", "djangocms_versioning/js/actions.js")
@@ -269,11 +354,14 @@ def get_list_actions(self):
269354
"""
270355
Collect rendered actions from implemented methods and return as list
271356
"""
272-
return [
357+
actions = [
273358
self._get_preview_link,
274359
self._get_edit_link,
275-
self._get_manage_versions_link,
276-
]
360+
]
361+
if "state_indicator" not in self.versioning_list_display:
362+
# State indicator mixin loaded?
363+
actions.append(self._get_manage_versions_link)
364+
return actions
277365

278366
def get_preview_link(self, obj):
279367
return format_html(
@@ -310,14 +398,9 @@ def extend_list_display(self, request, modifier_dict, list_display):
310398

311399
def get_list_display(self, request):
312400
# get configured list_display
313-
list_display = self.list_display
401+
list_display = super().get_list_display(request)
314402
# Add versioning information and action fields
315-
list_display += (
316-
"get_author",
317-
"get_modified_date",
318-
"get_versioning_state",
319-
self._list_actions(request)
320-
)
403+
list_display += self.versioning_list_display + (self._list_actions(request),)
321404
# Get the versioning extension
322405
extension = _cms_extension()
323406
modifier_dict = extension.add_to_field_extension.get(self.model, None)
@@ -326,6 +409,14 @@ def get_list_display(self, request):
326409
return list_display
327410

328411

412+
class ExtendedIndicatorVersionAdminMixin(StateIndicatorMixin, ExtendedVersionAdminMixin):
413+
versioning_list_display = (
414+
"get_author",
415+
"get_modified_date",
416+
"state_indicator",
417+
)
418+
419+
329420
class VersionChangeList(ChangeList):
330421
def get_filters_params(self, params=None):
331422
"""Removes the grouper param from the filters as the main grouper
@@ -697,7 +788,7 @@ def archive_view(self, request, object_id):
697788
),
698789
args=(version.content.pk,),
699790
),
700-
back_url=version_list_url(version.content),
791+
back_url=self.back_link(request, version),
701792
)
702793
return render(
703794
request, "djangocms_versioning/admin/archive_confirmation.html", context
@@ -777,7 +868,7 @@ def unpublish_view(self, request, object_id):
777868
),
778869
args=(version.content.pk,),
779870
),
780-
back_url=version_list_url(version.content),
871+
back_url=self.back_link(request, version),
781872
)
782873
extra_context = OrderedDict(
783874
[
@@ -891,7 +982,7 @@ def revert_view(self, request, object_id):
891982
),
892983
args=(version.content.pk,),
893984
),
894-
back_url=version_list_url(version.content),
985+
back_url=self.back_link(request, version),
895986
)
896987
return render(
897988
request, "djangocms_versioning/admin/revert_confirmation.html", context
@@ -933,7 +1024,7 @@ def discard_view(self, request, object_id):
9331024
),
9341025
args=(version.content.pk,),
9351026
),
936-
back_url=version_list_url(version.content),
1027+
back_url=self.back_link(request, version),
9371028
)
9381029
return render(
9391030
request, "djangocms_versioning/admin/discard_confirmation.html", context
@@ -969,14 +1060,6 @@ def compare_view(self, request, object_id):
9691060
),
9701061
**persist_params
9711062
)
972-
return_url = request.GET.get("back", version_list_url(v1.content))
973-
try:
974-
# Is return url a valid url?
975-
resolve(urlparse(return_url)[2])
976-
except Resolver404:
977-
# If not ignore
978-
return_url = None
979-
9801063
# Get the list of versions for the grouper. This is for use
9811064
# in the dropdown to choose a version.
9821065
version_list = Version.objects.filter_by_content_grouping_values(
@@ -987,7 +1070,7 @@ def compare_view(self, request, object_id):
9871070
"version_list": version_list,
9881071
"v1": v1,
9891072
"v1_preview_url": v1_preview_url,
990-
"return_url": return_url,
1073+
"return_url": self.back_link(request, v1),
9911074
}
9921075

9931076
# Now check if version 2 has been specified and add to context
@@ -1015,6 +1098,18 @@ def compare_view(self, request, object_id):
10151098
request, "djangocms_versioning/admin/compare.html", context
10161099
)
10171100

1101+
@staticmethod
1102+
def back_link(request, version=None):
1103+
back_url = request.GET.get("back", None)
1104+
if back_url:
1105+
try:
1106+
# Is return url a valid url?
1107+
resolve(urlparse(back_url)[2])
1108+
except Resolver404:
1109+
# If not ignore
1110+
back_url = None
1111+
return back_url or (version_list_url(version.content) if version else None)
1112+
10181113
def changelist_view(self, request, extra_context=None):
10191114
"""Handle grouper filtering on the changelist"""
10201115
if not request.GET:

djangocms_versioning/cms_config.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,15 @@
1717
from cms.utils import get_language_from_request
1818
from cms.utils.i18n import get_language_list, get_language_tuple
1919
from cms.utils.plugins import copy_plugins_to_placeholder
20+
from cms.utils.urlutils import admin_reverse
2021

2122
from . import indicators, versionables
2223
from .admin import VersioningAdminMixin
24+
from .constants import INDICATOR_DESCRIPTIONS
2325
from .datastructures import BaseVersionableItem, VersionableItem
2426
from .exceptions import ConditionFailed
2527
from .helpers import (
26-
get_latest_admin_viewable_page_content,
28+
get_latest_admin_viewable_content,
2729
inject_generic_relation_to_version,
2830
register_versionadmin_proxy,
2931
replace_admin_for_models,
@@ -143,7 +145,7 @@ def handle_content_model_manager(self, cms_config):
143145
for versionable in cms_config.versioning:
144146
replace_manager(versionable.content_model, "objects", PublishedContentManagerMixin)
145147
replace_manager(versionable.content_model, "admin_manager", AdminManagerMixin,
146-
_group_by_key=[versionable.grouper_field_name] + list(versionable.extra_grouping_fields))
148+
_group_by_key=list(versionable.grouping_fields))
147149

148150
def handle_admin_field_modifiers(self, cms_config):
149151
"""Allows for the transformation of a given field in the ExtendedVersionAdminMixin
@@ -270,7 +272,7 @@ def on_page_content_archive(version):
270272
page.clear_cache(menu=True)
271273

272274

273-
class VersioningCMSPageAdminMixin(indicators.IndicatorStatusMixin, VersioningAdminMixin):
275+
class VersioningCMSPageAdminMixin(VersioningAdminMixin):
274276
def get_readonly_fields(self, request, obj=None):
275277
fields = super().get_readonly_fields(request, obj)
276278
if obj:
@@ -331,7 +333,7 @@ def copy_language(self, request, object_id):
331333
if not target_language or target_language not in get_language_list(site_id=page.node.site_id):
332334
return HttpResponseBadRequest(force_str(_("Language must be set to a supported language!")))
333335

334-
target_page_content = get_latest_admin_viewable_page_content(page, target_language)
336+
target_page_content = get_latest_admin_viewable_content(page, language=target_language)
335337

336338
# First check that we are able to edit the target
337339
if not self.has_change_permission(request, obj=target_page_content):
@@ -361,6 +363,24 @@ def change_innavigation(self, request, object_id):
361363
return HttpResponseForbidden(force_str(e))
362364
return super().change_innavigation(request, object_id)
363365

366+
@property
367+
def indicator_descriptions(self):
368+
"""Publish indicator description to CMSPageAdmin"""
369+
return INDICATOR_DESCRIPTIONS
370+
371+
@classmethod
372+
def get_indicator_menu(cls, request, page_content):
373+
"""Get the indicator menu for PageContent object taking into account the
374+
currently available versions"""
375+
menu_template = "admin/cms/page/tree/indicator_menu.html"
376+
status = page_content.content_indicator()
377+
if not status or status == "empty": # pragma: no cover
378+
return super().get_indicator_menu(request, page_content)
379+
versions = page_content._version # Cache from .content_indicator()
380+
back = admin_reverse("cms_pagecontent_changelist") + f"?language={request.GET.get('language')}"
381+
menu = indicators.content_indicator_menu(request, status, versions, back=back)
382+
return menu_template if menu else "", menu
383+
364384

365385
class VersioningCMSConfig(CMSAppConfig):
366386
"""Implement versioning for core cms models

djangocms_versioning/cms_toolbars.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626

2727
from djangocms_versioning.constants import DRAFT, PUBLISHED
2828
from djangocms_versioning.helpers import (
29-
get_latest_admin_viewable_page_content,
29+
get_latest_admin_viewable_content,
3030
version_list_url,
3131
)
3232
from djangocms_versioning.models import Version
@@ -260,7 +260,7 @@ def get_page_content(self, language=None):
260260
if not language:
261261
language = self.current_lang
262262

263-
return get_latest_admin_viewable_page_content(self.page, language)
263+
return get_latest_admin_viewable_content(self.page, language=language)
264264

265265
def populate(self):
266266
self.page = self.request.current_page or getattr(self.toolbar.obj, "page", None)

djangocms_versioning/constants.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,12 @@
1818
OPERATION_DRAFT = "operation_draft"
1919
OPERATION_PUBLISH = "operation_publish"
2020
OPERATION_UNPUBLISH = "operation_unpublish"
21+
22+
INDICATOR_DESCRIPTIONS = {
23+
"published": _("Published"),
24+
"dirty": _("Changed"),
25+
"draft": _("Draft"),
26+
"unpublished": _("Unpublished"),
27+
"archived": _("Archived"),
28+
"empty": _("Empty"),
29+
}

0 commit comments

Comments
 (0)