Django CMS 创建自定义插件
前言
CMS插件是一个可重复使用内容发布者,它能够嵌入到django CMS页面上,或者利用django CMS placeholder嵌入到任意内容。它们不需要进一步的干预就能够自动的发布信息。这就意味着,当你发布网页内容时,不管是什么内容,总能保持最新。
它就像一个魔术,但是更快。
如果你的需求在内嵌的或者第三方的插件里都实现了,那么你很幸运,否则的话,你需要去实现自己的CMS插件。但是不用太担心,写一个CMS插件非常简单。
为什么需要写一个插件
如果要把django应用集成到django CMS页面上,插件是最方便的方法。例如:如果你在部署一个唱片公司的django CMS站点,你可能想在主页上放一个"最新发布"的版块,这样可能需要经常编辑该页面去更新信息。然而,一个明智的唱片公司同样会在django里管理它的目录,这样的话django就已经知道这周它要发布什么。
这是一个很好的机会去利用这些信息来简化你的工作,你所需要做的事就是创建一个CMS插件把它插入到你的主页上,让它去完成发布信息的工作。
插件是可重复使用的,这样你可能只需要稍作修改就可以用于类似目的。
概述
一个django CMS插件基本上由三部分组成
- editor插件:在每次部署时进行配置
- publisher插件:自动完成决定发布哪些内容
- template插件:渲染网页信息
这些与MVT (Model-View-Template)模式是一致的
- model插件存储配置信息
- view插件完成显示
- template插件渲染信息
所以,要编写你自己的plugin,你需要从下面开始
- 一个cms.models.pluginmodel.CMSPlugin子类去存储你的插件实例的配置
- 一个cms.plugin_base.CMSPluginBase子类定义你的操作逻辑
- 一个模板去渲染你的插件
cms.plugin_base.CMSPluginBase
注意事项
cms.plugin_base.CMSPluginBase 实际上是 django.contrib.admin.ModelAdmin 子类
因为 CMSPluginBase 从 ModelAdmin 子类化,所以 ModelAdmin 的几个重要的选项对CMS plugin开发者也适用。下面这些选项经常被用到::
- exclude
- fields
- fieldsets
- form
- formfield_overrides
- inlines
- radio_fields
- raw_id_fields
- readonly_fields
然而,并不是ModelAdmin的所有的操作在CMS plugin都能用,特别是一些ModelAdmin的changelist专用的那些选项是无效的。下面这些选项在CMS中需要被忽略:
- actions
- actions_on_top
- actions_on_bottom
- actions_selection_counter
- date_hierarchy
- list_display
- list_display_links
- list_editable
- list_filter
- list_max_show_all
- list_per_page
- ordering
- paginator
- preserve_fields
- save_as
- save_on_top
- search_fields
- show_full_result_count
- view_on_site
关于model及配置的旁白
Model插件从cms.models.pluginmodel.CMSPlugin继承而来,实际上它是可选的。
如果它永远只做同一件事,你的插件可以不去配置,例如:如果你的插件永远只是发布过去几天里销量最好的唱票。很明显,这个不够灵活,他并不能发布过去几个月销量最好的。
通常,如果你发现你需要去配置你的插件,这就需要定义一个model。
最简单插件、
你可以用python manage.py startapp去设置基本的应用布局(记得把你的插件加入INSTALLED_APPS),或者,你也可以只需要在当前的django应用里加入一个叫cms_plugins.py的文件。
可以把你的插件内容放到这个文件里,例如,你可以加入以下代码:
from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
from cms.models.pluginmodel import CMSPlugin
from django.utils.translation import ugettext_lazy as _
@plugin_pool.register_plugin
class HelloPlugin(CMSPluginBase):
model = CMSPlugin
render_template = "hello_plugin.html"
cache = False
这样,基本上就完成了。剩下的只需要去添加模板。在模板根目录添加hello_plugin.html文件
<h1>Hello {% if request.user.is_authenticated %}{{ request.user.first_name }} {{ request.user.last_name}}{% else %}Guest{% endif %}</h1>
该插件会在页面上显示欢迎信息,如果是登录用户,显示名字,否则显示Guest。
在cms_plugins.py文件,你会子类化cms.plugin_base.CMSPluginBase,这些类会定义了不同的插件。
有两个属性是这些类必须的:
model:model用于存储你的插件信息。如果你不打算存储一些特别信息,直接用cms.models.pluginmodel.CMSPlugin就可以了。在一个正常的admin class,你不需要提供这个信息。
name:显示在admin上的你的插件名字。通过,实际工作中我们会通过django.utils.translation.ugettext_lazy()将改字符串设成可翻译的。
如果render_plugin设为True,下面内容必须定义
render_template:插件的渲染模板
get_render_template:返回渲染插件模板的路径
除了这些属性,你也可以重写render()方法,该方法决定渲染插件的模板上下文变量。默认情况下,这个方法只会把instance和placeholder对象添加到你的context,插件可以通过重写这个方法添加更多的上下文内容。
你也可以重写其他的CMSPluginBase子类的方法,详细信息参考CMSPluginBase 。
调试
因为插件的modules通过django的importlib加载,你可能会碰到路径环境导致的问题。如果你的cms_plugins不能加载或者访问,尝试下面的操作:
$ python manage.py shell
>>> from importlib import import_module
>>> m = import_module("myapp.cms_plugins")
>>> m.some_test_function()
存储配置
许多情况下,你需要给你的插件实例存储配置。例如:如果你有一个插件显示最新的发布博客,你可能也希望能够选择显示条目的数量。
去实现这些功能,你需要在已安装的models.py文件里,创建一个model子类化cms.models.pluginmodel.CMSPlugin
接下来,我们来改进一下上面的HelloPlugin,给未授权用户添加可配置的名字
在models.py文件,添加如下内容
from cms.models.pluginmodel import CMSPlugin
from django.db import models
class Hello(CMSPlugin):
guest_name = models.CharField(max_length=50, default='Guest')
这个跟正常的model定义没有太大差别,唯一不同是它是从cms.models.pluginmodel.CMSPlugin 继承而不是django.db.models.Model.
现在,我们需要修改我们的插件定义来使用这个model,新的cms_plugins.py如下
from cms.plugin_base import CMSPluginBase
from cms.plugin_pool import plugin_pool
from django.utils.translation import ugettext_lazy as _
from .models import Hello
@plugin_pool.register_plugin
class HelloPlugin(CMSPluginBase):
model = Hello
name = _("Hello Plugin")
render_template = "hello_plugin.html"
cache = False
def render(self, context, instance, placeholder):
context = super(HelloPlugin, self).render(context, instance, placeholder)
return context
我们修改model属性,并且将model实例传递给context.
最后,更新模板,在模板里使用新的配置信息。
<h1>Hello {% if request.user.is_authenticated %}
{{ request.user.first_name }} {{ request.user.last_name}}
{% else %}
{{ instance.guest_name }}
{% endif %}</h1>
这儿,我们使用{{ instance.guest_name }}来取代固定的Guest字符串
关系处理
每次你的页面发布时,如果自定义插件在页面里,那么它就会被拷贝。所以,你的自定义插件有ForeignKey (from或者to)或者m2m,你需要拷贝这些关联对象。它不会自动帮你完成。
每个插件model会从基类继承空方法cms.models.pluginmodel.CMSPlugin.copy_relations(),在你的插件被拷贝时,它会被调用。所以,你可以在这儿适配你的目的。
典型情况下,你需要用它去拷贝关联对象。要实现该功能,你需要在你的插件model创建copy_relations()方法,老的instance会作为一个参数传入。
也有可能你决定不需要拷贝这些关联对象,你想让它们独立存在。这些取决于你想怎样让这些插件工作。
如果你想拷贝关联对象,你需要用两个近似的方法去实现,具体需要看你的插件和对象之间的关系 (from还是to)
For foreign key relations from other objects
你的插件可能有一些条目的外键指向它,这些是典型的admin内联场景。所以,你可能需要两个model,一个plugin,一个给那些条目。
class ArticlePluginModel(CMSPlugin):
title = models.CharField(max_length=50)
class AssociatedItem(models.Model):
plugin = models.ForeignKey(
ArticlePluginModel,
related_name="associated_item"
)
这样,你需要 copy_relations()方法去轮训关联条目并且拷贝它们,并将外键赋做新的插件
class ArticlePluginModel(CMSPlugin):
title = models.CharField(max_length=50)
def copy_relations(self, oldinstance):
# Before copying related objects from the old instance, the ones
# on the current one need to be deleted. Otherwise, duplicates may
# appear on the public version of the page
self.associated_item.all().delete()
for associated_item in oldinstance.associated_item.all():
# instance.pk = None; instance.pk.save() is the slightly odd but
# standard Django way of copying a saved model instance
associated_item.pk = None
associated_item.plugin = self
associated_item.save()
For many-to-many or foreign key relations to other objects
假定你得插件有关联对象
class ArticlePluginModel(CMSPlugin):
title = models.CharField(max_length=50)
sections = models.ManyToManyField(Section)
当插件被拷贝是,我们需要section保持不变,所以改成如下:
class ArticlePluginModel(CMSPlugin):
title = models.CharField(max_length=50)
sections = models.ManyToManyField(Section)
def copy_relations(self, oldinstance):
self.sections = oldinstance.sections.all()
如果你的插件有这两种关联域,你就需要用到以上的两种技术。
Relations between plugins
如果插件直接有关联,关系拷贝就会变得非常困难。细节查看GitHub issue copy_relations() does not work for relations between cmsplugins #4143
高级
Inline Admin
如果你想外键关系作为inline admin,你需要创建admin.StackedInlineclass,并且把插件放到inlines。然后,你可以用这个inline admin form作为你的外键引用。
class ItemInlineAdmin(admin.StackedInline):
model = AssociatedItem
class ArticlePlugin(CMSPluginBase):
model = ArticlePluginModel
name = _("Article Plugin")
render_template = "article/index.html"
inlines = (ItemInlineAdmin,)
def render(self, context, instance, placeholder):
context = super(ArticlePlugin, self).render(context, instance, placeholder)
items = instance.associated_item.all()
context.update({
'items': items,
})
return context
Plugin form
因为 cms.plugin_base.CMSPluginBase 从django.contrib.admin.ModelAdmin扩展而来, 你可以为你的插件定制化form,方法跟定制化admin form一样.
插件编辑机制使用的模板是cms/templates/admin/cms/page/plugin/change_form.html,你可能需要修改它。
如果你想定制化,最好的方法是:
- 从cms/templates/admin/cms/page/plugin/change_form.html扩展一个你自己的template来实现你想要的功能
- 在你的cms.plugin_base.CMSPluginBase子类里,将新模板赋值给变量change_form_template
从cms/templates/admin/cms/page/plugin/change_form.html扩展能够保证你的插件和其他的外观和功能统一。
处理media
如果你的插件依赖于特定的media文件, JavaScript或者stylesheets, 你可以通过django-sekizai把它们加入到你的插件模板。你的CMS模板是强制要求加入css 和 js sekizai 域名空间。更多信息请参考 django-sekizai documentation.
Sekizai style
要想充分利用django-sekizai, 最好使用一致的风格,下面是一些遵守的惯例:
- 一个 addtoblock一块. 每个 addtoblock包含一个外部css或者js文件,或者一个snippet. 这样的话django-sekizai非常容易检查重复文件.
- 外部文件应该在同一行,在 addtoblock tag 和 the HTML tags之间没有空格.
- 使用嵌入的javascript或CSS时, HTML tags 必须在新行.
一个好的例子:
{% load sekizai_tags %}
{% addtoblock "js" %}<script type="text/javascript" src="{{ MEDIA_URL }}myplugin/js/myjsfile.js"></script>{% endaddtoblock %}
{% addtoblock "js" %}<script type="text/javascript" src="{{ MEDIA_URL }}myplugin/js/myotherfile.js"></script>{% endaddtoblock %}
{% addtoblock "css" %}<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}myplugin/css/astylesheet.css">{% endaddtoblock %}
{% addtoblock "js" %}
<script type="text/javascript">
$(document).ready(function(){
doSomething();
});
</script>
{% endaddtoblock %}
一个不好的例子:
{% load sekizai_tags %}
{% addtoblock "js" %}<script type="text/javascript" src="{{ MEDIA_URL }}myplugin/js/myjsfile.js"></script>
<script type="text/javascript" src="{{ MEDIA_URL }}myplugin/js/myotherfile.js"></script>{% endaddtoblock %}
{% addtoblock "css" %}
<link rel="stylesheet" type="text/css" href="{{ MEDIA_URL }}myplugin/css/astylesheet.css"></script>
{% endaddtoblock %}
{% addtoblock "js" %}<script type="text/javascript">
$(document).ready(function(){
doSomething();
});
</script>{% endaddtoblock %}
Plugin Context
插件能够访问django模板,你可以通过with tag覆盖变量
例子:
{% with 320 as width %}{% placeholder "content" %}{% endwith %}
Plugin Context Processors
在渲染之前,插件context processor可以被调用去修改插件的context。可以通过CMS_PLUGIN_CONTEXT_PROCESSORS使能改功能。
一个插件context processor包含三个参数
- instance: 插件model实例
- placeholder: 当前插件所在的placeholder的实例.
- context: 当前使用的context, 包含request.
它返回一个字典,包含了添加到context中的所有变量。
例子:
def add_verbose_name(instance, placeholder, context):
'''
This plugin context processor adds the plugin model's verbose_name to context.
'''
return {'verbose_name': instance._meta.verbose_name}
Plugin Processors
在渲染之前,插件processor可以被调用去修改插件的输出。可以通过CMS_PLUGIN_PROCESSORS使能改功能。
一个插件processor包含三个参数
- instance: 插件model实例
- placeholder: 当前插件所在的placeholder的实例.
- rendered_content: 包含插件渲染内容的字符串.
- original_context: 插件的原始context.
例子
加入你要在主placeholder里面将所有插件放到一个用一个彩色盒子里,编辑每个插件的目标会非常复杂
在你的 settings.py:
CMS_PLUGIN_PROCESSORS = (
'yourapp.cms_plugin_processors.wrap_in_colored_box',
)
在你的 yourapp.cms_plugin_processors.py:
def wrap_in_colored_box(instance, placeholder, rendered_content, original_context):
'''
This plugin processor wraps each plugin's output in a colored box if it is in the "main" placeholder.
'''
# Plugins not in the main placeholder should remain unchanged
# Plugins embedded in Text should remain unchanged in order not to break output
if placeholder.slot != 'main' or (instance._render_meta.text_enabled and instance.parent):
return rendered_content
else:
from django.template import Context, Template
# For simplicity's sake, construct the template from a string:
t = Template('<div style="border: 10px {{ border_color }} solid; background: {{ background_color }};">{{ content|safe }}</div>')
# Prepare that template's context:
c = Context({
'content': rendered_content,
# Some plugin models might allow you to customise the colors,
# for others, use default colors:
'background_color': instance.background_color if hasattr(instance, 'background_color') else 'lightyellow',
'border_color': instance.border_color if hasattr(instance, 'border_color') else 'lightblue',
})
# Finally, render the content through that template, and return the output
return t.render(c)
嵌套插件
你可以让插件相互嵌套。要实现这个功能,需要完成以下几个事情:
models.py:
class ParentPlugin(CMSPlugin):
# add your fields here
class ChildPlugin(CMSPlugin):
# add your fields here
cms_plugins.py:
from .models import ParentPlugin, ChildPlugin
@plugin_pool.register_plugin
class ParentCMSPlugin(CMSPluginBase):
render_template = 'parent.html'
name = 'Parent'
model = ParentPlugin
allow_children = True # This enables the parent plugin to accept child plugins
# You can also specify a list of plugins that are accepted as children,
# or leave it away completely to accept all
# child_classes = ['ChildCMSPlugin']
def render(self, context, instance, placeholder):
context = super(ParentCMSPlugin, self).render(context, instance, placeholder)
return context
@plugin_pool.register_plugin
class ChildCMSPlugin(CMSPluginBase):
render_template = 'child.html'
name = 'Child'
model = ChildPlugin
require_parent = True # Is it required that this plugin is a child of another plugin?
# You can also specify a list of plugins that are accepted as parents,
# or leave it away completely to accept all
# parent_classes = ['ParentCMSPlugin']
def render(self, context, instance, placeholder):
context = super(ChildCMSPlugin, self).render(context, instance, placeholder)
return context
parent.html:
{% load cms_tags %}
<div class="plugin parent">
{% for plugin in instance.child_plugin_instances %}
{% render_plugin plugin %}
{% endfor %}
</div>
child.html:
<div class="plugin child">
{{ instance }}
</div>
扩展placeholder或者插件的上下文菜单
有三种方法去扩展placeholder或者插件的上下文菜单
- 扩展placeholder的上下文菜单
- 或者所有插件的上下文菜单
- 扩展当前插件的上下文菜单
你可以重写下面CMSPluginBase的三个方法来实现这个目的
- get_extra_placeholder_menu_items()
- get_extra_global_plugin_menu_items()
- get_extra_local_plugin_menu_items()
例子
class AliasPlugin(CMSPluginBase):
name = _("Alias")
allow_children = False
model = AliasPluginModel
render_template = "cms/plugins/alias.html"
def render(self, context, instance, placeholder):
context = super(AliasPlugin, self).render(context, instance, placeholder)
if instance.plugin_id:
plugins = instance.plugin.get_descendants(include_self=True).order_by('placeholder', 'tree_id', 'level',
'position')
plugins = downcast_plugins(plugins)
plugins[0].parent_id = None
plugins = build_plugin_tree(plugins)
context['plugins'] = plugins
if instance.alias_placeholder_id:
content = render_placeholder(instance.alias_placeholder, context)
print content
context['content'] = mark_safe(content)
return context
def get_extra_global_plugin_menu_items(self, request, plugin):
return [
PluginMenuItem(
_("Create Alias"),
reverse("admin:cms_create_alias"),
data={'plugin_id': plugin.pk, 'csrfmiddlewaretoken': get_token(request)},
)
]
def get_extra_placeholder_menu_items(self, request, placeholder):
return [
PluginMenuItem(
_("Create Alias"),
reverse("admin:cms_create_alias"),
data={'placeholder_id': placeholder.pk, 'csrfmiddlewaretoken': get_token(request)},
)
]
def get_plugin_urls(self):
urlpatterns = [
url(r'^create_alias/$', self.create_alias, name='cms_create_alias'),
]
return urlpatterns
def create_alias(self, request):
if not request.user.is_staff:
return HttpResponseForbidden("not enough privileges")
if not 'plugin_id' in request.POST and not 'placeholder_id' in request.POST:
return HttpResponseBadRequest("plugin_id or placeholder_id POST parameter missing.")
plugin = None
placeholder = None
if 'plugin_id' in request.POST:
pk = request.POST['plugin_id']
try:
plugin = CMSPlugin.objects.get(pk=pk)
except CMSPlugin.DoesNotExist:
return HttpResponseBadRequest("plugin with id %s not found." % pk)
if 'placeholder_id' in request.POST:
pk = request.POST['placeholder_id']
try:
placeholder = Placeholder.objects.get(pk=pk)
except Placeholder.DoesNotExist:
return HttpResponseBadRequest("placeholder with id %s not found." % pk)
if not placeholder.has_change_permission(request):
return HttpResponseBadRequest("You do not have enough permission to alias this placeholder.")
clipboard = request.toolbar.clipboard
clipboard.cmsplugin_set.all().delete()
language = request.LANGUAGE_CODE
if plugin:
language = plugin.language
alias = AliasPluginModel(language=language, placeholder=clipboard, plugin_type="AliasPlugin")
if plugin:
alias.plugin = plugin
if placeholder:
alias.alias_placeholder = placeholder
alias.save()
return HttpResponse("ok")
插件数据迁移
在版本3.1,django MPTT迁移到了django-treebeard,插件模式在这两个版本是不同的。Schema迁移并没有受影响,因为迁移系统( south & django)检测到了这个不同的基础。如果你的数据迁移有下面的这些:
MyPlugin = apps.get_model('my_app', 'MyPlugin')
for plugin in MyPlugin.objects.all():
... do something ...
你可以会碰到错误django.db.utils.OperationalError: (1054, "Unknown column 'cms_cmsplugin.level' in 'field list'")。因为不同的迁移执行顺序,model历史数据可能会失步。
为保持3.0和3.x的兼容,你应该在执行django CMS迁移之前强制执行数据迁移,django CMS会创建treebeard域。通过执行这个,数据迁移会永远在老的数据库模式上执行,并不会对新的产生冲突。
对south迁移,添加下面代码
from distutils.version import LooseVersion
import cms
USES_TREEBEARD = LooseVersion(cms.__version__) >= LooseVersion('3.1')
class Migration(DataMigration):
if USES_TREEBEARD:
needed_by = [
('cms', '0070_auto__add_field_cmsplugin_path__add_field_cmsplugin_depth__add_field_c')
]
对django迁移,添加下面代码
from distutils.version import LooseVersion
import cms
USES_TREEBEARD = LooseVersion(cms.__version__) >= LooseVersion('3.1')
class Migration(migrations.Migration):
if USES_TREEBEARD:
run_before = [
('cms', '0004_auto_20140924_1038')
]
下一篇开始会介绍如何自定义django CMS菜单
关注下方公众号获取更多文章
参考文档
http://docs.django-cms.org/en/release-3.4.x/how_to/custom_plugins.html