跳转至

第 7 章:表单

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

ListView 和 DetailView

目前,我们对列出所有博客帖子的页面和驱动单个帖子使用的是基于函数的视图。我们可以继续沿用这种方式,为创建、编辑和删除功能编写基于函数的视图;然而,这样做需要更多的代码,并且与专门为这些任务设计的泛型类视图相比容易出错。因此,我们将在本书的其余部分切换到使用泛型类视图。

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

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

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

def post_detail(request, pk):  # new
    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  # new

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

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

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

{% block content %}
    {% for post in post_list %} <!-- new -->
    <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'  # Change the context object name to '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 %}

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

Mixins

如果你仔细观察,你会注意到 ListViewcontext_object_name 属性是 MultipleObjectMixin 的一部分,而 DetailView 的则是 SingleObjectMixin 的一部分。为什么会有差异?

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

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

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

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

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

CreateView

我们现在需要一个视图让用户可以创建新的博客帖子。CreateView 正是为此目的而设计的泛型类视图。以下是我们在视图文件中需要的代码。

# blog/views.py
from django.views.generic import ListView, DetailView
from django.views.generic.edit import CreateView  # new
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):  # new
    model = Post
    template_name = "post_new.html"
    fields = ["title", "author", "body"]

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

然后,更新 URL 文件,为创建页面添加一个 URL 路由。

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

urlpatterns = [
    path("post/new/", BlogCreateView.as_view(), name="post_new"),  # 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 提供的,用于保护我们的表单免受跨站请求伪造。你应该在所有 Django 表单中使用它。
  • 然后,使用 {{ form }} 来渲染指定的字段以输出我们的表单数据。
  • 最后,指定一个 submit 类型的 input 并将值赋为"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>
            <!-- start new 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>
            <!-- end new 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  # new
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):  # new
    model = Post
    template_name = "post_edit.html"
    fields = ["title", "body"]

请注意,我们没有在字段中包含"author",因为我们假设帖子的作者在编辑期间不会更改。我们只希望标题和正文可供更新。

接下来我们需要为此更新视图添加一个新的 URL 路径。通过在顶部导入 BlogUpdateView 来更新 blog/urls.py,然后创建一个 URL 模式为 /post/pk/edit 的新路由。它将使用 BlogUpdateView 作为视图,URL 名称为 post_edit

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

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"),  # new
    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 %}

在顶部,我们在基础模板 base.html 上使用 extends,然后将此页面的内容夹在 content 块之间。我们再次使用 HTML <form></form> 标签、Django 的 csrf_token 用于安全,并给提交按钮赋值"Update"。

我们可以直接访问特定帖子的 URL,例如第一篇博客帖子的 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>
<!-- start new HTML... -->
<a href="{% url 'post_edit' post.pk %}">+ Edit Blog Post</a>
<!-- end new 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 和模板。

首先,通过在顶部导入 DeleteViewreverse_lazy 来更新 blog/views.py 文件,然后创建一个子类化 DeleteView 的新视图。reversereverse_lazy 都执行相同的任务:根据输入(如 URL 名称)生成 URL。区别在于它们何时被求值:reverse 立即执行,所以当 BlogDeleteView 被执行时,模型、template_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  # new
from django.urls import reverse_lazy  # new
from .models import Post

...

class BlogDeleteView(DeleteView):  # new
    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 名称。一个约定是在你的 URL 路径中添加 /delete/,正如我们在这里所做的。

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

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"),  # new
    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>
    <!-- new HTML below here... -->
    <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):  # new
        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):  # new
        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):  # new
        response = self.client.post(reverse("post_delete", args="1"))
        self.assertEqual(response.status_code, 302)

对于 test_post_createview,我们创建一个新的响应并检查页面具有 302 重定向状态码,并且在我们模型上创建的 last() 对象与新响应匹配。然后 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 应用。