概述
在Python生态语境下,虽然FastAPI大有一统天下的态势,如FastAPI生态:最佳实践、FastAPI-MCP、FastApiAdmin、Aegis Stack;但传统Web框架,如Django、Flask还远远没有颓败之势。基于Django的衍生生态,不要太多,GitHub上随随便便都能找出来几十个。
本文聚焦于CMS领域;提到CMS,都不需要谈及上下文,几乎等价于内容管理系统,Content Management System。在开发者领域,往CMS里塞入文件管理内容,显得很不专业。
wagtail
官网,由英国数字机构Torchbox开发并维护、基于Django框架的开源(GitHub,20.3K Star,4.5K Fork)CMS,提供直观的内容编辑界面,保持Django的灵活性和可扩展性,让开发者能够构建高度定制化的网站。
特性
- 流式字段(StreamField):灵活的内容编辑方式,支持多种内容块组合
- 强大的图片管理:内置图片库,支持裁剪、滤镜和焦点选择
- 直观的编辑界面:现代化管理后台,用户体验优秀
- 多站点支持:单个Wagtail实例可管理多个网站
- 版本控制:内容修订历史和草稿功能
- 搜索功能:集成Elasticsearch,提供强大的搜索能力
- 国际化支持:内置多语言内容管理
- API支持:提供RESTful API和GraphQL接口
实战
本地部署
bash
# 虚拟环境
python -m venv wagtail_env
source wagtail_env/bin/activate
# Windows
wagtail_env\Scripts\activate
# 安装Wagtail
pip install wagtail
# 创建新项目
wagtail start mysite
cd mysite
# 安装依赖并创建数据库
pip install -r requirements.txt
python manage.py migrate
# 创建超级用户
python manage.py createsuperuser
# 启动开发服务器
python manage.py runserver
浏览器打开http://127.0.0.1:8000/admin
页面是核心概念,每个页面都是一个Django模型。通过继承Page类,可创建自定义的页面类型,定义页面的字段和行为。
py
from django.db import models
from wagtail.models import Page
from wagtail.fields import RichTextField
from wagtail.admin.panels import FieldPanel
class BlogPage(Page):
date = models.DateField("发布日期")
intro = models.CharField(max_length=250, verbose_name="简介")
body = RichTextField(blank=True, verbose_name="正文")
content_panels = Page.content_panels + [
FieldPanel('date'),
FieldPanel('intro'),
FieldPanel('body'),
]
class Meta:
verbose_name = "博客文章"
StreamField允许编辑者自由组合不同类型的内容块,如文本、图片、视频、引用等。特别适合构建营销页面、产品介绍等需要丰富内容展示的场景,无需编写代码就能创建精美的页面。
py
from wagtail.fields import StreamField
from wagtail import blocks
from wagtail.images.blocks import ImageChooserBlock
class FlexiblePage(Page):
body = StreamField([
('heading', blocks.CharBlock(classname="title")),
('paragraph', blocks.RichTextBlock()),
('image', ImageChooserBlock()),
('quote', blocks.BlockQuoteBlock()),
], use_json_field=True)
content_panels = Page.content_panels + [
FieldPanel('body'),
]
图片管理系统,支持图片上传、裁剪、滤镜应用等功能。在模板中,可使用图片标签生成不同尺寸的图片,自动优化加载性能。图片处理是响应式的,能根据不同设备自动调整图片大小,提升用户体验。
py
# 在模型中使用图片字段
from wagtail.images.models import Image
class ArticlePage(Page):
header_image = models.ForeignKey(
'wagtailimages.Image',
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name='+'
)
content_panels = Page.content_panels + [
FieldPanel('header_image'),
]
在模板中使用:
{% load wagtailimages_tags %}
{% image page.header_image fill-800x400 %}
开发者创建自定义的StreamField块,实现特定的业务需求。通过继承StructBlock,可以组合多个字段创建复杂的内容结构,自定义块让内容管理更加结构化,并保持编辑界面的友好性。
py
from wagtail import blocks
class PersonBlock(blocks.StructBlock):
name = blocks.CharBlock(label="姓名")
photo = ImageChooserBlock(label="照片")
bio = blocks.TextBlock(label="简介")
class Meta:
template = 'blocks/person_block.html'
icon = 'user'
label = '团队成员'
class TeamPage(Page):
team_members = StreamField([
('person', PersonBlock()),
], use_json_field=True)
content_panels = Page.content_panels + [
FieldPanel('team_members'),
]
内置搜索功能,支持全文搜索和字段过滤。通过配置search_fields,可指定哪些字段参与搜索。支持多种搜索后端,包括数据库搜索和ES。
py
from wagtail.search import index
class BlogPage(Page):
date = models.DateField("发布日期")
intro = models.CharField(max_length=250)
body = RichTextField(blank=True)
search_fields = Page.search_fields + [
index.SearchField('intro'),
index.SearchField('body'),
index.FilterField('date'),
]
# 在视图中使用搜索
def search(request):
search_query = request.GET.get('query', None)
if search_query:
search_results = Page.objects.live().search(search_query)
else:
search_results = Page.objects.none()
return render(request, 'search_results.html', {
'search_results': search_results,
})
支持多语言网站的构建,可为同一页面创建不同语言版本。通过wagtail-localize插件,可实现内容的翻译管理和同步,为不同地区的用户提供本地化体验,包括页面内容、菜单、表单等。
py
# settings.py中配置
WAGTAIL_I18N_ENABLED = True
WAGTAIL_CONTENT_LANGUAGES = LANGUAGES = [
('zh-hans', '简体中文'),
('en', 'English'),
]
# 在模型中使用
from wagtail.models import TranslatableMixin
class MultilingualPage(TranslatableMixin, Page):
body = RichTextField()
class Meta:
unique_together = [
('translation_key', 'locale'),
]
Django-CMS
官网,基于Django的开源(GitHub,10.6K Star,3.2K Fork)CMS,把页面树、placeholder、plugin、前台编辑、多语言、版本和工作流这些能力拆成清晰层次,适合长期迭代、多人协作、和现有Django业务系统一起演进的内容平台。官方文档。
三个核心环节:页面树、内容块、编辑入口。
特点
- 适合内容复杂度高的站点,核心卖点:多语言、多站点、页面树、SEO友好URL、前台编辑、插件化内容编排。
- 不是单体框架,能和现有Django项目渐进式融合。不用把业务系统整个推倒重来,而是可以先把页面管理、内容区块、编辑入口接进来,再按需要叠加
apphook、版本管理、工作流、headless等扩展。 - 它把编辑体验和开发扩展拆成了两层,团队协作更顺:编辑侧要的是拖得动、改得快、预览顺;开发侧要的是结构稳定、扩展清楚、不会被一堆魔法模板反噬。
placeholder/plugin机制正好卡在这个交界面上。
适用场景
- 已有Django业务系统,希望把官网、专题页、活动页、品牌页面也纳入同一套工程体系。
- 内容结构复杂、栏目层级明显、需要运营同学频繁改版的站点。
- 需要多语言、多站点、编辑预览、版本管理这类能力,且未来还会继续扩展。
- 想要开发和编辑分层协作,由开发定义组件边界,内容团队负责组装页面。
不适用场景
- 只是做一个非常轻的静态官网,内容几乎不更新,用更轻的静态方案往往更省。
- 团队并不打算维护Django工程,只想要一个开箱即用的SaaS版CMS。
- 对依赖数量非常敏感,不愿接受CMS、插件、filer、versioning这一整套生态。
- 确定要走纯headless,且前端团队并不需要Django模板、placeholder、前台编辑这套能力。
实战
安装:pip install django-cms。
官方提供脚手架:
create_page()会同时把页面节点、SEO路径、导航信息这些基础骨架建起来,适合先验证栏目层级和URL设计。
py
from cms.api import create_page
from cms.models import Page
from django.contrib.auth import get_user_model
from pathlib import Path
import django
import os, sys
PROJECT_ROOT = Path.cwd() / "demo_project"
sys.path.insert(0, str(PROJECT_ROOT))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cmsdemo.settings")
django.setup()
User = get_user_model()
user, _ = User.objects.get_or_create(
username="mumu",
defaults={"email": "mumu@example.com"},
)
user.is_staff = True
user.is_superuser = True
user.save()
Page.objects.filter(
urls__path__in=["release-hub", "release-hub/weekly-digest"]
).delete()
root = create_page(
"Release Hub",
"base.html",
"en",
created_by=user,
in_navigation=True,
slug="release-hub",
reverse_id="release-hub-demo",
)
child = create_page(
"Weekly Digest",
"base.html",
"en",
created_by=user,
parent=root,
in_navigation=False,
slug="weekly-digest",
reverse_id="weekly-digest-demo",
)
for page in [root, child]:
content = page.get_admin_content("en")
print(f"title : {content.title}")
print(f"path : /en/{page.get_path('en')}/")
print(f"reverse id : {page.reverse_id}")
print(f"in nav : {content.in_navigation}")
print(f"children : {page.get_child_pages().count()}")
print("-" * 36)
placeholder/plugin体系组装可编辑内容区
py
from pathlib import Path
import django
import os, sys
from django.contrib.auth import get_user_model
from django.template import Context
from django.test import RequestFactory
from cms.api import add_plugin, create_page
from cms.models import Page
from cms.plugin_rendering import ContentRenderer
from cms.toolbar.toolbar import CMSToolbar
User = get_user_model()
user = User.objects.get(username="mumu")
page = Page.objects.filter(reverse_id="release-hub-demo").first()
if page is None:
page = create_page(
"Release Hub",
"base.html",
"en",
created_by=user,
in_navigation=True,
slug="release-hub",
reverse_id="release-hub-demo",
)
content = page.get_admin_content("en")
placeholder = content.placeholders.first()
placeholder.cmsplugin_set.all().delete()
# 添加插件
add_plugin(placeholder, "TextPlugin", "en", body="<p>Hello editors</p>")
add_plugin(
placeholder,
"LinkPlugin",
"en",
name="Stable docs",
link="https://docs.django-cms.org/en/stable/",
)
add_plugin(
placeholder,
"TextPlugin",
"en",
body="<p>Ship pages without leaving Django.</p>",
)
request = RequestFactory().get("/en/release-hub/")
request.user = user
request.session = {}
request.toolbar = CMSToolbar(request)
renderer = ContentRenderer(request)
html = renderer.render_placeholder(
placeholder,
Context({"request": request}),
language="en",
page=page,
)
print("slot:", placeholder.slot)
print("plugin count:", placeholder.get_plugins("en").count())
for plugin in placeholder.get_plugins("en"):
instance, _ = plugin.get_plugin_instance()
extra = getattr(instance, "body", "") or getattr(instance, "name", "")
print(f"{plugin.position:>2} {plugin.plugin_type:<10} {extra}")
print("html size:", len(html))
print("html head:", html[:140].replace("\\n", " "))
多语言、编辑入口、版本状态等运营链路里的关键节点都留接口。
同一个页面补德语内容版本,并直接取出对应draft状态和edit/preview URL。
py
import os, sys
from pathlib import Path
import django
from django.contrib.auth import get_user_model
from django.test import override_settings
from cms.api import create_page, create_page_content
from cms.models import Page
from cms.models.contentmodels import EmptyPageContent
from cms.toolbar.utils import get_object_edit_url, get_object_preview_url
from djangocms_versioning.models import Version
User = get_user_model()
user, _ = User.objects.get_or_create(
username="wong",
defaults={"email": "wong@johnny.com"},
)
user.is_staff = True
user.is_superuser = True
user.save()
page = Page.objects.filter(reverse_id="release-hub-demo").first()
if page is None:
page = create_page(
"Release Hub",
"base.html",
"en",
created_by=user,
in_navigation=True,
slug="release-hub",
reverse_id="release-hub-demo",
)
langs = [("en", "English"), ("de", "German")]
cms_langs = {
1: [
{
"code": "en",
"name": "English",
"public": True,
"redirect_on_fallback": True,
"hide_untranslated": False,
"fallbacks": ["de"],
},
{
"code": "de",
"name": "German",
"public": True,
"redirect_on_fallback": True,
"hide_untranslated": False,
"fallbacks": ["en"],
},
],
"default": {
"public": True,
"redirect_on_fallback": True,
"hide_untranslated": False,
},
}
with override_settings(LANGUAGES=langs, CMS_LANGUAGES=cms_langs):
content_de = page.get_admin_content("de")
if isinstance(content_de, EmptyPageContent):
create_page_content(
"de",
"Release Center DE",
page,
slug="release-zentrum",
created_by=user,
menu_title="Freigaben",
)
page = Page.objects.get(pk=page.pk)
page._clear_internal_cache()
for lang in ["en", "de"]:
content = page.get_admin_content(lang)
version = Version.objects.get_for_content(content)
print(f"{lang:<11} title={content.title}")
print(f"version: v{version.number} {version.state}")
print(f"path: /{lang}/{page.get_path(lang)}/")
print(f"edit url: {get_object_edit_url(content, language=lang)}")
print(f"preview url: {get_object_preview_url(content, language=lang)}")
最佳实践
- 先明确要不要引入
djangocms-versioning、djangocms-alias、djangocms-frontend这些官方扩展,不要把核心CMS与编辑工作流混在一起评估。 - 把页面模板、placeholder命名、可用plugin列表先设计清楚,避免后面运营把内容拼装能力用成隐形模板系统。
- 提前验证多语言、权限、预览链路和URL策略,尤其是老项目增量接入时的路径冲突与导航边界。