Django 教程第 9 部分:处理表单

在本教程中,我们将向你展示如何在 Django 中使用 HTML 表单,特别是编写表单以创建、更新和删除模型实例的最简单方法。作为演示的一部分,我们将扩展LocalLibrary 网站,以便图书管理员可以使用我们自己的表单(而不是使用管理应用程序)续借图书,并创建、更新和删除作者。

预备知识 完成所有之前的教程主题,包括Django 教程第 8 部分:用户身份验证和权限
目标 理解如何编写表单来从用户获取信息并更新数据库。理解基于类的通用编辑视图如何极大地简化创建用于处理单个模型的表单。

概述

HTML 表单是网页上一个或多个字段/小部件的集合,可用于收集用户提交给服务器的信息。表单是收集用户输入的灵活机制,因为有适合输入多种不同类型数据的部件,包括文本框、复选框、单选按钮、日期选择器等等。表单也是一种相对安全地与服务器共享数据的方式,因为它们允许我们通过带有跨站点请求伪造保护的 POST 请求发送数据。

虽然我们到目前为止还没有在本教程中创建任何表单,但我们已经在 Django 管理站点中遇到过它们——例如,下面的截图显示了一个用于编辑我们的 Book 模型之一的表单,它由许多选择列表和文本编辑器组成。

Admin Site - Book Add

处理表单可能很复杂!开发人员需要编写表单的 HTML,在服务器端(可能也在浏览器端)验证并正确清理输入的数据,重新提交带有错误消息的表单以通知用户任何无效字段,在数据成功提交后处理数据,最后以某种方式响应用户以指示成功。 Django 表单通过提供一个框架,允许你以编程方式定义表单及其字段,然后使用这些对象生成表单 HTML 代码并处理大部分验证和用户交互,从而大大减轻了所有这些步骤的工作量。

在本教程中,我们将向你展示创建和使用表单的一些方法,特别是通用编辑视图如何显著减少创建用于操作模型的表单所需的工作量。在此过程中,我们将通过添加一个表单来允许图书管理员续借图书,从而扩展我们的 LocalLibrary 应用程序,并且我们将创建页面来创建、编辑和删除图书和作者(重新实现上面所示的编辑图书表单的基本版本)。

HTML 表单

首先,简单介绍一下 HTML 表单。考虑一个简单的 HTML 表单,其中包含一个用于输入“团队”名称的文本字段及其关联的标签。

Simple name field example in HTML form

表单在 HTML 中定义为 <form>…</form> 标签内元素的集合,其中至少包含一个 type="submit"input 元素。

html
<form action="/team_name_url/" method="post">
  <label for="team_name">Enter name: </label>
  <input
    id="team_name"
    type="text"
    name="name_field"
    value="Default name for team." />
  <input type="submit" value="OK" />
</form>

虽然这里我们只有一个用于输入团队名称的文本字段,但表单*可能*有任意数量的其他输入元素及其关联的标签。字段的 type 属性定义了将显示哪种小部件。字段的 nameid 用于在 JavaScript/CSS/HTML 中识别该字段,而 value 定义了字段首次显示时的初始值。匹配的团队标签使用 label 标签指定(见上面的“输入名称”),其 for 字段包含关联 inputid 值。

submit 输入将默认显示为一个按钮。可以按下此按钮将表单中所有其他输入元素(在本例中,只有 team_name 字段)中的数据上传到服务器。表单属性定义了用于发送数据的 HTTP method 以及服务器上数据的目标 (action)。

  • action:当表单提交时,数据将被发送到何处进行处理的资源/URL。如果未设置(或设置为空字符串),则表单将提交回当前页面 URL。
  • method:用于发送数据的 HTTP 方法:postget
    • 如果数据将导致服务器数据库发生更改,则应始终使用 POST 方法,因为它能够更有效地抵御跨站点伪造请求攻击。
    • GET 方法只应用于不更改用户数据的表单(例如,搜索表单)。建议在需要能够书签或共享 URL 时使用它。

服务器的作用是首先呈现表单的初始状态——要么包含空白字段,要么预先填充初始值。用户按下提交按钮后,服务器将接收来自 Web 浏览器的数据并必须验证信息。如果表单包含无效数据,服务器应再次显示表单,这次在“有效”字段中显示用户输入的数据,并在无效字段旁显示描述问题的消息。一旦服务器收到包含所有有效表单数据的请求,它就可以执行适当的操作(例如:保存数据、返回搜索结果、上传文件等),然后通知用户。

正如你所想象的,创建 HTML、验证返回的数据、在需要时重新显示带错误报告的输入数据,以及对有效数据执行所需操作,所有这些都可能需要大量的努力才能“做对”。Django 通过省去一些繁重和重复的代码,让这一切变得容易得多!

Django 表单处理流程

Django 的表单处理使用我们之前教程中学到的所有相同技术(用于显示模型信息):视图获取请求,执行所需的任何操作,包括从模型读取数据,然后生成并返回一个 HTML 页面(来自模板,我们将上下文传递到其中,上下文包含要显示的数据)。更复杂的是,服务器还需要能够处理用户提供的数据,并在出现任何错误时重新显示页面。

下面显示了 Django 如何处理表单请求的流程图,从请求包含表单的页面(绿色显示)开始。

Updated form handling process doc.

根据上图,Django 表单处理主要完成以下事项:

  1. 用户首次请求时显示默认表单。

    • 如果你正在创建新记录,表单可能包含空白字段,或者它可能预先填充了初始值(例如,如果你正在更改记录,或者有有用的默认初始值)。
    • 此时表单被称为*未绑定*,因为它没有与任何用户输入的数据关联(尽管它可能有初始值)。
  2. 接收来自提交请求的数据并将其绑定到表单。

    • 将数据绑定到表单意味着当我们需要重新显示表单时,用户输入的数据和任何错误都可用。
  3. 清理和验证数据。

    • 清理数据会执行输入字段的清理,例如删除可能用于向服务器发送恶意内容的无效字符,并将它们转换为一致的 Python 类型。
    • 验证检查值是否适合该字段(例如,它们是否在正确的日期范围内,是否太短或太长等)
  4. 如果任何数据无效,重新显示表单,这次显示任何用户填充的值以及问题字段的错误消息。

  5. 如果所有数据都有效,则执行所需操作(例如保存数据、发送电子邮件、返回搜索结果、上传文件等)。

  6. 所有操作完成后,将用户重定向到另一个页面。

Django 提供了许多工具和方法来帮助你完成上述任务。最基本的是 Form 类,它简化了表单 HTML 的生成和数据清理/验证。在下一节中,我们将通过图书管理员续借图书页面的实际示例来描述表单的工作原理。

注意:理解 Form 的用法将有助于你理解我们稍后讨论的 Django 更“高级”的表单框架类。

使用表单和函数视图续借图书表单

接下来,我们将添加一个页面,允许图书管理员续借已借出的图书。为此,我们将创建一个表单,允许用户输入一个日期值。我们将用当前日期起 3 周的初始值(正常借阅期)填充该字段,并添加一些验证,以确保图书管理员不能输入过去的日期或未来过远的日期。输入有效日期后,我们会将其写入当前记录的 BookInstance.due_back 字段。

该示例将使用基于函数的视图和 Form 类。以下部分解释了表单的工作原理,以及你需要对我们正在进行的 LocalLibrary 项目进行的更改。

表单

Form 类是 Django 表单处理系统的核心。它指定了表单中的字段、它们的布局、显示小部件、标签、初始值、有效值,以及(验证后)与无效字段关联的错误消息。该类还提供了使用预定义格式(表格、列表等)在模板中呈现自身的方法,或获取任何元素值的方法(允许细粒度手动呈现)。

声明表单

Form 的声明语法与 Model 的声明语法非常相似,并共享相同的字段类型(以及一些相似的参数)。这很有道理,因为在这两种情况下,我们都需要确保每个字段处理正确类型的数据,被约束为有效数据,并具有用于显示/文档的描述。

表单数据存储在应用程序目录内的应用程序 forms.py 文件中。创建并打开文件 django-locallibrary-tutorial/catalog/forms.py。要创建 Form,我们导入 forms 库,从 Form 类派生,并声明表单的字段。下面是我们的图书续借表单的一个非常基本的表单类——将其添加到你的新文件中:

python
from django import forms

class RenewBookForm(forms.Form):
    renewal_date = forms.DateField(help_text="Enter a date between now and 4 weeks (default 3).")

表单字段

在本例中,我们有一个单独的DateField,用于输入续借日期,它将在 HTML 中以空白值、默认标签“续借日期:”和一些有用的使用文本:“输入现在到 4 周之间的日期(默认 3 周)。”呈现。由于没有指定其他可选参数,该字段将接受使用input_formats(YYYY-MM-DD (2024-11-06)、MM/DD/YYYY (02/26/2024)、MM/DD/YY (10/25/24))的日期,并将使用默认的widgetDateInput进行渲染。

还有许多其他类型的表单字段,你会发现它们与等效的模型字段类非常相似:

大多数字段通用的参数如下所示(它们具有合理的默认值):

  • required:如果为 True,则字段不能为空白或给定 None 值。字段默认是必需的,因此你将设置 required=False 以允许表单中出现空白值。
  • label:在 HTML 中渲染字段时使用的标签。如果未指定 label,Django 将通过将第一个字母大写并将下划线替换为空格来从字段名称创建标签(例如,Renewal date)。
  • label_suffix:默认情况下,标签后面会显示一个冒号(例如,Renewal date​:)。此参数允许你指定包含其他字符的不同后缀。
  • initial:表单显示时字段的初始值。
  • widget:要使用的显示小部件。
  • help_text(如上例所示):可在表单中显示的额外文本,用于解释如何使用该字段。
  • error_messages:字段的错误消息列表。如果需要,你可以用自己的消息覆盖这些消息。
  • validators:验证字段时将调用的函数列表。
  • localize:启用表单数据输入的本地化(更多信息请参阅链接)。
  • disabled:如果此项为 True,则显示该字段但其值不能编辑。默认值为 False

验证

Django 提供了许多地方来验证你的数据。验证单个字段最简单的方法是覆盖你想要检查的字段的 clean_<field_name>() 方法。因此,例如,我们可以通过实现 clean_renewal_date() 来验证输入的 renewal_date 值是否在现在和 4 周之间,如下所示。

更新你的 forms.py 文件,使其看起来像这样:

python
import datetime

from django import forms

from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _

class RenewBookForm(forms.Form):
    renewal_date = forms.DateField(help_text="Enter a date between now and 4 weeks (default 3).")

    def clean_renewal_date(self):
        data = self.cleaned_data['renewal_date']

        # Check if a date is not in the past.
        if data < datetime.date.today():
            raise ValidationError(_('Invalid date - renewal in past'))

        # Check if a date is in the allowed range (+4 weeks from today).
        if data > datetime.date.today() + datetime.timedelta(weeks=4):
            raise ValidationError(_('Invalid date - renewal more than 4 weeks ahead'))

        # Remember to always return the cleaned data.
        return data

有两点需要注意。首先,我们使用 self.cleaned_data['renewal_date'] 获取数据,并且无论我们是否在函数末尾更改数据,我们都会返回此数据。此步骤通过使用默认验证器获取“清理”和净化潜在不安全输入的数据,并将其转换为数据正确的标准类型(在本例中为 Python datetime.datetime 对象)。

第二点是,如果一个值超出我们的范围,我们会引发 ValidationError,并指定在输入无效值时希望在表单中显示的错误文本。上面的例子还将此文本包装在 Django 的翻译函数 gettext_lazy()(作为 _() 导入)中,如果你以后想翻译你的网站,这是一个很好的做法。

注意:表单和字段验证(Django 文档)中,还有许多其他验证表单的方法和示例。例如,在有多个相互依赖的字段的情况下,你可以覆盖Form.clean() 函数,并再次引发 ValidationError

这就是这个表单所需要的一切!

URL 配置

在我们创建视图之前,让我们为续借图书页面添加一个 URL 配置。将以下配置复制到 django-locallibrary-tutorial/catalog/urls.py 的底部:

python
urlpatterns += [
    path('book/<uuid:pk>/renew/', views.renew_book_librarian, name='renew-book-librarian'),
]

URL 配置会将格式为 /catalog/book/<bookinstance_id>/renew/ 的 URL 重定向到 views.py 中名为 renew_book_librarian() 的函数,并将 BookInstance ID 作为名为 pk 的参数发送。该模式仅在 pk 是格式正确的 uuid 时才匹配。

注意:我们可以随意命名捕获的 URL 数据,因为我们完全控制视图函数(我们没有使用期望特定名称参数的通用详细视图类)。但是,pk 是“主键”的缩写,这是一个合理的约定!

视图

如上文Django 表单处理流程所述,视图在首次调用时必须渲染默认表单,然后如果数据无效,则重新渲染带有错误消息的表单,或者如果数据有效,则处理数据并重定向到新页面。为了执行这些不同的操作,视图必须能够知道它是在首次调用以渲染默认表单,还是在后续调用以验证数据。

对于使用 POST 请求将信息提交到服务器的表单,最常见的模式是视图针对 POST 请求类型进行测试(if request.method == 'POST':)以识别表单验证请求,并使用 GET(使用 else 条件)来识别初始表单创建请求。如果你想使用 GET 请求提交数据,那么识别这是第一次还是后续视图调用的典型方法是读取表单数据(例如,读取表单中的隐藏值)。

图书续借流程将写入我们的数据库,因此,按照惯例,我们使用 POST 请求方法。下面的代码片段显示了此类函数视图的(非常标准的)模式。

python
import datetime

from django.shortcuts import render, get_object_or_404
from django.http import HttpResponseRedirect
from django.urls import reverse

from catalog.forms import RenewBookForm

def renew_book_librarian(request, pk):
    book_instance = get_object_or_404(BookInstance, pk=pk)

    # If this is a POST request then process the Form data
    if request.method == 'POST':

        # Create a form instance and populate it with data from the request (binding):
        form = RenewBookForm(request.POST)

        # Check if the form is valid:
        if form.is_valid():
            # process the data in form.cleaned_data as required (here we just write it to the model due_back field)
            book_instance.due_back = form.cleaned_data['renewal_date']
            book_instance.save()

            # redirect to a new URL:
            return HttpResponseRedirect(reverse('all-borrowed'))

    # If this is a GET (or any other method) create the default form.
    else:
        proposed_renewal_date = datetime.date.today() + datetime.timedelta(weeks=3)
        form = RenewBookForm(initial={'renewal_date': proposed_renewal_date})

    context = {
        'form': form,
        'book_instance': book_instance,
    }

    return render(request, 'catalog/book_renew_librarian.html', context)

首先,我们导入表单 (RenewBookForm) 和视图函数主体中使用的许多其他有用的对象/方法。

  • get_object_or_404():根据主键值从模型中返回指定对象,如果记录不存在,则引发 Http404 异常(未找到)。
  • HttpResponseRedirect:这将创建到指定 URL 的重定向(HTTP 状态码 302)。
  • reverse():这会根据 URL 配置名称和一组参数生成 URL。它等同于我们在模板中使用的 url 标签的 Python 版本。
  • datetime:一个用于处理日期和时间的 Python 库。

在视图中,我们首先在 get_object_or_404() 中使用 pk 参数来获取当前的 BookInstance(如果不存在,视图将立即退出,页面将显示“未找到”错误)。如果这*不是* POST 请求(由 else 子句处理),那么我们创建默认表单,为 renewal_date 字段传入一个 initial 值,即从当前日期起 3 周。

python
book_instance = get_object_or_404(BookInstance, pk=pk)

# If this is a GET (or any other method) create the default form
else:
    proposed_renewal_date = datetime.date.today() + datetime.timedelta(weeks=3)
    form = RenewBookForm(initial={'renewal_date': proposed_renewal_date})

context = {
    'form': form,
    'book_instance': book_instance,
}

return render(request, 'catalog/book_renew_librarian.html', context)

创建表单后,我们调用 render() 来创建 HTML 页面,指定模板和包含我们表单的上下文。在这种情况下,上下文还包含我们的 BookInstance,我们将在模板中使用它来提供有关我们正在续借的图书的信息。

然而,如果这是一个 POST 请求,那么我们创建 form 对象并用请求中的数据填充它。这个过程称为“绑定”,它允许我们验证表单。

然后我们检查表单是否有效,这会运行所有字段上的所有验证代码——包括检查日期字段是否实际是有效日期的通用代码,以及我们特定表单的 clean_renewal_date() 函数来检查日期是否在正确范围内的代码。

python
book_instance = get_object_or_404(BookInstance, pk=pk)

# If this is a POST request then process the Form data
if request.method == 'POST':

    # Create a form instance and populate it with data from the request (binding):
    form = RenewBookForm(request.POST)

    # Check if the form is valid:
    if form.is_valid():
        # process the data in form.cleaned_data as required (here we just write it to the model due_back field)
        book_instance.due_back = form.cleaned_data['renewal_date']
        book_instance.save()

        # redirect to a new URL:
        return HttpResponseRedirect(reverse('all-borrowed'))

context = {
    'form': form,
    'book_instance': book_instance,
}

return render(request, 'catalog/book_renew_librarian.html', context)

如果表单无效,我们再次调用 render(),但这次上下文中传递的表单值将包含错误消息。

如果表单有效,那么我们就可以开始使用数据了,通过 form.cleaned_data 属性访问它(例如,data = form.cleaned_data['renewal_date'])。在这里,我们只是将数据保存到关联 BookInstance 对象的 due_back 值中。

警告:虽然你也可以直接通过请求访问表单数据(例如,request.POST['renewal_date'] 或在使用 GET 请求时为 request.GET['renewal_date']),但不建议这样做。清理后的数据经过净化、验证并转换为 Python 友好的类型。

视图中表单处理部分的最后一步是重定向到另一个页面,通常是“成功”页面。在本例中,我们使用 HttpResponseRedirectreverse() 重定向到名为 'all-borrowed' 的视图(这是在Django 教程第 8 部分:用户身份验证和权限中作为“挑战”创建的)。如果你没有创建该页面,请考虑重定向到 URL / 的主页)。

这是表单处理本身所需的一切,但我们仍然需要限制视图的访问,只允许具有续借图书权限的已登录图书管理员访问。我们使用 @login_required 来要求用户登录,并使用 @permission_required 函数装饰器和我们现有的 can_mark_returned 权限来允许访问(装饰器按顺序处理)。请注意,我们可能应该在 BookInstance 中创建新的权限设置 (can_renew),但为了简化示例,我们将重用现有权限。

因此,最终的视图如下所示。请将其复制到 django-locallibrary-tutorial/catalog/views.py 的底部。

python
import datetime

from django.contrib.auth.decorators import login_required, permission_required
from django.shortcuts import get_object_or_404
from django.http import HttpResponseRedirect
from django.urls import reverse

from catalog.forms import RenewBookForm

@login_required
@permission_required('catalog.can_mark_returned', raise_exception=True)
def renew_book_librarian(request, pk):
    """View function for renewing a specific BookInstance by librarian."""
    book_instance = get_object_or_404(BookInstance, pk=pk)

    # If this is a POST request then process the Form data
    if request.method == 'POST':

        # Create a form instance and populate it with data from the request (binding):
        form = RenewBookForm(request.POST)

        # Check if the form is valid:
        if form.is_valid():
            # process the data in form.cleaned_data as required (here we just write it to the model due_back field)
            book_instance.due_back = form.cleaned_data['renewal_date']
            book_instance.save()

            # redirect to a new URL:
            return HttpResponseRedirect(reverse('all-borrowed'))

    # If this is a GET (or any other method) create the default form.
    else:
        proposed_renewal_date = datetime.date.today() + datetime.timedelta(weeks=3)
        form = RenewBookForm(initial={'renewal_date': proposed_renewal_date})

    context = {
        'form': form,
        'book_instance': book_instance,
    }

    return render(request, 'catalog/book_renew_librarian.html', context)

模板

创建视图中引用的模板(/catalog/templates/catalog/book_renew_librarian.html)并将下面的代码复制到其中。

django
{% extends "base_generic.html" %}

{% block content %}
  <h1>Renew: {{ book_instance.book.title }}</h1>
  <p>Borrower: {{ book_instance.borrower }}</p>
  <p {% if book_instance.is_overdue %} class="text-danger"{% endif %} >Due date: {{ book_instance.due_back }}</p>

  <form action="" method="post">
    {% csrf_token %}
    <table>
    {{ form.as_table }}
    </table>
    <input type="submit" value="Submit">
  </form>
{% endblock %}

这些大部分内容对于之前的教程来说是完全熟悉的。

我们扩展了基本模板,然后重新定义了内容块。我们可以引用 {{ book_instance }}(及其变量),因为它已在 render() 函数中传递到上下文对象中,我们使用这些变量来列出书名、借阅者和原始到期日。

表单代码相对简单。首先,我们声明 form 标签,指定表单提交的位置 (action) 和提交数据的方法 (在本例中为 POST) — 如果你还记得页面顶部HTML 表单概述,空 action 如所示,意味着表单数据将发布回当前页面 URL (这正是我们想要的)。在标签内部,我们定义了 submit 输入,用户可以按下该输入来提交数据。表单标签内添加的 {% csrf_token %} 是 Django 跨站点伪造保护的一部分。

注意:{% csrf_token %} 添加到你创建的每个使用 POST 提交数据的 Django 模板中。这将减少表单被恶意用户劫持的可能性。

剩下就是 {{ form }} 模板变量了,我们已将其传递到上下文字典中的模板。毫不奇怪,如所示使用时,它提供了所有表单字段的默认渲染,包括它们的标签、小部件和帮助文本——渲染结果如下所示:

html
<tr>
  <th><label for="id_renewal_date">Renewal date:</label></th>
  <td>
    <input
      id="id_renewal_date"
      name="renewal_date"
      type="text"
      value="2023-11-08"
      required />
    <br />
    <span class="helptext">
      Enter date between now and 4 weeks (default 3 weeks).
    </span>
  </td>
</tr>

注意:这可能不太明显,因为我们只有一个字段,但默认情况下,每个字段都定义在自己的表格行中。如果你引用模板变量 {{ form.as_table }},也会提供相同的渲染。

如果你输入了一个无效日期,你还会得到一个渲染在页面上的错误列表(参见下面的 error-list)。

html
<tr>
  <th><label for="id_renewal_date">Renewal date:</label></th>
  <td>
    <ul class="error-list">
      <li>Invalid date - renewal in past</li>
    </ul>
    <input
      id="id_renewal_date"
      name="renewal_date"
      type="text"
      value="2023-11-08"
      required />
    <br />
    <span class="helptext">
      Enter date between now and 4 weeks (default 3 weeks).
    </span>
  </td>
</tr>

使用表单模板变量的其他方式

如上所示,使用 {{ form.as_table }},每个字段都渲染为表格行。你也可以将每个字段渲染为列表项(使用 {{ form.as_ul }})或段落(使用 {{ form.as_p }})。

还可以通过使用点符号索引其属性来完全控制表单每个部分的渲染。因此,例如,我们可以访问 renewal_date 字段的许多单独项:

  • {{ form.renewal_date }}:整个字段。
  • {{ form.renewal_date.errors }}:错误列表。
  • {{ form.renewal_date.id_for_label }}:标签的 ID。
  • {{ form.renewal_date.help_text }}:字段帮助文本。

有关如何在模板中手动渲染表单和动态遍历模板字段的更多示例,请参阅使用表单 > 手动渲染字段(Django 文档)。

测试页面

如果你接受了Django 教程第 8 部分:用户身份验证和权限中的“挑战”,你将看到一个显示图书馆中所有借出图书的视图,该视图仅对图书馆工作人员可见。该视图可能看起来与此类似:

django
{% extends "base_generic.html" %}

{% block content %}
    <h1>All Borrowed Books</h1>

    {% if bookinstance_list %}
    <ul>

      {% for bookinst in bookinstance_list %}
      <li class="{% if bookinst.is_overdue %}text-danger{% endif %}">
        <a href="{% url 'book-detail' bookinst.book.pk %}">{{ bookinst.book.title }}</a> ({{ bookinst.due_back }}) {% if user.is_staff %}- {{ bookinst.borrower }}{% endif %}
      </li>
      {% endfor %}
    </ul>

    {% else %}
      <p>There are no books borrowed.</p>
    {% endif %}
{% endblock %}

我们可以通过将以下模板代码附加到上面的列表项文本旁边,为每个项目添加一个指向图书续借页面的链接。请注意,此模板代码只能在 {% for %} 循环内运行,因为 bookinst 值在此处定义。

django
{% if perms.catalog.can_mark_returned %}- <a href="{% url 'renew-book-librarian' bookinst.id %}">Renew</a>{% endif %}

注意:请记住,你的测试登录需要具有 catalog.can_mark_returned 权限才能看到上面添加的新“续借”链接并访问链接页面(也许使用你的超级用户帐户)。

你也可以手动构建一个测试 URL,例如:http://127.0.0.1:8000/catalog/book/<bookinstance_id>/renew/(通过导航到图书馆的图书详细信息页面并复制 id 字段可以获取有效的 bookinstance_id)。

它看起来怎么样?

如果成功,默认表单将如下所示:

Default form which displays the book details, due date, renewal date and a submit button appears in case the link works successfully

输入无效值的表单将如下所示:

Same form as above with an error message: invalid date - renewal in the past

包含续借链接的所有图书列表将如下所示:

Displays list of all renewed books along with their details. Past due is in red.

模型表单(ModelForms)

使用上述方法创建 Form 类非常灵活,允许你创建任何类型的表单页面,并将其与任何一个或多个模型关联。

然而,如果你只需要一个表单来映射*单个*模型的字段,那么你的模型将已经定义了表单中所需的大部分信息:字段、标签、帮助文本等等。与其在表单中重新创建模型定义,不如使用ModelForm 辅助类从模型创建表单。然后,这个 ModelForm 可以在你的视图中以与普通 Form 完全相同的方式使用。

下面显示了一个包含与我们原始 RenewBookForm 相同字段的基本 ModelForm。创建表单所需做的就是添加带有相关 model (BookInstance) 和要包含在表单中的模型 fields 列表的 class Meta

python
from django.forms import ModelForm

from catalog.models import BookInstance

class RenewBookModelForm(ModelForm):
    class Meta:
        model = BookInstance
        fields = ['due_back']

注意:你也可以使用 fields = '__all__' 包含表单中的所有字段,或者你可以使用 exclude(而不是 fields)来指定模型中*不*包含的字段)。

这两种方法都不推荐,因为添加到模型的新字段会自动包含在表单中(开发人员不一定会考虑可能的安全隐患)。

注意:这看起来可能没有比仅仅使用 Form 简单多少(在这种情况下,确实没有,因为我们只有一个字段)。然而,如果你有很多字段,它可以大大减少所需的代码量!

其余信息来自模型字段定义(例如,标签、小部件、帮助文本、错误消息)。如果这些信息不太正确,我们可以在 class Meta 中覆盖它们,指定一个包含要更改的字段及其新值的字典。例如,在此表单中,我们可能希望字段的标签为“续借日期”(而不是基于字段名称的默认标签:到期日期),并且我们还希望我们的帮助文本特定于此用例。下面的 Meta 向你展示了如何覆盖这些字段,如果默认值不足,你也可以类似地设置 widgetserror_messages

python
class Meta:
    model = BookInstance
    fields = ['due_back']
    labels = {'due_back': _('New renewal date')}
    help_texts = {'due_back': _('Enter a date between now and 4 weeks (default 3).')}

要添加验证,你可以使用与普通 Form 相同的方法——你定义一个名为 clean_<field_name>() 的函数,并为无效值引发 ValidationError 异常。与我们原始表单唯一的区别是模型字段名为 due_back 而不是 renewal_date。此更改是必需的,因为 BookInstance 中对应的字段名为 due_back

python
from django.forms import ModelForm

from catalog.models import BookInstance

class RenewBookModelForm(ModelForm):
    def clean_due_back(self):
       data = self.cleaned_data['due_back']

       # Check if a date is not in the past.
       if data < datetime.date.today():
           raise ValidationError(_('Invalid date - renewal in past'))

       # Check if a date is in the allowed range (+4 weeks from today).
       if data > datetime.date.today() + datetime.timedelta(weeks=4):
           raise ValidationError(_('Invalid date - renewal more than 4 weeks ahead'))

       # Remember to always return the cleaned data.
       return data

    class Meta:
        model = BookInstance
        fields = ['due_back']
        labels = {'due_back': _('Renewal date')}
        help_texts = {'due_back': _('Enter a date between now and 4 weeks (default 3).')}

上面的 RenewBookModelForm 类现在在功能上等同于我们原来的 RenewBookForm。你可以导入并使用它,无论你在何处使用 RenewBookForm,只要你同时将相应的表单变量名从 renewal_date 更新为 due_back,就像第二个表单声明中那样:RenewBookModelForm(initial={'due_back': proposed_renewal_date})

通用编辑视图

我们在上面的函数视图示例中使用的表单处理算法代表了表单编辑视图中极其常见的模式。Django 通过为创建、编辑和删除基于模型的视图创建通用编辑视图,为你抽象了大部分这种“样板”代码。这些视图不仅处理“视图”行为,还会自动为你从模型创建表单类(一个 ModelForm)。

注意:除了此处描述的编辑视图之外,还有一个 FormView 类,它在“灵活性”与“编码工作量”方面介于我们的函数视图和其他通用视图之间。使用 FormView,你仍然需要创建你的 Form,但你不必实现所有标准的表单处理模式。相反,你只需提供一个在提交被认为是有效后将调用的函数的实现。

在本节中,我们将使用通用编辑视图来创建页面,以添加创建、编辑和删除图书馆中 Author 记录的功能——有效地提供了管理站点部分的重新实现(如果你需要以比管理站点提供更灵活的方式提供管理功能,这可能很有用)。

视图

打开视图文件(django-locallibrary-tutorial/catalog/views.py)并将以下代码块附加到其底部:

python
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from django.urls import reverse_lazy
from .models import Author

class AuthorCreate(PermissionRequiredMixin, CreateView):
    model = Author
    fields = ['first_name', 'last_name', 'date_of_birth', 'date_of_death']
    initial = {'date_of_death': '11/11/2023'}
    permission_required = 'catalog.add_author'

class AuthorUpdate(PermissionRequiredMixin, UpdateView):
    model = Author
    # Not recommended (potential security issue if more fields added)
    fields = '__all__'
    permission_required = 'catalog.change_author'

class AuthorDelete(PermissionRequiredMixin, DeleteView):
    model = Author
    success_url = reverse_lazy('authors')
    permission_required = 'catalog.delete_author'

    def form_valid(self, form):
        try:
            self.object.delete()
            return HttpResponseRedirect(self.success_url)
        except Exception as e:
            return HttpResponseRedirect(
                reverse("author-delete", kwargs={"pk": self.object.pk})
            )

正如你所看到的,要创建、更新或删除视图,你需要分别从 CreateViewUpdateViewDeleteView 派生,然后定义相关的模型。我们还将这些视图的调用限制为仅限于具有 add_authorchange_authordelete_author 权限的已登录用户。

对于“创建”和“更新”情况,你还需要指定要在表单中显示的字段(使用与 ModelForm 相同的语法)。在本例中,我们展示了如何单独列出它们以及列出“所有”字段的语法。你还可以使用字段名/对的字典为每个字段指定初始值(这里我们出于演示目的任意设置了死亡日期——你可能希望将其删除)。默认情况下,这些视图在成功后将重定向到显示新创建/编辑的模型项的页面,在我们的例子中,这将是我们之前教程中创建的作者详细视图。你可以通过显式声明参数 success_url 来指定备用重定向位置。

AuthorDelete 类不需要显示任何字段,因此不需要指定这些字段。我们还设置了一个 success_url(如上所示),因为 Django 在成功删除 Author 后没有明显的默认 URL 可以导航到。上面我们使用 reverse_lazy() 函数在作者删除后重定向到我们的作者列表——reverse_lazy()reverse() 的惰性执行版本,这里使用它是因为我们正在为基于类的视图属性提供 URL。

如果作者删除始终成功,那就完事了。不幸的是,如果作者有相关的图书,删除 Author 将导致异常,因为我们的 Book 模型为作者的 ForeignKey 字段指定了 on_delete=models.RESTRICT。为了处理这种情况,视图会覆盖 form_valid() 方法,以便如果删除 Author 成功,则重定向到 success_url,否则,它只会重定向回相同的表单。我们将在下面的模板中更新,以明确你不能删除在任何 Book 中使用的 Author 实例。

URL 配置

打开你的 URL 配置文件(django-locallibrary-tutorial/catalog/urls.py)并将以下配置添加到文件底部:

python
urlpatterns += [
    path('author/create/', views.AuthorCreate.as_view(), name='author-create'),
    path('author/<int:pk>/update/', views.AuthorUpdate.as_view(), name='author-update'),
    path('author/<int:pk>/delete/', views.AuthorDelete.as_view(), name='author-delete'),
]

这里没有什么特别新的东西!你可以看到视图是类,因此必须通过 .as_view() 调用,并且你能够识别每种情况下的 URL 模式。我们必须使用 pk 作为捕获的主键值的名称,因为这是视图类期望的参数名称。

模板

“创建”和“更新”视图默认使用相同的模板,该模板将以你的模型命名:model_name_form.html(你可以使用视图中的 template_name_suffix 字段将后缀更改为除 _form 之外的其他内容,例如 template_name_suffix = '_other_suffix'

创建模板文件 django-locallibrary-tutorial/catalog/templates/catalog/author_form.html 并复制下面的文本。

django
{% extends "base_generic.html" %}

{% block content %}
<form action="" method="post">
  {% csrf_token %}
  <table>
    {{ form.as_table }}
  </table>
  <input type="submit" value="Submit" />
</form>
{% endblock %}

这与我们之前的表单类似,并使用表格渲染字段。另请注意我们如何再次声明 {% csrf_token %} 以确保我们的表单能够抵御 CSRF 攻击。

“删除”视图期望找到一个以 [model_name]_confirm_delete.html 格式命名的模板(同样,你可以使用视图中的 template_name_suffix 更改后缀)。创建模板文件 django-locallibrary-tutorial/catalog/templates/catalog/author_confirm_delete.html 并复制下面的文本。

django
{% extends "base_generic.html" %}

{% block content %}

<h1>Delete Author: {{ author }}</h1>

{% if author.book_set.all %}

<p>You can't delete this author until all their books have been deleted:</p>
<ul>
  {% for book in author.book_set.all %}
    <li><a href="{% url 'book-detail' book.pk %}">{{book}}</a> ({{book.bookinstance_set.all.count}})</li>
  {% endfor %}
</ul>

{% else %}
<p>Are you sure you want to delete the author?</p>

<form action="" method="POST">
  {% csrf_token %}
  <input type="submit" action="" value="Yes, delete.">
</form>
{% endif %}

{% endblock %}

这个模板应该很熟悉。它首先检查作者是否用于任何书籍,如果是,则显示必须在删除作者记录之前删除的书籍列表。如果不是,它会显示一个表单,要求用户确认是否要删除作者记录。

最后一步是将页面连接到侧边栏。首先,我们将作者创建链接添加到基本模板中,以便所有登录的用户(被视为“员工”且有权创建作者(catalog.add_author))都能在所有页面中看到它。打开 /django-locallibrary-tutorial/catalog/templates/base_generic.html,并添加允许用户创建作者的行(在显示“所有借出”图书链接的同一块中)。请记住使用其名称 'author-create' 引用 URL,如下所示。

django
{% if user.is_staff %}
<hr>
<ul class="sidebar-nav">
<li>Staff</li>
   <li><a href="{% url 'all-borrowed' %}">All borrowed</a></li>
{% if perms.catalog.add_author %}
   <li><a href="{% url 'author-create' %}">Create author</a></li>
{% endif %}
</ul>
{% endif %}

我们将作者更新和删除的链接添加到作者详情页。打开 catalog/templates/catalog/author_detail.html 并附加以下代码:

django
{% block sidebar %}
  {{ block.super }}

  {% if perms.catalog.change_author or perms.catalog.delete_author %}
  <hr>
  <ul class="sidebar-nav">
    {% if perms.catalog.change_author %}
      <li><a href="{% url 'author-update' author.id %}">Update author</a></li>
    {% endif %}
    {% if not author.book_set.all and perms.catalog.delete_author %}
      <li><a href="{% url 'author-delete' author.id %}">Delete author</a></li>
    {% endif %}
    </ul>
  {% endif %}

{% endblock %}

此块会覆盖基本模板中的 sidebar 块,然后使用 {{ block.super }} 拉取原始内容。然后,它会附加更新或删除作者的链接,但仅当用户具有正确的权限并且作者记录未与任何图书关联时。

页面现在可以测试了!

测试页面

首先,使用具有作者添加、更改和删除权限的帐户登录网站。

导航到任何页面,然后选择侧边栏中的“创建作者”(URL 为 http://127.0.0.1:8000/catalog/author/create/)。页面应如下面的截图所示。

Form Example: Create Author

输入字段值,然后按提交保存作者记录。现在你应该会被带到一个新作者的详细视图,其 URL 类似于 http://127.0.0.1:8000/catalog/author/10

Form Example: Author Detail showing Update and Delete links

你可以通过选择“更新作者”链接(URL 类似 http://127.0.0.1:8000/catalog/author/10/update/)来测试编辑记录——我们不显示截图,因为它看起来就像“创建”页面!

最后,我们可以通过在详细信息页面的侧边栏中选择“删除作者”来删除该页面。如果作者记录未用于任何书籍,Django 将显示如下所示的删除页面。按“是,删除。”以删除记录并转到所有作者列表。

Form with option to delete author

挑战自我

创建一些表单来创建、编辑和删除 Book 记录。你可以使用与 Authors 完全相同的结构(对于删除,请记住在删除所有相关联的 BookInstance 记录之前,你不能删除 Book),并且你必须使用正确的权限。如果你的 book_form.html 模板只是 author_form.html 模板的复制-重命名版本,那么新的“创建图书”页面将如下面的截图所示:

Screenshot displaying various fields in the form like title, author, summary, ISBN, genre and language

总结

创建和处理表单可能是一个复杂的过程!Django 通过提供声明、渲染和验证表单的编程机制,大大简化了这一过程。此外,Django 提供了通用的表单编辑视图,可以完成几乎所有工作来定义可以创建、编辑和删除与单个模型实例关联的记录的页面。

表单还有很多可以做的(请查看下面的另请参阅列表),但你现在应该了解如何在自己的网站中添加基本表单和表单处理代码。

另见

页面(文档)未找到 /en-US/docs/Learn_web_development/Extensions/Server-side/Django/authentication_and_sessions