第 7 章:表单

第 7 章:表单

在本章中,我们将继续完善博客应用,切换到基于类的视图并添加表单,以便用户可以创建、编辑或删除他们的任何博客条目。HTML 表单是 Web 开发中比较复杂且容易出错的部分之一。每当你接受用户输入时,都会存在重大的安全问题,因为用户正在向你的数据库上传信息。所有表单必须正确渲染、验证并保存到数据库。手动编写这些代码既耗时又复杂,因此 Django 带有强大的内置表单通用编辑视图,用于常见任务,如显示、创建、更新或删除表单。

ListView 和 DetailView

目前,我们使用基于函数的视图来显示列出所有博客文章的页面和展示独立文章。我们可以继续这条路线,为创建、编辑和删除功能创建基于函数的视图;然而,这样做需要更多的代码,并且与专门为此设计的通用基于类视图相比更容易出错。因此,我们将在本书的剩余部分切换到使用通用基于类视图。

让我们看看当前的 blog/views.py 文件。

# blog/views.py
from django.shortcuts import render, get_object_or_404
from .models import Post

def post_list(request):
    posts = Post.objects.all()
    return render(request, "home.html", {"posts": posts})

def post_detail(request, pk):
    post = get_object_or_404(Post, pk=pk)
    return render(request, "post_detail.html", {"post": post})

切换到通用基于类视图相当直接。以下是更新后的视图代码:

# blog/views.py
from django.views.generic import ListView, DetailView
from .models import Post

class BlogListView(ListView):
    model = Post
    template_name = "home.html"

class BlogDetailView(DetailView):
    model = Post
    template_name = "post_detail.html"

在顶部,我们导入通用视图 ListViewDetailView,以及我们的模型 Post。我们之前在留言板应用中使用过 ListView,但 DetailView 是新的。它用于详情页面,格式与 ListView 非常相似。我们传入通用基于类视图,并为两个视图定义模型和模板。仅此而已。

接下来是 URL 文件。我们将视图导入修改为新的 BlogListViewBlogDetailView。然后,更新两个路由中的第二个参数,指定视图名称并添加 as_view() 来将类转换为可调用的视图函数。

# blog/urls.py
from django.urls import path
from .views import BlogListView, BlogDetailView  # 新增

urlpatterns = [
    path("post/<int:pk>/", BlogDetailView.as_view(), name="post_detail"),  # 新增
    path("", BlogListView.as_view(), name="home"),  # 新增
]

最后的更新是我们的模板。之前,在基于函数的列表和详情视图中,我们将上下文对象分别命名为 postspost。但是对于通用基于类视图的 ListView,你需要知道默认的命名模式是 <model>_list;由于我们的模型是 Post,上下文对象将是 post_list。更新后的代码如下:

<!-- templates/home.html -->
{% extends "base.html" %}

{% block content %}
{% for post in post_list %}  <!-- 新增 -->
<div class="post-entry">
    <h2><a href="{{ post.get_absolute_url }}">{{ post.title }}</a></h2>
    <p>{{ post.body }}</p>
</div>
{% endfor %}
{% endblock content %}

你想更改上下文对象的名称吗?你可以!Django 几乎总是提供自定义行为的方式。要更新的属性叫做 context_object_name。下面的更新将把它改回 posts 而不是 post_list。我们不会在书中实施这个更改,但你完全可以自己尝试。

# blog/views.py
...
class BlogListView(ListView):
    model = Post
    template_name = "home.html"
    context_object_name = "posts"  # 将上下文对象名称改为 'posts'
...

DetailView 中的上下文对象名称也有默认值,可以是模型名称(本例中为 post)或 object。这意味着我们可以保持当前模板不变。

<!-- templates/post_detail.html -->
{% extends "base.html" %}

{% block content %}
<div class="post-entry">
    <h2>{{ post.title }}</h2>
    <p>{{ post.body }}</p>
</div>
{% endblock content %}

或者我们可以像这样将 post 改为 object

<!-- templates/post_detail.html -->
{% extends "base.html" %}

{% block content %}
<div class="post-entry">
    <h2>{{ object.title }}</h2>
    <p>{{ object.body }}</p>
</div>
{% endblock content %}

如果你在浏览器中刷新一篇独立博客文章页面,你会看到两者都有效。你喜欢哪种命名模式是个人的偏好。我喜欢使用模型名称,所以我们在详情视图模板中将继续使用 post。如果你不喜欢模板上下文对象的这些内置名称,你可以在 DetailView 中覆盖现有的 context_object_name 变量。

Mixin

如果你仔细观察,你会注意到 ListViewcontext_object_name 属性是 MultipleObjectMixin 的一部分,而 DetailViewcontext_object_name 则是 SingleObjectMixin 的一部分。为什么有这个区别?

Mixin(混入)是一种多重继承类型,你可以通过包含额外的类来向类添加特定功能。Mixin 用于基于类的视图(CBV)和通用基于类的视图(GCBV)中,以添加可重用的行为或功能,而无需重复代码。但你需要知道自己在做什么才能有效使用它们。

全面介绍 Django mixin 超出了入门书的范围,但值得简要解释一下继承结构是如何工作的。为此,我们将依赖 Classy Class-Based Views(CCBV)网站,该网站致力于描述每个 Django GCBV 的完整方法和属性。如果你决定使用 GCBV,这是一个非常宝贵的资源。查看 ListView 的条目,你可以在页面顶部看到其祖先列表。ListView 使用的类层次结构从底层开始。

  1. ListView
  2. MultipleObjectTemplateResponseMixin
  3. TemplateResponseMixin
  4. BaseListView
  5. MultipleObjectMixin
  6. ContextMixin
  7. View

基类 View 被所有基于类的视图使用。你不需要记住所有这些 mixin 才能使用 GCBV。但随着时间的推移,当你尝试自定义它们的行为时,你很可能会发现自己要查找需要覆盖的特定属性或方法。

基于函数视图的支持者可能会说这太荒谬了;你应该看到所有正在使用的代码。基于类视图的粉丝则会反驳说,当你只想更改一两行代码时,反复编写相同的样板代码是荒谬的。我属于后者,但你可以理解为什么一些非常有经验的 Django 开发者更喜欢基于函数的视图。

CreateView

我们现在需要一个用户可以创建新博客文章的视图。CreateView 就是为此目的而设计的通用基于类视图。这是我们需要在 views 文件中编写的代码:

# blog/views.py
from django.views.generic import ListView, DetailView
from django.views.generic.edit import CreateView  # 新增
from .models import Post

class BlogListView(ListView):
    model = Post
    template_name = "home.html"

class BlogDetailView(DetailView):
    model = Post
    template_name = "post_detail.html"

class BlogCreateView(CreateView):  # 新增
    model = Post
    template_name = "post_new.html"
    fields = ["title", "author", "body"]

在顶部,我们从 django.views.generic.edit 模块导入 CreateView。然后我们创建一个类 BlogCreateView,它扩展了 CreateView,指定了模型 Post、模板名称 post_new.html 以及我们希望在表单上显示的数据库字段,即 titleauthorbody。仅此而已!这个视图的基于函数版本要长得多。

然后,更新 URL 文件以添加创建页面的 URL 路由。

# blog/urls.py
from django.urls import path
from .views import BlogListView, BlogDetailView, BlogCreateView  # 新增

urlpatterns = [
    path("post/new/", BlogCreateView.as_view(), name="post_new"),  # 新增
    path("post/<int:pk>/", BlogDetailView.as_view(), name="post_detail"),
    path("", BlogListView.as_view(), name="home"),
]

很简单,对吧?这是我们已经见过的 URL、视图和模板模式。我们导入了新视图 BlogCreateView 并设置了新的路径。它位于 post/new/,使用BlogCreateView 视图,URL 名称为 post_new

唯一剩下的就是我们的模板。在文本编辑器中创建一个新模板 templates/post_new.html。然后添加以下代码:

<!-- templates/post_new.html -->
{% extends "base.html" %}

{% block content %}
<h1>New post</h1>
<form action="" method="post">{% csrf_token %}
    {{ form }}
    <input type="submit" value="Save">
</form>
{% endblock content %}

让我们分解一下我们做了什么:

  • 在第一行,我们扩展了基础模板。
  • 使用 HTML <form> 标签和 POST 方法,因为我们正在发送数据。如果我们从表单接收数据,例如在搜索框中,我们会使用 GET。
  • 添加 {% csrf_token %},Django 提供此功能(CSRF 文档)来保护我们的表单免受跨站请求伪造攻击。你应该在你的所有 Django 表单中使用它。
  • 然后,为了输出表单数据,我们使用 { form } 来渲染指定的字段。
  • 最后,指定一个 submit 类型的输入,并赋值为 “Save”。

要查看效果,使用 python manage.py runserver 启动服务器,然后访问创建新页面 http://127.0.0.1:8000/post/new

博客新建页面

尝试创建一个新的博客文章,然后点击”Save”按钮提交。

保存第三篇博客文章

完成后,它会重定向到文章详情页 http://127.0.0.1:8000/post/3/。成功!

第三篇博客文章页面

与其让用户猜测创建新文章页面的位置,不如在我们的基础模板中添加一个链接。它将采用 <a href="{% url 'post_new' %}"></a> 的形式,其中 post_new 是我们的 URL 名称。

更新后的 templates/base.html 文件应该如下所示:

<!-- templates/base.html -->
{% load static %}
<html>
<head>
    <title>Django blog</title>
    <link href="https://fonts.googleapis.com/css?family=Source+Sans+Pro:400"
          rel="stylesheet">
    <link href="{% static 'css/base.css' %}" rel="stylesheet">
</head>
<body>
    <div>
        <header>
            <!-- 开始新 HTML... -->
            <div class="nav-left">
                <h1><a href="{% url 'home' %}">Django blog</a></h1>
            </div>
            <div class="nav-right">
                <a href="{% url 'post_new' %}">+ New Blog Post</a>
            </div>
            <!-- 结束新 HTML... -->
        </header>
        {% block content %}
        {% endblock content %}
    </div>
</body>
</html>

导航到首页,右上角的”+ New Blog Post”链接将可见。

带有新建按钮的首页

点击它将跳转到创建新文章页面 http://127.0.0.1:8000/post/new/

如果我们使用基于函数的视图来创建此功能,我们需要一个专门的 blog/forms.py 文件和一个更长的视图来解释如何处理表单、验证数据并将其保存到数据库。而通过使用 CreateView,我们依赖内置代码来处理所有这些事项。

UpdateView

我们的下一个任务是添加一个带有表单的页面,用于编辑现有的博客文章。事实证明,通用基于类视图 UpdateView 正是为此设计的。我们将将其添加到博客中,并且添加新视图、新 URL 路径、最后新模板的模式应该会让你感到熟悉。

让我们从视图开始。在 blog/views.py 文件顶部导入 UpdateView,然后创建一个扩展它的新类 BlogUpdateView。与 CreateView 一样,我们只需要定义三样东西:模型、模板名称和希望在表单上显示的字段。

# blog/views.py
from django.views.generic import ListView, DetailView
from django.views.generic.edit import CreateView, UpdateView  # 新增
from .models import Post

class BlogListView(ListView):
    model = Post
    template_name = "home.html"

class BlogDetailView(DetailView):
    model = Post
    template_name = "post_detail.html"

class BlogCreateView(CreateView):
    model = Post
    template_name = "post_new.html"
    fields = ["title", "author", "body"]

class BlogUpdateView(UpdateView):  # 新增
    model = Post
    template_name = "post_edit.html"
    fields = ["title", "body"]

注意,我们没有在字段中包含”author”,因为我们假设文章的作者在编辑时不会更改。我们只希望 titlebody 文本可供更新。

接下来,我们需要为这个更新视图添加一个新的 URL 路径。更新 blog/urls.py,在顶部导入 BlogUpdateView,然后创建一个带有 URL 模式 /post/<int:pk>/edit/ 的新路由。它将使用 BlogUpdateView 作为视图,并拥有一个名为 post_edit 的 URL。

# blog/urls.py
from django.urls import path
from .views import (
    BlogListView,
    BlogDetailView,
    BlogCreateView,
    BlogUpdateView,   # 新增
)

urlpatterns = [
    path("post/new/", BlogCreateView.as_view(), name="post_new"),
    path("post/<int:pk>/", BlogDetailView.as_view(), name="post_detail"),
    path("post/<int:pk>/edit/", BlogUpdateView.as_view(), name="post_edit"),  # 新增
    path("", BlogListView.as_view(), name="home"),
]

第三步是为更新页面添加一个新模板。从视图中我们知道它应该命名为 post_edit.html,所以将该文件添加到 templates 目录中。它应该包含以下代码:

<!-- templates/post_edit.html -->
{% extends "base.html" %}

{% block content %}
<h1>Edit post</h1>
<form action="" method="post">{% csrf_token %}
    {{ form }}
    <input type="submit" value="Update">
</form>
{% endblock content %}

在顶部,我们使用 extends 继承基础模板 base.html,然后将此页面的内容包裹在 content 块之间。我们再次使用 HTML <form></form> 标签、Django 的 csrf_token 来保证安全,并将提交按钮的值设为”Update”。

我们可以直接访问特定文章的 URL,例如 http://127.0.0.1:8000/post/1/edit/ 来编辑第一篇博客文章。然而,更好的方法是在 post_detail.html 模板中添加一个到独立博客页面的链接。

<!-- templates/post_detail.html -->
{% extends "base.html" %}

{% block content %}
<div class="post-entry">
    <h2>{{ post.title }}</h2>
    <p>{{ post.body }}</p>
</div>
<!-- 开始新 HTML... -->
<a href="{% url 'post_edit' post.pk %}">+ Edit Blog Post</a>
<!-- 结束新 HTML... -->
{% endblock content %}

我们使用 <a href>...</a>{% url ... %} 标签添加了一个链接。在其中,我们指定了 URL 的目标名称 post_edit,并传递了所需的参数,即文章的主键 post.pk

现在,如果你点击一篇博客条目,你会看到新的 “+ Edit Blog Post” 超链接。

带有编辑按钮的博客页面

如果你点击”+ Edit Blog Post”,你将被重定向到 /post/1/edit/(如果是你的第一篇博客文章,因此 URL 中有 1)。注意,表单预先填充了数据库中该文章的现有数据。让我们做一点更改……

博客编辑页面

点击”Update”按钮后,我们将被重定向到文章的详情视图,更改在其中可见。导航到首页可以看到更改与其他所有条目并列显示。

带有已编辑文章的博客首页

DeleteView

本章要添加的最后一个功能是删除博客文章的能力。我们将使用另一个通用基于类视图 DeleteView 来实现这一点,并创建必要的视图、URL 和模板。

首先,更新 blog/views.py 文件,在顶部导入 DeleteViewreverse_lazy,然后创建一个继承自 DeleteView 的新视图。reversereverse_lazy 执行相同的任务:基于输入(如 URL 名称)生成 URL。区别在于它们的求值时间:reverse 立即执行,所以当 BlogDeleteView 被执行时,modeltemplate_namesuccess_url 方法会立即加载。但 success_url 需要找出与 URL 名称”home”关联的结果 URL 路径是什么。它并不总能及时做到这一点!这就是为什么我们在这个示例中使用 reverse_lazy:它将 URL 调度器的实际调用推迟到需要时才进行,而不是在我们的 BlogDeleteView 类被求值时。

# blog/views.py
from django.views.generic import ListView, DetailView
from django.views.generic.edit import CreateView, UpdateView, DeleteView  # 新增
from django.urls import reverse_lazy  # 新增
from .models import Post

...

class BlogDeleteView(DeleteView):  # 新增
    model = Post
    template_name = "post_delete.html"
    success_url = reverse_lazy("home")

DeleteView 需要模型、模板名称和成功 URL。我们在这里提供了所有三个。记住,在博客文章被删除后,我们必须将用户重定向到某处。在这里,那是 URL 名称为”home”的首页。

细心的读者可能会注意到,CreateViewUpdateView 也有重定向,但我们不需要指定 success_url。为什么?如果可用的话,Django 默认会使用模型对象上的 get_absolute_url(),这是一个方便的特性,但你只有通过阅读像这样的书或仔细阅读文档才能知道。更有可能是,随着经验积累,你之前使用过这些 GCBV,并隐约记得关于重定向的事情,然后查阅关于模型表单的文档再进行实现。作为 Django 开发者,没有办法记住你需要知道的一切;相反,随着时间的推移,你会开始看到大的模式,并能够查找文档中的实现细节。

视图完成后,我们可以转向 URL。这是一个类似的模式:导入视图 BlogDeleteView,设置 URL 路径,指定视图,并包含 URL 名称。惯例是将 /delete/ 添加到 URL 路径中,就像我们在这里所做一样。

# blog/urls.py
from django.urls import path
from .views import (
    BlogListView,
    BlogDetailView,
    BlogCreateView,
    BlogUpdateView,
    BlogDeleteView,   # 新增
)

urlpatterns = [
    path("post/new/", BlogCreateView.as_view(), name="post_new"),
    path("post/<int:pk>/", BlogDetailView.as_view(), name="post_detail"),
    path("post/<int:pk>/edit/", BlogUpdateView.as_view(), name="post_edit"),
    path("post/<int:pk>/delete/", BlogDeleteView.as_view(), name="post_delete"),  # 新增
    path("", BlogListView.as_view(), name="home"),
]

最后,我们需要添加一个确认用户是否要删除博客文章的模板文件。它将被称为 templates/post_delete.html,包含以下代码:

<!-- templates/post_delete.html -->
{% extends "base.html" %}

{% block content %}
<h1>Delete post</h1>
<form action="" method="post">{% csrf_token %}
    <p>Are you sure you want to delete "{{ post.title }}"?</p>
    <input type="submit" value="Confirm">
</form>
{% endblock content %}

注意,我们使用 post.title 来显示博客文章的标题。我们也可以使用 object.title,因为它也由 DetailView 提供。

我们可以在独立博客页面 post_detail.html 上添加删除博客文章的链接。

<!-- templates/post_detail.html -->
{% extends "base.html" %}

{% block content %}
<div class="post-entry">
    <h2>{{ post.title }}</h2>
    <p>{{ post.body }}</p>
</div>
<div>
    <p><a href="{% url 'post_edit' post.pk %}">+ Edit Blog Post</a></p>
    <!-- 以下为新 HTML... -->
    <p><a href="{% url 'post_delete' post.pk %}">+ Delete Blog Post</a></p>
</div>
{% endblock content %}

如果你再次使用 python manage.py runserver 启动服务器并刷新任何文章页面,你会看到”Delete Blog Post”链接。

博客删除文章链接

点击链接将跳转到博客文章的删除页面,其中显示博客文章的标题。

博客删除文章页面

如果你点击”Confirm”按钮,它将重定向到首页,该博客文章已被删除!

删除文章后的首页

测试

现在是时候进行测试,以确保一切现在——以及将来——按预期工作了。我们添加了创建、更新和删除的新视图,所以这意味着三个新测试:

  • def test_post_createview
  • def test_post_updateview
  • def test_post_deleteview

如下所示,在 test_post_detailview 之后将新测试更新到现有的 tests.py 文件中:

# blog/tests.py
...

def test_post_createview(self):  # 新增
    response = self.client.post(
        reverse("post_new"),
        {
            "title": "New title",
            "body": "New text",
            "author": self.user.id,
        },
    )
    self.assertEqual(response.status_code, 302)
    self.assertEqual(Post.objects.last().title, "New title")
    self.assertEqual(Post.objects.last().body, "New text")

def test_post_updateview(self):  # 新增
    response = self.client.post(
        reverse("post_edit", args="1"),
        {
            "title": "Updated title",
            "body": "Updated text",
        },
    )
    self.assertEqual(response.status_code, 302)
    self.assertEqual(Post.objects.last().title, "Updated title")
    self.assertEqual(Post.objects.last().body, "Updated text")

def test_post_deleteview(self):  # 新增
    response = self.client.post(reverse("post_delete", args="1"))
    self.assertEqual(response.status_code, 302)

对于 test_post_createview,我们创建一个新响应,并检查页面是否返回 302 重定向状态码,以及模型中创建的最后一个对象是否与新响应匹配。然后,test_post_updateview 检查我们是否可以更新在 setUpTestData 中创建的初始文章,因为这些数据在整个测试类中都可用。最后一个新测试 test_post_deleteview 确认删除文章时会发生 302 重定向。

以后当然可以添加更多测试,例如针对模板的测试,但至少我们已经覆盖了所有新功能。按 Control + c 停止本地 Web 服务器,然后运行这些测试。它们应该全部通过。

(.venv) $ python manage.py test
Found 8 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
........
----------------------------------------------------------------------
Ran 8 tests in 0.101s
OK
Destroying test database for alias 'default'...

结论

通过少量代码,我们已经构建了一个支持创建、读取、更新和删除博客文章的博客应用。虽然有多种方法可以实现相同的功能——我们可以使用基于函数的视图或编写自己的基于类的视图——但我们展示了 Django 仅需少量代码即可实现这一点。

然而,请注意一个潜在的安全问题:目前任何用户都可以更新或删除博客条目,而不仅仅是创建者!幸运的是,Django 有基于权限限制访问的内置功能,我们将在本书后面介绍。

但现在,我们的博客应用正在运行,在下一章中,我们将添加用户账户,使用户能够注册、登录和退出 Web 应用。