-
Notifications
You must be signed in to change notification settings - Fork 29
/
cms_config.py
404 lines (352 loc) · 17.1 KB
/
cms_config.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
import collections
from cms.app_base import CMSAppConfig, CMSAppExtension
from cms.extensions.models import BaseExtension
from cms.models import PageContent, Placeholder
from cms.utils import get_language_from_request
from cms.utils.i18n import get_language_list, get_language_tuple
from cms.utils.plugins import copy_plugins_to_placeholder
from cms.utils.urlutils import admin_reverse
from django.conf import settings
from django.contrib.admin.utils import flatten_fieldsets
from django.core.exceptions import (
ImproperlyConfigured,
ObjectDoesNotExist,
PermissionDenied,
)
from django.http import (
HttpResponse,
HttpResponseBadRequest,
HttpResponseForbidden,
)
from django.utils.encoding import force_str
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
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 (
get_latest_admin_viewable_content,
inject_generic_relation_to_version,
is_editable,
register_versionadmin_proxy,
replace_admin_for_models,
replace_manager,
)
from .managers import AdminManagerMixin, PublishedContentManagerMixin
from .models import Version
from .plugin_rendering import CMSToolbarVersioningMixin
class VersioningCMSExtension(CMSAppExtension):
def __init__(self):
self.versionables = []
self.add_to_context = {}
self.add_to_field_extension = {}
@cached_property
def versionables_by_content(self):
"""Returns a dict of {content_model_cls: VersionableItem obj}"""
return {
versionable.content_model: versionable for versionable in self.versionables
}
def is_content_model_versioned(self, content_model):
"""Returns if the content model is registered for versioning.
"""
return content_model in self.versionables_by_content
@cached_property
def versionables_by_grouper(self):
"""Returns a dict of {grouper_model_cls: VersionableItem obj}"""
return {
versionable.grouper_model: versionable
for versionable in self.versionables
# TODO: Comment on/document why this is here
if versionable.concrete
}
def is_grouper_model_versioned(self, grouper_model):
"""Returns if the grouper model has been registered for versioning"""
return grouper_model in self.versionables_by_grouper
def handle_versioning_setting(self, cms_config):
"""Check the versioning setting has been correctly set
and add it to the masterlist if all is ok
"""
# First check that versioning is correctly defined
if not isinstance(cms_config.versioning, collections.abc.Iterable):
raise ImproperlyConfigured("versioning not defined as an iterable")
for versionable in cms_config.versioning:
if not isinstance(versionable, BaseVersionableItem):
raise ImproperlyConfigured(
f"{versionable!r} is not a subclass of djangocms_versioning.datastructures.BaseVersionableItem"
)
# NOTE: Do not use the cached property here as this is
# still changing and needs to be calculated on the fly
registered_so_far = [v.content_model for v in self.versionables]
if versionable.content_model in registered_so_far:
raise ImproperlyConfigured(
f"{versionable.content_model!r} has already been registered"
)
# Checks passed. Add versionable to our master list
self.versionables.append(versionable)
def handle_versioning_add_to_confirmation_context_setting(self, cms_config):
"""
Check versioning_add_to_confirmation_context has been correctly set
and add it to the master dict if all is ok
"""
add_to_context = cms_config.versioning_add_to_confirmation_context
supported_keys = ["unpublish"]
for key, value in add_to_context.items():
if key not in supported_keys:
raise ImproperlyConfigured(
f"{key!r} is not a supported dict key in the versioning_add_to_confirmation_context setting"
)
if key not in self.add_to_context:
self.add_to_context[key] = collections.OrderedDict()
self.add_to_context[key].update(value)
def handle_admin_classes(self, cms_config):
"""Replaces admin model classes for all registered content types
with an admin model class that inherits from `versionable.content_admin_mixin`.
"""
replace_admin_for_models(
[
(versionable.content_model, versionable.content_admin_mixin)
for versionable in cms_config.versioning
]
)
def handle_version_admin(self, cms_config):
"""
Registers version admin for all registered content types
with filtering by content type applied, so only versions for
that specific content type are shown.
"""
for versionable in cms_config.versioning:
if versionable.concrete:
register_versionadmin_proxy(versionable)
def handle_content_model_generic_relation(self, cms_config):
"""Adds `versions` GenericRelation field to all provided
content models.
"""
for versionable in cms_config.versioning:
inject_generic_relation_to_version(versionable.content_model)
def handle_content_model_manager(self, cms_config):
"""Replaces default manager in provided content models with
one inheriting from PublishedContentManagerMixin.
"""
for versionable in cms_config.versioning:
replace_manager(versionable.content_model, "objects", PublishedContentManagerMixin)
replace_manager(versionable.content_model, "admin_manager", AdminManagerMixin,
_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
"""
extended_admin_field_modifiers = getattr(cms_config, "extended_admin_field_modifiers", None)
if not isinstance(extended_admin_field_modifiers, list):
raise ImproperlyConfigured("extended_admin_field_modifiers must be list of dictionaries")
for modifier in extended_admin_field_modifiers:
for key in modifier.keys():
self.add_to_field_extension[key] = modifier[key]
def configure_app(self, cms_config):
if hasattr(cms_config, "extended_admin_field_modifiers"):
self.handle_admin_field_modifiers(cms_config)
# Validation to ensure either the versioning or the
# versioning_add_to_confirmation_context config has been defined
has_extra_context = hasattr(
cms_config, "versioning_add_to_confirmation_context"
)
has_models_to_register = hasattr(cms_config, "versioning")
if not has_extra_context and not has_models_to_register:
raise ImproperlyConfigured(
"The versioning or versioning_add_to_confirmation_context setting must be defined"
)
# No exception raised so now configure based on those settings
if has_extra_context:
self.handle_versioning_add_to_confirmation_context_setting(cms_config)
if has_models_to_register:
self.handle_versioning_setting(cms_config)
self.handle_admin_classes(cms_config)
self.handle_version_admin(cms_config)
self.handle_content_model_generic_relation(cms_config)
self.handle_content_model_manager(cms_config)
def copy_page_content(original_content):
"""Copy the PageContent object and deepcopy its
placeholders and plugins.
"""
# Copy content object
content_fields = {
field.name: getattr(original_content, field.name)
for field in PageContent._meta.fields
# Don't copy the pk as we're creating a new obj.
# The creation date should reflect the date it was copied on,
# so don't copy that either.
if field.name not in (PageContent._meta.pk.name, "creation_date")
}
# Use original manager to not create a new Version object here
new_content = PageContent._original_manager.create(**content_fields)
# Copy placeholders
new_placeholders = []
for placeholder in original_content.placeholders.all():
placeholder_fields = {
field.name: getattr(placeholder, field.name)
for field in Placeholder._meta.fields
# don't copy primary key because we're creating a new obj
# and handle the source field later
if field.name not in [Placeholder._meta.pk.name, "source"]
}
if placeholder.source:
placeholder_fields["source"] = new_content
new_placeholder = Placeholder.objects.create(**placeholder_fields)
# Copy plugins
placeholder.copy_plugins(new_placeholder)
new_placeholders.append(new_placeholder)
new_content.placeholders.add(*new_placeholders)
# If pagecontent has an associated content or page extension, also copy this!
for field in PageContent._meta.related_objects:
if hasattr(original_content, field.name):
extension = getattr(original_content, field.name)
if isinstance(extension, BaseExtension):
extension.copy(new_content, new_content.language)
return new_content
def label_from_instance(obj, language):
"""
Override the label for each grouper select option
"""
title = obj.get_title(language) or _("No available title")
path = obj.get_path(language)
path = f"/{path}/" if path else _("Unpublished")
return f"{title} ({path})"
def on_page_content_publish(version):
"""Url path and cache operations to do when a PageContent obj is published"""
page = version.content.page
language = version.content.language
page._update_url_path(language)
if page.is_home:
page._remove_title_root_path()
page._update_url_path_recursive(language)
page.clear_cache(menu=True)
def on_page_content_unpublish(version):
"""Url path and cache operations to do when a PageContent obj is unpublished"""
page = version.content.page
language = version.content.language
page._update_url_path_recursive(language)
page.clear_cache(menu=True)
def on_page_content_draft_create(version):
"""Clear cache when a new PageContent draft is created."""
page = version.content.page
page.clear_cache(menu=True)
def on_page_content_archive(version):
"""Clear cache when a new PageContent version is archived."""
page = version.content.page
page.clear_cache(menu=True)
class VersioningCMSPageAdminMixin(VersioningAdminMixin):
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj)
if obj:
version = Version.objects.get_for_content(obj)
if not version.check_modify.as_bool(request.user):
form = self.get_form_class(request)
if form.fieldsets:
fields = flatten_fieldsets(form.fieldsets)
fields = list(fields)
for f_name in ["slug", "overwrite_url"]:
fields.remove(f_name)
return fields
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
if obj:
version = Version.objects.get_for_content(obj)
if not version.check_modify.as_bool(request.user):
for f_name in ["slug", "overwrite_url"]:
form.declared_fields[f_name].widget.attrs["readonly"] = True
return form
def get_queryset(self, request):
urls = ("cms_pagecontent_get_tree",)
queryset = super().get_queryset(request)
if request.resolver_match.url_name in urls:
versionable = versionables.for_content(queryset.model)
# TODO: Improve the grouping filters to use anything defined in the
# apps versioning config extra_grouping_fields
grouping_filters = {}
if "language" in versionable.extra_grouping_fields:
grouping_filters["language"] = get_language_from_request(request)
return queryset.filter(pk__in=versionable.distinct_groupers(**grouping_filters))
return queryset
# CAVEAT:
# - PageContent contains the template, this can differ for each language,
# it is assumed that templates would be the same when copying from one language to another
# FIXME: The long term solution will require knowing:
# - why this view is an ajax call
# - where it should live going forwards (cms vs versioning)
# - A better way of making the feature extensible / modifiable for versioning
def copy_language(self, request, object_id):
target_language = request.POST.get("target_language")
# CAVEAT: Avoiding self.get_object because it sets the page cache,
# We don't want a draft showing to a regular site visitor!
# source_page_content = self.get_object(request, object_id=object_id)
source_page_content = PageContent._original_manager.get(pk=object_id)
if source_page_content is None:
raise self._get_404_exception(object_id)
page = source_page_content.page
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_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):
raise PermissionDenied
for placeholder in source_page_content.get_placeholders():
# Try and get a matching placeholder, only if it exists
try:
target = target_page_content.get_placeholders().get(slot=placeholder.slot)
except ObjectDoesNotExist:
continue
plugins = placeholder.get_plugins_list(source_page_content.language)
if not target.has_add_plugins_permission(request.user, plugins):
return HttpResponseForbidden(force_str(_("You do not have permission to copy these plugins.")))
copy_plugins_to_placeholder(plugins, target, language=target_language)
return HttpResponse("ok")
def change_innavigation(self, request, object_id):
page_content = self.get_object(request, object_id=object_id)
version = Version.objects.get_for_content(page_content)
try:
version.check_modify(request.user)
except ConditionFailed as e:
# Send error message
return HttpResponseForbidden(force_str(e))
return super().change_innavigation(request, object_id)
@property
def indicator_descriptions(self):
"""Publish indicator description to CMSPageAdmin"""
return INDICATOR_DESCRIPTIONS
@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": # 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')}"
menu = indicators.content_indicator_menu(request, status, versions, back=back)
return menu_template if menu else "", menu
class VersioningCMSConfig(CMSAppConfig):
"""Implement versioning for core cms models
"""
cms_enabled = True
djangocms_versioning_enabled = getattr(
settings, "VERSIONING_CMS_MODELS_ENABLED", True
)
versioning = [
VersionableItem(
content_model=PageContent,
grouper_field_name="page",
extra_grouping_fields=["language"],
version_list_filter_lookups={"language": get_language_tuple},
copy_function=copy_page_content,
grouper_selector_option_label=label_from_instance,
on_publish=on_page_content_publish,
on_unpublish=on_page_content_unpublish,
on_draft_create=on_page_content_draft_create,
on_archive=on_page_content_archive,
content_admin_mixin=VersioningCMSPageAdminMixin,
)
]
cms_toolbar_mixin = CMSToolbarVersioningMixin
PageContent.add_to_class("is_editable", is_editable)
PageContent.add_to_class("content_indicator", indicators.content_indicator)