本系列目录

本系列以 Django 5.2 LTS 和个人博客为例。Django1—Django8 是文章序号。

  1. Django1:部署——从本地启动到服务器运行
  2. Django2:配置——看懂项目结构与请求流程
  3. Django3:数据库——模型设计、关系与迁移
  4. Django4:ORM——增删改查、事务与查询优化
  5. Django5:页面——视图、模板、搜索与分页
  6. Django6:使用——表单、文章管理与 Admin
  7. Django7:权限——登录、会话与访问控制
  8. 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/,分别命名为 minecreateupdatedelete。可直接采用第二篇的完整 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:登录与权限 会解释用户身份、会话和作者权限的具体实现。


上一篇:Django5:页面——视图、模板、搜索与分页 · 下一篇:Django7:权限——登录、会话与访问控制