8000 feat: Add `FrontendEditableAdminMixin` endpoint to plugins by fsbraun · Pull Request #8062 · django-cms/django-cms · GitHub
[go: up one dir, main page]

Skip to content

feat: Add FrontendEditableAdminMixin endpoint to plugins #8062

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Nov 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions cms/admin/pageadmin.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@
from cms.constants import MODAL_HTML_REDIRECT
from cms.models import (
CMSPlugin,
EmptyPageContent,
GlobalPagePermission,
Page,
PageContent,
Expand Down Expand Up @@ -170,6 +169,7 @@ def get_urls(self):
"""Get the admin urls
"""
info = f"{self.model._meta.app_label}_{self.model._meta.model_name}"

def pat(regex, fn):
return re_path(regex, self.admin_site.admin_view(fn), name=f'{info}_{fn.__name__}')

Expand Down Expand Up @@ -764,6 +764,7 @@ def get_urls(self):
"""Get the admin urls
"""
info = f"{self.model._meta.app_label}_{self.model._meta.model_name}"

def pat(regex, fn):
return re_path(regex, self.admin_site.admin_view(fn), name=f'{info}_{fn.__name__}')

Expand Down Expand Up @@ -917,7 +918,6 @@ def response_add(self, request, obj):
return HttpResponse(MODAL_HTML_REDIRECT.format(url=url))
return super().response_add(request, obj)


def get_filled_languages(self, request, page):
site_id = get_site(request).pk
filled_languages = page.get_languages()
Expand Down Expand Up @@ -1132,7 +1132,6 @@ def change_template(self, request, object_id):
if to_template not in (placeholder_set[0] for placeholder_set in get_cms_setting('PLACEHOLDERS')):
return HttpResponseBadRequest(_("Placeholder selection not valid"))


page_content.template = to_template
page_content.save()

Expand Down
117 changes: 75 additions & 42 deletions cms/admin/placeholderadmin.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from django.contrib.admin.helpers import AdminForm
from django.contrib.admin.utils import get_deleted_objects
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db import models, transaction
from django.http import (
HttpResponse,
HttpResponseBadRequest,
Expand All @@ -33,6 +33,7 @@
from cms.models.placeholdermodel import Placeholder
from cms.models.placeholderpluginmodel import PlaceholderReference
from cms.models.pluginmodel import CMSPlugin
from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
from cms.signals import post_placeholder_operation, pre_placeholder_operation
from cms.toolbar.utils import get_plugin_tree
Expand Down Expand Up @@ -80,72 +81,48 @@ def _instance_overrides_method(base, instance, method_name):
return unbound_method != bound_method


class FrontendEditableAdminMixin:
class BaseEditableAdminMixin:
"""
Adding ``FrontendEditableAdminMixin`` to models admin class allows to open that admin
in the frontend by double-clicking on fields rendered with the ``render_model`` template
tag.
Base class for FrontendEditableAdminMixin to be re-used by
PlaceholderAdmin
"""
frontend_editable_fields = []

def get_urls(self):
"""
Register the url for the single field edit view
"""
info = f"{self.model._meta.app_label}_{self.model._meta.model_name}"
def pat(regex, fn):
return re_path(regex, self.admin_site.admin_view(fn), name=f"{info}_{fn.__name__}")
url_patterns = [
pat(r'edit-field/(%s)/([a-z\-]+)/$' % SLUG_REGEXP, self.edit_field),
]
return url_patterns + super().get_urls()

def _get_object_for_single_field(self, object_id, language):
# Quick and dirty way to retrieve objects for django-hvad
# Cleaner implementation will extend this method in a child mixin
try:
# First see if the model uses the admin manager pattern from cms.models.manager.ContentAdminManager
manager = self.model.admin_manager
except AttributeError:
# If not, use the default manager
manager = self.model.objects
try:
return manager.language(language).get(pk=object_id)
except AttributeError:
return manager.get(pk=object_id)

@xframe_options_sameorigin
def edit_field(self, request, object_id, language):
"""Endpoint which manages frontend-editable fields"""
obj = self._get_object_for_single_field(object_id, language)
opts = obj.__class__._meta
saved_successfully = False
cancel_clicked = request.POST.get("_cancel", False)
raw_fields = request.GET.get("edit_fields")
fields = [field for field in raw_fields.split(",") if field in self.frontend_editable_fields]
admin_obj = self._get_model_admin(obj)
allowed_fields = getattr(admin_obj, "frontend_editable_fields", [])
fields = [field for field in raw_fields.split(",") if field in allowed_fields]
if not fields:
context = {
'opts': opts,
'message': _("Field %s not found") % raw_fields
}
return render(request, 'admin/cms/page/plugin/error_form.html', context)
if not request.user.has_perm(f"{self.model._meta.app_label}.change_{self.model._meta.model_name}"):
if not request.user.has_perm(f"{admin_obj.model._meta.app_label}.change_{admin_obj.model._meta.model_name}"):
context = {
'opts': opts,
'message': _("You do not have permission to edit this item")
}
return render(request, 'admin/cms/page/plugin/error_form.html', context)
# Dynamically creates the form class with only `field_name` field
# enabled
form_class = self.get_form(request, obj, fields=fields)
form_class = admin_obj.get_form(request, obj, fields=fields)
if not cancel_clicked and request.method == 'POST':
form = form_class(instance=obj, data=request.POST)
if form.is_valid():
form.save()
new_object = form.save(commit=False)
admin_obj.save_model(request, new_object, form, change=True) # Call save model like the admin does
saved_successfully = True
else:
form = form_class(instance=obj)
admin_form = AdminForm(form, fieldsets=[(None, {'fields': fields})], prepopulated_fields={},
model_admin=self)
media = self.media + admin_form.media
media = admin_obj.media + admin_form.media
context = {
'CMS_MEDIA_URL': get_cms_setting('MEDIA_URL'),
'title': opts.verbose_name,
Expand All @@ -168,10 +145,52 @@ def edit_field(self, request, object_id, language):
})
return render(request, 'admin/cms/page/plugin/confirm_form.html', context)
if not cancel_clicked and request.method == 'POST' and saved_successfully:
if isinstance(admin_obj, CMSPluginBase):
# Update the structure board by populating the data bridge
return admin_obj.render_close_frame(request, obj)
return render(request, 'admin/cms/page/plugin/confirm_form.html', context)
return render(request, 'admin/cms/page/plugin/change_form.html', context)


class FrontendEditableAdminMixin(BaseEditableAdminMixin):
"""
Adding ``FrontendEditableAdminMixin`` to models admin class allows to open that admin
in the frontend by double-clicking on fields rendered with the ``render_model`` template
tag.
"""
def get_urls(self) -> list[str]:
"""
Register the url for the edit field view
"""
info = f"{self.model._meta.app_label}_{self.model._meta.model_name}"

def pat(regex, fn):
return re_path(regex, self.admin_site.admin_view(fn), name=f"{info}_{fn.__name__}")
url_patterns = [
pat(r'edit-field/(%s)/([a-z\-]+)/$' % SLUG_REGEXP, self.edit_field),
]
return url_patterns + super().get_urls()

def _get_model_admin(self, obj: models.Model) -> admin.ModelAdmin:
# FrontendEditableAdminMixin needs to be added to the model's model admin class.
# Hence, the relevant admin is the model admin itself.
return self

def _get_object_for_single_field(self, object_id: int, language: str) -> models.Model:
# Quick and dirty way to retrieve objects for django-hvad
# Cleaner implementation will extend this method in a child mixin
try:
# First see if the model uses the admin manager pattern from cms.models.manager.ContentAdminManager
manager = self.model.admin_manager
except AttributeError:
# If not, use the default manager
manager = self.model.objects
try:
return manager.language(language).get(pk=object_id)
except AttributeError:
return manager.get(pk=object_id)


class PlaceholderAdminMixinBase(forms.MediaDefiningClass):
def __new__(cls, name, bases, attrs):
super_new = super().__new__
Expand All @@ -197,7 +216,9 @@ class PlaceholderAdminMixin(metaclass=PlaceholderAdminMixinBase):


@admin.register(Placeholder)
class PlaceholderAdmin(admin.ModelAdmin):
class PlaceholderAdmin(BaseEditableAdminMixin, admin.ModelAdmin):
"""Placeholder admin manages placeholders and their plugins, as well as the preview, edit, and
structure endpoints."""

def has_add_permission(self, request):
# Placeholders are created by the system
Expand All @@ -221,18 +242,20 @@ def delete_view(self, request, object_id, extra_context=None):
# but the admin's delete view is not available for placeholders.
raise PermissionDenied

def get_urls(self):
def get_urls(self) -> list[str]:
"""
Register the plugin specific urls (add/edit/copy/remove/move)
"""
info = f"{self.model._meta.app_label}_{self.model._meta.model_name}"

def pat(regex, fn):
return re_path(regex, self.admin_site.admin_view(fn), name=f"{info}_{fn.__name__}")
url_patterns = [
re_path(r'^cms_wizard/', include('cms.wizards.urls')),
pat(r'^copy-plugins/$', self.copy_plugins),
pat(r'^add-plugin/$', self.add_plugin),
pat(r'^edit-plugin/([0-9]+)/$', self.edit_plugin),
pat(r'^edit-plugin/([0-9]+)/([a-z\-]+)/$', self.edit_field),
pat(r'^delete-plugin/([0-9]+)/$', self.delete_plugin),
pat(r'^clear-placeholder/([0-9]+)/$', self.clear_placeholder),
pat(r'^move-plugin/$', self.move_plugin),
Expand All @@ -244,6 +267,18 @@ def pat(regex, fn):
]
return url_patterns

def _get_object_for_single_field(self, object_id: int, language: str) -> CMSPlugin:
# For BaseEditableAdminMixin: This (private) method retrieves the corresponding CMSPlugin and
# downcasts it to the appropriate plugin model. language is ignored. This provides the plugin for
# edit_field"""
plugin = get_object_or_404(CMSPlugin, pk=object_id) # Returns a CMSPlugin instance
return plugin.get_bound_plugin() # Returns the plugin model instance of the appropriate type

def _get_model_admin(self, obj: CMSPlugin) -> admin.ModelAdmin:
# For BaseEditableAdminMixin: This (private) method retrieves the model admin for the plugin model
# which is the plugin instance itself.
return obj.get_plugin_class_instance(admin=self.admin_site)

def _get_operation_language(self, request):
# Unfortunately the ?language GET query
# has a special meaning on the CMS.
Expand Down Expand Up @@ -1126,5 +1161,3 @@ def clear_placeholder(self, request, placeholder_id):
}
request.current_app = self.admin_site.name
return TemplateResponse(request, "admin/cms/page/plugin/delete_confirmation.html", context)


2 changes: 0 additions & 2 deletions cms/admin/settingsadmin.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,5 +136,3 @@ def get_model_perms(self, request):
Return empty perms dict thus hiding the model from admin index.
"""
return {}


19 changes: 8 additions & 11 deletions cms/templatetags/cms_tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,7 @@
from classytags.values import ListValue, StringValue
from django import template
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist
from django.core.mail import mail_managers
from django.db.models import Model
from django.template.loader import render_to_string
Expand Down Expand Up @@ -531,25 +529,20 @@ def _get_editable_context(self, context, instance, language, edit_fields,
else:
lang = get_language()
opts = instance._meta
# Django < 1.10 creates dynamic proxy model subclasses when fields are
# deferred using .only()/.exclude(). Make sure to use the underlying
# model options when it's the case.
if getattr(instance, '_deferred', False):
opts = opts.proxy_for_model._meta
with force_language(lang):
extra_context = {}
if edit_fields == 'changelist':
instance.get_plugin_name = "{} {} list".format(smart_str(_('Edit')), smart_str(opts.verbose_name))
instance.get_plugin_name = lambda: f"{smart_str(_('Edit'))} {smart_str(opts.verbose_name)} list"
extra_context['attribute_name'] = 'changelist'
elif editmode:
instance.get_plugin_name = "{} {}".format(smart_str(_('Edit')), smart_str(opts.verbose_name))
instance.get_plugin_name = lambda: f"{smart_str(_('Edit'))} {smart_str(opts.verbose_name)}"
if not context.get('attribute_name', None):
# Make sure CMS.Plugin object will not clash in the frontend.
extra_context['attribute_name'] = '-'.join(
edit_fields
) if not isinstance('edit_fields', str) else edit_fields
else:
instance.get_plugin_name = "{} {}".format(smart_str(_('Add')), smart_str(opts.verbose_name))
instance.get_plugin_name = lambda: f"{smart_str(_('Add'))} {smart_str(opts.verbose_name)}"
extra_context['attribute_name'] = 'add'
extra_context['instance'] = instance
extra_context['generic'] = opts
Expand Down Expand Up @@ -578,7 +571,11 @@ def _get_editable_context(self, context, instance, language, edit_fields,
url_base = reverse(view_url, args=(instance.pk,))
else:
if not view_url:
view_url = f'admin:{opts.app_label}_{opts.model_name}_edit_field'
if isinstance(instance, CMSPlugin):
# Plugins do not have a registered admin. They are managed by the placeholder admin.
view_url = 'admin:cms_placeholder_edit_field'
else:
view_url = f'admin:{opts.app_label}_{opts.model_name}_edit_field'
if view_url.endswith('_changelist'):
url_base = reverse(view_url)
else:
Expand Down
28 changes: 28 additions & 0 deletions cms/tests/test_templatetags.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from django.utils.timezone import now
from django.utils.translation import override as force_language
from djangocms_text_ckeditor.cms_plugins import TextPlugin
from djangocms_text_ckeditor.models import Text
from sekizai.context import SekizaiContext

import cms
Expand Down Expand Up @@ -689,3 +690,30 @@ def test_render_model_add_block(self):
output = self.render_template_obj(template, {'category': Category()}, request)
expected = 'wrapped'
self.assertEqual(expected, output)


class EditablePluginsTemplateTags(CMSTestCase):
def setUp(self):
self.page = create_page('Test', 'simple.html', 'en')
self.placeholder = self.page.get_placeholders('en')[0]
self.plugin = add_plugin(self.placeholder, TextPlugin, 'en', body='<b>Test</b>')

def test_render_model_plugin(self):
"""The render_model template tags also works with a plugin."""
template = """{% load cms_tags %}{% render_model plugin "body" "body" %}"""
# The template html tags will render the object editable in the frontend
expectation = (
f'<template class="cms-plugin cms-plugin-start cms-plugin-djangocms_text_ckeditor-text-body-{ self.plugin.pk } cms-render-model"></template>'
'&lt;b&gt;Test&lt;/b&gt;'
f'<template class="cms-plugin cms-plugin-end cms-plugin-djangocms_text_ckeditor-text-body-{ self.plugin.p 5B56 k } cms-render-model"></template>'
)

endpoint = get_object_edit_url(self.page.get_content_obj("en")) # view in edit mode
request = RequestFactory().get(endpoint)
request.user = self.get_superuser()
request.current_page = self.page
request.toolbar = CMSToolbar(request) # add toolbar

output = self.render_template_obj(template, {'plugin': self.plugin}, request)

self.assertEqual(output, expectation)
Loading
0