Django 实战:做一个个人博客 · 第 6 / 8 篇(展开目录)

全系列用 Django 5.2 LTS 做一个个人博客:游客看文章,作者登录后写文章,管理员维护分类和账号。Django1—Django8 是文章序号,不是 Django 版本号。

  1. Django1:起步——安装、运行与项目结构
  2. Django2:配置与路由——一个请求在 Django 里怎么走
  3. Django3:模型与迁移——把博客的数据存进数据库
  4. Django4:ORM——查询、更新、统计与事务
  5. Django5:页面——视图、模板、搜索与分页
  6. Django6:表单与后台——让作者在网页上写文章
  7. Django7:登录与权限——谁能改哪篇文章
  8. Django8:上线与维护——测试、部署、备份与发布

下载可运行的示例项目(ZIP)

约 25 分钟 · 登录相关的路由在第 7 篇补齐

本篇目标: 作者能在网页上新建、编辑、删除自己的文章;管理员能在后台维护分类、标签和全部文章。

完成标志: 作者存一篇草稿,游客看不到;改成“已发布”后,游客能看到。

1. 先走一遍使用流程

  1. 管理员在 /admin/ 创建分类“技术”、标签“Django”“Python”,再建一个普通作者账号 Alice。
  2. Alice 在 /accounts/login/ 登录,点“写文章”:
字段 示例
标题 我的第一篇 Django 实战文章
网址标识 my-first-django-article
正文 今天学习了模型、视图和表单……
分类 / 标签 技术 / Django、Python
状态 草稿
  1. 保存后跳到“我的文章”。此时游客看不到它。
  2. Alice 点“编辑”,把状态改成“已发布”——文章出现在首页。
  3. 想撤下就改回草稿;想彻底删除就走删除确认页。

规则:网址标识只能用英文字母、数字、连字符、下划线,全站唯一;标题至少 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
  • fields 是一份白名单:只有这些字段允许用户填写。
  • author 故意不在列表里——作者由服务器根据登录用户决定,绝不能让浏览器提交。
  • clean_<字段名>() 用来加自定义验证规则。长度、必填、唯一性等检查 ModelForm 会根据模型自动完成。

3. 一次提交是怎么处理的

先看整体流程,再看代码。选择不同场景,看看服务器分别怎么回应:

交互演示加载中…(需要启用 JavaScript)

4. 新建文章

blog/views.py 需要的导入(与第 5 篇的合并):

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": "写文章"})

对照上面的演示看三个关键点:

  1. **commit=False**:先拿到对象但不保存,让服务器有机会设置 author。
  2. **form.save_m2m()**:用了 commit=False 就必须手动保存多对多关系。文章保存前还没有 id,标签关系没法提前写入。
  3. POST → 跳转 → GET:保存成功后跳转,而不是直接渲染页面。这样用户刷新时不会重复提交。

transaction.atomic() 保证文章和标签关系一起成功或一起失败。

5. 编辑文章

@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 写在查询里:只能取到自己的文章,别人的文章一律 404。这是权限控制(第 7 篇详细讲)。
  • instance=post 告诉表单“更新这条记录”。漏写它,表单会去新建一篇。

隐藏按钮不是权限控制

页面上不显示别人文章的“编辑”链接,攻击者照样可以手动输入 /posts/5/edit/。必须在视图里判断。

6. 删除文章: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})

确认页 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 %}

为什么不能用一个普通链接直接删除?因为 GET 请求可能被浏览器预加载、被爬虫访问、被误点。会改变数据的操作只接受 POST,而 POST 又必须通过 CSRF 检查({% csrf_token %})。

7. 表单模板与“我的文章”

新建和编辑共用 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 自动渲染所有字段和错误提示。

“我的文章”视图——注意它不限定已发布,所以作者能看到自己的草稿:

@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 %}

get_status_display 把 published 显示成“已发布”。只有已发布的文章才显示“查看”链接,草稿去编辑页查看正文。

路由使用第 2 篇的完整 blog/urls.py(mine、create、update、delete 四条)。

8. 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 列表页预加载作者和分类,避免 N+1

前台和后台的规则不同

前台“只能改自己的文章”是写在视图里的规则,Admin 不受它约束——有后台权限的人可以管理所有文章。所以普通作者不要勾选“工作人员状态”(is_staff)。

9. 验收清单

用管理员建两个普通作者 Alice 和 Bob,然后:

  • Alice 存草稿 → 游客首页看不到
  • Alice 发布 → 游客能打开详情
  • Bob 直接访问 Alice 文章的编辑、删除地址 → 404
  • Alice 打开删除确认页但不提交 → 文章还在
  • 提交空标题、4 个字的标题、重复的 slug → 显示错误,已填内容保留,数据库没有多出半篇文章

小练习

1. 如果忘了写 form.save_m2m(),会出现什么现象?

文章能保存成功,但勾选的标签全部丢失。因为 commit=False 时,多对多关系要等你手动调用 save_m2m() 才写入。

2. Bob 在浏览器开发者工具里给表单加了一个 author=Alice的id 字段再提交,会怎样?

没有影响。author 不在 PostForm.Meta.fields 白名单里,表单会忽略它;视图又明确设置了 post.author = request.user。文章作者仍然是 Bob。