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> 标签内的元素集合,其中至少包含一个 input 元素的 type="submit"

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 将根据字段名称创建一个标签,方法是将第一个字母大写并将下划线替换为空格(例如,续订日期)。
  • label_suffix:默认情况下,冒号显示在标签之后(例如,续订日期)。此参数允许您指定包含其他字符的不同后缀。
  • initial:显示表单时字段的初始值。
  • widget:要使用的显示小部件。
  • help_text(如上例所示):可以在表单中显示的其他文本,以解释如何使用该字段。
  • error_messages:字段的错误消息列表。如果需要,您可以用自己的消息覆盖这些消息。
  • validators:验证字段时将调用的函数列表。
  • localize:启用表单数据输入的本地化(有关更多信息,请参阅链接)。
  • disabled:如果为True,则显示该字段,但无法编辑其值。默认为False

验证

Django 提供了许多可以在其中验证数据的位置。验证单个字段最简单的方法是覆盖要检查的字段的clean_<fieldname>()方法。例如,我们可以通过实现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”任何我们喜欢的名称,因为我们可以完全控制视图函数(我们没有使用期望具有特定名称的参数的通用详细信息视图类)。但是,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值中。

警告:虽然您也可以通过请求直接访问表单数据(例如,如果使用 GET 请求,则为request.POST['renewal_date']request.GET['renewal_date']),但这不推荐。清理后的数据经过清理、验证并转换为 Python 友好的类型。

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

表单处理本身所需的一切都在这里,但我们仍然需要将对视图的访问权限限制为仅登录的管理员,他们有权续订书籍。我们使用@login_required来要求用户已登录,并使用我们现有的can_mark_returned权限的@permission_required函数装饰器来允许访问(装饰器按顺序处理)。请注意,我们可能应该在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)以及提交数据的方法(在本例中为“HTTP 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 }},也会提供相同的渲染。

如果您输入无效日期,您还会在页面上看到错误列表的渲染(请参见下面的errorlist)。

html
<tr>
  <th><label for="id_renewal_date">Renewal date:</label></th>
  <td>
    <ul class="errorlist">
      <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/(有效的bookinstance_id可以通过导航到图书馆中的书籍详细信息页面并复制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.

ModelForm

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

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

下面显示了一个基本的ModelForm,其中包含与我们原始的RenewBookForm相同的字段。您只需添加class Meta以及关联的modelBookInstance)和要在表单中包含的模型fields列表即可创建表单。

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。只要您还将相应的表单变量名称从renewal_date更新为due_back(如第二个表单声明中所示:RenewBookModelForm(initial={'due_back': proposed_renewal_date}),您就可以在当前使用RenewBookForm的任何地方导入并使用它。

通用编辑视图

我们在上面的函数视图示例中使用的表单处理算法代表了表单编辑视图中极其常见的模式。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相同的语法)。在这种情况下,我们展示了如何单独列出它们以及列出“所有”字段的语法。您还可以使用field_name/value对的字典为每个字段指定初始值(这里我们任意设置了死亡日期以进行演示——您可能希望将其删除)。默认情况下,这些视图将在成功时重定向到显示新创建/编辑的模型项目的页面,在本例中将是我们之前教程中创建的作者详细信息视图。您可以通过显式声明参数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 提供了通用的表单编辑视图,这些视图可以完成几乎所有定义可以创建、编辑和删除与单个模型实例关联的记录的页面的工作。

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

另请参阅