Django6:表单与后台——让作者在网页上写文章
Django 实战:做一个个人博客 · 第 6 / 8 篇(展开目录)
全系列用 Django 5.2 LTS 做一个个人博客:游客看文章,作者登录后写文章,管理员维护分类和账号。Django1—Django8 是文章序号,不是 Django 版本号。
本篇目标: 作者能在网页上新建、编辑、删除自己的文章;管理员能在后台维护分类、标签和全部文章。
完成标志: 作者存一篇草稿,游客看不到;改成“已发布”后,游客能看到。
1. 先走一遍使用流程
- 管理员在
/admin/创建分类“技术”、标签“Django”“Python”,再建一个普通作者账号 Alice。 - Alice 在
/accounts/login/登录,点“写文章”:
| 字段 | 示例 |
|---|---|
| 标题 | 我的第一篇 Django 实战文章 |
| 网址标识 | my-first-django-article |
| 正文 | 今天学习了模型、视图和表单…… |
| 分类 / 标签 | 技术 / Django、Python |
| 状态 | 草稿 |
- 保存后跳到“我的文章”。此时游客看不到它。
- Alice 点“编辑”,把状态改成“已发布”——文章出现在首页。
- 想撤下就改回草稿;想彻底删除就走删除确认页。
规则:网址标识只能用英文字母、数字、连字符、下划线,全站唯一;标题至少 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": "写文章"})
对照上面的演示看三个关键点:
- **
commit=False**:先拿到对象但不保存,让服务器有机会设置author。 - **
form.save_m2m()**:用了commit=False就必须手动保存多对多关系。文章保存前还没有 id,标签关系没法提前写入。 - 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。
- 感谢你赐予我前进的力量

