django CMS 插件体系深度解析:模型、视图与模板三要素及自定义插件实战
【免费下载链接】django-cmsThe easy-to-use and developer-friendly enterprise CMS powered by Django项目地址: https://gitcode.com/gh_mirrors/dj/django-cms
CMS Plugin(CMS 插件)是 django CMS 中可复用的内容发布组件,可以被插入到 CMS 页面(或任何使用了 django CMS 占位符的内容)中,实现信息的自动发布、无需人工干预。本文围绕 docs/explanation/plugins.rst 的核心脉络,系统讲解插件的概念、三大组成要素(模型 / 视图 / 模板)、CMSPluginBase继承自ModelAdmin的可生效与不可生效选项,并结合仓库源码与 docs/how_to/09-custom_plugins.rst 实操指南,给出从"最简单的插件"到"带模型配置、嵌套子插件、关系复制、插件处理器"的完整实战方案。读完本文,你将掌握如何判断"何时该写插件、何时该用 apphook",并能独立编写、注册、配置和发布一个生产可用的自定义 CMS 插件。
什么是 CMS Plugin:可复用的内容发布器
CMS Plugin 是 django CMS 三大核心构建块之一。根据 docs/explanation/composition.rst 的定义,一个 django CMS 站点由三类组件拼装而成:
- 内容对象(Content Object):持有可编辑内容并暴露占位符的对象,页面(Page)是最典型的一种;
- 插件(Plugin):编辑器可以拖入占位符的可复用内容组件,它拥有编辑器通过模型填写的数据以及渲染该数据的模板;
- Apphook:把 Django 应用挂载到页面树上的标准方式。
三者的关系可以概括为:内容对象拥有占位符,占位符容纳插件,插件负责组合(composition)。插件永远活在占位符里,从不独立存在——即使同一个插件出现在多个内容对象上,每一次出现都是一个独立的插件实例,拥有各自的配置数据。
插件最核心的价值在于自动发布信息:一旦配置好并插入页面,它就会持续发布最新内容,无需人工维护。这意味着你发布在网页上的任何内容都能随时保持最新——"像魔法一样,只不过更快"。
为什么要写自己的插件
插件是将另一个 Django 应用的内容集成到 django CMS 页面中最便捷的方式。文档中给出了一个唱片公司网站的经典例子:假设你要在首页放一个"最新发行(Latest releases)"栏目,你可以定期手动编辑页面更新信息,但唱片公司通常本来就用 Django 管理自己的曲库——Django 已经知道本周的新发行是什么。此时只需创建一个 CMS 插件插入首页,剩下的事情全部交给插件自动完成。
插件还是可复用的:同一家公司如果正在发行一系列瑞士朋克经典再版唱片,你可以在该系列页面上插入同一个插件,仅做略微不同的配置,即可发布该系列近期新发行的信息。
插件还是 Apphook?一个决策辅助
在动手写插件前,先回答一个问题:这些内容到底"住在哪里"?如果它适合放进别人页面上的某个占位符里,它就是插件;如果它是一类独立的东西——拥有自己的列表视图、详情视图和 URL——那它就是一个应用,需要通过 apphook 挂载。
| 你想要… | 选择… | 为什么 |
|---|---|---|
| 一个编辑器可拖入任意占位符的可复用内容组件 | 插件 | 插件是占位符内部的组合单元 |
| 一个完整的子应用(博客、曲库、投票、搜索) | Apphook | Apphook 拥有 URL 前缀并自带内容对象 |
| 一个完全由编辑器组合内容的页面 | 页面 + 插件 | 页面内容对象的默认流程 |
| 一个主体由 Django 视图驱动的页面 | 页面 + Apphook | 页面提供 URL,应用提供视图 |
| 让编辑器选择哪些记录显示在页面内嵌列表中 | 插件(模型引用你的记录) | 组件住在页面上,只有数据住在应用模型里 |
| 让编辑器通过移动页面来移动子站点的 URL | Apphook | 视图和内容对象是你的,URL 属于页面 |
首页放活动预告并且在/events/下有完整活动子站 | 两者都要——预告用插件,子站用 apphook | 常见组合:插件渲染摘要,apphook 拥有/events/及其以下 |
值得注意的边界情况:一个需要自己详情 URL 的"产品卡片"插件仍然是插件(它住在占位符里),但详情 URL 应该来自挂载在 CMS 页面上的 apphook,这样 URL 才是编辑器可控的;同一页面上多个"相同"插件是相互独立的实例,共享模型和模板但不共享数据;跨页面复用同一份内容而不复制它,则应使用 Alias 内容对象配合 Alias 插件嵌入。
插件的三个组成部分
一个 django CMS 插件本质上由三个组件构成,与 Django 熟悉的 Model-View-Template 模式一一对应:
| 组件 | 功能 | 继承自 |
|---|---|---|
| model(可选) | 插件实例配置 | CMSPlugin |
| view | 显示逻辑 | CMSPluginBase |
| template | 渲染 | —— |
model:CMSPlugin 子类(可选)
插件模型——即 cms.models.pluginmodel.CMSPlugin 的子类——是可选的。如果某个插件只需要做一件事、不需要任何配置,你可以完全不要模型。例如一个只发布"过去七天最畅销唱片"的插件就不需要配置;当然这样也失去了灵活性——你无法用同一个插件去发布"上个月最畅销"的信息。因此实践中你会发现,通常还是需要模型来保存配置。
从源码看,CMSPlugin基类自身带有若干由 CMS 内部管理的字段(见 cms/models/pluginmodel.py):placeholder(所属占位符外键)、parent(父插件外键,根级插件为None)、position(占位符与语言内的唯一位置)、language、plugin_type(插件类名)、creation_date、changed_date等。子类化时有两条硬性限制:CMSPlugin的子类不能再被子类化;子类不能定义名为text的字段。
在 django CMS 4 中,插件实例的创建与删除统一由占位符管理(详见后文"通过占位符 API 创建与删除插件"),这也是保证插件树完整性的关键设计。
view:CMSPluginBase(显示逻辑)
cms.plugin_base.CMSPluginBase 是插件的"视图"层,负责显示逻辑。一个值得注意的实现事实是:CMSPluginBase实际上是django.contrib.admin.ModelAdmin的子类——这在 cms/plugin_base.py 的类定义中明确可见。这意味着插件开发者在编写插件时,可以沿用大量熟悉的ModelAdmin选项。
CMSPluginBase还使用了一个自定义元类CMSPluginBaseMetaclass(cms/plugin_base.py),它在类创建时自动完成多项校验与默认值设置:
- 校验
model属性必须是CMSPlugin或其子类,否则抛出SubClassNeededError; - 校验必须定义
render_template属性或get_render_template方法,否则抛出ImproperlyConfigured; - 未指定
form时,自动基于模型生成一个ModelForm(排除position、placeholder、language、plugin_type、path、depth等 CMS 内部字段); - 未指定
fieldsets时,根据模型字段自动生成基础字段区与"高级选项"折叠区; - 未指定
name时,自动把类名HelloPlugin转换为更友好的"Hello Plugin"。
可用的 ModelAdmin 选项
由于CMSPluginBase继承自ModelAdmin,以下ModelAdmin选项对 CMS 插件开发者是有效的,并且非常常用:
excludefieldsfieldsetsformformfield_overridesinlinesradio_fieldsraw_id_fieldsreadonly_fields
这些选项让插件编辑表单完全等同于一个可定制的 Django 管理后台表单。例如通过form挂载自定义ModelForm以实现字段清洗(见下文安全部分),通过inlines把外键关联对象以内联表单形式展示。
被 CMS 忽略的 ModelAdmin 选项
需要注意,并非所有ModelAdmin选项在 CMS 插件中都有效。特别是任何仅被ModelAdmin的changelist(列表页)使用的选项都不会产生效果,因为插件根本没有列表页。原文档明确列出的无效选项包括:
actions、actions_on_top、actions_on_bottom、actions_selection_counterdate_hierarchylist_display、list_display_links、list_editable、list_filterlist_max_show_all、list_per_pageordering、paginatorprepopulated_fields、preserve_fieldssave_as、save_on_topsearch_fields、show_full_result_countview_on_site
template:渲染层
插件模板负责最终输出。模板通过render_template属性(静态指定)或get_render_template方法(动态返回模板路径)提供,二者至少必须定义其一(当render_plugin为默认值True时)。
插件的render()方法(cms/plugin_base.py)决定模板上下文:默认实现只把instance和placeholder加入 context,覆盖时建议先调用super().render(...)以保留这两个默认变量,再补充自定义上下文。
实战:编写第一个最简单的插件
完整的实操教程见 docs/how_to/09-custom_plugins.rst,这里继承其核心步骤并补充源码细节。
插件代码放在应用的cms_plugins.py文件中(可通过python -m manage startapp创建插件应用,记得加入INSTALLED_APPS;也可以直接往已有应用添加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 gettext_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。
两个必需的类属性
在CMSPluginBase子类上有两个必需属性:
model:用于存储插件信息的模型。如果插件不需要保存任何特殊信息(如配置),可以直接使用 CMSPlugin。注意与普通 admin 类的区别:普通 admin 通过admin.site.register(Model, Admin)注册,模型信息由注册机制提供;而插件不是这样注册的,所以必须显式给出model。name:插件在 admin 中显示的名称。通常建议用gettext_lazy标记为可翻译字符串(可选);未指定时默认取类名的友好化形式(元类会自动把HelloPlugin转为"Hello Plugin")。
渲染模板:二选一
当render_plugin为True(默认值)时,以下二者必须定义其一:
render_template:渲染该插件的模板路径;get_render_template:返回模板路径的方法(用于根据上下文动态选择模板)。
在render_plugin = False的情况下插件完全不渲染,此时两者都可以不定义(但allow_children不能与render_plugin = False同时为True,这是 cms/plugin_pool.py 中的显式校验)。
插件的发现与注册机制
@plugin_pool.register_plugin装饰器背后是 cms/plugin_pool.py 中的PluginPool.register_plugin():它校验插件必须是CMSPluginBase的子类,以类名作为注册键,重复注册会抛出PluginAlreadyRegistered。
插件的发现则由discover_plugins()(cms/plugin_pool.py)完成:它调用 Django 的autodiscover_modules("cms_plugins")遍历所有已安装应用中名为cms_plugins.py的模块并自动导入。这就是插件文件必须命名为cms_plugins.py的原因。导入后插件会按module和name排序。若你的cms_plugins模块加载失败或不可访问,可在 shell 中直接验证:
$ python -m manage shell >>> from importlib import import_module >>> m = import_module("myapp.cms_plugins") >>> m.some_test_function() # 来自 myapp.cms_plugins 模块的函数存储配置:为插件添加模型
很多插件需要保存实例级配置。例如一个显示最新博客文章的插件,可能想配置显示条数;一个画廊插件需要选择要展示的图片。做法是在某个已安装应用的models.py中创建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')与普通 Django 模型的唯一区别就是继承CMSPlugin而非models.Model。然后修改插件定义:
from cms.plugin_base import CMSPluginBase from cms.plugin_pool import plugin_pool from django.utils.translation import gettext_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().render(context, instance, placeholder) return context最后更新模板,用可配置的{{ instance.guest_name }}替换硬编码的 Guest:
<h1>Hello {% if request.user.is_authenticated %} {{ request.user.first_name }} {{ request.user.last_name}} {% else %} {{ instance.guest_name }} {% endif %}</h1>命名字段时的两个注意事项
- 不能把模型字段命名为与任何已安装插件的模型同名(小写形式),因为 Django 对子类模型使用隐式一对一关系。使用全部核心插件时,需要避开的名称包括:
file、googlemap、link、picture、snippetptr、teaser、twittersearch、twitterrecententries、video。 - 建议避免使用
page作为模型字段名:CMSPlugin上已声明了一个page属性(cms/models/pluginmodel.py),虽然其使用已废弃,但仍作为兼容性垫片存在。
处理关联对象:copy_relations
一些用户操作会触发插件的复制,最典型的是复制粘贴占位符内容。如果自定义插件带有外键(指向它或从它出发)或多对多关系,你有责任在 CMS 复制插件时复制这些关联对象——CMS 不会自动帮你做。
每个插件模型都从基类继承了空的copy_relations方法(cms/models/pluginmodel.py),插件被复制时会调用它。典型做法是在插件模型上实现接收旧实例参数的copy_relations方法;当然你也可以决定不复制关联对象,或为新副本选择完全不同的关联,取决于插件的行为设计。
场景一:外键从其他对象指向插件
这通常出现在把关联条目做成插件 admin 内联(inline)的情况下:
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): # 复制前先删除当前实例上已有的关联对象, # 否则公开版页面上可能出现重复 self.associated_item.all().delete() for associated_item in oldinstance.associated_item.all(): # instance.pk = None; instance.save() 是 Django 复制已保存 # 模型实例的标准(略显奇特但正确)做法 associated_item.pk = None associated_item.plugin = self associated_item.save()场景二:多对多或外键从插件指向其他对象
class ArticlePluginModel(CMSPlugin): title = models.CharField(max_length=50) sections = models.ManyToManyField(Section) def copy_relations(self, oldinstance): self.sections.set(oldinstance.sections.all())如果插件两类关系都有,通常需要同时使用上面两种复制技巧。插件与插件之间的关联复制要困难得多,仓库文档提到可参考copy_relations() does not work for relations between cmsplugins这一已知议题(见 docs/how_to/09-custom_plugins.rst)。
给已有插件添加模型(数据迁移)
当需要为已有插件新增模型时,必须小心处理,否则现有插件实例会从 CMS 界面消失(见仓库文档引用的 Issue #7476)。正确流程为:
- 定义模型:在
models.py中定义继承CMSPlugin的模型,所有字段必须带有有意义的默认值,以便后续自动迁移; - 更新插件类:在
cms_plugins.py中把model属性指向新模型; - 生成迁移但先不应用:执行
python manage.py makemigrations; - 编写数据迁移:在刚生成的迁移文件中追加
RunPython操作,遍历CMSPlugin.objects.filter(plugin_type=plugin_type)为每个既有实例创建对应模型记录(把pk、cmsplugin_ptr、placeholder、parent、language、position、creation_date等字段从旧实例拷贝过去,然后save()); - 应用迁移并测试:执行迁移后全面测试,确认既有实例正常显示、新模型功能按预期工作。
嵌套插件:父子结构
CMS 插件支持嵌套。实现嵌套需要父插件声明allow_children = True,并在父插件模板中渲染子插件。以 docs/how_to/09-custom_plugins.rst 的示例为骨架:
# models.py class ParentPlugin(CMSPlugin): # 在此添加字段 class ChildPlugin(CMSPlugin): # 在此添加字段# 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 # 允许父插件接受子插件 # 也可以指定允许作为子插件的列表,或完全不指定以接受全部 # child_classes = ['ChildCMSPlugin'] # 条目可以是 glob 模式,例如 child_classes = ['Bootstrap*'] # 特殊值 child_classes = 'auto' 表示只接受那些在 parent_classes # 中显式声明本插件的插件(由子插件主动"加入") def render(self, context, instance, placeholder): context = super().render(context, instance, placeholder) return context @plugin_pool.register_plugin class ChildCMSPlugin(CMSPluginBase): render_template = "child.html" name = "Child" model = ChildPlugin # 限制父插件比设置 require_parent = True 更可取: # 显式命名 parent_classes 本身已强制插件必须有父级, # 两者同时设置是冗余的。 # 这里 "*" 展开为所有已注册插件,表示任何插件都可作为父级—— # 但该插件必须有一个父级(不能直接添加到占位符)。 parent_classes = ['*'] def render(self, context, instance, placeholder): context = super(ChildCMSPlugin, self).render(context, instance, placeholder) return context父插件模板通过instance.child_plugin_instances遍历子插件,并用{% render_plugin %}渲染(见 cms/plugin_base.py 中allow_children的文档说明):
<!-- 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>如果子插件需要访问父插件的属性,可在表单初始化时通过self.instance.parent.get_bound_plugin()获取父实例。
关于父子约束,源码提供了更精细的控制(cms/plugin_base.py):
child_classes:父插件侧限制,只允许列表中的插件作为子级;条目支持 glob 模式(如"Bootstrap*"、"*Link*"),会针对所有已注册插件名展开;[]或匹配不到任何插件的模式表示不允许任何子插件,而None(默认)表示无限制;特殊值"auto"表示恰好接受那些在自己的parent_classes中显式列出本插件的插件。parent_classes:子插件侧限制,列出允许的父类;[]表示不允许任何父级(只能添加到占位符),None(默认)表示无限制。require_parent:该插件是否必须作为另一个插件的子级。disable_child_plugins:在结构模式下禁用子插件的拖拽。- 相关限制还可以通过
CMS_PLACEHOLDER_CONF中的child_classes/parent_classes/require_parent键按占位符覆盖。
限制插件可用的模型:allowed_models 与 allowed_plugins
(本特性在文档中标明为 5.1 版本新增,源码实现在 cms/plugin_pool.py 的get_all_plugins_for_model。)django CMS 提供两套互补的过滤机制控制插件与模型的搭配:
插件级过滤(allowed_models)——限制某个插件可用于哪些模型:
@plugin_pool.register_plugin class PageSpecificPlugin(CMSPluginBase): name = "Page Only Plugin" model = CMSPlugin render_template = "page_specific.html" allowed_models = ["cms.pagecontent"] # 仅限 CMS 页面allowed_models的取值:
None(默认):可用于所有带占位符的模型;- 格式为
"app_label.modelname"的模型标识列表(如["cms.pagecontent", "myapp.mymodel"]); - 空列表
[]:不能用于任何模型。
模型标识会被自动规范化为小写(见 cms/plugin_base.py 的元类处理),因此["cms.PageContent"]与["cms.pagecontent"]等价。
模型级过滤(allowed_plugins)——限制某个模型上可添加哪些插件:
from django.db import models from cms.models import PlaceholderField class BlogPost(models.Model): title = models.CharField(max_length=200) placeholders = PlaceholderRelationField() # 只允许在博客文章中放这些插件 allowed_plugins = ['TextPlugin', 'LinkPlugin', 'PicturePlugin']allowed_plugins的取值:None(默认,所有插件都允许,但仍受插件自身allowed_models过滤)、插件类名列表、空列表[](不允许任何插件)。
两者同时定义时,两个过滤都必须通过插件才可用(对应源码 cms/plugin_pool.py 的双重过滤逻辑)。此外,插件还支持allowed_slots属性(cms/plugin_base.py)按占位符 slot 名(支持"footer_*"这类 glob)限制可用位置,与CMS_PLACEHOLDER_CONF的plugins/excluded_plugins互为补充,两个过滤同样必须同时通过。需要注意:allowed_plugins中出现的未注册插件名、allowed_models中不存在的模型标识都会被静默忽略,不抛错误。
通过占位符 API 创建与删除插件
(文档标明为 4.0 版本新增,见 docs/how_to/09-custom_plugins.rst。)插件存在于占位符内部,从 django CMS 4 起占位符统一管理插件的创建与删除,并负责对整棵插件树做必要调整。不通过占位符创建或删除插件会导致插件树损坏。
创建插件有两种方式:
# 方式一:占位符的 add_plugin 方法 new_instance = MyPluginModel( plugin_data="secret", placeholder=placeholder_to_add_to, position=1, # 占位符中的第一个插件 ) placeholder_to_add_to.add_plugin(new_instance) assert new_instance.pk is not None # 已保存到数据库# 方式二:cms.api.add_plugin 函数 new_plugin = cms.api.add_plugin( placeholder_to_add_to, "MyPlugin", position='first-child', # 占位符中的第一个位置(无父级) data=dict(plugin_data="secret"), )删除插件(会连同其所有子插件一起删除):
old_instance.placeholder.delete_plugin(old_instance)警告:不要用PluginModel.objects.create(...)或PluginModel.objects.delete()来创建或删除插件实例——这很可能抛出数据库完整性异常,或产生不一致的插件树导致意外行为;也不要使用queryset.delete()批量删除插件,这会破坏插件树。
高级特性与扩展点
将插件标记为 slot
(5.1 版本新增,源码属性见 cms/plugin_base.py。)把插件的is_slot属性设为True可将其标记为槽位——一个结构性容器,用户不能直接编辑它。插件仍会正常渲染,但在结构面板上双击不会打开编辑对话框;移动插件或添加子插件不受影响。适用于没有可配置字段、或完全由父插件管理的插件:
@plugin_pool.register_plugin class SeparatorPlugin(CMSPluginBase): name = "Separator" render_template = "separator.html" is_slot = True对于第三方插件,无需修改其源码即可在AppConfig.ready()中标记:
class MyAppConfig(AppConfig): name = "myapp" def ready(self): from cms.plugin_pool import plugin_pool plugin_pool.get_plugin("SomeThirdPartyPlugin").is_slot = True自定义结构面板外观
(5.1 版本新增。)在插件的模型(CMSPlugin子类,而非CMSPluginBase子类)上定义add_structureboard_classes方法,其返回值会被追加到结构面板中包裹该插件的cms-draggable容器的 CSS 类上,可配合自定义 CSS 实现按状态/配置区分插件外观——典型场景是标记停用或草稿插件。方法返回的类还会应用到该插件的子插件上,从而可以为整个子树设置样式。注意该方法在结构面板渲染期间被调用,应保持轻量、避免数据库查询。
扩展占位符或插件的上下文菜单
通过覆盖CMSPluginBase上的两个方法(cms/plugin_base.py)可以扩展上下文菜单,返回PluginMenuItem实例列表(cms/plugin_base.py,PluginMenuItem支持名称、URL、POST 数据、确认文本与自定义 action):
get_extra_placeholder_menu_items(request, placeholder):为所有占位符的上下文菜单添加条目;get_extra_plugin_menu_items(request, plugin):为所有插件的上下文菜单添加条目。
典型用法(如内置的AliasPlugin模式)是在菜单项中 POSTplugin_id/placeholder_id与 CSRF token 到插件自定义 URL,实现"创建别名"之类的操作;插件还可以通过get_plugin_urls()注册自己的 URL 模式,它们会被挂载到 django CMS 页面 admin 的插件路径下(默认形如/admin/cms/page/plugin/<plugin-name>/)。
插件上下文处理器(Plugin Context Processors)
通过CMS_PLUGIN_CONTEXT_PROCESSORS设置启用,它们是可调用对象,在所有插件渲染前修改其上下文。接收三个参数:instance(插件模型实例)、placeholder(插件所在的占位符实例)、context(当前上下文,含 request)。返回值是一个字典,包含要加入上下文的变量:
def add_verbose_name(instance, placeholder, context): return {'verbose_name': instance._meta.verbose_name}插件处理器(Plugin Processors)
通过CMS_PLUGIN_PROCESSORS设置启用,在所有插件渲染后修改其输出。接收四个参数:instance、placeholder、rendered_content(渲染后的字符串)、original_context(渲染插件所用的原始上下文)。例如在 settings 中配置CMS_PLUGIN_PROCESSORS = ('yourapp.cms_plugin_processors.wrap_in_colored_box',),然后在处理器中为main占位符的每个插件输出套上彩色边框盒子。注意:插件处理器也会作用于嵌入 Text 插件的子插件,包裹式输出可能产生非法 HTML(如<p>内嵌套<div>);可通过instance._render_meta.text_enabled判断是否为内嵌渲染并原样返回。
内联 admin 与插件表单
由于CMSPluginBase继承自ModelAdmin,可以像定制 admin 一样定制插件表单。外键关联可以做成admin.StackedInline并放入插件的inlines元组。插件编辑界面使用的模板是cms/templates/admin/cms/page/plugin/change_form.html;自定义的最佳方式是新建模板extends该模板以保持统一外观,然后在插件类上设置change_form_template指向它(默认值定义在 cms/plugin_base.py)。
处理媒体资源与内联脚本
如果插件依赖 JS 或 CSS,应在插件输出模板中用 django-sekizai 的{% addtoblock "js" %}/{% addtoblock "css" %}引入(CMS 模板始终强制存在css和js两个 sekizai 命名空间);sekizai 对 admin 侧的插件模板无效。使用规范:每个addtoblock只放一个外部文件或一段内联代码(便于 sekizai 去重);外部文件应写成单行且addtoblock标签与 HTML 标签间无空格换行。内联 JavaScript 是潜在安全风险,应尽量避免——django CMS 4.2 起已从自身代码库移除全部内联 JS 以便设置有意义的 CSP 头;如果项目 CSP 不允许内联 JS,通过 Sekizai 提供的内联 JS 也不会被执行。编辑模式下内容刷新后会触发DOMContentLoaded、window.load(推荐监听它执行插件 JS)以及为兼容保留的cms-content-refreshjQuery 事件。
安全注意事项:插件输出是受信任的 HTML
插件渲染的任何内容都会作为**标记(markup)**插入页面。django CMS 渲染每个插件的模板、拼接结果字符串并在交给外层模板前标记为安全——这不是缺失的检查,而是占位符能工作的根本原因:模板输出本身就是 HTML,二次转义会把每个插件的<div>变成可见的<div>。Django 自身的render_to_string()、Template.render()和{% include %}返回安全字符串也是同理。
由此得出明确的结论:占位符管线无法保护你免受插件渲染内容的伤害,转义是插件自身的责任,且发生在插件模板内部。
- 不要破坏自动转义:Django 模板默认转义变量,
{{ instance.headline }}是安全的;而|safe、mark_safe()、带未转义参数的format_html()、{% autoescape off %}都会告诉 Django"这个值已是可信标记",只应作用于你自己代码产出的标记,绝不用于来自表单、请求、导入或外部 API 的值。 - 在保存时净化,而非渲染时:需要存 HTML 的插件(富文本正文、嵌入片段)应在存储时清洗(例如用
nh3.clean()配合允许标签/属性白名单),让数据库里已是安全值,使模板、订阅源、API、搜索索引等所有消费方共同受益。渲染时净化更弱:它每次请求都执行,且此时值已与周围模板标记无法区分,白名单要么宽到失去意义要么窄到破坏布局。若字段本不打算包含标记,则无需净化——保持自动转义开启,直接{{ instance.body }}渲染即可。 - 谁算攻击者:内容编辑器在你的信任边界内。被授权添加插件的员工用户按设计可以改变访客所见内容,页面级权限控制的是他们能碰哪些页面而非能放什么内容(参见 docs/explanation/permissions.rst——权限不能替代信任)。但这不意味着上面的建议可有可无:编辑器账号可能被盗用或共享、内容可能从其他系统导入、插件字段可能被无人审核的程序化来源填充——把你没有亲手渲染的一切都当作不可信数据。CSP 是第二道防线,可限制注入脚本造成的损害。
超越 Python 插件:djangocms-frontend
到目前为止描述的所有插件都需要一个 Python 类——CMSPluginBase子类,通常还有一个模型。djangocms-frontend提供了两条更轻量的插件路径,底层都会转换为完整的 django CMS 插件:
- 模板组件(Template components):编写一个 Django 模板,放到某个应用内的
cms_components目录,djangocms-frontend 会在启动时自动检测它。字段直接在模板中声明——无需任何 Python 文件。 - 自定义组件(Custom components):在
cms_components.py文件中编写一个 Python 类(继承CMSFrontendComponent),把字段声明为 Django 表单字段属性,并用@components.register装饰器注册。这让你对新增/编辑表单拥有完全控制权——fieldsets、自定义校验、mixins——同时省去完整CMSPluginBase子类的样板代码。
两条路径都与框架无关(djangocms-frontend 自带的组件和 mixin 面向 Bootstrap 5,但你不受其约束)。它们的取舍在于范围:模板组件完全不能包含 Python 代码;自定义组件不能给插件或模型类添加方法。当需要这种完全控制时,CMSPluginBase子类才是正确的工具。分步教程和示例请参阅 djangocms-frontend 自己的文档。
结语:从概念到源码的完整链路
回顾整条链路:插件是 django CMS 中"占位符内部的组合单元",由可选的CMSPlugin模型、继承自ModelAdmin的CMSPluginBase视图类和渲染模板三部分组成;通过 cms/plugin_pool.py 的autodiscover_modules("cms_plugins")自动发现、@plugin_pool.register_plugin注册;实例的创建与删除必须经由占位符 API(Placeholder.add_plugin/delete_plugin或cms.api.add_plugin)以保证插件树一致;关系的复制依赖copy_relations()钩子;嵌套、模型/槽位限制、菜单扩展、插件处理器等高级能力均有对应的类属性与源码方法可直接查阅。
对于"插件还是 apphook"的抉择,记住一句口诀:能塞进占位符的是插件,自带列表/详情/URL 的是应用(apphook 挂载)。围绕 docs/how_to/09-custom_plugins.rst 的完整教程与 cms/plugin_base.py 的类文档(含大量示例与注意事项),再结合本文梳理的源码依据,你已具备编写、注册、配置、安全加固并发布自定义 django CMS 插件的完整能力。
【免费下载链接】django-cmsThe easy-to-use and developer-friendly enterprise CMS powered by Django项目地址: https://gitcode.com/gh_mirrors/dj/django-cms
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考