Django6:使用——表单、文章管理与 Admin
本系列目录
本系列以 Django 5.2 LTS 和个人博客为例。Django1—Django8 是文章序号。
- Django1:部署——从本地启动到服务器运行
- Django2:配置——看懂项目结构与请求流程
- Django3:数据库——模型设计、关系与迁移
- Django4:ORM——增删改查、事务与查询优化
- Django5:页面——视图、模板、搜索与分页
- Django6:使用——表单、文章管理与 Admin
- Django7:权限——登录、会话与访问控制
- Django8:维护——测试、日志、备份与升级
下载全部教程与可运行示例(ZIP)。解压后进入 django-series/example/,按第一篇运行。
前面的内容已经能展示公开文章。接下来要让作者通过网页填写标题、正文、分类与标签,保存草稿,修改内容,再发布给读者。这正是表单和后台管理各自发挥作用的地方。
本篇采用 Django 自带的 ModelForm 和 Admin,不需要额外前端框架。完整配套项目已包含所有路由和模板;手工搭建时,登录入口在第七篇补齐后,才能完成需要登录的前台操作。
1. 先走一遍真实使用流程
管理员先在 /admin/ 创建“技术”分类、“Django”和“Python”标签,再创建一个普通作者账号。作者在 /accounts/login/ 登录,进入“写文章”:
| 输入项 | 示例 |
|---|---|
| 标题 | 我的第一篇 Django 实战文章 |
| 网址标识 | my-first-django-article |
| 正文 | 今天学习了模型、视图和表单…… |
| 分类 | 技术 |
| 标签 | Django、Python |
| 状态 | 草稿 |
保存后跳转到“我的文章”。此时游客看不到它。作者点击“编辑”,将状态改为“已发布”并保存,文章才会出现在首页。想撤下文章,可以重新改成草稿;想永久移除,则通过删除确认页提交。
本例网址标识使用 ASCII 字母、数字、连字符或下划线,且全站唯一。中文标题可自由填写,但至少需要 5 个字符。正文按纯文本保存,标签是已有标签的多选项。
2. ModelForm 负责哪些工作
完整 blog/forms.py:
from django import forms
from .models import Post
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ["title", "slug", "body", "category", "tags", "status"]
widgets = {"body": forms.Textarea(attrs={"rows": 12})}
def clean_title(self):
title = self.cleaned_data["title"].strip()
if len(title) < 5:
raise forms.ValidationError("标题至少需要 5 个字符。")
return title
Meta.model 指明对应模型,fields 明确允许用户编辑的字段。作者字段不在列表里,因为它必须由服务器根据登录用户确定;浏览器提交一个作者 ID,不能成为授权依据。
clean_title() 增加“标题至少 5 个字符”的业务规则。字段长度、必填、选项合法性、关联对象和模型唯一性等检查,也由 ModelForm 按对应规则处理。对于重复 slug,通常会显示字段错误;数据库唯一约束仍然处理并发时的最终冲突。正式系统如需将并发冲突转换为友好提示,应在事务块外识别预期的完整性错误,而不是吞掉所有数据库异常。ModelForm 使用说明
3. 新建文章:先验证,再绑定作者
blog/views.py 需要下列公共导入;完整文件还包含第五篇的公开页面导入:
import logging
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.core.paginator import Paginator
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_GET, require_http_methods
from .forms import PostForm
from .models import Post
logger = logging.getLogger(__name__)
新建视图如下:
@login_required
@require_http_methods(["GET", "POST"])
def post_create(request):
form = PostForm(request.POST if request.method == "POST" else None)
if request.method == "POST" and form.is_valid():
with transaction.atomic():
post = form.save(commit=False)
post.author = request.user
post.save()
form.save_m2m()
messages.success(request, "文章已保存。")
logger.info("post_created post_id=%s user_id=%s", post.pk, request.user.pk)
return redirect("blog:mine")
return render(request, "blog/post_form.html", {"form": form, "heading": "写文章"})
GET 请求得到空白表单;POST 请求把用户提交的数据绑定到表单。is_valid() 成功后,form.save(commit=False) 先生成模型实例,服务器写入 post.author,再保存主表和多对多关系。
使用 commit=False 后,要记得 form.save_m2m()。文章在保存前还没有主键,标签关系不能提前写入。事务将文章与关系作为一组数据库操作处理。
这里刻意使用 request.POST if request.method == "POST" else None,使“提交了空表单”仍是绑定表单,能够显示必填错误。request.POST or None 在空 POST 时会把它误当成未提交状态。
保存成功后重定向到“我的文章”,避免用户刷新成功页时重复提交 POST。这就是常见的 POST → Redirect → GET 流程。保存草稿也能看到它,不会误跳到只展示公开内容的详情页。
4. 编辑文章:instance 决定更新对象
@login_required
@require_http_methods(["GET", "POST"])
def post_update(request, pk):
post = get_object_or_404(Post, pk=pk, author=request.user)
form = PostForm(request.POST if request.method == "POST" else None, instance=post)
if request.method == "POST" and form.is_valid():
with transaction.atomic():
form.save()
messages.success(request, "修改已保存。")
return redirect("blog:mine")
return render(request, "blog/post_form.html", {"form": form, "heading": "编辑文章"})
查询包含 author=request.user,只允许当前作者取得自己的对象。instance=post 表示表单更新这条记录;漏写它会让表单尝试创建新记录。
即使网页没有显示别人的编辑链接,攻击者仍可能直接构造 URL,所以权限判断必须在视图中执行。这个条件同时适用于 GET 显示编辑页和 POST 保存修改。并发编辑自己的同一篇文章仍可能出现后保存覆盖先保存,本例尚未加入版本号冲突检测。
5. 删除文章:GET 确认,POST 执行
@login_required
@require_http_methods(["GET", "POST"])
def post_delete(request, pk):
post = get_object_or_404(Post, pk=pk, author=request.user)
if request.method == "POST":
post.delete()
messages.success(request, "文章已删除。")
return redirect("blog:mine")
return render(request, "blog/post_confirm_delete.html", {"post": post})
删除链接打开确认页面,不直接改变数据库。只有收到通过 CSRF 检查的 POST,并且文章属于当前用户,才执行删除。
确认模板 templates/blog/post_confirm_delete.html:
{% extends 'base.html' %}
{% block content %}
<h1>删除文章</h1>
<p>确认删除《{{ post.title }}》?删除后无法在页面恢复。</p>
<form method="post">
{% csrf_token %}
<button type="submit">确认删除</button>
<a href="{% url 'blog:mine' %}">取消</a>
</form>
{% endblock %}
模板中的 {% csrf_token %} 为提交提供 CSRF token;实际验证由中间件完成。把删除动作写成普通 GET 链接,会让抓取、预取或误点都可能造成数据变化。Django CSRF 说明
6. 公用编辑模板与“我的文章”
新建与编辑共用 templates/blog/post_form.html:
{% extends 'base.html' %}
{% block content %}
<h1>{{ heading }}</h1>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">保存</button>
</form>
{% endblock %}
form.as_p 渲染表单字段及验证错误。生产产品可以改成逐字段布局,但错误提示和 CSRF token 都需要保留。HTML 的 required 只是浏览器端便利,服务端 is_valid() 才能处理绕过浏览器约束的请求。
“我的文章”视图:
@login_required
@require_GET
def post_mine(request):
posts = Post.objects.filter(author=request.user)
page_obj = Paginator(posts, 10).get_page(request.GET.get("page"))
return render(request, "blog/post_mine.html", {"page_obj": page_obj})
对应 templates/blog/post_mine.html:
{% extends 'base.html' %}
{% block content %}
<h1>我的文章</h1>
{% for post in page_obj %}
<article>
<h2>{{ post.title }}</h2>
<p>{{ post.get_status_display }}</p>
{% if post.status == 'published' %}<a href="{{ post.get_absolute_url }}">查看</a>{% endif %}
<a href="{% url 'blog:update' post.pk %}">编辑</a>
<a href="{% url 'blog:delete' post.pk %}">删除</a>
</article>
{% empty %}<p>还没有文章,点击“写文章”开始。</p>{% endfor %}
{% include 'blog/pagination.html' %}
{% endblock %}
这里按作者筛选,没有只取已发布,因此作者能找到自己的草稿。公开查看链接只为已发布文章显示;草稿可以通过编辑页面检查正文。
需要接入的路由为 /my/posts/、/posts/new/、/posts/<int:pk>/edit/、/posts/<int:pk>/delete/,分别命名为 mine、create、update、delete。可直接采用第二篇的完整 blog/urls.py。
7. Admin 用于管理员维护数据
完整 blog/admin.py:
from django.contrib import admin
from .models import Category, Post, Tag
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = ("title", "author", "category", "status", "created_at")
list_filter = ("status", "category")
search_fields = ("title", "body", "author__username")
prepopulated_fields = {"slug": ("title",)}
filter_horizontal = ("tags",)
readonly_fields = ("created_at", "updated_at", "views")
list_select_related = ("author", "category")
admin.site.register(Category)
admin.site.register(Tag)
| 配置 | 后台效果 |
|---|---|
list_display |
列表显示作者、分类、状态等列 |
list_filter |
可以按状态和分类筛选 |
search_fields |
搜索标题、正文和作者用户名 |
prepopulated_fields |
根据标题辅助填写 slug |
filter_horizontal |
提供标签双栏选择控件 |
readonly_fields |
创建时间等字段只读 |
list_select_related |
提前加载作者和分类,减少列表查询 |
自动填 slug 只是辅助:对于中文标题和相同标题,仍要检查并手动填写符合规则的唯一值。管理员通过 Admin 添加文章时,需要显式选择作者;前台作者页面则由服务器自动绑定当前用户。
本例 Admin 面向可信管理员,具有相应模型权限的后台人员可以跨作者管理内容。前台的“只能改自己的文章”不会自动变成 Admin 的规则。普通作者账号不勾选 is_staff,不授予后台权限。Admin 官方参考
8. 从读者角度验收功能
用管理员创建普通作者 Alice 和 Bob。Alice 登录后新建草稿,游客首页不应出现这篇文章;Alice 发布后,游客能打开详情;Bob 直接访问 Alice 的编辑和删除地址,应得到 404。Alice 打开删除确认页但不提交时,文章仍然存在;确认提交后,文章从数据库和页面移除。
再尝试空标题、少于 5 个字符的标题和重复 slug,页面应显示错误并保留可以修正的输入。数据库里不应因此多出半篇文章或丢失标签关联。
下一篇 Django7:登录与权限 会解释用户身份、会话和作者权限的具体实现。