Django-项目蓝图-全-

Django 项目蓝图(全)

原文:zh.annas-archive.org/md5/9264A540D01362E1B15A5AC7EC06D652

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Django 可能是当今最流行的 Web 开发框架之一。这是大多数 Python 开发人员在开发任何规模的 Web 应用程序时会选择的框架。

凭借其经过验证的性能、可扩展性和安全性记录,以及其著名的一揽子方法,Django 被一些行业巨头使用,包括 Instagram、Pinterest 和 National Geographic。

本书适用于对 Django 有初步了解并对如何使用它创建简单网站有基本概念的人。它将向您展示如何将您的技能提升到下一个级别,开发像电子商务网站这样复杂的应用程序,并实现快速搜索。

本书涵盖的内容

第一章,“Blueblog – 一个博客平台”,带您开始使用 Django,并介绍如何使用该框架的基本概念。它还向您介绍了本书其余部分使用的开发技术。

第二章,“Discuss – 一个 Hacker News 克隆”,带您创建一个类似流行的 Hacker News 讨论论坛的 Web 应用程序。我们将介绍高级技术,根据用户反馈对 Web 应用程序的内容进行排序和排名,然后介绍防止垃圾邮件的技术。

第三章,“Djagios – 一个基于 Django 的 Nagios 克隆”,涵盖了使用 Django 创建类似 Nagios 的应用程序,可以监视和报告远程服务器系统状态。

第四章,“汽车租赁应用程序”,向您展示如何创建汽车租赁应用程序,并自定义 Django 管理应用程序,为我们的用户提供功能齐全的内容管理系统。

第五章,“多语言电影数据库”,帮助您创建类似 IMDB 的电影网站列表,允许用户对电影进行评论和评价。本章的主要重点是允许您的 Web 应用程序以多种语言提供国际化和本地化版本。

第六章,“Daintree – 一个电子商务网站”,向您展示如何使用 Elasticsearch 搜索服务器软件和 Django 创建类似亚马逊的电子商务网站,实现快速搜索。

第七章,“Form Mason – 自己的猴子”,帮助您创建一个复杂而有趣的 Web 应用程序,允许用户动态定义 Web 表单,然后要求其他人回答这些表单,这与 SurveyMonkey 和其他类似网站的性质相似。

附录,“开发环境设置详细信息和调试技术”,在这里我们将深入研究设置的细节,并解释我们采取的每个步骤。我们还将看到一种调试 Django 应用程序的技术。

本书所需内容

要创建和运行本书中将开发的所有 Web 应用程序,您需要以下软件的工作副本:

  • Python 编程语言

  • pip:用于安装 Python 包的软件包管理器

  • virtualenv:用于创建 Python 包的隔离环境的工具

您可以从www.python.org/downloads/下载适用于您操作系统的 Python 编程语言。您需要 Python 3 来跟随本书中的示例。

您可以在pip.pypa.io/en/stable/installing/找到安装 pip 软件包管理工具的说明。

您可以按照以下链接中的说明安装 virtualenv:virtualenv.pypa.io/en/latest/installation.html

这本书适合谁

如果您是一名 Django 网络开发人员,能够使用该框架构建基本的网络应用程序,那么这本书适合您。本书将通过引导您开发六个令人惊叹的网络应用程序,帮助您更深入地了解 Django 网络框架。

约定

在本书中,您会发现一些文本样式,用于区分不同类型的信息。以下是这些样式的一些示例以及它们的含义解释。

文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:“我们可以通过使用include指令来包含其他上下文。”

代码块设置如下:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

当我们希望引起您对代码块的特定部分的注意时,相关的行或项目会以粗体显示:

[default]
exten => s,1,Dial(Zap/1|30)
exten => s,2,Voicemail(u100)
exten => s,102,Voicemail(b100)
exten => i,1,Voicemail(s0)

任何命令行输入或输出都以以下方式编写:

# cp /usr/src/asterisk-addons/configs/cdr_mysql.conf.sample
 /etc/asterisk/cdr_mysql.conf

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,会在文本中显示为:“单击下一步按钮将您移动到下一个屏幕。”

注意

警告或重要提示会以这样的方式出现在一个框中。

提示

提示和技巧会以这样的方式出现。

第一章:Blueblog-博客平台

我们将从一个简单的 Django 博客平台开始。近年来,Django 已经成为 Web 框架中的明星领导者之一。当大多数人决定开始使用 Web 框架时,他们的搜索结果要么是Ruby on RailsRoR),要么是 Django。两者都是成熟、稳定且被广泛使用的。似乎使用其中一个的决定主要取决于你熟悉哪种编程语言。Ruby 程序员选择 RoR,Python 程序员选择 Django。在功能方面,两者都可以用来实现相同的结果,尽管它们对待事物的方式有所不同。

如今最受欢迎的博客平台之一是 Medium,被许多知名博客作者广泛使用。它的流行源于其优雅的主题和简单易用的界面。我将带你创建一个类似的 Django 应用程序,其中包含大多数博客平台没有的一些惊喜功能。这将让你体验到即将到来的东西,并展示 Django 有多么多才多艺。

在开始任何软件开发项目之前,最好先大致规划一下我们想要实现的目标。以下是我们的博客平台将具有的功能列表:

  • 用户应该能够注册账户并创建他们的博客

  • 用户应该能够调整他们博客的设置

  • 用户应该有一个简单的界面来创建和编辑博客文章

  • 用户应该能够在平台上的其他博客上分享他们的博客文章

我知道这似乎是很多工作,但 Django 带有一些contrib包,可以大大加快我们的工作速度。

contrib 包

contrib包是 Django 的一部分,其中包含一些非常有用的应用程序,Django 开发人员决定应该随 Django 一起发布。这些包含的应用程序提供了令人印象深刻的功能集,包括我们将在此应用程序中使用的一些功能:

  • 管理是一个功能齐全的 CMS,可用于管理 Django 站点的内容。管理应用程序是 Django 流行的重要原因。我们将使用此功能为网站管理员提供界面,以便在我们的应用程序中进行数据的审查和管理

  • Auth 提供用户注册和身份验证,而无需我们做任何工作。我们将使用此模块允许用户在我们的应用程序中注册、登录和管理他们的个人资料

注意

contrib模块中还有很多好东西。我建议你查看完整列表docs.djangoproject.com/en/stable/ref/contrib/#contrib-packages

我通常在所有我的 Django 项目中至少使用三个contrib包。它们提供了通常需要的功能,如用户注册和管理,并使你能够专注于项目的核心部分,为你提供一个坚实的基础来构建。

设置我们的开发环境

对于这第一章,我将详细介绍如何设置开发环境。对于后面的章节,我只会提供最少的说明。有关我如何设置开发环境以及原因的更多详细信息,请参阅附录,开发环境设置详细信息和调试技术

让我们从为我们的项目创建目录结构开始,设置虚拟环境并配置一些基本的 Django 设置,这些设置需要在每个项目中设置。让我们称我们的博客平台为 BlueBlog。

注意

有关即将看到的步骤的详细说明,请参阅附录,开发环境设置详细信息和调试技术。如果您对我们为什么要做某事或特定命令的作用感到不确定,请参考该文档。

要开始一个新项目,您需要首先打开您的终端程序。在 Mac OS X 中,它是内置终端。在 Linux 中,终端根据每个发行版单独命名,但您不应该有找到它的麻烦;尝试在程序列表中搜索单词终端,应该会显示相关内容。在 Windows 中,终端程序称为命令行。您需要根据您的操作系统启动相关程序。

注意

如果您使用 Windows 操作系统,您需要稍微修改书中显示的命令。请参考附录中的在 Windows 上开发部分,了解详情。

打开您操作系统的相关终端程序,并通过以下命令创建我们项目的目录结构;使用以下命令cd(进入)到根项目目录:

> mkdir –p blueblog
> cd blueblog

接下来让我们创建虚拟环境,安装 Django,并启动我们的项目:

> pyvenv blueblogEnv
> source blueblogEnv/bin/activate
> pip install django
> django-admin.py startproject blueblog src

搞定这些之后,我们就可以开始开发我们的博客平台了。

数据库设置

在您喜欢的编辑器中打开$PROJECT_DIR/src/blueblog/settings.py中的设置,并确保DATABASES设置变量与以下内容匹配:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
    }
}

为了初始化数据库文件,请运行以下命令:

> cd src
> python manage.py migrate

静态文件设置

设置开发环境的最后一步是配置staticfiles contrib应用程序。staticfiles 应用程序提供了许多功能,使得管理项目的静态文件(css、图片、JavaScript)变得容易。虽然我们的使用将是最小化的,但您应该仔细查看 Django 文档中关于 staticfiles 的详细信息,因为它在大多数真实世界的 Django 项目中被广泛使用。您可以在docs.djangoproject.com/en/stable/howto/static-files/找到文档。

为了设置 staticfiles 应用程序,我们必须在settings.py文件中配置一些设置。首先确保django.contrib.staticfiles已添加到INSTALLED_APPS中。Django 应该默认已经做了这个。

接下来,将STATIC_URL设置为您希望静态文件从中提供的任何 URL。我通常将其保留为默认值/static/。这是 Django 在您使用静态模板标签获取静态文件路径时将放入您的模板中的 URL。

一个基础模板

接下来让我们设置一个基础模板,所有应用程序中的其他模板都将从中继承。我喜欢将项目源文件夹中多个应用程序使用的模板放在名为 templates 的目录中。为了设置这一点,在设置文件的TEMPLATES配置字典的DIRS数组中添加os.path.join(BASE_DIR, 'templates'),然后在$PROJECT_ROOT/src中创建一个名为 templates 的目录。接下来,使用您喜欢的文本编辑器,在新文件夹中创建一个名为base.html的文件,内容如下:

<html>
<head>
    <title>BlueBlog</title>
</head>
<body>
    {% block content %}
    {% endblock %}
</body>
</html>

与 Python 类继承自其他类一样,Django 模板也可以继承自其他模板。就像 Python 类的函数可以被子类覆盖一样,Django 模板也可以定义子模板可以覆盖的块。我们的base.html模板提供了一个供继承模板覆盖的块,称为content

使用模板继承的原因是代码重用。我们应该将我们希望在网站的每个页面上可见的 HTML,如标题、页脚、版权声明、元标记等,放在基础模板中。然后,任何继承自它的模板将自动获得所有这些常见的 HTML,我们只需要覆盖我们想要自定义的块的 HTML 代码。你将看到这种在本书中的项目中使用创建和覆盖基础模板中的块的原则。

用户帐户

数据库设置完成后,让我们开始创建我们的应用程序。如果你记得的话,我们功能列表中的第一件事是允许用户在我们的网站上注册帐户。正如我之前提到的,我们将使用 Django contrib 包中的 auth 包来提供用户帐户功能。

为了使用 auth 包,我们需要在设置文件(位于$PROJECT_ROOT/src/blueblog/settings.py)中的INSTALLED_APPS列表中添加它。在设置文件中,找到定义INSTALLED_APPS的行,并确保字符串django.contrib.auth是列表的一部分。默认情况下应该是这样的,但如果不是,请手动添加。

你会看到 Django 默认情况下包含了 auth 包和其他一些 contrib 应用程序到列表中。一个新的 Django 项目默认包含这些应用程序,因为几乎所有的 Django 项目最终都会使用它们。

注意

如果需要将 auth 应用程序添加到列表中,请记住使用引号括起应用程序名称。

我们还需要确保MIDDLEWARE_CLASSES列表包含django.contrib.sessions.middleware.SessionMiddlewaredjango.contrib.auth.middleware.AuthenticationMiddlewaredjango.contrib.auth.middleware.SessionAuthenticationMiddleware。这些中间件类让我们在视图中访问已登录的用户,并确保如果我更改了我的帐户密码,我将从先前登录的所有其他设备中注销。

随着你对各种 contrib 应用程序及其用途的了解越来越多,你可以开始删除你知道在项目中不需要的任何应用程序。现在,让我们添加允许用户在我们的应用程序中注册的 URL、视图和模板。

用户帐户应用程序

为了创建与用户帐户相关的各种视图、URL 和模板,我们将开始一个新的应用程序。要这样做,在命令行中输入以下内容:

> python manage.py startapp accounts

这将在src文件夹内创建一个新的accounts文件夹。我们将在这个文件夹内的文件中添加处理用户帐户的代码。为了让 Django 知道我们想要在项目中使用这个应用程序,将应用程序名称(accounts)添加到INSTALLED_APPS设置变量中;确保用引号括起来。

帐户注册

我们将要处理的第一个功能是用户注册。让我们从在accounts/views.py中编写注册视图的代码开始。确保views.py的内容与这里显示的内容匹配:

from django.contrib.auth.forms import UserCreationForm
from django.core.urlresolvers import reverse
from django.views.generic import CreateView

class UserRegistrationView(CreateView):
    form_class = UserCreationForm
    template_name = 'user_registration.html'

    def get_success_url(self):
        return reverse('home')

我将在稍后解释这段代码的每一行都做了什么。但首先,我希望你能达到一个状态,可以注册一个新用户并亲自看看流程是如何工作的。接下来,我们将为这个视图创建模板。为了创建模板,你首先需要在accounts文件夹内创建一个名为templates的新文件夹。文件夹的名称很重要,因为 Django 会自动在具有该名称的文件夹中搜索模板。要创建这个文件夹,只需输入以下命令:

> mkdir accounts/templates

接下来,在templates文件夹内创建一个名为user_registration.html的新文件,并输入下面显示的代码:

{% extends "base.html" %}

{% block content %}
<h1>Create New User</h1>
<form action="" method="post">{% csrf_token %}
    {{ form.as_p }}
    <input type="submit" value="Create Account" />
</form>
{% endblock %}

最后,删除blueblog/urls.py中的现有代码,并替换为以下内容:

from django.conf.urls import include
from django.conf.urls import url
from django.contrib import admin
from django.views.generic import TemplateView
from accounts.views import UserRegistrationView

urlpatterns = [
    url(r'^admin/', include(admin.site.urls)),
    url(r'^$', TemplateView.as_view(template_name='base.html'), name='home'),
    url(r'^new-user/$', UserRegistrationView.as_view(), name='user_registration'),
]

这就是我们在项目中需要的所有代码来实现用户注册!让我们进行一个快速演示。通过输入以下命令来运行开发服务器:

> python manage.py runser
ver

在浏览器中,访问http://127.0.0.1:8000/new-user/,您将看到一个用户注册表单。填写表单并点击提交。成功注册后,您将被带到一个空白页面。如果有错误,表单将再次显示,并显示适当的错误消息。让我们验证一下我们的新账户是否确实在数据库中创建了。

在下一步中,我们将需要一个管理员账户。Django auth contrib 应用程序可以为用户账户分配权限。具有最高权限级别的用户被称为超级用户。超级用户账户可以自由地管理应用程序并执行任何管理员操作。要创建超级用户账户,请运行以下命令:

> python manage.py createsuperuser

注意

由于您已经在终端中运行了runserver命令,您需要先按下终端中的Ctrl + C来退出。然后您可以在同一个终端中运行createsuperuser命令。运行createsuperuser命令后,您需要再次启动runserver命令来浏览网站。

如果您想保持runserver命令运行,并在新的终端窗口中运行createsuperuser命令,您需要确保通过运行与我们创建新项目时相同的source blueblogEnv/bin/activate命令来激活此应用程序的虚拟环境。

创建完账户后,访问http://127.0.0.1:8000/admin/并使用管理员账户登录。您将看到一个名为Users的链接。点击该链接,您应该会看到我们应用程序中注册的用户列表。其中将包括您刚刚创建的用户。

恭喜!在大多数其他框架中,要实现一个可用的用户注册功能,需要付出更多的努力。Django 以其一应俱全的方式,使我们能够以最少的努力实现相同的功能。

接下来,我将解释您编写的每行代码的作用。

通用视图

以下是用户注册视图的代码:

class UserRegistrationView(CreateView):
    form_class = UserCreationForm
    template_name = 'user_registration.html'

    def get_success_url(self):
        return reverse('home')

我们的视图对于做了这么多工作来说非常简短。这是因为我们使用了 Django 最有用的功能之一,即通用视图,而不是从头开始编写处理所有工作的代码。通用视图是 Django 提供的基类,提供了许多 Web 应用程序通常需要的功能。通用视图的强大之处在于能够轻松地对其进行大量定制。

注意

您可以在docs.djangoproject.com/en/stable/topics/class-based-views/上的文档中阅读更多关于 Django 通用视图的信息。

在这里,我们使用了CreateView通用视图。这个通用视图可以使用模板显示ModelForm,并在提交时,如果表单数据无效,可以重新显示页面并显示错误,或者调用表单的save方法并将用户重定向到可配置的 URL。CreateView可以以多种方式进行配置。

如果您希望从某个 Django 模型自动生成ModelForm,只需将model属性设置为model类,表单将自动从模型的字段生成。如果您希望表单只显示模型的某些字段,请使用fields属性列出您想要的字段,就像使用ModelForm时所做的那样。

在我们的情况下,我们不是自动生成ModelForm,而是提供了我们自己的UserCreationForm。我们通过在视图上设置form_class属性来实现这一点。这个表单是 auth contrib 应用的一部分,它提供了字段和一个save方法,可以用来创建一个新用户。随着我们在后面的章节中开始开发更复杂的应用程序,您会发现这种从 Django 提供的小型可重用部分组合解决方案的主题是 Django Web 应用程序开发中的常见做法,我认为这是框架中最好的特性之一。

最后,我们定义了一个get_success_url函数,它执行简单的反向 URL 并返回生成的 URL。CreateView调用此函数以获取在提交有效表单并成功保存时将用户重定向到的 URL。为了快速启动并运行某些东西,我们省略了一个真正的成功页面,只是将用户重定向到一个空白页面。我们以后会修复这个问题。

模板和 URL

模板扩展了我们之前创建的基本模板,简单地使用CreateView传递给它的表单,使用form.as_p方法显示表单,您可能在之前的简单 Django 项目中见过。

urls.py文件更有趣一些。您应该熟悉其中的大部分内容,我们包含管理站点 URL 的部分以及我们为视图分配 URL 的部分。我想在这里解释一下TemplateView的用法。

CreateView一样,TemplateView是 Django 提供给我们的另一个通用视图。顾名思义,这个视图可以向用户呈现和显示模板。它有许多自定义选项。最重要的是template_name,它告诉它要呈现和显示给用户的模板是哪一个。

我们本可以创建另一个视图类,它是TemplateView的子类,并通过设置属性和覆盖函数来自定义它,就像我们为注册视图所做的那样。但我想向您展示 Django 中使用通用视图的另一种方法。如果您只需要自定义通用视图的一些基本参数;在这种情况下,我们只想设置视图的template_name参数,您可以将值作为函数关键字参数传递给类的as_view方法,这样只需要传递key=value对。在urls.py文件中包含它时。在这里,我们传递模板名称,当用户访问它的 URL 时,视图呈现的模板。由于我们只需要一个占位符 URL 来重定向用户,我们只需使用空白的base.html模板。

提示

通过传递键/值对来自定义通用视图的技术只有在您有兴趣自定义非常基本的属性时才有意义,就像我们在这里做的那样。如果您想要更复杂的自定义,我建议您子类化视图,否则您将很快得到难以维护的混乱代码。

登录和注销

注册完成后,让我们编写代码为用户提供登录和注销的功能。首先,用户需要一种方式从站点上的任何页面转到登录和注册页面。为此,我们需要在我们的模板中添加页眉链接。这是展示模板继承如何可以在我们的模板中导致更清洁和更少代码的绝佳机会。

在我们的base.html文件的body标签后面添加以下行:

{% block header %}
<ul>
    <li><a href="">Login</a></li>
    <li><a href="">Logout</a></li>
    <li><a href="{% url "user_registration"%}">Register Account</a></li>
</ul>
{% endblock %}

如果您现在打开我们站点的主页(在http://127.0.0.1:8000/),您应该看到我们之前空白页面上的三个链接。它应该类似于以下截图:

登录和注销

单击注册账户链接。您将看到我们之前的注册表单,以及相同的三个链接。请注意我们只将这些链接添加到base.html模板中。但由于用户注册模板扩展了基本模板,所以它在我们的努力下获得了这些链接。这就是模板继承真正发挥作用的地方。

您可能已经注意到登录/注销链接的href为空。让我们从登录部分开始。

登录视图

让我们先定义 URL。在blueblog/urls.py中从 auth 应用程序导入登录视图:

from django.contrib.auth.views import login

接下来,将其添加到urlpatterns列表中:

url(r'^login/$', login, {'template_name': 'login.html'}, name='login'),

然后,在accounts/templates中创建一个名为login.html的新文件。输入以下内容:

{% extends "base.html" %}

{% block content %}
<h1>Login</h1>
<form action="{% url "login" %}" method="post">{% csrf_token %}
    {{ form.as_p }}

    <input type="hidden" name="next" value="{{ next }}" />
    <input type="submit" value="Submit" />
</form>
{% endblock %}

最后,打开blueblog/settings.py并在文件末尾添加以下行:

LOGIN_REDIRECT_URL = '/'

让我们回顾一下我们在这里所做的事情。首先,请注意,我们没有创建自己的代码来处理登录功能,而是使用了 auth 应用程序提供的视图。我们使用from django.contrib.auth.views import login导入它。接下来,我们将其与登录/URL 关联起来。如果您还记得用户注册部分,我们将模板名称作为关键字参数传递给as_view()函数中的主页视图。这种方法用于基于类的视图。对于旧式的视图函数,我们可以将一个字典传递给url函数,作为关键字参数传递给视图。在这里,我们使用了我们在login.html中创建的模板。

如果您查看登录视图的文档(docs.djangoproject.com/en/stable/topics/auth/default/#django.contrib.auth.views.login),您会发现成功登录后,它会将用户重定向到settings.LOGIN_REDIRECT_URL。默认情况下,此设置的值为/accounts/profile/。由于我们没有定义这样的 URL,我们将更改设置以指向我们的主页 URL。

接下来,让我们定义登出视图。

登出视图

blueblog/urls.py中使用from django.contrib.auth.views import logout导入登出视图,并将以下内容添加到urlpatterns列表中:

url(r'^logout/$', logout, {'next_page': '/login/'}, name='logout'),

就是这样。登出视图不需要模板;它只需要配置一个 URL,以在登出后将用户重定向到该 URL。我们只需将用户重定向回登录页面。

导航链接

在添加了登录/登出视图之后,我们需要让之前在导航菜单中添加的链接带用户到这些视图。将templates/base.html中的链接列表更改为以下内容:

<ul>
    {% if request.user.is_authenticated %}
    <li><a href="{% url "logout" %}">Logout</a></li>
    {% else %}
    <li><a href="{% url "login" %}">Login</a></li>
    <li><a href="{% url "user_registration"%}">Register Account</a></li>
    {% endif %}
</ul>

如果用户尚未登录,这将向用户显示登录和注册账户链接。如果他们已经登录,我们使用request.user.is_authenticated函数进行检查,只会显示登出链接。您可以自行测试所有这些链接,并查看需要多少代码才能使我们网站的一个重要功能运行。这一切都是因为 Django 提供的 contrib 应用程序。

博客

用户注册已经完成,让我们开始处理应用程序的博客部分。我们将为博客创建一个新应用程序,在控制台中输入以下内容:

> python manage.py startapp blog
> mkdir blog/templates

将博客应用程序添加到settings.py文件中的INSTALLED_APPS列表中。应用程序创建并安装后,让我们开始使用我们将使用的模型。

模型

blog/models.py中,输入下面显示的代码:

from django.contrib.auth.models import User
from django.db import models

class Blog(models.Model):
    owner = models.ForeignKey(User, editable=False)
    title = models.CharField(max_length=500)

    slug = models.CharField(max_length=500, editable=False)

class BlogPost(models.Model):
    blog = models.ForeignKey(Blog)
    title = models.CharField(max_length=500)
    body = models.TextField()

    is_published = models.BooleanField(default=False)

    slug = models.SlugField(max_length=500, editable=False)

在输入此代码后,运行以下命令为这些模型创建数据库表:

> python manage.py makemigrations blog
> python manage.py migrate blog

这将创建支持我们新模型所需的数据库表。模型非常基本。您可能以前没有使用过的一个字段类型是SlugField。Slug 是用于唯一标识某物的一段文本。在我们的情况下,我们使用两个 slug 字段来标识我们的博客和博客文章。由于这些字段是不可编辑的,我们将不得不编写代码为它们赋一些值。我们稍后会研究这个问题。

创建博客视图

让我们创建一个视图,用户可以在其中设置他的博客。让我们创建一个用户将用来创建新博客的表单。创建一个新文件blog/forms.py,并输入以下内容:

from django import forms

from blog.models import Blog

class BlogForm(forms.ModelForm):
    class Meta:
        model = Blog

        fields = [
                 'title'
                 ]

这将创建一个模型表单,允许仅对我们的Blog模型的标题字段进行编辑。让我们创建一个模板和视图来配合这个表单。

创建一个名为blog/templates/blog_settings.html的文件,并输入以下 HTML 代码:

{% extends "base.html" %}

{% block content %}
<h1>Blog Settings</h1>
<form action="{% url "new-blog" %}" method="post">{% csrf_token %}
    {{ form.as_p }}

    <input type="submit" value="Submit" />
</form>
{% endblock %}

您可能已经注意到,我在博客设置命名的 URL 上使用了url标签,但尚未创建该 URL 模式。在创建视图后,我们将这样做,但请记住名称,确保我们的 URL 得到相同的名称。

注意

创建视图、模板和 URL 的顺序没有固定的规定。你可以自行决定哪种方式更适合你。

在你的blog/views.py文件中,添加以下代码来创建视图:

from django.core.urlresolvers import reverse
from django.http.response import HttpResponseRedirect
from django.utils.text import slugify
from django.views.generic import CreateView

from blog.forms import BlogForm

class NewBlogView(CreateView):
    form_class = BlogForm
    template_name = 'blog_settings.html'

    def form_valid(self, form):
        blog_obj = form.save(commit=False)
        blog_obj.owner = self.request.user
        blog_obj.slug = slugify(blog_obj.title)

        blog_obj.save()
        return HttpResponseRedirect(reverse('home'))

修改blueblog/urls.py。在文件顶部添加from blog.views import NewBlogView,并将其添加到urlpatterns列表中:

url(r'^blog/new/$', NewBlogView.as_view(), name='new-blog'),

作为最后一步,我们需要一些方式让用户访问我们的新视图。将base.html中的标题块更改为以下内容:

{% block header %}
<ul>
    {% if request.user.is_authenticated %}
    <li><a href="{% url "new-blog" %}">Create New Blog</a></li>
    <li><a href="{% url "logout" %}">Logout</a></li>
    {% else %}
    <li><a href="{% url "login" %}">Login</a></li>
    <li><a href="{% url "user_registration"%}">Register Account</a></li>
    {% endif %}
</ul>
{% endblock %}

要测试我们的最新功能,打开http://127.0.0.1:8000上的主页,然后点击创建新博客链接。它将呈现一个表单,您可以在其中输入博客标题并保存您的新博客。页面应该类似于以下截图:

创建博客视图

我们添加的大部分代码都很基本。有趣的部分是NewBlogView。让我们看看它是如何工作的。首先,注意我们是从CreateView通用视图中继承的。创建视图允许我们轻松地显示和处理一个将创建给定模型的新对象的表单。要配置它,我们可以设置视图的modelfields属性,然后创建视图将使用它们生成模型表单,或者我们可以手动创建模型表单并将其分配给视图,就像我们在这里做的那样。

我们还配置了用于显示表单的模板。然后我们定义form_valid函数,当表单提交有效数据时,创建视图将调用该函数。在我们的实现中,我们调用模型表单的save方法,并将commit关键字参数设置为False。这告诉表单使用传递的数据创建我们模型的新对象,但不保存创建的对象到数据库。然后我们将新博客对象的所有者设置为登录的用户,并将其 slug 设置为用户输入的标题的 slugified 版本。slugify 是 Django 提供的众多实用函数之一。一旦我们根据我们的要求修改了博客对象,我们保存它并从form_valid函数返回HttpResponseRedirect。这个响应返回给浏览器,然后将用户带到主页。

到目前为止,我们的主页只是一个带有导航栏的空白页面。但它有一个严重的问题。首先通过导航栏中的链接创建一个新的博客。成功创建新博客后,我们将被重定向回主页,再次看到一个链接来创建另一个博客。但这不是我们想要的行为。理想情况下,我们的用户应该限制为每个帐户一个博客。

让我们来解决这个问题。首先,我们将限制博客创建视图,只允许用户在没有博客的情况下创建博客。在blog/views.py中导入HttpResponseForbiddenBlog模型:

from django.http.response import HttpResponseForbidden
from blog.models import Blog

NewBlogView类中添加一个dispatch方法,其中包含以下代码:

def dispatch(self, request, *args, **kwargs):
    user = request.user
    if Blog.objects.filter(owner=user).exists():
        return HttpResponseForbidden ('You can not create more than one blogs per account')
    else:
        return super(NewBlogView, self).dispatch(request, *args, **kwargs)

dispatch方法是要在通用视图上覆盖的最有用的方法之一。当视图 URL 被访问时,它是第一个被调用的方法,并根据请求类型决定是否调用视图类上的getpost方法来处理请求。因此,如果您想要在所有请求类型(GET、POST、HEAD、PUT 等)上运行一些代码,dispatch是要覆盖的最佳方法。

在这种情况下,我们确保用户没有与其帐户关联的博客对象。如果有,我们将使用HttpResponseForbidden响应类返回Not Allowed响应。试一下。如果您之前已经创建了博客,现在甚至不能访问新的博客页面,而应该看到一个错误。

最后一件事。在注销后尝试访问 URLhttp://127.0.0.1:8000/blog/new/。注意您将收到AnonymousUser对象不可迭代的错误。这是因为即使您没有以注册用户的身份登录,视图的代码仍然假定您是。此外,您应该无法在未登录的情况下访问新博客页面。为了解决这个问题,首先将这两个导入行放在blog/views.py的顶部:

from django.utils.decorators import method_decorator
from django.contrib.auth.decorators import login_required

然后更改 dispatch 方法的定义行以匹配以下内容:

@method_decorator(login_required)
def dispatch(self, request, *args, **kwargs):

如果您现在尝试在未登录的情况下访问页面,您应该会看到Page not found (404)的 Django 错误页面。如果您查看该页面的 URL,您将看到 Django 正在尝试提供/accounts/login/的 URL。这是login_required装饰器的默认行为。为了解决这个问题,我们需要更改设置文件中LOGIN_URL变量的值。将其放在blueblog/settings.py中:

LOGIN_URL = '/login/'

现在尝试访问http://localhost:8000/blog/new/,您将被重定向到登录页面。如果输入正确的用户名/密码组合,您将登录并被带到您之前尝试访问的页面,创建新博客页面。这个功能是免费提供给我们的,因为我们使用了 Django 的内置登录视图。

我们将在后面的章节中讨论method_decoratorlogin_required装饰器。如果您现在想要更多关于这些的信息,请查看 Django 文档中它们的文档。它在解释这两者方面做得非常出色。

您可以在docs.djangoproject.com/en/stable/topics/auth/default/#the-login-required-decorator找到login_required的文档。对于method_decorator,您可以查看docs.djangoproject.com/en/stable/topics/class-based-views/intro/#decorating-the-class

主页

现在是时候为我们的用户创建一个合适的主页,而不是显示一个空白页面和一些导航链接。此外,当创建新博客链接导致错误页面时,向用户显示它似乎非常不专业。让我们通过创建一个包含一些智能的主页视图来解决所有这些问题。我们将在博客应用程序中放置我们的主页视图的代码。从技术上讲,它可以放在任何地方,但我个人喜欢将这样的视图放在项目的主要应用程序(在这种情况下是博客)或创建一个新的应用程序来放置这样的常见视图。在您的blog/views.py文件中,从django.views.generic中导入TemplateView通用视图,并放入以下视图的代码:

class HomeView(TemplateView):
    template_name = 'home.html'

    def get_context_data(self, **kwargs):
        ctx = super(HomeView, self).get_context_data(**kwargs)

        if self.request.user.is_authenticated():
            ctx['has_blog'] = Blog.objects.filter(owner=self.request.user).exists()

        return ctx

通过在blueblog/urls.py中导入它from blog.views import HomeView,并将现有的根 URL 配置从url(r'^$', TemplateView.as_view(template_name='base.html'), name='home'),更改为url(r'^$', HomeView.as_view(), name='home'),,将此新视图绑定到主页 URL。

由于不再需要TemplateView类,您可以从导入中将其删除。您应该已经对我们在这里做什么有了一个很好的想法。唯一新的东西是TemplateView及其get_context_data方法。TemplateView是 Django 内置的另一个通用视图。我们通过提供模板文件名来配置它,并且视图通过将我们的get_context_data函数返回的字典作为上下文传递给模板来呈现该模板。在这里,如果用户有与其帐户关联的现有博客,我们将has_blog上下文变量设置为True

我们的观点已经完成,我们需要对base.html模板进行一些更改,并添加一个新的home.html模板。对于base.html模板,更改头部块中的代码以匹配:

{% block header %}
<ul>
    {% if request.user.is_authenticated %}
    {% block logged_in_nav %}{% endblock %}
    <li><a href="{% url "logout" %}">Logout</a></li>
    {% else %}
    <li><a href="{% url "login" %}">Login</a></li>
    <li><a href="{% url "user_registration"%}">Register Account</a></li>
    {% endif %}
</ul>
{% endblock %}

我们已经删除了创建新博客链接,并用另一个名为logged_in_nav的块进行了替换。这个想法是每个从基本模板继承的页面都可以在这里添加导航链接,以显示给已登录的用户。最后,创建一个名为blog/templates/home.html的新文件,并添加以下代码:

{% extends "base.html" %}

{% block logged_in_nav %}
{% if not has_blog %}
<li><a href="{% url "new-blog" %}">Create New Blog</a></li>
{% else %}
<li><a href="">Edit Blog Settings</a></li>
{% endif %}
{% endblock %}

就像我们讨论的那样,主页模板覆盖了logged_in_nav块,以添加一个链接来创建一个新的博客(如果用户没有现有的博客),或者编辑现有博客的设置。您可以通过访问主页来测试我们所有的更改,看看已经创建了博客的用户和没有博客的新用户。您会看到只有在用户还没有创建博客时,才会显示创建新博客的链接。

接下来,让我们来处理设置视图。

博客设置视图

将视图的代码放在blog/views.py中:

class UpdateBlogView(UpdateView):
    form_class = BlogForm
    template_name = 'blog_settings.html'
    success_url = '/'
    model = Blog

    @method_decorator(login_required)
    def dispatch(self, request, *args, **kwargs):
        return super(UpdateBlogView, self).dispatch(request, *args, **kwargs)

您需要从django.views.generic中导入UpdateView。还要更新同一文件中HomeViewget_context_data方法,使其与此匹配。

def get_context_data(self, **kwargs):
    ctx = super(HomeView, self).get_context_data(**kwargs)

    if self.request.user.is_authenticated():
        if Blog.objects.filter(owner=self.request.user).exists():
            ctx['has_blog'] = True
            ctx['blog'] = Blog.objects.get(owner=self.request.user)
    return ctx

blog/templates/blog_settings.html更改为以下内容:

{% extends "base.html" %}

{% block content %}
<h1>Blog Settings</h1>
<form action="" method="post">{% csrf_token %}
    {{ form.as_p }}

    <input type="submit" value="Submit" />
</form>
{% endblock %}

我们唯一做的改变是删除了之前在表单动作中明确定义的 URL。这样,表单将始终提交到提供它的 URL。这一点很重要,我们以后会看到。

按照以下代码更新blog/templates/home.html

{% extends "base.html" %}

{% block logged_in_nav %}
{% if not has_blog %}
<li><a href="{% url "new-blog" %}">Create New Blog</a></li>
{% else %}
<li><a href="{% url "update-blog" pk=blog.pk %}">Edit Blog Settings</a></li>
{% endif %}
{% endblock %}

最后,在blueblog/urls.py中导入UpdateBlogView,并将以下内容添加到urlpatterns

url(r'^blog/(?P<pk>\d+)/update/$', UpdateBlogView.as_view(), name='update-blog'),

就是这样。使用您在上一节中用来创建博客的用户访问主页,这次您会看到一个链接来编辑您的博客,而不是创建一个新的。在这里要看的有趣的地方是UpdateView子类;UpdateBlogView。我们只定义了表单类、模板名称、成功的 URL 和模型,就得到了一个完整的可工作的更新视图。通过配置这些东西,并且我们的 URL 设置使得我们要编辑的对象的主键作为关键字参数pk传递给我们的视图,UpdateView会显示一个与我们要编辑的模型实例相关联的表单。在主页视图中,我们将用户的博客添加到上下文中,并在主页模板中使用它来生成一个用于更新视图的 URL。

在表单中,我们需要更改表单的动作属性,以便在提交时,它会发布到当前页面。由于我们在创建和更新视图中使用相同的模板,我们需要表单提交到渲染自身的任何 URL。正如您将在即将到来的项目中看到的那样,在 Django 中使用相同模板与类似视图是一种常见的做法。而 Django 通用视图的结构使这更容易实现。

创建和编辑博客文章

让我们创建用户可以使用来创建和编辑博客文章的视图。让我们从创建新博客文章开始。我们之前已经创建了模型,所以让我们从我们将使用的表单和模板开始。在blog/forms.py中,创建这个表单:

class BlogPostForm(forms.ModelForm):
    class Meta:
        model = BlogPost

        fields = [
                 'title',
                 'body'
                 ]

您还需要导入BlogPost模型。对于模板,创建一个名为blog/templates/blog_post.html的新文件,并添加以下内容:

{% extends "base.html" %}

{% block content %}
<h1>Create New Blog Post</h1>
<form action="" method="post">{% csrf_token %}
    {{ form.as_p }}

    <input type="submit" value="Submit" />
</form>
{% endblock %}

blog/views.py中,导入BlogPostFormBlogPost模型,然后创建NewBlogPostView

class NewBlogPostView(CreateView):
    form_class = BlogPostForm
    template_name = 'blog_post.html'

    @method_decorator(login_required)
    def dispatch(self, request, *args, **kwargs):
        return super(NewBlogPostView, self).dispatch(request, *args, **kwargs)

    def form_valid(self, form):
        blog_post_obj = form.save(commit=False)
        blog_post_obj.blog = Blog.objects.get(owner=self.request.user)
        blog_post_obj.slug = slugify(blog_post_obj.title)
        blog_post_obj.is_published = True

        blog_post_obj.save()

        return HttpResponseRedirect(reverse('home'))

blueblog/urls.py中,导入前面的视图,并添加以下 URL 模式:

url(r'blog/post/new/$', NewBlogPostView.as_view(), name='new-blog-post'),

最后,将主页模板blog/template/home.html更改为链接到我们的新页面:

{% extends "base.html" %}

{% block logged_in_nav %}
    {% if not has_blog %}
    <li><a href="{% url "new-blog" %}">Create New Blog</a></li>
    {% else %}
    <li><a href="{% url "update-blog" pk=blog.pk %}">Edit Blog Settings</a></li>
    <li><a href="{% url "new-blog-post" %}">Create New Blog Post</a></li>
    {% endif %}
{% endblock %}

到目前为止,所有这些代码对你来说应该都很熟悉。我们使用了模型表单和通用视图来获得我们需要的功能,而我们需要做的只是配置一些东西。我们没有写一行代码来创建相关的表单字段,验证用户输入,并处理各种错误和成功的情况。

您可以通过在主页上导航中使用创建新博客文章链接来测试我们的新视图。

编辑博客文章

与之前对Blog模型所做的一样,我们将使用相同的模板为博客文章创建一个编辑视图。但首先,我们需要为用户添加一种查看他的博客文章并链接到编辑页面的方式。为了保持简单,让我们将此列表添加到我们的主页视图中。在HomeView中,编辑get_context_data方法以匹配以下内容:

def get_context_data(self, **kwargs):
    ctx = super(HomeView, self).get_context_data(**kwargs)

    if self.request.user.is_authenticated():
        if Blog.objects.filter(owner=self.request.user).exists():
            ctx['has_blog'] = True
            blog = Blog.objects.get(owner=self.request.user)

            ctx['blog'] = blog
            ctx['blog_posts'] = BlogPost.objects.filter(blog=blog)

    return ctx

blog/templates/home.html的末尾,在logged_in_nav块结束后,添加以下代码来覆盖内容块并显示博客文章:

{% block content %}
<h1>Blog Posts</h1>
<ul>
    {% for post in blog_posts %}
    <li>{{ post.title }} | <a href="">Edit Post</a></li>
    {% endfor %}
</ul>
{% endblock %}

如果您现在访问主页,您将看到用户发布的帖子列表。让我们创建编辑帖子的功能。在blog/views.py中创建以下视图:

class UpdateBlogPostView(UpdateView):
    form_class = BlogPostForm
    template_name = 'blog_post.html'
    success_url = '/'
    model = BlogPost

    @method_decorator(login_required)
    def dispatch(self, request, *args, **kwargs):
        return super(UpdateBlogPostView, self).dispatch(request, *args, **kwargs)

将此视图导入到您的blueblog/urls.py文件中,并添加以下模式:

url(r'blog/post/(?P<pk>\d+)/update/$', UpdateBlogPostView.as_view(), name='update-blog-post'),

编辑我们之前在主页模板中创建的博客文章列表,以添加编辑帖子的 URL:

{% for post in blog_posts %}
    <li>{{ post.title }} | <a href="{% url "update-blog-post" pk=post.pk %}">Edit Post</a></li>
{% endfor %}

如果您现在打开主页,您会看到可以单击编辑帖子链接,并且它会带您到博客文章的编辑页面。我们需要修复的最后一件事是编辑博客文章页面的标题。您可能已经注意到,即使在编辑时,标题也会显示创建新博客文章。为了解决这个问题,请将blog/templates/blog_post.html中的h1标签替换为以下内容:

<h1>{% if object %}Edit{% else %}Create{% endif %} Blog Post</h1>

UpdateView通过模板传递给模板的上下文包括一个名为object的变量。这是用户当前正在编辑的实例。我们在模板中检查此变量的存在。如果找到它,我们知道正在编辑现有的博客文章。如果没有,我们知道正在创建新的博客文章。我们检测到这一点并相应地设置标题。

查看博客文章

要添加一个显示博客文章的视图,请在blog/views.py中添加以下视图类:

class BlogPostDetailsView(DetailView):
    model = BlogPost
    template_name = 'blog_post_details.html'

记得从django.views.generic中导入DetailView通用视图。接下来,使用以下代码创建blog/templates/blog_post_details.html模板:

{% extends "base.html" %}

{% block content %}
<h1>{{ object.title }}</h1>
<p>{{ object.body }}</p>
{% endblock %}

导入详细视图,并将以下 URL 模式添加到urls.py文件中:

url(r'blog/post/(?P<pk>\d+)/$', BlogPostDetailsView.as_view(), name='blog-post-details'),

最后,在主页模板中更改博客文章列表,以从帖子标题链接到帖子详细页面:

{% for post in blog_posts %}
    <li><a href="{% url "blog-post-details" pk=post.pk %}">{{ post.title }}</a> | <a href="{% url "update-blog-post" pk=post.pk %}">Edit Post</a></li>
{% endfor %}

在主页上,博客文章标题现在应该链接到详细页面。

多个用户

到目前为止,我们只使用了一个用户账户,并使我们的网站适用于该用户。让我们进入令人兴奋的部分,并将帖子分享到其他用户的博客中。但是,一旦多个用户加入到混合中,我们在继续之前应该看一下一件事。

安全性

为了展示我们应用程序中完全缺乏安全性,让我们创建一个新的用户账户。使用页眉链接注销并注册一个新账户。接下来,用该用户登录。您应该会进入主页,并且在列表中不应该看到任何博客文章。

现在,在 URLhttp://127.0.0.1:8000/blog/post/1/update/中输入。您应该会在编辑视图中看到我们从第一个用户创建的博客文章。更改博客文章的标题或正文,然后单击保存。您将被重定向回主页,并且似乎保存成功了。重新登录到第一个账户,您会看到博客文章的标题已更新。这是一个严重的安全漏洞,必须修复,否则任何用户都可以无限制地编辑其他用户的博客文章。

我们再次解决这个问题的简单方式展示了 Django 框架的强大和简单。将以下方法添加到UpdateBlogPostView类中:

def get_queryset(self):
    queryset = super(UpdateBlogPostView, self).get_queryset()
    return queryset.filter(blog__owner=self.request.user)

就是这样!再次尝试打开http://127.0.0.1:8000/blog/post/1/update/。这次,您不会再看到允许您编辑另一个用户的博客文章,而是会看到一个 404 页面。

在查看UpdateView通用视图的工作方式后,可以理解这小段代码的作用。通用视图调用许多小方法,每个方法都有特定的工作。以下是UpdateView类定义的一些方法的列表:

  • get_object

  • get_queryset

  • get_context_object_name

  • get_context_data

  • get_slug_field

拥有这些小方法的好处是,为了改变子类的功能,我们可以只覆盖其中一个并实现我们的目的,就像我们在这里所做的那样。阅读 Django 文档,了解通用视图使用的这些方法和许多其他方法的作用。

对于我们的情况,get_queryset方法,正如其名称所示,获取在其中搜索要编辑的对象的查询集。我们从超级方法中获取默认的queryset(它只返回self.model.objects.all()),并返回一个进一步过滤的版本,只包括当前登录用户拥有的博客文章。您应该熟悉关系过滤器。如果这些对您来说是新的,请阅读 Django 教程,熟悉模型查询集过滤的基础知识。

现在如果您尝试访问其他人的博客文章,您会看到 404 的原因是,当CreateView尝试获取要编辑的对象时,它收到的查询集只包括当前登录用户拥有的博客文章。由于我们试图编辑其他人的博客文章,它不包括在该查询集中。找不到要编辑的对象,CreateView返回 404。

分享博客文章

博客文章分享功能允许用户选择要与其博客文章分享的另一个用户的博客。这将允许用户通过在更受欢迎的作家的博客上分享其内容来获得更多读者,读者将能够在一个地方阅读更相关的内容,而不需要发现更多的博客。

使分享成为可能的第一步是在BlogPost模型上添加一个字段,指示帖子与哪些博客共享。将此字段添加到blog/models.py中的BlogPost模型:

shared_to = models.ManyToManyField(Blog, related_name='shared_posts')

我们只是添加了一个基本的 Django 多对多关系字段。如果您想复习一下多对多字段提供的功能,我建议您再次查看 Django 教程,特别是处理 M2M 关系的部分。

关于新字段需要注意的一点是,我们必须明确指定related_name。您可能知道,每当您使用任何关系字段(ForeignKeyOneToManyManyToMany)将一个模型与另一个模型关联时,Django 会自动向另一个模型添加一个属性,以便轻松访问链接的模型。

在添加shared_to字段之前,BlogPost模型已经有一个指向Blog模型的ForeignKey。如果您查看了Blog模型上可用的属性(使用 shell),您会发现一个blogpost_set属性,这是一个管理器对象,允许访问引用该BlogBlogPost模型。如果我们尝试添加ManyToMany字段而没有related_name,Django 会抱怨,因为新的关系还会尝试添加一个反向关系,也称为blogpost_set。因此,我们需要给反向关系取另一个名字。

定义了 M2M 关系后,您现在可以通过在Blog模型上使用shared_posts属性的all()方法来访问与博客模型共享的博客文章。稍后我们将看到一个例子。

定义新字段后,运行以下命令迁移您的数据库以创建新的关系:

> python manage.py makemigrations blog
> python manage.py migrate blog

接下来,让我们创建一个视图,允许用户选择要与其博客文章分享的博客。此添加到blog/views.py

class ShareBlogPostView(TemplateView):
    template_name = 'share_blog_post.html'

    @method_decorator(login_required)
    def dispatch(self, request, *args, **kwargs):
        return super(ShareBlogPostView, self).dispatch(request, *args, **kwargs)

    def get_context_data(self, pk, **kwargs):
        blog_post = BlogPost.objects.get(pk=pk)
        currently_shared_with = blog_post.shared_to.all()
        currently_shared_with_ids = map(lambda x: x.pk, currently_shared_with)
        exclude_from_can_share_list = [blog_post.blog.pk] + list(currently_shared_with_ids)

        can_be_shared_with = Blog.objects.exclude(pk__in=exclude_from_can_share_list)

        return {
            'post': blog_post,
            'is_shared_with': currently_shared_with,
            'can_be_shared_with': can_be_shared_with
        }

这个视图是模板视图的子类。到目前为止,您应该对它的工作原理有一个很好的了解。这里要看的重要部分是get_context_data方法内的代码。首先,我们使用从解析的 URL 模式中收集的关键字参数中传递的id获取博客文章对象。接下来,我们获取此帖子已经与之共享的所有博客对象的列表。我们这样做是因为我们不希望混淆用户,允许他们分享已经与之共享的博客的帖子。

下一行代码使用 Python 内置的map方法处理帖子共享的博客的查询集。map是在 Python 中处理任何类型的列表(或类似列表的对象)时最有用的方法之一。它的第一个参数是一个接受一个参数并返回一个参数的函数,第二个参数是一个列表。然后,map在输入列表中的每个元素上调用给定的函数,并收集结果到最终返回的列表中。在这里,我们使用lambda来提取此帖子已经共享的博客对象的 ID。

最后,我们可以获取可以与此帖子共享的博客对象列表。我们使用exclude方法来排除已经共享帖子的博客对象。我们将这些传递给模板上下文。接下来,让我们看看您需要在blog/templates/share_blog_post.html中创建的模板:

{% extends "base.html" %}

{% block content %}
{% if can_be_shared_with %}
<h2>Share {{ post.title }}</h2>
<ul>
    {% for blog in can_be_shared_with %}
    <li><a href="{% url "share-post-with-blog" post_pk=post.pk blog_pk=blog.pk %}">{{ blog.title }}</a></li>
    {% endfor %}
</ul>
{% endif %}

{% if is_shared_with %}
<h2>Stop sharing with:</h2>
<ul>
    {% for blog in is_shared_with %}
    <li><a href="{% url "stop-sharing-post-with-blog" post_pk=post.pk blog_pk=blog.pk %}">{{ blog.title }}</a></li>
    {% endfor %}
</ul>
{% endif %}
{% endblock %}

这个模板中没有什么特别的。让我们继续讨论这些模板所指的两个 URL 和视图,因为没有这些,我们无法呈现这个模板。首先,让我们看看SharepostWithBlog,您需要在blog/views.py中创建它。您还需要在文件顶部添加此导入行:

from django.views.generic import View

视图的代码如下:

class SharePostWithBlog(View):
    @method_decorator(login_required)
    def dispatch(self, request, *args, **kwargs):
        return super(SharePostWithBlog, self).dispatch(request, *args, **kwargs)

    def get(self, request, post_pk, blog_pk):
        blog_post = BlogPost.objects.get(pk=post_pk)
        if blog_post.blog.owner != request.user:
            return HttpResponseForbidden('You can only share posts that you created')

        blog = Blog.objects.get(pk=blog_pk)
        blog_post.shared_to.add(blog)

        return HttpResponseRedirect(reverse('home'))

将其导入到blueblog/urls.py中,并使用以下 URL 模式添加它:

url(r'blog/post/(?P<pk>\d+)/share/$', SharePostWithBlog.as_view(), name='share-blog-post-with-blog'),

与我们以前的所有视图不同,这个视图不太适合 Django 提供的任何通用视图中。但是 Django 有一个基本的通用视图,使我们的生活比创建处理请求的函数更容易。

当您需要完全自定义处理请求时,可以使用View通用视图。与所有通用视图一样,它具有一个dispatch方法,您可以重写以在进一步处理请求之前拦截请求。在这里,我们确保用户在允许他们继续之前已登录。

View子类中,您创建与您想要处理的请求类型相同名称的方法。在这里,我们创建一个get方法,因为我们只关心处理GET请求。View类负责在客户端使用正确的请求方法时调用我们的方法。在我们的 get 方法中,我们正在进行基本检查,以查看用户是否拥有博客帖子。如果是,我们将博客添加到BlogPost模型的shared_to ManyToMany关系中。

我们需要创建的最后一个视图是允许用户删除他们已经共享的博客帖子。该视图的代码如下所示:

class StopSharingPostWithBlog(View):
    @method_decorator(login_required)
    def dispatch(self, request, *args, **kwargs):
        return super(StopSharingPostWithBlog, self).dispatch(request, *args, **kwargs)

    def get(self, request, post_pk, blog_pk):
        blog_post = BlogPost.objects.get(pk=post_pk)
        if blog_post.blog.owner != request.user:
            return HttpResponseForbidden('You can only stop sharing posts that you created')

        blog = Blog.objects.get(pk=blog_pk)
        blog_post.shared_to.remove(blog)

        return HttpResponseRedirect(reverse('home'))

SharePostWithBlog视图一样,这个视图是View通用视图的子类。代码几乎与先前的视图完全相同。唯一的区别是在先前的视图中,我们使用了blog_post.shared_to.add,而在这个视图中,我们使用了blog_post.shared_to.remove方法。

最后,将这两个视图导入到blueblog/urls.py中,并添加以下模式:

url(r'blog/post/(?P<post_pk>\d+)/share/to/(?P<blog_pk>\d+)/$', SharePostWithBlog.as_view(), name='share-post-with-blog'),
    url(r'blog/post/(?P<post_pk>\d+)/stop/share/to/(?P<blog_pk>\d+)/$', StopSharingPostWithBlog.as_view(), name='stop-sharing-post-with-blog'),

为了在首页显示一个链接到分享此帖子页面,编辑home.html模板,将content块内的整个代码更改为以下内容:

{% if blog_posts %}
<h2>Blog Posts</h2>
<ul>
    {% for post in blog_posts %}
    <li>
        <a href="{% url "blog-post-details" pk=post.pk %}">{{ post.title }}</a> |
        <a href="{% url "update-blog-post" pk=post.pk %}">Edit Post</a> |
        <a href="{% url "share-blog-post" pk=post.pk %}">Share Post</a>
    </li>
    {% endfor %}
</ul>
{% endif %}

就是这样。现在当您访问主页时,每篇博客帖子旁边应该有一个分享帖子链接。单击它后,您将看到第二个页面,其中包含在其他用户博客上分享博客帖子的链接。单击该链接应该分享您的帖子,并在同一页面上显示相应的删除链接。当然,为了测试这一点,您应该创建第二个用户帐户,并使用该帐户添加一个博客。

我们应该做的最后一件事是修改HomeViewget_context_data方法,以便在博客帖子列表中也包括共享的帖子:

def get_context_data(self, **kwargs):
    ctx = super(HomeView, self).get_context_data(**kwargs)

    if self.request.user.is_authenticated():
            if Blog.objects.filter(owner=self.request.user).exists():
            ctx['has_blog'] = True
            blog = Blog.objects.get(owner=self.request.user)

            ctx['blog'] = blog
            ctx['blog_posts'] = BlogPost.objects.filter(blog=blog)
            ctx['shared_posts'] = blog.shared_posts.all()

    return ctx

将其添加到blog/templates/home.html模板的content块的底部:

{% if shared_posts %}
<h2>Shared Blog Posts</h2>
<ul>
    {% for post in shared_posts %}
    <li>
        <a href="{% url "blog-post-details" pk=post.pk %}">{{ post.title }}</a>
    </li>
    {% endfor %}
</ul>
{% endif %}
{% endblock %}

就是这样,我们的第一个应用程序已经完成了!如果你现在打开主页,你应该会看到每篇博客文章旁边有一个分享帖子链接。点击这个链接应该会打开另一个页面,你可以在那里选择与哪个博客分享这篇文章。为了测试它,你应该使用我们之前创建的另一个账户创建另一个博客,当时我们正在查看我们应用程序的安全性。一旦你配置了另一个博客,你的分享博客文章页面应该看起来类似于这样:

分享博客文章

点击另一个博客的标题应该会分享这篇文章并带你回到主页。如果你再次点击同一篇文章的分享帖子链接,你现在应该会看到一个标题,上面写着停止与...分享,以及你与之分享这篇文章的博客的名称。

如果你现在登录另一个账户,你应该会看到这篇文章现在已经分享到那里,并列在分享的博客文章部分下面。

总结

在本章中,我们看到了如何启动我们的应用程序并正确设置它,以便我们可以快速开发东西。我们研究了使用模板继承来实现代码重用,并为我们的网站提供导航栏等共同元素。以下是我们迄今为止涵盖的主题列表:

  • 使用 sqlite3 数据库的基本项目布局和设置

  • 简单的 Django 表单和模型表单用法

  • Django 贡献应用程序

  • 使用django.contrib.auth为应用程序添加用户注册和身份验证

  • 模板继承

  • 用于编辑和显示数据库对象的通用视图

  • 数据库迁移

我们将会在本书的其余章节中运用我们在这里学到的教训。

第二章:Discuss - 一个 Hacker News 克隆

在本章中,我们将创建一个类似 Hacker News 或 Reddit 的 Web 应用程序,用户可以分享和讨论网络内容的链接。我们将称该应用程序为Discuss。为了保持简单,我们将模拟 Hacker News 的极简外观,它只有文本,界面非常简单。另一方面,Reddit 的外观要丰富得多,并且有许多额外的功能,我们不会将这些功能添加到我们的网站上。

以下是本章我们将涵盖的大纲:

  • 允许用户提交他们自己的内容

  • 允许用户对其他用户提交的内容进行投票

  • 基于简单算法对用户提交的内容进行排名

  • 防止垃圾邮件发送者滥用我们的网站使用验证码

章节代码包

如果您已经开发了一些 Django 应用程序,您可能会知道,对于大多数应用程序,当您开始时所做的大部分代码和配置都是相同的。您以相同的方式设置数据库,也许会更改数据库DB)名称和用户/密码对,设置媒体、静态 URL 和根路径,然后使用内置的auth contrib应用程序和提供的视图添加用户身份验证,只创建足够简单的模板,以便在开始时完成工作。

在每一章的开始,为您介绍基本设置将会非常无聊,无论是对您来说阅读还是对我来说写作都是如此。相反,我提供了我称之为代码包。这些是zip文件,其中包含已经设置好的 Django 应用程序,这样我们就可以直接跳到代码的有趣部分,而不必一遍又一遍地进行繁琐的设置过程。

别担心,我不会跳过我们尚未看过的新 Django 功能。每个代码包都包含已在以前的章节中向您解释过的代码。例如,本章的代码包包含一个 Django 应用程序,其中已经设置好了用户注册、登录和注销视图、模板和 URL。这是我们在上一章中已经详细讨论过的内容。

要使用这些代码包,您需要下载它们,解压缩到项目根目录中,并为它们创建一个虚拟环境。然后,您需要运行以下命令,在您的新虚拟环境中安装 Django:

> pip install django
> python manage.py migrate

完成所有这些步骤后,您将准备好开始处理应用程序的有趣部分。在接下来的所有章节中,我已经为您提供了代码包的链接,并假设您已经提取并设置了虚拟环境。

提示

如果您不确定如何使用代码包,每个 ZIP 文件中都有一个Readme.txt。您应该阅读这个文件,以了解如何开始使用代码包。

要求

对于任何复杂的应用程序,在我们开始编码之前知道我们需要处理的功能总是一个好主意。让我们看看我们希望在本章中创建的内容。

我们希望拥有一个基于 Django 的链接分享和讨论网站,就像 Hacker News 一样。该网站应该有用户帐户,允许用户分享链接,有一个页面列出这些链接,允许用户投票和评论这些链接。

此外,我们希望对滥发垃圾邮件和恶意用户采取防范措施,如果不加以控制,他们会降低我们网站的内容质量。

以列表形式,这是我们希望我们的应用程序提供的功能:

  • 用户注册和身份验证(已在代码包中提供)

  • 用户提交的链接

  • 对其他用户提交的链接进行投票

  • 对提交进行评论并回复其他用户的评论

  • 一种算法,根据一些因素对提交的链接进行排名,包括该链接的投票数、评论数和提交的时间

  • 阻止垃圾邮件发送者创建脚本,可以自动向我们的网站提交洪水般的内容

开始

到目前为止,如果您按照本章开头给出的说明进行操作,您应该已经准备好测试应用程序了。让我们看看目前的情况。通过从应用程序文件夹中的终端运行以下命令来启动应用程序。在运行此命令之前,您需要确保您的虚拟环境已激活:

> python manage.py runserver

在浏览器中打开http://127.0.0.1:8000,你应该会看到以下基本页面:

开始

如您所见,我们有登录创建新帐户的链接。您应该继续创建一个新帐户。使用此帐户登录,您将看到两个链接被注销链接替换。这是我们将来要使用的基本应用程序设置。您应该确保您能够在这一点上使用应用程序,因为所有进一步的开发都将建立在此基础之上。

链接提交

让我们看看我们想要与链接提交相关的功能。这只是我们在本章开头看到的功能列表的一部分:

  • 用户提交的链接

  • 对其他用户提交的链接进行投票

  • 对提交进行评论并回复其他用户的评论

让我们考虑一下我们需要实现这个的模型。首先,我们需要一个模型来保存有关单个提交的信息,比如标题、URL、谁提交了链接以及何时提交的信息。接下来,我们需要一种方式来跟踪用户对提交的投票。这可以通过从提交模型到User模型的ManyToMany字段来实现。这样,每当用户对提交进行投票时,我们只需将他们添加到相关对象的集合中,如果他们决定撤回他们的投票,我们就将他们移除。

评论作为一个功能是独立于链接提交的,因为它可以作为一个链接到提交模型的单独模型来实现。我们将在下一节中讨论评论。现在,我们将集中在链接提交上。

首先,让我们在我们的项目中为链接提交相关的功能创建一个新的应用程序。在 CLI 中运行以下命令:

> python manage.py startapp links

然后,将我们新创建的应用程序添加到INSTALLED_APPS设置变量中。现在我们准备好编写代码了。

让我们从模型开始。这是Link model的代码。这段代码应该在links/models.py中:

from django.contrib.auth.models import User
from django.db import models

class Link(models.Model):
    title = models.CharField(max_length=100)
    url = models.URLField()

    submitted_by = models.ForeignKey(User)
    upvotes = models.ManyToManyField(User, related_name='votes')

    submitted_on = models.DateTimeField(auto_now_add=True, editable=False)

请注意,我们必须为upvotes字段设置related_name。如果我们没有这样做,当我们尝试运行我们的应用程序时,我们将从 Django 那里得到一个错误。Django 会抱怨在Link模型中有两个与User模型的关系,都试图创建一个名为link的反向关系。为了解决这个问题,我们通过upvotes字段明确地命名了从User模型到Link模型的反向关系。User模型现在应该有一个名为votes的属性,可以用来获取用户已经投票的提交的列表。

保存了这段代码后,您需要进行迁移并运行迁移,以便 Django 为新模型创建数据库表。为此,请输入以下命令:

> python manage.py makemigrations
> python manage.py migrate

接下来,让我们来处理模板和视图。我们将为视图定制我们在上一章中看到的通用CreateView。将这段代码放在links/views.py中:

from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse
from django.http.response import HttpResponseRedirect
from django.utils.decorators import method_decorator
from django.views.generic import CreateView

from links.models import Link

class NewSubmissionView(CreateView):
    model = Link
    fields = (
        'title', 'url'
    )

    template_name = 'new_submission.html'

    @method_decorator(login_required)
    def dispatch(self, *args, **kwargs):
        return super(NewSubmissionView, self).dispatch(*args, **kwargs)

    def form_valid(self, form):
        new_link = form.save(commit=False)
        new_link.submitted_by = self.request.user
        new_link.save()

        self.object = new_link
        return HttpResponseRedirect(self.get_success_url())

    def get_success_url(self):
        return reverse('home')

这应该看起来很熟悉,对于我们在上一章中已经创建的CreateView子类。然而,仔细看!这一次,我们不定义一个自定义表单类。相反,我们只是指向模型——在这种情况下是Link——CreateView会自动为我们创建一个模型表单。这就是内置的 Django 通用视图的强大之处。它们为您提供多种选项,以便根据您需要进行多少定制来获得您想要的内容。

我们定义了modelfields属性。model属性不言自明。fields属性在这里的含义与ModelForm子类中的含义相同。它告诉 Django 我们希望进行编辑的字段。在我们的link模型中,标题和提交 URL 是我们希望用户控制的唯二字段,因此我们将它们放入字段列表中。

这里还要注意的一件重要的事情是form_valid函数。请注意,它没有任何对super的调用。与我们以前的代码不同,在那里我们总是调用父类方法来覆盖的方法,但在这里我们不这样做。这是因为CreateViewform_valid调用了表单的save()方法。这将尝试保存新的链接对象,而不给我们设置其submitted_by字段的机会。由于submitted_by字段是必需的,不能为null,因此对象将不会被保存,我们将不得不处理数据库异常。

因此,我们选择不调用父类的form_valid方法,而是自己编写了代码。为此,我需要知道基本方法的作用。因此,我查阅了它的文档docs.djangoproject.com/en/1.9/ref/class-based-views/mixins-editing/#django.views.generic.edit.ModelFormMixin.form_valid

"保存表单实例,为视图设置当前对象,并重定向到 get_success_url()."

如果您查看我们的form_valid函数的代码,您会发现我们做了完全相同的事情。如果您遇到类似情况,Django 文档是澄清事情的最佳资源。它拥有我使用过的所有开源项目中遇到的最好的文档之一。

最后,我们需要链接提交功能的模板和 URL 配置。在links目录中创建一个名为templates的新文件夹,并将此代码保存在名为new_submission.html的文件中:

{% extends "base.html" %}

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

discuss/urls.py中,导入新视图:

from links.views import NewSubmissionView

为此视图创建新的 URL 配置:

url(r'^new-submission/$', NewSubmissionView.as_view(), name='new-submission'),

就是这样。我们需要编写的所有代码以实现基本的链接提交流程都已经完成。但是,为了能够测试它,我们需要为用户提供一些访问这个新视图的方式。在我们的base.html模板中的导航栏似乎是放置此链接的好地方。更改项目根目录中templates目录中base.htmlnav HTML 标签的代码以匹配以下代码:

<nav>
    <ul>
        {% if request.user.is_authenticated %}
        <li><a href="{% url "new-submission" %}">Submit New Link</a></li>
        <li><a href="{% url "logout" %}">Logout</a></li>
        {% else %}
        <li><a href="{% url "login" %}">Login</a></li>
        <li><a href="{% url "user-registration"%}">Create New Account</a></li>
        {% endif %}
    </ul>
</nav>

要测试它,运行开发服务器并打开主页。您将在顶部导航菜单中看到提交新链接选项。单击它,您将看到一个类似以下页面的页面。填写数据并单击提交。如果您填写的数据没有错误,您应该会被重定向到主页。

链接提交

虽然这样可以工作,但这并不是最好的用户体验。在不给用户任何关于他们的链接是否提交成功的反馈的情况下将用户重定向到主页是不好的。让我们下一步来修复这个问题。我们将为提交创建一个详细页面,如果用户成功提交了新链接,我们将把他们带到详细页面。

让我们从视图开始。我们将使用 Django 提供的DetailView通用视图。在您的links/views.py文件中,导入DetailView

from django.views.generic import DetailView

为我们的提交详细视图创建子类:

class SubmissionDetailView(DetailView):
    model = Link
    template_name = 'submission_detail.html'

links/templates目录中创建submission_detail.html模板,并放入以下 Django 模板代码:

{% extends "base.html" %}

{% block content %}
    <h1><a href="{{ object.url }}" target="_blank">{{ object.title }}</a></h1>
    <p>submitted by: <b>{{ object.submitted_by.username }}</b></p>
    <p>submitted on: <b>{{ object.submitted_on }}</b></p>
{% endblock %}

通过首先导入它,在discuss/urls.py中为此视图配置 URL:

from links.views import SubmissionDetailView

然后,将其添加到urlpatterns列表的 URL 模式中:

url(r'^submission/(?P<pk>\d+)/$', SubmissionDetailView.as_view(), name='submission-detail'),

最后,我们需要编辑NewSubmissionViewget_success_url方法,在成功创建新提交时将用户重定向到我们的新详细视图:

def get_success_url(self):
    return reverse('submission-detail', kwargs={'pk': self.object.pk})

就是这样。现在当你创建一个新的提交时,你应该会看到你的新提交的详细页面:

链接提交

现在链接提交已经完成,让我们来看看实现评论功能。

评论

我们希望我们已登录的用户能够对提交进行评论。我们也希望用户能够回复其他用户的评论。为了实现这一点,我们的comment模型需要能够跟踪它所在的提交,并且还需要有一个链接到它的父评论(如果它是在回复其他用户的评论时创建的)。

如果你曾经在互联网上使用过论坛,我们的评论部分的工作方式应该很熟悉。我对所有这些论坛的抱怨是它们允许这种层次结构的评论永无止境地延续下去。然后你最终会看到 10 级深的评论,延伸到屏幕之外:

Comment 1
    Comment 2
        Comment 3
            Comment 4
                Comment 5
                    Comment 6
                        Comment 7
                            Comment 8
                                Comment 9
                                    Comment 10

虽然有许多解决这个问题的方法,最简单的可能是在一定级别之后切断嵌套回复。在我们的情况下,没有评论可以回复评论 2。相反,它们必须全部回复评论 1或父提交。这将使实现更容易,我们稍后会看到。

根据我们迄今为止的讨论,我们知道我们的评论模型将需要外键到我们的提交模型,还需要自己引用自己以便引用父评论。这种自我引用,或者正如 Django 文档所称的递归关系,是我在使用 Django 创建 Web 应用的五年(甚至更长)中可能只用过一次的东西。这并不是经常需要的东西,但有时会产生优雅的解决方案,就像你将在这里看到的。

为了简化事情,我们将首先实现对链接提交的评论,然后再添加处理对评论的回复的代码。让我们从模型开始。将以下内容添加到links/models.py中:

class Comment(models.Model):
    body = models.TextField()

    commented_on = models.ForeignKey(Link)
    in_reply_to = models.ForeignKey('self', null=True)

    commented_by = models.ForeignKey(User)
    created_on = models.DateTimeField(auto_now_add=True, editable=False)

这里的in_reply_to字段是递归外键,允许我们创建评论和回复的层次结构。正如你所看到的,创建递归外键是通过给模型名称self而不是像通常情况下使用模型名称来实现的。

创建并运行迁移以将此模型添加到我们的数据库中:

> python manage.py makemigrations
> python manage.py migrate

接下来,让我们考虑视图和模板。由于我们现在只实现对提交的评论,因此在提交详细页面上也能看到创建新评论的表单是有意义的。让我们首先创建表单。创建一个新的links/forms.py文件,并添加以下代码:

from django import forms

from links.models import Comment

class CommentModelForm(forms.ModelForm):
    link_pk = forms.IntegerField(widget=forms.HiddenInput)

    class Meta:
        model = Comment
        fields = ('body',)

我们将为Comment模型创建一个简单的模型表单,并添加一个额外的字段,用于跟踪评论需要关联的链接。为了使表单可用于我们的提交详细模板,通过在文件顶部添加以下内容将表单导入links/views.py中:

from links.forms import CommentModelForm

我们还将添加代码来显示提交的评论在详细页面上。因此,我们需要在视图文件中导入Comment模型。在导入表单的行之后,添加另一行代码导入模型:

from links.models import Comment

为了能够显示与提交相关的评论以及创建新提交的表单,我们需要在提交详细页面的模板上下文中使这两个内容可用。为此,在SubmissionDetailView中添加一个get_context_data方法:

def get_context_data(self, **kwargs):
    ctx = super(SubmissionDetailView, self).get_context_data(**kwargs)

    submission_comments = Comment.objects.filter(commented_on=self.object)
    ctx['comments'] = submission_comments

    ctx['comment_form'] = CommentModelForm(initial={'link_pk': self.object.pk})

    return ctx

我们将在一会儿传递给CommentModelForm的初始属性。我们还需要创建一个视图,用于提交新评论表单。以下是你需要添加到links/views.py中的代码:

class NewCommentView(CreateView):
    form_class = CommentModelForm
    http_method_names = ('post',)
    template_name = 'comment.html'

    @method_decorator(login_required)
    def dispatch(self, *args, **kwargs):
        return super(NewCommentView, self).dispatch(*args, **kwargs)

    def form_valid(self, form):
        parent_link = Link.objects.get(pk=form.cleaned_data['link_pk'])

        new_comment = form.save(commit=False)
        new_comment.commented_on = parent_link
        new_comment.commented_by = self.request.user

        new_comment.save()

        return HttpResponseRedirect(reverse('submission-detail', kwargs={'pk': parent_link.pk}))

    def get_initial(self):
        initial_data = super(NewCommentView, self).get_initial()
        initial_data['link_pk'] = self.request.GET['link_pk']

    def get_context_data(self, **kwargs):
        ctx = super(NewCommentView, self).get_context_data(**kwargs)
        ctx['submission'] = Link.objects.get(pk=self.request.GET['link_pk'])

        return ctx

即使我们在提交详细页面上显示表单,但是如果用户在提交表单时输入不正确的数据,比如按下带有空主体的提交按钮,我们需要一个模板,可以再次显示表单以及错误。在links/templates中创建comment.html模板:

{% extends "base.html" %}

{% block content %}
    <h1>New Comment</h1>
    <p>
        <b>You are commenting on</b>
        <a href{% url 'submission-detail' pk=submission.pk %}">{{ submission.title }}</a>
    </p>

    <form action="" method="post">{% csrf_token %}
        {{ form.as_p }}
        <input type="submit" value="Post Comment" />
    </form>
{% endblock %}

您应该已经了解CreateView子类的大部分代码是做什么的。新的一点是get_inital方法。我们稍后会详细看一下。现在,让我们让评论功能运行起来。

让我们将新视图添加到discuss/urls.py中。首先,导入视图:

from links.views import NewCommentView

然后,将其添加到 URL 模式中:

url(r'new-comment/$', NewCommentView.as_view(), name='new-comment'),

最后,将links/templates/submission_detail.html更改为以下内容:

{% extends "base.html" %}

{% block content %}
    <h1><a href="{{ object.url }}" target="_blank">{{ object.title }}</a></h1>
    <p>submitted by: <b>{{ object.submitted_by.username }}</b></p>
    <p>submitted on: <b>{{ object.submitted_on }}</b></p>

    <p>
        <b>New Comment</b>
        <form action="{% url "new-comment" %}?link_pk={{ object.pk }}" method="post">{% csrf_token %}
            {{ comment_form.as_p }}
            <input type="submit" value="Comment" />
        </form>
    </p>

    <p>
        <b>Comments</b>
        <ul>
            {% for comment in comments %}
            <li>{{ comment.body }}</li>
            {% endfor %}
        </ul>
    </p>
{% endblock %}

如果您注意到我们模板中的表单操作 URL,您将看到我们已将link_pk GET 参数添加到其中。如果您回顾一下您为NewCommentView编写的代码,您将看到我们在get_context_dataget_inital函数中使用此参数值来获取用户正在评论的Link对象。

提示

我将保存get_initial方法的描述,直到下一节,当我们开始添加对评论的回复时。

让我们看看我们到目前为止做了什么。使用runserver命令启动应用程序,在浏览器中打开主页,然后登录。由于我们还没有任何访问旧提交的方式,我们需要创建一个新的提交。这样做,您将看到新的详细页面。它应该类似于以下截图:

评论

添加评论,它应该出现在同一页上。以下是添加了一些评论的截图:

评论

如果您将正文留空并按下评论按钮,您应该会看到您之前创建的评论模板,并带有错误消息:

评论

有了基本的提交评论功能,让我们看看如何实现对评论的回复。正如我们已经看到的,我们的评论模型有一个字段来指示它是作为对另一条评论的回复而发表的。因此,为了将评论存储为对另一条评论的回复,我们所要做的就是正确设置in_reply_to字段。让我们首先修改我们的Comment模型表单,以接受除了link_pk之外,还有一个parent_comment_pk,以指示新评论是否是对哪条(如果有的话)评论的回复。在CommentModelForm中添加这个字段,就在link_pk字段之后:

parent_comment_pk = forms.IntegerField(widget=forms.HiddenInput, required=False)

现在我们需要一个地方向用户显示一个表单,以便他发表回复。我们可以在提交详情页面上显示每条评论一个表单,但对于有多条评论的提交,这样做会使页面看起来非常凌乱。在实际项目中,我们可能会使用 JavaScript 在用户点击评论旁边的回复链接并提交时动态生成一个表单。然而,现在我们更专注于 Django 后端,因此我们将想出另一种不涉及大量前端工作的方法。

第三种方式,我们将在这里使用,是在每条评论旁边放一个小链接,让用户转到一个单独的页面,在那里他们可以记录他们的回复。以下是该页面的视图。将其放在links/views.py中:

class NewCommentReplyView(CreateView):
    form_class = CommentModelForm
    template_name = 'comment_reply.html'

    @method_decorator(login_required)
    def dispatch(self, *args, **kwargs):
        return super(NewCommentReplyView, self).dispatch(*args, **kwargs)

    def get_context_data(self, **kwargs):
        ctx = super(NewCommentReplyView, self).get_context_data(**kwargs)
        ctx['parent_comment'] = Comment.objects.get(pk=self.request.GET['parent_comment_pk'])

        return ctx

    def get_initial(self):
        initial_data = super(NewCommentReplyView, self).get_initial()

        link_pk = self.request.GET['link_pk']
        initial_data['link_pk'] = link_pk

        parent_comment_pk = self.request.GET['parent_comment_pk']
        initial_data['parent_comment_pk'] = parent_comment_pk

        return initial_data

    def form_valid(self, form):
        parent_link = Link.objects.get(pk=form.cleaned_data['link_pk'])
        parent_comment = Comment.objects.get(pk=form.cleaned_data['parent_comment_pk'])

        new_comment = form.save(commit=False)
        new_comment.commented_on = parent_link
        new_comment.in_reply_to = parent_comment
        new_comment.commented_by = self.request.user

        new_comment.save()

        return HttpResponseRedirect(reverse('submission-detail', kwargs={'pk': parent_link.pk}))

到目前为止,您应该已经多次使用了CreateView,对它应该感到舒适。这里唯一新的部分是get_initial方法,我们之前在NewCommentView中也使用过。在 Django 中,每个表单都可以有一些初始数据。这是在表单未绑定时显示的数据。表单的绑定性是一个重要的概念。我花了一段时间才理解它,但它非常简单。在 Django 中,表单基本上有两个功能。它可以在网页的 HTML 代码中显示,或者可以验证一些数据。

如果您在初始化表单类的实例时传入了一些数据来验证它,那么表单就是绑定的。假设您有一个名为SomeFormform类,其中有两个字段,名称和城市。假设您初始化了一个没有任何数据的表单对象:

form = SomeForm()

您已经创建了一个未绑定的表单实例。表单没有与之关联的任何数据,因此无法验证任何内容。但是,可以通过在模板中调用{{ form.as_p }}来在网页上显示它(前提是它通过上下文传递到模板)。它将呈现为一个具有两个空字段的表单:namecity

现在假设您在初始化表单时传入了一些数据:

form = SomeForm({'name': 'Jibran', 'city': 'Dubai'})

这将创建一个绑定的表单实例。您可以在此表单对象上调用is_valid(),它将验证传递的数据。您还可以像以前一样在 HTML 模板中呈现表单。但是,这次,它将使用您在此处传递的值呈现具有两个字段值的表单。如果由于某种原因,您传递的值未经验证(例如,如果您将城市字段的值留空),则表单将在包含无效数据的字段旁边显示适当的错误消息。

这就是绑定和未绑定表单的概念。现在让我们看看表单中的初始数据是用来做什么的。您可以通过将其传递给初始关键字参数来在初始化实例时将初始数据传递给表单:

form = SomeForm(initial={'name': 'Jibran'})

表单仍然是未绑定的,因为您没有传入数据属性(这是构造函数的第一个非关键字参数),但是如果现在呈现它,名称字段将具有值'Jibran',而城市字段仍将为空。

当我第一次了解初始数据时遇到的困惑是为什么需要它。我可以只传递与数据参数相同的数据字典,表单仍然只会收到一个字段的值。这样做的问题在于,当您使用一些数据初始化表单时,它将自动尝试验证该数据。假设城市字段是必填字段,如果您尝试在网页上呈现表单,它将在城市字段旁边显示一个错误,指出这是一个必填字段。初始数据参数允许您为表单字段提供值,而不触发该数据的验证。

在我们的情况下,CreateView调用get_initial方法以获取用作表单初始数据的字典。我们使用将在 URL 参数中传递的提交 ID 和父评论 ID 来创建link_pkparent_comment_pk表单字段的初始值。这样,当我们的表单在 HTML 网页上呈现时,它将已经具有这两个字段的值。查看form_valid方法,然后从表单的cleaned_data属性中提取这两个值,并用它来获取提交和父评论以关联回复。

get_context_data方法只是将父评论对象添加到上下文中。我们在模板中使用它来告诉用户他们正在回复哪条评论。让我们来看看模板,您需要在links/templates/comment_reply.html中创建它:

{% extends "base.html" %}

{% block content %}
    <h1>Reply to comment</h1>
    <p>
        <b>You are replying to:</b>
        <i>{{ parent_comment.body }}</i>
    </p>

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

这里没有什么花哨的。请注意我们如何在视图的get_context_data方法中使用了parent_comment对象。确保用户始终获得有关他们即将采取的操作的相关信息是良好的 UI 实践。

discuss/urls.py中导入我们的新视图:

from links.views import NewCommentReplyView

将此模式添加到 URL 模式列表中:

url(r'new-comment-reply/$', NewCommentReplyView.as_view(), name='new-comment-reply'),

最后,我们需要给用户一个链接来到达这个页面。正如我们之前讨论的那样,在提交详细信息页面的每条评论旁边放置一个名为回复的链接。为此,请注意links/templates/submission_detail.html中的以下行:

<li>{{ comment.body }}</li>

将其更改为以下内容:

<li>{{ comment.body }} (<a href="{% url "new-comment-reply" %}?link_pk={{ object.pk }}&parent_comment_pk={{ comment.pk }}">Reply</a>)</li>

请注意,我们在创建 URL 时使用 GET 参数传递提交 ID 和父评论 ID。我们在提交页面上的评论表单中也是这样做的。这是在创建 Django 应用程序时经常使用的常见技术。这些是我们在评论回复视图中使用的相同 URL 参数,用于填充表单的初始数据并访问父评论对象。

让我们试一试。在提交详细页面的评论中,点击回复。如果您关闭了旧的提交详细页面,您可以创建一个新的提交并添加一些评论。点击回复链接,您将看到一个新页面,上面有评论正文的表单。在这里输入一些文本,然后点击提交按钮。记住您输入的文本。我们将在接下来的几个步骤中寻找它。在我的测试中,我输入了回复评论 1。让我们看看我们的提交详细页面是如何显示我们的新回复评论的:

评论

看起来好像起作用了。但是,如果您仔细看,您会注意到我们做的回复(在我的情况下,回复评论 1文本)显示在评论列表的末尾。它应该显示在评论 1之后,并且最好向右缩进一点,以表示层次结构。让我们来修复这个问题。首先,在links/views.py文件的SubmissionDetailViewget_context_data方法中,注意这一行:

submission_comments = Comment.objects.filter(commented_on=self.object)

将其更改为以下内容:

submission_comments = Comment.objects.filter(commented_on=self.object, in_reply_to__isnull=True)

我们在这里做的是只包括没有父评论的评论。我们通过只获取in_reply_to字段设置为NULL的评论来实现这一点。如果您保存此更改并刷新提交详细页面,您会注意到您的回复评论已经消失了。让我们把它带回来。修改link/templates/submission_detail.html并更改显示评论的段落(循环遍历评论列表的段落)以匹配以下内容:

<p>
    <b>Comments</b>
    <ul>
        {% for comment in comments %}
        <li>
            {{ comment.body }} (<a href="{% url "new-comment-reply" %}?link_pk={{ object.pk }}&parent_comment_pk={{ comment.pk }}">Reply</a>)
            {% if comment.comment_set.exists %}
            <ul>
                {% for reply in comment.comment_set.all %}
                <li>{{ reply.body }}</li>
                {% endfor %}
            </ul>
            {% endif %}
        </li>
        {% endfor %}
    </ul>
</p>

这里的新部分在if标签之间。首先,我们使用由外键自身创建的反向关系来查看此评论是否有任何其他指向它的评论。我们知道指向这条评论的唯一评论将是对这条评论的回复。如果有的话,我们将创建一个新列表,并打印每个回复的正文。由于我们已经决定只允许对第一级评论进行回复,我们不会创建任何链接让用户回复这些回复。一旦您保存了这些更改,让我们看看我们的提交详细页面现在是什么样子的:

评论

这更像是!我们现在有了一个完整的链接提交和评论系统。太棒了!现在让我们继续其他功能。

投票

我们需要允许用户对提交进行投票。为了保持简单,我们只允许upvotes。用户可以表示他们喜欢一个提交。没有办法表示不赞成。这样可以保持代码和用户界面的简单。我们还希望确保一个用户只能对一个提交进行一次upvote,并且如果他们改变主意或者错误地对一个提交进行了upvote,他们可以取消他们的upvotes

如果您再看一下Link模型,您会看到我们已经有了一个upvotes字段,它是与User模型的机器到机器M2M)关联。这是我们需要允许并跟踪用户的upvotes的唯一数据库条目。为了给提交投票,用户将点击提交旁边的一个链接。到目前为止,我们能够在没有列出所有提交的页面的情况下进行。现在创建一个是个好主意,这样我们就可以访问和投票各种提交。我们不能每次想测试某些东西时都创建新的提交。

首先,在links/views.py中创建此视图。首先从django.views.generic导入TemplateView

class HomeView(TemplateView):
    template_name = 'home.html'

    def get_context_data(self, **kwargs):
        ctx = super(HomeView, self).get_context_data(**kwargs)
        ctx['submissions'] = Link.objects.all()

        return ctx

接下来,将模板更改为template/home.html如下:

{% extends "base.html" %}

{% block content %}
    <h1>Welcome to Discuss</h1>
    <h2>Submissions</h2>
    <ul>
        {% for submission in submissions %}
        <li>
            <a href="{{ submission.url }}" target="_blank">{{ submission.title }}</a>
            <i><a href="{% url "submission-detail" pk=submission.pk %}">Comments</a></i>
        </li>
        {% endfor %}
    </ul>
{% endblock %}

discuss/urls.py的顶部导入我们的新HomeView,并注意discuss/urls.py中的主页 URL 配置:

url(r'^$', TemplateView.as_view(template_name='home.html'), name='home'),

将前面的代码更改为:

url(r'^$', HomeView.as_view(), name='home'),

最后,在导航栏中为我们的用户提供一个方便的链接到主页。在base.html模板(在项目根目录的templates目录中)中,将这个作为导航列表的第一个列表元素添加到用户认证条件之外:

<li><a href="{% url "home" %}">Home</a></li>

就是这样。这段代码中没有什么新东西。它很容易理解,你现在应该对这里发生的事情有一个清晰的想法。让我们看看最终结果。如果你现在通过浏览器打开我们应用的主页,浏览到http://127.0.0.1:8000,你应该会看到类似以下截图的内容。当然,你的页面不会和这个一样,因为你会添加自己的测试内容:

投票

你会看到一个提交列表。如果你点击任何一个,你会在新标签页中看到该提交的链接。你还会看到每个提交旁边有一个评论链接。点击这个链接会带你到提交的详细页面。

让我们稍微谈谈我们将如何实现投票功能。我们在Link模型中创建的 M2M upvotes字段应该给你一些提示。每当用户给一个提交投票,我们就将他们添加到这个关系中。由于 M2M 关系确保如果我们多次添加相同的对象,它不会创建一个新记录,我们很容易确保一个用户只能对一个提交投票一次。

让我们创建一个视图,将已登录用户添加到提交的投票者列表中,然后将他们带回到主页。我们还将在主页上为每个提交添加一个链接,让用户使用这个新视图对提交进行投票。

links/views.py中,从django.views.generic中导入View通用视图类,然后创建这个视图:

class UpvoteSubmissionView(View):
    def get(self, request, link_pk, **kwargs):
        link = Link.objects.get(pk=link_pk)
        link.upvotes.add(request.user)

        return HttpResponseRedirect(reverse('home'))

接下来,在discuss/urls.py中导入这个新视图,并将其添加到 URL 模式中:

url(r'^upvote/(?P<link_pk>\d+)/$', UpvoteSubmissionView.as_view(), name='upvote-submission'),

templates/home.html中,在提交标题链接上方添加投票链接:

<a href="{% url "upvote-submission" link_pk=submission.pk %}">Upvote</a>

打开主页,你会看到每个提交标题旁边有一个投票链接。点击链接应该会带你回到主页。它应该看起来类似以下截图:

投票

如果你给一个链接投票,它会立即带你回到主页,而没有任何提示表明你的投票已经记录下来。这个问题的解决方法很简单。将刚刚添加到主页模板的投票链接 HTML 行更改为以下内容:

{% if request.user in submission.upvotes.all %}
  Upvoted
{% else %}
  <a href="{% url "upvote-submission" link_pk=submission.pk %}">Upvote</a>
{% endif %}

如果你再次打开主页,你会看到已经投票的提交旁边有一个简单的已投票文本,而不是之前看到的链接。我们还应该允许用户取消对提交的投票。首先,在links/views.py中创建一个新的视图:

class RemoveUpvoteFromSubmissionView(View):
    def get(self, request, link_pk, **kwargs):
        link = Link.objects.get(pk=link_pk)
        link.upvotes.remove(request.user)

        return HttpResponseRedirect(reverse('home'))

这几乎与我们创建的用于记录新投票的视图相同。唯一的区别是这里我们使用相关管理器的移除方法。接下来,我们需要将其添加到discuss/urls.py的 URL 文件中。在这里导入我们的新视图,并添加以下 URL 配置:

url(r'^upvote/(?P<link_pk>\d+)/remove/$', RemoveUpvoteFromSubmissionView.as_view(), name='remove-upvote'),

最后,让我们将之前在主页上添加的已投票标签更改为一个链接,以取消投票。在你的templates/home.html文件中,注意以下几行:

{% if request.user in submission.upvotes.all %}
  Upvoted
{% else %}

将它们更改为以下内容:

{% if request.user in submission.upvotes.all %}
  <a href="{% url "remove-upvote" link_pk=submission.pk %}">Remove Upvote</a>
      {% else %}

就是这样!现在当你访问主页时,你会看到所有你已经投票的提交旁边的取消投票链接。点击链接,你将被重定向回主页,你的投票将被取消。你应该再次看到该提交的投票链接,因为你可以再次投票。

提交排名

我们列表中的下一个功能是使用智能算法对提交进行排名。让我们看看我们的功能描述需要什么:

提示

一种算法,用于根据一些因素的数量,包括该链接的投票数、评论数和提交的年龄,以某种定义的顺序对提交的链接进行排名

我们的数据库中有所有这些信息。我们需要创建一个算法,利用所有这些信息给每个提交一个排名。然后,我们只需使用这个排名对提交进行排序,并按排序顺序显示它们。为了保持简单,让我们使用以下算法:

rank = number of votes + number of comments – number of days since submission

看起来很简单,除了可能是自提交以来的天数计算。然而,Python 标准库中的datetime模块让我们轻而易举地做到了这一点。在 Python 中,如果你减去两个datetime对象,你会得到一个timedelta对象。这个对象表示两个datetime对象之间的时间差。它有一个名为days的属性,这个属性保存了两个日期之间的天数。我们将从datetime.datetime.now()得到的日期减去提交的submitted_on字段,并使用结果timedelta对象的days属性。

让我们将这个算法插入到我们的主页视图中,这样我们的提交将按照它们的排名列出。将links/views.py中的HomeView更改为以下代码:

class HomeView(TemplateView):
    template_name = 'home.html'

    def get_context_data(self, **kwargs):
        ctx = super(HomeView, self).get_context_data(**kwargs)

        now = timezone.now()
        submissions = Link.objects.all()
        for submission in submissions:
            num_votes = submission.upvotes.count()
            num_comments = submission.comment_set.count()

            date_diff = now - submission.submitted_on
            number_of_days_since_submission = date_diff.days

            submission.rank = num_votes + num_comments - number_of_days_since_submission

        sorted_submissions = sorted(submissions, key=lambda x: x.rank, reverse=True)
        ctx['submissions'] = sorted_submissions

        return ctx

您还需要使用以下方式从 Django 实用程序中导入timezone模块:

from django.utils import timezone

这是因为 Django 使用了所谓的timezone感知datetimes。有关此内容的更多详细信息,请阅读 Django 关于timezone感知的文档docs.djangoproject.com/en/stable/topics/i18n/timezones/#naive-and-aware-datetime-objects

这段新代码可能看起来有点复杂,但相信我,它非常简单。让我们一行一行地看一下。我们首先使用timezone.now()函数获取当前日期时间。接下来,我们获取我们想要在主页上显示的所有提交,并开始循环遍历它们。

在循环中,我们首先使用count()方法计算提交的投票数和评论数,这是您之前在 Django querysets上看到的。这里唯一不同的是,我们将其用于RelatedManager对象返回的查询集,用于多对多的 upvotes 字段和与评论模型的反向关系。

如前所述,我们然后使用 Python 日期算术计算自提交以来的天数。最后,我们计算并分配提交的排名给对象。

循环结束后,我们的每个Link对象都有一个rank属性,保存了它的最终排名。然后我们使用 Python 内置的sorted函数对这个列表进行排序。当你在 Python 和 Django 中经常使用列表时,sorted函数是你会经常使用的东西。你应该通过阅读文档来熟悉它的语法和特性docs.python.org/3/library/functions.html#sorted。相信我,慢慢阅读这份文档并完全理解它是非常值得的。我使用sorted内置函数的次数已经数不清了。它是不可或缺的。

最后,我们将排序后的提交列表分配给submissions上下文变量。因为我们已经在主页模板中使用了这个变量,所以我们不需要改变HomeView之外的任何东西。如果你现在打开主页,你会看到提交的排序顺序已经改变,现在反映了我们的新算法。

这是一个很好的地方,可以反映 Django 使用的模型-视图-模板架构提供的模块化的好处。正如你所看到的,我们添加了一个相当重要的功能,但我们从未改变过主页的 URL 或模板。因为这些是独立的模块,我们只改变了视图代码,其他一切仍然可以与我们的新的和改进的排序顺序一起工作。

垃圾邮件保护

我们想要在我们的应用程序中拥有的最后一个功能是垃圾邮件保护。我们希望用户能够在我们的网站上发布内容,但我们希望防止垃圾邮件滥用。垃圾邮件,你可能知道,是指恶意的互联网用户在网站上发布不当或无关的内容。通常,垃圾邮件发送者使用专门针对允许用户提交内容的网站创建的脚本,比如我们的网页应用程序。虽然我们不能轻易地阻止垃圾邮件发送者手动向我们的网站提交垃圾邮件内容,但我们可以确保他们无法使用脚本一键生成大量垃圾邮件。通常情况下,如果垃圾邮件发送者无法在网站上使用他们的脚本,他们会转向更容易的目标。

我想让你从这个功能中学到的重要概念不是如何实现垃圾邮件保护。这是你根据自己项目的需求来决定的事情。我将在这里展示如何使用其他开发人员创建的开源 Django 应用程序来为您自己的 Django 项目添加功能。这是一个你应该熟悉的重要概念。大多数情况下,如果你在开发网页应用程序时寻找解决问题的方法,搜索互联网会找到许多其他程序员开发的开源应用程序,用于解决相同的问题。你可以找到解决各种问题的应用程序,从提供新类型的表单字段(例如,使用 JavaScript 日历的日历表单字段)到提供完整的基于 Django 的论坛应用程序,你可以轻松集成到你的 Django 网站中,并为用户提供易于使用和外观良好的论坛。

我们将使用谷歌的ReCaptcha服务来为我们提供一个阻止垃圾邮件的机制。你可以在www.google.com/recaptcha了解更多关于这项服务。你还需要在这里注册一个账户并创建一个 API 密钥。它会要求一个标签,我设置为讨论 Django 蓝图,一个域,我设置为127.0.0.1所有者字段应该有你的电子邮件地址。一旦你提交了这个表单,你将看到一个屏幕,显示你的公钥和私钥。保持这个页面打开,因为我们一会儿会用到这些值。

接下来,我们需要找到一个 Django 应用程序,允许我们使用 ReCaptcha 服务。谷歌搜索引导我到github.com/praekelt/django-recaptcha。这似乎是一个维护良好且简单的解决方案。为了使用它,我们首先必须在我们的虚拟环境中安装它。在命令行上,确保你的虚拟环境是激活的。然后,使用以下pip命令安装这个软件包:

> pip install django-recaptcha

这将安装该软件包。接下来,在discuss/settings.py文件中将captcha添加到INSTALLED_APPS列表中。还要在设置文件中添加RECAPTCHA_PUBLIC_KEYRECAPTCHA_PRIVATE_KEY变量。将它们的值设置为我之前要求你保持打开的 Google ReCaptcha API 密钥页面上给你的适当密钥。站点密钥是公钥,秘密密钥是私钥。最后,在你的settings.py文件中,设置以下变量:

NOCAPTCHA = True

设置完成了。我们准备在我们的表单中使用ReCaptcha。为了演示,我只会将它添加到你在提交详细页面上看到的评论表单中。打开links/forms.py并在顶部添加这个导入:

from captcha.fields import ReCaptchaField

然后,将这个字段添加到CommentModelForm中:

captcha = ReCaptchaField()

就是这样!你已经成功地将 Google 的ReCaptcha添加到你的网站上!让我们试试看。打开任何提交的详细页面,现在,在我们之前用于评论的正文字段下面,你会看到 Google 的ReCaptcha框:

垃圾邮件保护

现在,如果您在不选择我不是机器人复选框的情况下提交表单,您将被带到评论表单页面,并显示错误消息,指出需要填写验证码字段。在选择此框之前,您将无法提交评论。

从将ReCaptcha添加到我们的网站中,我们可以得出两个要点。首先,请注意,我们使用另一位程序员贡献的开源代码轻松地添加了一个相对复杂的功能。其次,请注意,由于 Django 提供的模块化和模板与代码之间的分离,我们只需将ReCaptcha小部件添加到表单中即可。我们甚至不需要更改视图代码或模板。一切都很顺利。

总结

这是一个非常有趣的章节。您学到了更多关于 Django 提供的内置通用视图,并详细了解了ModelForms以及我们如何自定义它们。我们找出了 Django 遵循的模块化 MVC 模式和第三方开源 Django 应用程序的一些好处,以及我们如何将它们包含在我们的项目中。

您还学到了如何在我们的表单上传递数据,即使它们放在不同的页面上,以及如何创建一个显示在两个页面上的表单(评论表单),同时确保数据在两者之间同步。

总的来说,我们最终创建的应用程序既有趣又是一个完整的产品。

第三章:Djagios - Django 中的 Nagios 克隆

在本章中,我们将创建一个类似于Nagios的服务器状态监控解决方案。如果您从未听说过 Nagios,那是可以理解的,因为它不是在 Web 开发人员的日常对话中经常出现的东西。简而言之,Nagios 可以在一个屏幕上告诉您服务器的状态(可以达到数千台)。您可以根据条件配置警报,例如,如果某个关键服务器变得无响应,这样您就可以在用户开始注意到任何服务降级之前解决问题。Nagios 是一个令人惊叹的软件,被全球数百万组织使用。

本章的目标是创建一个类似的东西,尽管非常简单。我们的 Nagios 克隆品,创意地命名为Djagios,将允许用户设置监视其服务器的简单统计信息。我们将允许监视以下内容:

  • 系统负载

  • 磁盘使用情况

我们还将开发一个网页,用户可以在其中以漂亮的表格格式查看这些数据。用户还将看到他们的服务器的概述,以及这些系统上是否有任何活动警报。

以下是本章我们将要研究的一些内容:

  • Django 管理命令以及如何创建自定义命令

  • 使用 Django shell 快速测试代码的小片段

  • Django 模型字段的复杂验证

  • 内置通用视图的稍微复杂的用法

  • 创建一个 API 端点以接受来自外部来源的数据

  • 使用简单的共享密钥保护这些 API 端点

  • 使用简单工具测试 API 端点

代码包

本章的代码包已经设置了一个基本的 Django 应用程序,并配置了一个 SQLite 数据库。但是,代码包中没有太多代码,因为本章不需要用户帐户或任何其他预先存在的设置。您可以解压缩代码包,创建一个新的虚拟环境,激活它,并从代码文件夹中运行以下命令以启动和运行:

> pip install django
> python manage.py migrate

要求

在我们开始编写代码之前,让我们谈谈我们对最终产品的期望。如前所述,我们希望创建一个服务器监控解决方案。它将具体做什么?我们如何实现所需的功能?

由于我们对 Djagios 的灵感来自 Nagios,让我们看看 Nagios 是如何工作的。虽然 Nagios 是一个庞大的应用程序,具有可以理解的复杂编程,但它最终是一个客户端-服务器应用程序。服务器,也就是另一台计算机,包含 Nagios 安装。客户端,也就是您想要监视的系统,运行小型插件脚本来收集数据并将其推送到服务器。服务器接收这些数据点,并根据其配置情况发送警报(如果需要)。它还存储这些数据点,并可以以简单的表格布局显示它们,让您立即了解基础架构中所有计算机系统的概况。

我们将创建类似的东西。我们的服务器将是一个 Django 应用程序,将使用 HTTP 端点接受数据点。该应用程序还将包括一个网页,其中所有这些数据点将显示在客户端旁边。我们的客户端将是简单的 shell 脚本,用于将数据上传到我们的服务器。

注意

在本章的其余部分,我将把 Django 应用程序称为服务器,将您想要监视的系统称为节点。这些是您在编程生涯中会遇到的许多其他项目中常用的术语,它们在这些其他项目中通常意味着类似的东西。

与其一次性开发所有这些东西,我们将采取渐进式的方法。我们首先创建模型来存储我们的数据点。接下来,我们不会直接转向创建 HTTP 端点来接受数据点和客户端插件脚本,而是采取更简单的方法,想出一种方法来生成一些虚假数据进行测试。最后,我们将创建网页来向用户显示客户端节点的最新状态和触发的警报。

通过使用虚假数据进行测试,我们可以确信我们的状态页面和警报系统正常工作。然后我们可以继续下一步,即创建 HTTP 端点以从客户端和客户端插件脚本收集数据点。

在现实世界的项目中,逐步构建软件系统通常是完成项目的最佳方式。创建简单的功能并对其进行广泛测试,以确保其正常工作。一旦您对其正确性有信心,就添加更多功能并重复测试阶段。这种方式类似于建造高楼。如果您确信基础牢固,您可以一次建造一层,而不必担心整个建筑会倒在您头上。

模型

我们记录数据点需要记录什么信息?我们肯定需要记录发送数据的节点的名称。我们还需要记录获取数据点的时间,以便我们可以找出节点的最新状态。当然,我们需要知道数据点的类型和值。数据点的类型只是我们正在测量的数量的名称,例如 CPU 使用率,内存使用率,正常运行时间等。

目前,我认为这些是我们需要测量的所有东西:

  • 节点名称

  • 日期和时间

  • 类型

  • 价值

在考虑我们模型中需要哪些字段时,我想到了另一种方法。它涉及为每种数据点类型创建不同的模型,因此我们可以有名为SystemLoadMemoryUsageDiskUsageUptime等的 Django 模型。然而,一旦我进一步考虑了一下,我发现这样做将非常限制,因为现在每当我们想要测量新的东西时,我们都需要定义一个新的模型。在我们的模型中添加数据点的类型作为另一个字段,可以在记录新类型的信息方面给我们很大的灵活性。

后面您将看到这两种方法的利弊。

让我们在项目中开始一个新的 Django 应用程序。在命令行中,输入以下内容,确保您的虚拟环境已激活,并且您在项目文件夹中:

> python manage.py startapp data_collector

将这个新应用程序添加到djagios/settings.py中的INSTALLED_APPS列表中,然后将我们的数据点模型添加到data_collector/models.py中:

class DataPoint(models.Model):
    node_name = models.CharField(max_length=250)
    datetime = models.DateTimeField(auto_now_add=True)

    data_type = models.CharField(max_length=100)
    data_value = models.FloatField()

保存文件,然后运行迁移以将其添加到我们的数据库中:

> python manage.py makemigrations data_collector
> python manage.py migrate

虽然模型非常简单,但有一件事情你应该知道。为了保持简单,我决定仅存储数字值;因此,data_value字段是FloatField类型。如果这是一个现实世界的项目,您可能会有一系列要求,这些要求将决定您是否可以做出相同的妥协。例如,您可能还必须记录文本值,例如,您可能正在运行的某些服务的状态。对于 Djagios,我们只接受数字值,因为我们想要测量的所有统计数据都只是数字。

虚假数据生成

在进行下一步,即创建状态页面之前,我们应该想出一种方法来生成一些虚假数据。这将在创建状态页面并在途中调试任何问题时帮助我们。没有任何数据,我们可能会花时间创建完美的状态页面,只是后来发现当我们添加数据时,其中的某些方面,如设计或数据布局方案,不起作用。

Django 管理命令

Django 有一个非常有用的功能,叫做管理命令。它允许我们创建可以与我们编写的 Django 应用程序代码以及我们的模型进行交互的 Python 脚本。

我们不能只编写一个简单的 Python 脚本来导入我们的模型,原因是 Django,像所有的 Web 框架一样,有许多依赖项,并且需要进行复杂的设置,以确保在使用其功能之前一切都配置正确。例如,访问数据库取决于 Django 知道设置文件在哪里,因为它包含数据库的配置。不知道如何读取数据库,就无法查询模型。

让我们进行一个小测试。确保您的虚拟环境已激活,并且您在项目目录中。接下来,通过输入以下内容启动 Python shell:

> python

这将启动一个新的 Python 交互式 shell,在这里您可以输入 Python 语句并立即执行它们。您会知道您在 Python shell 中,因为您的提示符将更改为>>>。现在,让我们尝试在这个 shell 中导入我们的DataPoint模型:

>>> from data_collector.models import DataPoint

按下Enter,您可能会对打印的巨大错误消息感到惊讶。不用担心,您不需要阅读所有内容。最重要的部分是最后一行。它将类似于这样(尽管可能会在不同的 Django 版本之间略有变化):

django.core.exceptions.ImproperlyConfigured: Requested setting DEFAULT_INDEX_TABLESPACE, but settings are not configured. You must either define the environment variable DJANGO_SETTINGS_MODULE or call settings.configure() before accessing settings.

看起来令人生畏,但让我们分部分来看。冒号:之前的第一部分是异常名称。在这里,Django 引发了ImproperlyConfigured异常。然后有一句话告诉您请求了某个设置,但设置尚未配置。最后一句是关于如何解决此问题的有用消息。

虽然您可以按照错误消息中列出的步骤并使您的脚本在 Django 之外运行,但使用管理命令几乎总是最佳选择。使用管理命令,您无需手动设置 Django。在运行脚本之前,它会自动为您完成,并且您可以避免担心设置环境变量,如DJANGO_SETTINGS_MODULE或调用settings.configure()

要创建一个新的管理命令,我们首先必须创建两个新的 Python 模块来保存我们的命令脚本。从项目根目录的命令行中输入以下内容:

> mkdir -p data_collector/management/commands
> touch data_collector/management/__init__.py
> touch data_collector/management/commands/__init__.py

这些命令的作用是在data_collector模块文件夹下创建一个名为management的模块,然后在management模块中创建另一个名为commands的模块。我们首先创建模块的文件夹,然后创建空的__init__.py文件,以便 Python 将这些文件夹识别为模块。

接下来,让我们尝试创建一个简单的管理命令,只需打印出我们数据库中目前为止的数据点列表。创建一个新的data_collector/management/commands/sample_data.py文件,并给它以下内容:

from django.core.management import BaseCommand

from data_collector.models import DataPoint

class Command(BaseCommand):
    def handle(self, *args, **options):
        print('All data points:')
        print(DataPoint.objects.all())

保存此文件,然后返回到命令提示符。然后运行以下命令:

> python manage.py sample_data

您应该看到以下输出:

All data points:
[]

就是这样。这就是创建 Django 管理命令的全部内容。正如您所看到的,我们能够在命令行上运行脚本时使用我们的DataPoint模型的方法,而不是作为 HTTP 响应视图的一部分运行。关于 Django 管理命令的一些注意事项如下:

  • 您的命令与包含命令的文件的名称相同。

  • 为了成为有效的管理命令,源文件应始终定义一个Command类,它将是BaseCommand的基类。

  • Django 将调用您的Command类的handle方法。这个方法是您想要从脚本提供的任何功能的起点。

接下来,当我们修改sample_data.py命令以实际添加示例数据时,我们将看一下*args**options参数。如果您想进一步了解 Django 管理命令的信息,您应该查看docs.djangoproject.com/en/stable/howto/custom-management-commands/上的文档。

让我们修改我们的命令类来添加虚假数据。这是修改后的Command类代码:

class Command(BaseCommand):
    def add_arguments(self, parser):
        parser.add_argument('node_name', type=str)
        parser.add_argument('data_type', type=str)
        parser.add_argument('data_value', type=float)

    def handle(self, *args, **options):
        node_name = options['node_name']
        data_type = options['data_type']
        data_value = options['data_value']

        new_data_point = DataPoint(node_name=node_name, data_type=data_type, data_value=data_value)
        new_data_point.save()

        print('All data points:')
        print(DataPoint.objects.all())

这里有一些新东西。让我们首先看一下add_arguments方法。大多数管理命令需要参数才能做一些有用的事情。由于我们正在向数据库添加示例数据,我们的命令将需要添加的值。这些值以参数的形式提供给命令行。如果你没有太多使用命令行的经验,参数就是在命令名称后面输入的所有东西。例如,让我们看一下我们用来在项目中创建新的 Django 应用程序的命令:

> python manage.py startapp APP_NAME

在这里,我们使用了startapp Django 管理命令,这是一个内置命令,而应用程序名称是应用程序的参数。

我们希望我们的自定义命令接受三个参数:节点名称、数据类型和数据点的值。在add_arguments方法中,我们告诉 Django 为这个命令需要和解析三个参数。

handle方法的options参数是一个字典,保存了用户定义并传递给命令的所有参数。在handle方法中,我们只是将每个选项的值分配给一个变量。如果用户在调用命令时漏掉或添加了额外的参数,Django 将打印出一个错误消息,并告诉他们需要的参数是什么。例如,如果我现在调用命令而没有任何参数,会发生什么:

> python manage.py sample_data
usage: manage.py sample_data [-h] [--version] [-v {0,1,2,3}]
                             [--settings SETTINGS] [--pythonpath PYTHONPATH]
                             [--traceback] [--no-color]
                             node_name data_type data_value
manage.py sample_data: error: the following arguments are required: node_name, data_type, data_value

如果用户忘记了如何使用管理命令,这是有用的信息。

现在我们有了变量中的参数值,我们创建一个新的数据点并保存它。最后,我们打印出数据库中所有的数据点。让我们现在尝试运行我们的命令,看看我们得到什么输出:

> python manage.py sample_data web01 load 5
All data points:
[<DataPoint: DataPoint object>]

虽然命令成功创建了一个新的数据点,但输出并不是很有用。我们不知道这个数据点包含什么信息。让我们来修复这个问题。

更好的模型表示

每当 Django 打印出一个模型实例时,它首先尝试查看模型类是否定义了__str__方法。如果找到这个方法,它就使用它的输出;否则,它会退回到一个默认实现,只打印类名,就像我们在这里看到的那样。为了让 Django 打印出一个更有用的数据点模型表示,将这个__str__方法添加到我们的DataPoint模型类中:

def __str__(self):
    return 'DataPoint for {}. {} = {}'.format(self.node_name, self.data_type, self.data_value)

让我们现在再次运行我们的sample_data命令,看看它的输出如何改变了:

> python manage.py sample_data web01 load 1.0
All data points:
[<DataPoint: DataPoint for web01\. load = 5.0>, <DataPoint: DataPoint for web01\. load = 1.0]

好了。现在我们看到我们添加的数据点已经正确保存到数据库中。继续创建更多的数据点。使用尽可能多的不同节点名称,但尝试将数据类型限制为loaddisk_usage中的一个,因为我们稍后将创建特定于这些数据类型的代码。这是我为参考添加到数据库的示例数据:

节点名称 数据类型 数据值
web01 load 5.0
web01 load 1.0
web01 load 1.5
web01 disk_usage 0.5
web02 load 7.0
web02 load 9.0
web02 disk_usage 0.85
dbmaster disk_usage 0.8
dbmaster disk_usage 0.95

现在我们有了一种添加示例数据并将其添加到数据库中的方法,让我们创建一个状态页面,向用户展示所有这些数据。

状态页面

我们的状态页面需要以一种视图显示用户完整基础设施的状态。为此,表感觉像是一个合适的设计组件。由于对用户最重要的信息将是他们服务器的最新状态,我们的状态页面将需要仅显示每个节点的一行表,并且仅列出我们在数据库中为该节点拥有的每种不同数据类型的最新值。

对于我添加到数据库中的示例数据,我们理想情况下希望状态页面上有一个类似这样的表:

状态页面

正如你所看到的,我们只提到每个节点一次,并且将不同的数据类型分组,这样所有关于节点的信息都显示在一个地方,用户不必在表中搜索他们要找的内容。作为一个奖励,我们还以一种好看的方式显示最后更新的时间,而不仅仅是显示最新数据点的时间。

如果你认为像这样以一种好看和整合的方式显示我们的数据点不会简单,恐怕你是对的。我们可以使用DataPoint.objects.all()从数据库中获取所有数据点,然后在我们的 Python 代码中对它们进行分组,但一旦我们数据库中的数据点数量增加,这种方法就会变得低效。对于服务器监控解决方案,拥有几百万数据点并不罕见。我们不能每次用户想要查看状态页面时都去获取和分组所有百万数据点。这将使加载页面变得难以忍受缓慢。

幸运的是,SQL——用于从数据库查询数据的语言——为我们提供了一些非常强大的结构,我们可以使用它们来获取我们想要的信息,而不必遍历我们数据点表中可能有的所有数据行。让我们想想我们需要什么。

首先,我们想知道我们数据库中的不同节点名称。对于每个节点名称,我们还需要知道可用的数据类型。在我们的示例中,虽然web01web02都有loaddisk_usage数据类型可用,但dbmaster节点只有disk_usage数据类型(或指标)的数据。对于这样的情况,SQL 语言为我们提供了一个DISTINCT查询子句。在我们的查询中添加DISTINCT指示数据库仅返回唯一行。也就是说,所有重复行只返回一次。这样,我们就可以获取我们数据库中所有不同节点和数据类型的列表,而无需遍历每条记录。

我们需要进行一些实验,以找出如何将 SQL 查询转换为我们可以在 Django ORM 中使用的内容。我们可以编写我们的视图代码,然后不断更改它以找出获取我们想要的数据的正确方法,但这非常麻烦。相反,Django 为我们提供了一个非常方便的 shell 来进行这些实验。

如果你还记得,本章的开头,我向你展示了为什么你不能只启动一个 Python shell 并导入模型。Django 抱怨在使用之前没有被正确设置。相反,Django 有自己的启动 Python shell 的方式,确保在开始使用 shell 之前满足了设置 Django 的所有依赖关系。要启动这个 shell,输入以下内容:

> python manage.py shell

像之前一样,这会让你进入一个 Python shell,你可以通过改变的提示来告诉。现在,让我们尝试导入我们的DataPoint模型:

>>> from data_collector.models import DataPoint

这次你不应该会得到任何错误。现在输入以下内容:

>>
> DataPoint.objects.all()
[<DataPoint: DataPoint for web01\. load = 5.0>, <DataPoint: DataPoint for web01\. load = 1.0>, <DataPoint: DataPoint for web01\. load = 1.5>, <DataPoint: DataPoint for web02\. load = 7.0>, <DataPoint: DataPoint for web02\. load = 9.0>, <DataPoint: DataPoint for dbmaster. disk_usage = 0.8>, <DataPoint: DataPoint for dbmaster. disk_usage = 0.95>, <DataPoint: DataPoint for web01\. disk_usage = 0.5>, <DataPoint: DataPoint for web02\. disk_usage = 0.85>]

正如你所看到的,你可以查询模型并立即看到查询的输出。Django shell 是 Django 中最有用的组件之一,你经常会发现自己在 shell 中进行实验,以找出在编写最终代码之前正确的做法。

所以,回到我们从数据库中获取不同节点名称和数据类型的问题。如果你在 Django 文档中搜索distinct关键字,你应该会在结果中看到这个链接:

docs.djangoproject.com/en/stable/ref/models/querysets/#distinct

如果您阅读文档中的内容,您应该会发现这正是我们需要使用DISTINCT子句的原因。但是我们如何使用它呢?让我们在 shell 中尝试一下:

>>> DataPoint.objects.all().distinct()
[<DataPoint: DataPoint for web01\. load = 5.0>, <DataPoint: DataPoint for web01\. load = 1.0>, <DataPoint: DataPoint for web01\. load = 1.5>, <DataPoint: DataPoint for web02\. load = 7.0>, <DataPoint: DataPoint for web02\. load = 9.0>, <DataPoint: DataPoint for dbmaster. disk_usage = 0.8>, <DataPoint: DataPoint for dbmaster. disk_usage = 0.95>, <DataPoint: DataPoint for web01\. disk_usage = 0.5>, <DataPoint: DataPoint for web02\. disk_usage = 0.85>]

嗯?这没有改变任何东西。为什么?让我们想想这里发生了什么。我们要求 Django 查询数据库中的所有数据点,然后仅返回每个重复数据的一行。如果您熟悉 SQL,不同的子句通过比较您选择的数据行中的每个字段来工作。但是,由于默认情况下,Django 在查询模型时会选择数据库表中的所有行,因此 SQL 查询看到的数据也包括主键,这根据定义对于每一行都是唯一的。这就是为什么我们看到所有数据,即使我们使用了不同的子句。

为了使用不同的子句,我们需要限制我们要求数据库返回给我们的数据中的字段。对于我们特定的用例,我们只需要知道节点名称和数据类型的唯一对。Django ORM 提供了另一个方法values,我们可以使用它来限制 Django 选择的字段。让我们首先尝试一下没有不同子句,看看返回什么数据:

>>> DataPoint.objects.all().values('node_name', 'data_type')
[{'data_type': u'load', 'node_name': u'web01'}, {'data_type': u'load', 'node_name': u'web01'}, {'data_type': u'load', 'node_name': u'web01'}, {'data_type': u'load', 'node_name': u'web02'}, {'data_type': u'load', 'node_name': u'web02'}, {'data_type': u'disk_usage', 'node_name': u'dbmaster'}, {'data_type': u'disk_usage', 'node_name': u'dbmaster'}, {'data_type': u'disk_usage', 'node_name': u'web01'}, {'data_type': u'disk_usage', 'node_name': u'web02'}]

这似乎起了作用。现在我们的数据只包括我们想要运行不同查询的两个字段。让我们也添加不同的子句,看看我们得到了什么:

>>> DataPoint.objects.all().values('node_name', 'data_type').distinct()
[{'data_type': u'load', 'node_name': u'web01'}, {'data_type': u'load', 'node_name': u'web02'}, {'data_type': u'disk_usage', 'node_name': u'dbmaster'}, {'data_type': u'disk_usage', 'node_name': u'web01'}, {'data_type': u'disk_usage', 'node_name': u'web02'}]

哇!这似乎起了作用。现在我们的 Django ORM 查询只返回唯一的节点名称和数据类型对,这正是我们需要的。

重要的一点要注意的是,当我们在 ORM 查询中添加了values方法后,返回的数据不再是我们的DataPoint模型类。相反,它是只包含我们要求的字段值的字典。因此,您在模型上定义的任何函数都无法在这些字典上访问。如果您仔细想想,这是显而易见的,因为没有完整的字段,Django 无法填充模型对象。即使您在values方法参数中列出了所有模型字段,它仍然只会返回字典,而不是模型对象。

现在我们已经弄清楚了如何以我们想要的格式获取数据,而无需循环遍历我们数据库中的每一行数据,让我们为我们的状态页面创建模板、视图和 URL 配置。从视图代码开始,将data_collector/views.py更改为以下内容:

from django.views.generic import TemplateView

from data_collector.models import DataPoint

class StatusView(TemplateView):
    template_name = 'status.html'

    def get_context_data(self, **kwargs):
        ctx = super(StatusView, self).get_context_data(**kwargs)

        nodes_and_data_types = DataPoint.objects.all().values('node_name', 'data_type').distinct()

        status_data_dict = dict()
        for node_and_data_type_pair in nodes_and_data_types:
            node_name = node_and_data_type_pair['node_name']
            data_type = node_and_data_type_pair['data_type']

            data_point_map = status_data_dict.setdefault(node_name, dict())
            data_point_map[data_type] = DataPoint.objects.filter(
                node_name=node_name, data_type=data_type
            ).latest('datetime')

        ctx['status_data_dict'] = status_data_dict

        return ctx

这有点复杂,所以让我们分成几部分。首先,我们使用之前想出的查询获取节点名称和数据类型对的列表。我们将查询的结果存储在nodes_and_data_types中,类似于以下内容:

[{'data_type': u'load', 'node_name': u'web01'}, {'data_type': u'load', 'node_name': u'web02'}, {'data_type': u'disk_usage', 'node_name': u'dbmaster'}, {
'data_type': u'disk_usage', 'node_name': u'web01'}, {'data_type': u'disk_usage', 'node_name': u'web02'}]

正如我们之前看到的,这是我们数据库中所有唯一的节点名称和数据类型对的列表。因此,由于我们的dbmaster节点没有任何load数据类型的数据,您在此列表中找不到该对。稍后我会解释为什么运行不同的查询有助于我们减少对数据库的负载。

接下来,我们循环遍历每对;这是您在代码中看到的 for 循环。对于每个节点名称和数据类型对,我们运行一个查询,以获取最新的数据点。首先,我们筛选出我们感兴趣的数据点,即与我们指定的节点名称和数据类型匹配的数据点。然后,我们调用latest方法并获取最近更新的数据点。

latest方法接受一个字段的名称,使用该字段对查询进行排序,然后根据该排序返回数据的最后一行。应该注意的是,latest可以与任何可以排序的字段类型一起使用,包括数字,而不仅仅是日期时间字段。

我想指出这里使用了setdefault。在字典上调用setdefault可以确保如果提供的键在字典中不存在,那么第二个参数传递的值将被设置为该键的值。这是一个非常有用的模式,我和很多 Python 程序员在创建字典时使用,其中所有的键都需要具有相同类型的值-在这种情况下是一个字典。

这使我们可以忽略键以前不存在于字典中的情况。如果不使用setdefault,我们首先必须检查键是否存在。如果存在,我们将修改它。如果不存在,我们将创建一个新的字典,修改它,然后将其分配给status_data_dict

setdefault方法也返回给定键的值,无论它是否必须将其设置为默认值。我们在代码中将其保存在data_point_map变量中。

最后,我们将status_data_dict字典添加到上下文中并返回它。我们将在我们的模板中看到如何处理这些数据并向用户显示它。我之前说过我会解释不同的查询是如何帮助我们减少数据库负载的。让我们看一个例子。假设我们的基础设施中有相同的三个节点,我们在样本数据中看到了:web01web02dbmaster。假设我们已经运行了一整天的监控,每分钟收集所有三个节点的负载和磁盘使用情况的统计数据。做一下计算,我们应该有以下结果:

节点数 x 数据类型数 x 小时数 x60:

3 x 2 x 24 x 60 = 8640

因此,我们的数据库有 8,640 个数据点对象。现在,有了我们在视图中的代码,我们只需要从数据库中检索六个数据点对象,就可以向用户显示一个更新的状态页面,再加上一个不同的查询。如果我们必须获取所有数据点,我们将不得不从数据库中传输所有这些 8,640 个数据点的数据,然后只使用其中的六个。

对于模板,创建一个名为templates的文件夹在data_collector目录中。然后,在模板文件夹中创建一个名为status.html的文件,并给它以下内容:

{% extends "base.html" %}

{% load humanize %}

{% block content %}
<h1>Status</h1>

<table>
    <tbody>
        <tr>
            <th>Node Name</th>
            <th>Metric</th>
            <th>Value</th>
            <th>Last Updated</th>
        </tr>

        {% for node_name, data_type_to_data_point_map in status_data_dict.items %}
            {% for data_type, data_point in data_type_to_data_point_map.items %}
            <tr>
                <td>{% if forloop.first %}{{ node_name }}{% endif %}</td>
                <td>{{ data_type }}</td>
                <td>{{ data_point.data_value }}</td>
                <td>{{ data_point.datetime|naturaltime }}</td>
            </tr>
            {% endfor %}
        {% endfor %}
    </tbody>
</table>
{% endblock %}

这里不应该有太多意外。忽略load humanize行,我们的模板只是使用我们在视图中生成的数据字典创建一个表。两个嵌套的for循环可能看起来有点复杂,但看一下我们正在循环的数据应该会让事情变得清晰:

{u'dbmaster': {u'disk_usage': <DataPoint: DataPoint for dbmaster. disk_usage = 0.95>},
 u'web01': {u'disk_usage': <DataPoint: DataPoint for web01\. disk_usage = 0.5>,
            u'load': <DataPoint: DataPoint for web01\. load = 1.5>},
 u'web02': {u'disk_usage': <DataPoint: DataPoint for web02\. disk_usage = 0.85>,
            u'load': <DataPoint: DataPoint for web02\. load = 9.0>}}

第一个 for 循环获取节点名称和将数据类型映射到最新数据点的字典。然后内部 for 循环遍历数据类型和该类型的最新数据点,并生成表行。我们使用forloop.first标志仅在内部循环第一次运行时打印节点名称。Django 在模板中提供了一些与 for 循环相关的其他有用的标志。查看文档docs.djangoproject.com/en/stable/ref/templates/builtins/#for

当我们打印数据点的datetime字段时,我们使用naturaltime过滤器。这个过滤器是 Django 提供的 humanize 模板标签的一部分,这就是为什么我们需要在模板的开头使用load humanize行。naturaltime模板过滤器以易于人类理解的格式输出日期时间值,例如,两秒前,一小时前,20 分钟前等等。在你加载humanize模板标签之前,你需要将django.contrib.humanize添加到djagios/settings.pyINSTALLED_APPS列表中。

完成我们的状态页面的最后一步是将其添加到 URL 配置中。由于状态页面是用户最常想要从监控系统中看到的页面,让我们把它作为主页。让djagios/urls.py中的 URL 配置文件包含以下内容:

from django.conf.urls import url

from data_collector.views import StatusView

urlpatterns = [
    url(r'^$', StatusView.as_view(), name='status'),
]

就是这样。运行开发服务器:

> python manage.py runserver

访问http://127.0.0.1:8000上的状态页面。如果您迄今为止已经按照步骤进行操作,您应该会看到一个类似以下页面的状态页面。当然,您的页面将显示来自您的数据库的数据:

状态页面

警报

现在我们已经有了一个基本的状态页面,让我们谈谈允许用户配置一些警报条件。目前,我们将通过在状态页面上以红色显示该节点的信息来通知用户任何警报条件。

首先,我们需要弄清楚我们希望用户设置什么样的警报。从那里,我们可以弄清楚技术细节。所以,让我们考虑一下。鉴于我们记录的所有数据类型都具有数值数值,用户应该能够设置阈值是有意义的。例如,他们可以设置警报,如果任何节点的系统负载超过 1.0,或者如果节点的磁盘使用率超过 80%。

此外,也许我们的用户不希望为每个节点设置相同的警报条件。数据库节点预计会处理大量的系统负载,因此也许我们的用户希望为数据库节点设置单独的警报条件。最后,如果他们正在对一些节点进行维护,他们可能希望停止一些警报的触发。

从所有这些来看,似乎我们的警报需要具有以下字段:

  • 触发的数据类型

  • 触发的最大值

  • 触发的最小值

  • 触发的节点名称

  • 如果警报当前处于活动状态

其中,数据类型和活动状态是必填字段,不应为空。节点名称可以是空字符串,在这种情况下,将检查每个节点的警报条件。如果节点名称不是空字符串,则将检查名称与提供的字符串完全匹配的节点。

至于最大值和最小值,其中一个是必需的。这样用户可以仅设置最大值的警报,而不必关心数据点的最小值。这将需要在模型中进行手动验证。

模型

让我们看看模型。为了保持简单,我们将使用data_collector应用程序,而不是为警报创建一个新的应用程序。以下是我们的Alert模型的代码。将其放在data_collector/models.py中的DataPoint模型代码之后:

class Alert(models.Model):
    data_type = models.CharField(max_length=100)
    min_value = models.FloatField(null=True, blank=True)
    max_value = models.FloatField(null=True, blank=True)
    node_name = models.CharField(max_length=250, blank=True)

    is_active = models.BooleanField(default=True)

    def save(self, *args, **kwargs):
        if self.min_value is None and self.max_value is None:
            raise models.exceptions.ValidationError('Both min and max value can not be empty for an alert')

        super(Alert, self).save(*args, **kwargs)

由于我们对最小和最大字段的特殊要求,我们不得不重写save方法。您可能已经注意到,我们的自定义save方法如果未设置最小和最大值,则会引发错误。由于没有办法使用正常的 Django 字段配置表达这种条件,我们不得不重写save方法并在这里添加我们的自定义逻辑。如果您有一些依赖于多个字段的自定义验证要求,这在 Django 中是一种非常常见的做法。

还有一件事要注意,那就是对最小和最大FloatFieldblank=True参数。这是必需的,以便从该模型构建的任何模型表单(稍后我们将用于createupdate视图)允许这些字段的空值。

创建并运行迁移以将其添加到您的数据库中。

> python manage.py makemigrations data_collector
> python manage.py migrate data_collector

管理视图

用户将需要一些视图来管理警报。他们将需要页面来查看系统中定义的所有警报,创建新警报和编辑现有警报的页面,以及删除不再需要的警报的某种方式。所有这些都可以使用 Django 提供的通用视图和一些模板来实现。让我们开始吧!

首先,让我们先看看列表视图。将其添加到data_collector/views.py中:

class AlertListView(ListView):
    template_name = 'alerts_list.html'
    model = Alert

记得从django.views.generic中导入ListView和从data_collector.models中导入Alert。接下来,在data_collector/templates中创建alerts_list.html模板文件,并给它以下内容:

{% extends "base.html" %}

{% block content %}
<h1>Defined Alerts</h1>

{% if object_list %}
<table>
    <tr>
        <th>Data Type</th>
        <th>Min Value</th>
        <th>Max Value</th>
        <th>Node Name</th>
        <th>Is Active</th>
    </tr>

    {% for alert in object_list %}
    <tr>
        <td>{{ alert.data_type }}</td>
        <td>{{ alert.min_value }}</td>
        <td>{{ alert.max_value }}</td>
        <td>{{ alert.node_name }}</td>
        <td>{{ alert.is_active }}</td>
    </tr>
    {% endfor %}
</table>
{% else %}
<i>No alerts defined</i>
{% endif %}
{% endblock %}

最后,编辑djagios/urls.py。导入新视图,然后将其添加到 URL 模式中:

url(r'^alerts/$', AlertListView.as_view(), name='alerts-list'),

要测试它,打开http://127.0.0.1:8000/alerts/。你应该会看到没有定义警报的消息。列表视图非常基本。ListVew通用视图使用指定模型的所有对象渲染模板,提供object_list模板上下文变量中的对象列表。接下来,让我们看看创建新警报的视图。

data_collector/view.py文件中,首先导入以下内容:

from django.core.urlresolvers import reverse
from django.views.generic import CreateView

然后添加这个视图类:

class NewAlertView(CreateView):
    template_name = 'create_or_update_alert.html'
    model = Alert
    fields = [
        'data_type', 'min_value', 'max_value', 'node_name', 'is_active'
    ]

    def get_success_url(self):
        return reverse('alerts-list')

在视图代码中没有新内容。模板代码也非常简单。将这段代码放入data_collector/templates/create_or_update_alert.html中:

{% extends "base.html" %}

{% block content %}
{% if object %}
<h1>Update Alert</h1>
{% else %}
<h1>New Alert</h1>
{% endif %}

<form action="" method="post">{% csrf_token %}
    {{ form.as_p }}
    <input type="submit" value="{% if object %}Update{% else %}Create{% endif %}" />
    <a href="{% url 'alerts-list' %}">Cancel</a>
</form>
{% endblock %}

和以前的章节一样,我们使用object上下文变量来决定这个模板是从CreateView还是UpdateView中使用的,并根据此更改一些元素。否则,它非常直接了当。让我们也看看UpdateView的代码:

class EditAlertView(UpdateView):
    template_name = 'create_or_update_alert.html'
    model = Alert
    fields = [
        'data_type', 'min_value', 'max_value', 'node_name', 'is_active'
    ]

    def get_success_url(self):
        return reverse('alerts-list')

这几乎是前一个创建视图的完全相同的副本。确保你已经导入了UpdateView通用视图。我们仍然需要将这两个视图添加到我们的 URL 配置中。在djagios/urls.py文件中,导入NewAlertViewEditAlertView,并添加这些模式:

url(r'^alerts/new/$', NewAlertView.as_view(), name='alerts-new'),
url(r'^alerts/(?P<pk>\d+)/edit/$', EditAlertView.as_view(), name='alerts-edit'),

在我们测试这些视图之前,我们应该添加链接,让用户可以到达这些视图。修改alerts_list.html模板以匹配这段代码:

{% extends "base.html" %}

{% block content %}
<h1>Defined Alerts</h1>

{% if object_list %}
<table>
    <tr>
        <th>Data Type</th>
        <th>Min Value</th>
        <th>Max Value</th>
        <th>Node Name</th>
        <th>Is Active</th>
    </tr>

    {% for alert in object_list %}
    <tr>
        <td>{{ alert.data_type }}</td>
        <td>{{ alert.min_value }}</td>
        <td>{{ alert.max_value }}</td>
        <td>{{ alert.node_name }}</td>
        <td>{{ alert.is_active }}</td>
        <td><a href="{% url 'alerts-edit' pk=alert.pk %}">Edit</a></td>
    </tr>
    {% endfor %}
</table>
{% else %}
<i>No alerts defined</i>
{% endif %}
<p><a href="{% url 'alerts-new' %}">Add New Alert</a></p>
{% endblock %}

已添加了两行新的高亮显示的行。现在,让我们看看我们的警报列表页面是什么样子的。和以前一样,在浏览器中打开http://127.0.0.1:8000/alerts/。你应该会看到以下页面:

管理视图

点击添加新警报链接,你应该会看到创建警报的表单。填写一些示例数据,然后点击创建按钮。如果你的表单没有任何错误,你应该会回到警报列表视图,并且你的屏幕现在应该列出新的警报,如下面的截图所示:

管理视图

现在剩下的就是允许用户删除他们的警报的选项。为此,创建一个从通用DeleteView继承的视图,记得首先从django.views.generic中导入DeleteView。以下是你应该放入data_collector/view.py中的代码:

class DeleteAlertView(DeleteView):
    template_name = 'delete_alert.html'
    model = Alert

    def get_success_url(self):
        return reverse('alerts-list')

创建一个新的data_collector/templates/delete_alert.html模板:

{% extends "base.html" %}

{% block content %}
<h1>Delete alert?</h1>
<p>Are you sure you want to delete this alert?</p>
<form action="" method="post">{% csrf_token %}
    {{ form.as_p }}
    <input type="submit" value="Delete" />
    <a href="{% url 'alerts-list' %}">Cancel</a>
</form>
{% endblock %}

接下来,在djagios/urls.py中导入DeleteAlertView,并添加这个新的模式:

url(r'^alerts/(?P<pk>\d+)/delete/$', DeleteAlertView.as_view(), name='alerts-delete'),

最后,让我们从警报列表页面添加一个链接到删除视图。编辑alerts_list.html模板,在编辑链接后面添加这一行:

<td><a href="{% url 'alerts-delete' pk=alert.pk %}">Delete</a></td>

现在当你打开警报列表视图时,你应该会看到一个删除链接。你的屏幕应该看起来类似于以下截图:

管理视图

如果你点击删除链接,你应该会看到一个确认页面。如果你确认删除,你会发现你的警报将从列表页面消失。这些是我们需要管理警报的所有视图。让我们继续检测警报条件并在状态页面显示它们。

在状态页面显示触发的警报

正如我之前所说,我们希望我们的用户在状态页面上看到任何触发警报的节点都被突出显示。假设他们定义了一个警报,当任何节点的磁盘使用率超过0.85时触发,而我们对dbmaster磁盘使用率的最新数据点的值为0.9。当用户访问状态页面时,我们希望显示dbmaster节点的磁盘使用情况的行以红色突出显示,以便用户立即意识到警报并能够采取措施纠正这一情况。

data_collector/view.py中的StatusView更改为匹配以下代码。更改的部分已经高亮显示:

class StatusView(TemplateView):
    template_name = 'status.html'

    def get_context_data(self, **kwargs):
        ctx = super(StatusView, self).get_context_data(**kwargs)

        alerts = Alert.objects.filter(is_active=True)

        nodes_and_data_types = DataPoint.objects.all().values('node_name', 'data_type').distinct()

        status_data_dict = dict()
        for node_and_data_type_pair in nodes_and_data_types:
            node_name = node_and_data_type_pair['node_name']
            data_type = node_and_data_type_pair['data_type']

            latest_data_point = DataPoint.objects.filter(node_name=node_name, data_type=data_type).latest('datetime')
 latest_data_point.has_alert = self.does_have_alert(latest_data_point, alerts)

            data_point_map = status_data_dict.setdefault(node_name, dict())
            data_point_map[data_type] = latest_data_point

        ctx['status_data_dict'] = status_data_dict

        return ctx

    def does_have_alert(self, data_point, alerts):
 for alert in alerts:
 if alert.node_name and data_point.node_name != alert.node_name:
 continue

 if alert.data_type != data_point.data_type:
 continue

 if alert.min_value is not None and data_point.data_value < alert.min_value:
 return True
 if alert.max_value is not None and data_point.data_value > alert.max_value:
 return True

 return False

我们在这里所做的是,对于我们检索到的每个数据点,检查它是否触发了任何警报。我们通过比较每个警报中的最小值和最大值与数据点值来做到这一点,但只有当数据点数据类型和节点名称与警报中的匹配时。如果数据点值超出了警报范围,我们将标记数据点为触发了警报。

这是我在许多项目中经常使用的另一种技术。由于模型只是 Python 对象,你可以在运行时向它们附加额外的信息。不需要在DataPoint类上定义has_alert。只需在需要时将其添加到对象中。不过要小心。这样做并不是一个好的编程实践,因为试图理解DataPoint类的人将不知道has_alert属性甚至存在,除非他们查看视图类的代码。由于我们只在视图和模板中使用这个属性,对我们来说是可以的。但是,如果我们传递DataPoint对象并且更多的代码开始使用这个属性,最好还是在类本身上定义它,这样查看类代码的人就会知道它的存在。

我们还需要修改status.html模板,以利用我们已经添加到数据点的has_alert属性。将其更改为以下代码。与之前一样,修改的部分已经被突出显示:

{% extends "base.html" %}

{% load humanize %}

{% block content %}
<h1>Status</h1>

<table>
    <tbody>
        <tr>
            <th>Node Name</th>
            <th>Metric</th>
            <th>Value</th>
            <th>Last Updated</th>
        </tr>

        {% for node_name, data_type_to_data_point_map in status_data_dict.items %}
            {% for data_type, data_point in data_type_to_data_point_map.items %}
            <tr {% if data_point.has_alert %}class="has-alert"{% endif %}>
                <td>{% if forloop.first %}{{ node_name }}{% endif %}</td>
                <td>{{ data_type }}</td>
                <td>{{ data_point.data_value }}</td>
                <td>{{ data_point.datetime|naturaltime }}</td>
            </tr>
            {% endfor %}
        {% endfor %}
    </tbody>
</table>

<style type="text/css" media="all">
 tr.has-alert td:not(:first-child) {
 color: red;
 }
</style>
{% endblock %}

就是这样。为了测试它,你需要创建一些在你的数据库中由DataPoints触发的Alert对象。对于我使用的示例数据,我创建了一个数据类型为disk_usage,最大值为 0.5 的Alert对象。创建警报后,我的状态屏幕突出显示了触发警报的节点。你的屏幕会显示类似的内容:

在状态页面上显示触发的警报

为了测试我们的突出显示代码是否正确工作,我添加了另一个dbmaster磁盘使用率指标的数据点,使用以下命令:

> python manage.py sample_data dbmaster disk_usage 0.2

刷新状态页面后,dbmaster节点的警报条件消失了。你应该进行类似的测试来亲自看看。

就是这样!虽然很辛苦,但我们的监控工具现在开始成形了。我们有一个显示最新节点状态的状态页面,突出显示任何有警报的节点。一旦警报条件解决,突出显示就会消失。我们也有一个页面来管理我们的警报。总的来说,我们可以说应用程序的用户界面部分几乎已经完成了。一个相当有帮助的东西是一个导航栏。在templates/base.htmlbody标签开始后添加这个:

<ul>
    <li><a href="{% url 'status' %}">Home</a></li>
    <li><a href="{% url 'alerts-list' %}">Alerts</a></li>
</ul>

刷新状态页面,你应该会看到页面顶部有一个简单的导航菜单。

接受来自远程系统的数据

现在用户可以看到他们基础设施的状态并管理警报了,是时候继续下一步了:从真实来源获取数据,而不是使用 Django 管理命令输入示例数据。

为此,我们将创建一个接受来自远程系统的 API 端点。API 端点只是一个不需要渲染模板的 Django 视图的花哨名称。API 端点的响应通常要么只是一个 200 OK 状态,要么是一个 JSON 响应。API 端点不是为人类用户使用的。相反,它们是为不同的软件系统连接在一起并共享信息而设计的。

我们需要创建的 API 端点将是一个简单的视图,接受一个带有创建新DataPoint对象所需信息的 POST 请求。为了确保恶意用户不能用随机数据垃圾邮件式地填充我们的数据库,我们还将在 API 端点中添加一个简单的身份验证机制,以便它只接受来自授权来源的数据。

要创建一个 API 端点,我们将使用django.view.generic.View类,只实现 POST 处理程序。为了解析请求数据,我们将动态创建一个模型表单。编辑data_collector/views.py并添加以下代码:

from django.forms.models import modelform_factory
from django.http.response import HttpResponse
from django.http.response import HttpResponseBadRequest
from django.http.response import HttpResponseForbidden
from django.views.generic import View

class RecordDataApiView(View):
    def post(self, request, *args, **kwargs):
        # Check if the secret key matches
        if request.META.get('HTTP_AUTH_SECRET') != 'supersecretkey':
            return HttpResponseForbidden('Auth key incorrect')

        form_class = modelform_factory(DataPoint, fields=['node_name', 'data_type', 'data_value'])
        form = form_class(request.POST)
        if form.is_valid():
            form.save()
            return HttpResponse()
        else:
            return HttpResponseBadRequest()

这里有一些新的东西需要我们注意。首先,我们使用了请求对象的META属性来访问请求。如果您了解 HTTP 协议的工作原理,您应该熟悉头部。如果不了解,可以在www.jmarshall.com/easy/http/找到一个很好的解释。详细解释头部超出了本书的范围,但简单地说,头部是客户端在 HTTP 请求中添加的额外信息。在下一节中,当我们测试 API 视图时,我们将看到如何添加它们。

Django 会自动规范化所有头部名称并将它们添加到META字典中。在这里,我们使用自定义头部Auth-Secret来确保只有拥有我们秘钥的客户端才能使用这个视图。

注意

有关 META 字典中的内容以及其构造方式的更多信息,请参阅 Django 文档docs.djangoproject.com/en/stable/ref/request-response/#django.http.HttpRequest.META

接下来,我们需要看的是modelform_factory函数。这是 Django 提供的一个很好的小函数,它返回给定模型的ModelForm子类。您可以使用此函数的参数对模型表单进行一定程度的自定义。在这里,我们限制了可以编辑的字段数量。为什么首先使用模型表单呢?

我们从 API 端点中想要的是创建新的DataPoint模型的方法。模型表单正好提供了我们需要做到这一点的功能,而且它们还为我们处理了数据验证。我们本可以在forms.py文件中创建一个单独的模型表单类,然后像以前一样在视图中使用它,但我们没有这样做的两个原因。

首先,这是我们的代码中唯一使用模型表单的地方,用于DataPoint方法。如果我们需要在其他地方也使用它,那么在单个地方定义模型表单将是最佳的编程实践。然而,由于我们不需要,在需要时动态定义模型表单就可以了。

其次,我们不需要对模型表单类进行任何自定义。如果我们想要,比如,像之前所做的那样覆盖save方法,我们将被迫定义类而不是使用modelform_factory方法。

获取模型表单类之后,我们可以像使用任何模型表单类一样使用它,要么创建新的数据点,要么返回指示数据验证失败的响应。要使我们的新端点通过 URL 可用,请在djagios/urls.py中导入以下内容:

from django.views.decorators.csrf import csrf_exempt
from data_collector.views import RecordDataApiView

然后,添加此 URL 模式:

url(r'^record/$', csrf_exempt(RecordDataApiView.as_view()), name='record-data'),

使用csrf_exempt装饰器是因为默认情况下,Django 对 POST 请求使用 CSRF 保护。然而,这通常用于 Web 表单,而不是 API 端点。因此,我们必须禁用它,否则 Django 不会允许我们的 POST 请求成功。现在,让我们看看如何测试我们的新视图。

提示

您可以在docs.djangoproject.com/en/stable/ref/csrf/获取有关 Django 提供的 CSRF 保护的更多信息。

测试 API 端点

您不能简单地在浏览器中测试此 API 端点,因为它是一个 POST 请求,而且没有模板可以在浏览器中呈现一个表单。但是,有很多很好的工具可用于进行手动的 POST 请求。我建议您使用的是 Postman。它是一个 Google Chrome 应用,因此您不需要安装任何依赖项。只要您的计算机上安装了 Google Chrome,您就可以从www.getpostman.com/获取 Postman。安装后,启动它,您应该看到一个类似以下屏幕的界面。如果您的 Postman 界面不完全相同,不要担心。可能是您下载的版本更新了。Postman 的主要部分应该是相同的。

测试 API 端点

使用 Postman 很简单。我将逐步为您讲解整个过程,包括每个步骤的图像,以便清楚地说明我的意思。在这个过程结束时,我们应该能够使用我们的 API 端点生成一个新的数据点。

顺便说一句,如果您使用的是 Linux 或 Unix 操作系统,如 Ubuntu 或 Mac OS X,并且更习惯使用命令行,您可以使用curl实用程序来进行 POST 请求。对于更简单的请求,它通常更快。要使用curl进行与我在 Postman 中演示的相同请求,请在命令提示符上键入以下内容:

> c
url http://127.0.0.1:8000/record/ -H 'Auth-Secret: supersecretkey' -d node_name=web01 -d data_type=disk_usage -d data_value=0.2

要使用 Postman 进行此请求,请执行以下步骤:

  1. 选择请求类型。我们要进行 POST 请求,因此从下拉菜单中选择 POST:测试 API 端点

  2. 输入您要发出请求的 URL。在我们的情况下,它是http://127.0.0.1:8000/record/测试 API 端点

  3. 添加我们的自定义身份验证标头。打开标头选项卡,并添加值为supersecretkeyAuth-Secret标头:测试 API 端点

  4. 最后,将我们的 POST 参数添加到Body部分。我使用的示例数据如下:

  • node_name: web01

  • data_type: disk_usage

  • data_value: 0.72

测试 API 端点

就是这样。我们的请求现在已经设置好了。单击 URL 文本框旁边的发送按钮,您应该在参数体下方看到一个空的响应。要确认请求是否正常工作,请查看响应的状态代码。它应该是200 OK

测试 API 端点

打开我们应用程序的状态页面http://127.0.0.1:8000/,您应该看到最新的数据点值显示在那里。就是这样,我们完成了!

注意

正如本节开头所解释的那样,您还可以使用诸如curl之类的命令行工具来上传数据到 API。使用这样的工具,您可以编写 shell 脚本,自动从计算机系统更新 Web 应用程序的真实数据。这也是 Nagios 和许多数据监控工具的运行方式。服务器有 API 端点来监听数据,然后简单的脚本从客户节点收集并上传数据到服务器。

摘要

这是一个相当苛刻的章节,你学到了很多新信息。

首先,我们看了 Django 管理命令。它们是 Django 的一个重要特性。您运行的所有 Django 命令,例如python manage.py startapp,也是管理命令,因此您应该已经知道它们可以有多么强大。在更大的项目中,您几乎总是有一些管理命令来自动化您的任务。

我们还看了 Django 如何使用我们模型类上的__str__方法创建模型的字符串表示。它不仅在控制台打印时使用。每当您尝试将模型对象用作字符串时,甚至在模板中,Django 都会使用这个表示,因此拥有一个可以立即为您提供有关对象的所有重要信息的良好格式非常重要。

本章还介绍了高级查询方法,特别是distinctvalues方法,允许您发出更复杂的 SQL 查询,以从数据库中获取您想要的数据格式。然而,这只是个开始。在以后的章节中,我们可能需要使用更复杂的查询方法。您可能需要查看 Django 文档中关于queryset方法的更多信息,网址为docs.djangoproject.com/en/stable/ref/models/querysets/

除了以我们想要的格式从数据库中获取数据之外,我们还研究了如何准备一个相当复杂的数据结构,以便将所有必需的信息传递给模板,然后看到如何在我们的模板中使用该数据结构。

通常,您需要确保通过复杂的数据验证规则才能将数据保存到数据库。在本章中,我们看到了如何通过覆盖模型类的save方法来实现这一点。

最后,您学会了如何创建简单的 API 端点以及如何使用curl或 Postman 对其进行测试。总的来说,这是一个介绍了许多新概念的重要章节,这些概念将在以后的章节中使用。

第四章:汽车租赁应用程序

对于本章,我们的假想客户是一家汽车租赁公司。他们希望我们创建一个网站,供他们的客户访问,查看可用的汽车库存,并最终预订其中一辆汽车。客户还希望有一个管理面板,他们可以在其中管理库存和预订请求。

我们将在(鼓声)Django 中创建这个 Web 应用程序!您现在应该对 Django 有足够的信心,以至于 Web 应用程序的前端对我们来说不是挑战。本章的重点将放在定制 Django 内置的admin应用程序上,以满足我们客户的要求。大多数情况下,当您需要为创建的 Web 应用程序创建管理面板时,您可以通过定制 Django admin 来做几乎您需要的一切。有时候,要求足够复杂,您需要创建一个自定义管理面板,但这很少见。因此,我们在这里获得的知识将对您的 Web 开发职业非常有用。

本章的主要要点如下:

  • 定制 Django admin 模型表单

  • 向管理对象列表页面添加自定义过滤器

  • 覆盖和定制 Django admin 模板

代码包

正如我所提到的,到目前为止,您应该已经牢牢掌握了创建基本 Web 应用程序的组件,包括视图、模板、模型和 URL 配置,因此我们在本章不会讨论 Web 应用程序的这些部分。因此,本章的代码包比以前的要大得多。我已经创建了所有的模型和一些视图、模板和 URL。我们将主要关注如何驯服 Django admin 应用程序以满足我们的需求。

我想不出一个花哨的名字来为这个项目命名,所以我只是把项目称为carrental。像往常一样,创建一个新的虚拟环境,在这个环境中安装 Django,并运行迁移命令来初始化数据库。对于这个项目,我们需要安装另一个 Python 包,Pillow,这是一个用于 Python 的图像处理库。要安装它,请在虚拟环境激活时运行以下命令:

> pip install Pillow

这可能需要一两分钟,因为可能需要进行一些编译。安装 Pillow 要复杂一些,因为它依赖于第三方库。如果安装命令对您失败了,请查看pillow.readthedocs.org/en/3.0.x/installation.html上有关安装 Pillow 的文档。该页面有每个操作系统的逐步指南,按照那里的指南,您应该能够轻松安装 Pillow。只需记住,您需要该库来运行和处理汽车租赁应用程序。

安装了 Pillow 后,使用runserver命令运行开发服务器,并在http://127.0.0.1:8000上打开 Web 应用程序。您应该会看到以下页面:

代码包

固定装置

我们的数据库是空的,但现在我们没有任何视图来向我们的数据库添加对象。我们可以像上一章那样创建一个管理命令,但有一个更简单的方法。我已经向数据库添加了三个Car对象,然后创建了这些数据的转储,您可以加载。这样的数据转储称为固定装置。我们将稍后讨论固定装置;现在让我们看看如何使用它们来加载我们的数据库中的数据。

在命令行上,在虚拟环境激活的情况下,在项目根目录中运行此命令:

> python manage.py loaddata frontend/fixtures/initial.json
Installed 3 object(s) from 1 fixture(s)

刷新网页,现在您应该看到一个类似于这样的网页:

固定装置

现在我们的数据库中有三辆汽车。您应该玩一会儿这个应用程序。它为每辆汽车都有一个详细页面,并允许您从详细页面提交预订请求。

注意

如果您尝试使用预订表单,请注意开始和结束日期需要采用 YYYY-MM-DD 格式。例如,2016-12-22 是表单接受的有效日期格式。

要了解更多关于固定装置的信息,请查看 Django 文档docs.djangoproject.com/en/stable/howto/initial-data/。固定装置是 Django 的一个功能,它允许你使用多种格式将数据库中的数据转储到简单的文本文件中。最常用的格式是 JSON。一旦你有了一个固定装置文件,你就可以使用它来填充你的数据库,就像我们在这里使用loaddata命令一样。

在我们继续进行管理定制之前,我想谈谈我在这个应用程序的模型中使用的一些新东西。你应该看一下frontend/models.py,看看我们的模型是如何配置的,然后阅读下面的信息,解释了这些新概念。

图片和文件字段

我想花一分钟介绍一下ImageField模型字段。这是我们第一次看到它,使用它与其他模型字段有些不同。这是我们使用这个字段的Car模型:

class Car(models.Model):
    name = models.CharField(max_length=100)
    image = models.ImageField(upload_to='car_images')
    description = models.TextField()
    daily_rent = models.IntegerField()

    is_available = models.BooleanField()

    def get_absolute_url(self):
        return reverse('car-details', kwargs={'pk': self.pk})

注意

这一部分关于ImageField的所有信息也适用于FileField

ImageField与我们查看过的所有其他数据库模型字段都有一些特殊之处。首先,它需要 Pillow 图像处理库才能工作,这就是为什么我们在本章的开头安装它的原因。如果我们在没有安装 Pillow 的情况下尝试运行我们的应用程序,Django 会抱怨并且不会启动开发服务器。

其次,ImageField是少数几个依赖于在使用之前进行一些设置的 Django 数据库模型字段之一。如果你看一下carrental/settings.py文件的末尾,你会看到我已经设置了MEDIA_ROOTMEDIA_URL变量。

最后,你可以看到我们传递了一个upload_to参数给ImageField并将其设置为car_imagesFileFieldImageField数据库模型字段都需要这个参数。这个参数是相对于配置的MEDIA_ROOT的文件夹名称,任何通过 Image/File 字段上传到你的应用程序的文件都将被保存在这里。这是一个我花了一些时间才弄明白的概念,所以我会进一步解释一下。

你应该看到我已经将MEDIA_ROOT设置为项目根目录中的media文件夹。如果你看一下media文件夹,你应该会看到另一个名为car_images的文件夹。这与我们传递给upload_to参数的名称相同。这就是我说upload_to参数是相对于配置的媒体根目录的文件夹名称时的意思。

提示

当我开始使用 Django 时,我有一些困难理解MEDIA_ROOTSTATIC_ROOT之间的区别。简而言之,MEDIA_ROOT是站点用户上传的所有文件所在的位置。这些文件是使用表单和 Image/File 字段上传的。

STATIC_ROOT是你放置与你的 Web 应用程序相关的静态文件的位置。这些包括 CSS 文件、JavaScript 文件和任何其他作为静态文件提供的文件。这与你的 Web 应用程序的 Django 部分无关;这些文件是原样提供给用户的,通常通过诸如 nginx 之类的 Web 服务器。

现在你已经配置好了一切,那么如何使用ImageField上传文件呢?嗯,Django 支持几种不同的方法来做这个。在我们的代码中,我们将使用ModelForm,它会为我们处理所有的细节。还有其他方法。如果你想了解更多细节,你应该查看处理文件上传的 Django 文档。它非常全面,列出了处理文件上传的所有不同方式。你可以在docs.djangoproject.com/en/stable/topics/http/file-uploads/上查看。

获取绝对 URL

我们在Car模型中第一次看到的另一件事是get_absolute_url。实现上没有什么特别之处。它只是一个返回 URL 的类方法,它使用reverse函数和对象的主键构建 URL。这并不是什么新鲜事。自第一章以来,我们一直在为详细页面创建这样的 URL。这里有趣的是 Django 对模型类上的get_absolute_url方法赋予了特殊意义。Django 有许多地方会自动使用get_absolute_url方法的返回值,如果该方法存在于模型对象上。例如,CreateView通用方法会使用它。如果您没有在视图类上提供success_url属性和自定义的get_success_url方法,Django 将尝试从新创建的对象上的get_absolute_url方法获取重定向的 URL,如果该方法在模型类中定义了。

Django 还在管理员应用程序中使用此方法,我们稍后会看到。如果您感兴趣,可以查看其文档:

docs.djangoproject.com/en/stable/ref/models/instances/#get-absolute-url/

Django 管理员应用程序

现在我们已经看了代码包中使用的新功能,让我们继续讨论本章的主题——Django管理员应用程序。管理员应用程序很可能是 Django 比其他类似的 Web 框架更受欢迎的主要原因之一。它体现了 Django“电池包含”的本质。通过最小的配置,管理员应用程序提供了一个功能齐全且非常定制的 CMS,足以与 WordPress 和 Drupal 等大型名称媲美。

在本章中,您将学习如何轻松配置和自定义管理员,以获得您在 Web 应用程序的管理员面板中所需的大部分功能。让我们首先解决我们虚构客户的最紧迫问题,即汽车租赁业主的能力来添加和编辑汽车详情。

当您启动一个新应用程序时,Django 默认会在应用程序文件夹中创建一个admin.py文件。更改我们项目中的frontend/admin.py文件以匹配此内容:

from django.contrib import admin
from frontend.models import Car
admin.site.register(Car)

就是这样。真的!总共只有三行代码,您就可以编辑和添加Car对象到您的数据库中。这就是 Django 的强大之处,就在这三行代码中。让我们来测试一下。在浏览器中,访问http://127.0.0.1:8000/admin,您应该会看到类似以下页面:

Django 管理员应用程序

提示

如果您的管理员看起来略有不同,不要担心。Django 偶尔会更新管理员的主题,取决于您使用的 Django 版本,您的管理员可能看起来略有不同。但是,所有功能都会在那里,几乎总是具有相同的界面布局。

哎呀,有一件事我们忘了。我们没有创建一个可以登录的用户。这很容易解决。在命令行中,激活虚拟环境后运行以下命令:

> python manage.py createsuperuser

跟着提示创建一个新用户。创建用户后,使用该用户登录到管理员。登录后,您应该会看到类似于以下内容:

Django 管理员应用程序

在这个屏幕上需要注意的几件事。首先,Django 默认会添加链接来管理用户。其次,我们配置在管理员中显示的任何模型都会按其应用程序名称进行分组。因此,管理Cars的链接显示在定义模型的应用程序标签Frontend下。

提示

如果您仔细观察,您可能会注意到管理员列出了我们Car模型的复数名称。它是如何知道复数名称的呢?嗯,它只是在我们模型名称的前面添加了一个's'。在很多情况下,这并不适用,例如,如果我们有一个名为Bus的模型。对于这种情况,Django 允许我们配置模型的复数名称。

让我们尝试编辑我们数据库中的一辆汽车。单击Cars链接,您应该会看到类似以下的屏幕:

Django 管理员应用程序

列表看起来并不是很有用。我们不知道哪个汽车对象是哪个。我们稍后会解决这个问题。现在,只需单击列表中的顶部汽车对象,您应该会看到一个页面,您可以在该页面上编辑该对象的详细信息:

Django 管理员应用程序

注意

Django 管理员文档将此列表称为更改列表。在本章中,我将称其为列表视图。

让我们更改汽车的名称。我将Dodge Charger更改为My New Car Name。更改名称后,滚动到页面底部,然后单击保存。为了确保我们的更改确实已保存,打开我们应用程序的主页http://127.0.0.1:8000/,您会看到您编辑的汽车将显示新名称。

让我们尝试更复杂的事情——添加一辆新汽车!单击屏幕右侧的ADD CAR按钮,然后根据需要填写详细信息。只需确保选择is_available复选框;否则,新汽车将不会显示在主页上。我填写了如下截图所示的表单:

Django 管理员应用程序

我还从 Google Images 下载了一辆汽车的图片,并将其选中为Image字段。单击保存按钮,然后再次访问主页。您添加的新汽车应该会显示在列表的末尾:

Django 管理员应用程序

正如我在本节开始时提到的,Django 管理员的强大是 Django 流行的主要原因之一。到目前为止,您应该明白为什么了。在三行代码中,我们有了一个完整且可用的内容管理系统,尽管不太美观,但客户可以用它来编辑和添加汽车到他们的网站。

然而,在其当前形式下,管理员看起来像是一个快速的黑客工作。客户可能不会对此感到满意。他们甚至在打开编辑页面之前都看不到他们即将编辑的汽车是哪辆。让我们首先解决这个问题。稍后我们会回到刚刚为管理员编写的代码。

显示汽车名称

在上一章中,我们看到了模型类上的__str__方法。我还说过,Django 在需要显示模型的字符串表示时会使用这个方法。嗯,这正是 Django 管理员在Car模型的列表视图中所做的:它显示了它的字符串表示。让我们通过将字符串表示更改为用户可以理解的内容来使列表更加用户友好。在frontend/models.py文件中,向Car模型类添加这个__str__方法:

def __str__(self):
    return self.name

让我们看看现在Car对象的列表是什么样子的:

显示汽车名称

这是一个更好的用户体验,因为用户现在可以看到他们即将编辑的汽车是哪一辆。

预订管理

让我们暂时保持汽车管理员部分不变,转而进入Booking模型的管理员。每当网站访问者通过汽车详情页面上的立即预订表单提交时,我们都会创建一个新的Booking模型记录。我们需要一种方法来允许客户查看这些预订询问,根据一些标准对其进行筛选,并接受或拒绝它们。让我们看看如何做到这一点。首先,让我们确保我们的Booking模型显示为管理员面板中的一个项目。为此,请在frontend/admin.py文件中添加以下两行:

from frontend.models import Booking
admin.site.register(Booking)

如果你现在查看 URL 为http://127.0.0.1:8000/admin/的管理员面板,你应该会看到Booking模型已经被添加为一个链接。打开链接,你应该会看到一个类似于我们之前看到的Car模型的列表页面。如果你提交了任何预订请求,它们应该会显示在列表中。这不够美观,但至少它能用。让我们把它做得更好。首先,我们需要给管理员更多关于每个预订询问的信息。如果我们能显示客户的姓名、预订开始和结束日期,以及预订是否已经被批准,那就太好了。

虽然我们可以再次使用__str__方法来创建一个包含所有这些信息的字符串,但是在一个列中显示这么多信息并不美观。此外,我们将错过 Django 管理员为每个模型列表页面提供的排序功能。

让我们看看如何在列表视图中显示我们模型的多个字段。在此过程中,你还将更多地了解管理员内部是如何工作的。

幕后一瞥

如果你花一分钟思考一下,我们只用几行代码就能实现的成就,你可能会对 Django 管理员的强大感到惊讶。这种力量是如何实现的呢?嗯,这个问题的答案非常复杂。即使我自己还没有完全理解管理员应用是如何工作的。这是一个非常复杂的编程部分。

注意

尽管管理员应用非常复杂,但它仍然是 Python 代码。如果你感到有冒险精神,或者只是某一天感到无聊,试着查看管理员应用的源代码。它在VIRTUAL_ENV/lib/python3.5/site-packages/django/contrib/admin文件夹中。用你为项目创建的虚拟环境的文件夹替换VIRTUAL_ENV

管理员系统的主要组件之一是ModelAdmin类。就像models.Model类允许我们使用非常简单的类定义来定义复杂的数据库模型一样,ModelAdmin类允许我们非常详细地定制模型的管理员界面。让我们看看如何使用它来向我们的预订询问列表添加额外的字段。修改frontend/admin.py文件以匹配以下内容:

from django.contrib import admin

from frontend.models import Car
from frontend.models import Booking

class BookingModelAdmin(admin.ModelAdmin):
    list_display = ['customer_name', 'booking_start_date', 'booking_end_date', 'is_approved']

admin.site.register(Car)
admin.site.register(Booking, BookingModelAdmin)

现在,如果你打开Booking模型的管理员列表页面,你应该会看到类似于这样的东西,所有重要的字段都显示出来:

幕后一瞥

这为用户提供了一个非常好的表格视图。客户现在可以看到所有相关的细节,并且可以根据自己的需求对表格进行排序。Django 还很贴心地以一种好看的格式显示日期值。让我们看看我们在这里做了什么。

我们首先创建了一个名为BookingModelAdminModelAdmin子类。然后,我们使用list_display属性配置我们想在列表页面显示的字段。最后,我们需要将我们的ModelAdmin类与Booking模型类关联起来,以便管理员可以根据我们的要求自定义自己。我们使用以下方法来做到这一点:

admin.site.register(Booking, BookingModelAdmin)

如果你看一下我们如何注册Car模型,它看起来与Booking模型类似:

admin.site.register(Car)

这是因为它是同样的东西。如果你没有提供自定义的ModelAdmin子类,Django 会使用默认选项,这就是我们在Car模型中看到的。

改善用户体验

虽然我们通过在列表页面上显示相关字段来改进了基本的管理员界面,但我们可以做得更多。让我们看看管理员可能想要为网站收到的预订询问采取的一些操作:

  • 只查看已批准的预订询问或尚未批准的预订询问

  • 通过客户姓名搜索预订

  • 快速批准或不批准预订询问

  • 选择多个预订询问对象,并向客户发送关于他们批准/不批准的电子邮件

过滤对象

对于我们的第一个功能,我们希望允许用户对显示的对象进行筛选。页面上应该有一个筛选器,允许他们只查看已批准或未批准的预订。为此,Django 管理在ModelAdmin子类上提供了list_filter属性。list_filter属性包含一个可以进行筛选的字段列表。在我们的BookingModelAdmin类中,添加以下list_filter属性:

list_filter = ['is_approved']

就是这样。一旦您将这行添加到BookingModelAdmin中,打开预订列表页面;在右侧,您应该看到一个新的侧边栏,您可以选择要查看的预订——只有已批准的或未批准的,或两者都有。它应该看起来类似于以下的屏幕截图:

过滤对象

搜索对象

就像 Django 管理内置了对过滤器的支持一样,它还提供了一种易于使用的添加搜索的方法。我们希望客户能够通过客户名称字段搜索预订。为此,请将search_fields属性添加到BookingModelAdmin类中:

search_fields = ['customer_name']

就是这样。一旦您添加了这个属性,您应该在预订对象列表的顶部看到一个搜索框。输入一些示例查询,看看它是如何工作的。如果您有多个要进行搜索的字段,也可以将其添加到search_fields列表中。

如果列表中有多个字段名称,Django 将进行 OR 搜索。这只是意味着对于给定的搜索,具有至少一个匹配字段值的所有记录都将显示。

快速编辑

我们列表中的第三个功能是允许管理员快速标记预订为批准/未批准。Django 管理提供了另一个内置功能,我们可以配置以获得我们需要的功能。在您的BookingModelAdmin类中,添加list_editable属性:

list_editable = ['is_approved']

如果您现在打开预订列表页面,您会注意到在以前的is_approved列中显示的图标已经被替换为复选框和保存按钮添加到列表的末尾。您可以选择要批准的预订的复选框,并取消选择要不批准的预订,并单击保存。然后 Django 将一次保存您对多个对象的更改。

到目前为止,我们的预订列表页面看起来类似于以下的屏幕截图:

快速编辑

管理操作

我们功能列表中的最后一项是允许用户选择多个预订查询对象,并向每个包含预订批准状态的Booking对象的customer_email发送电子邮件。目前,我们将只是在控制台上打印出电子邮件来测试这个功能。我们将在后面的章节中查看如何从 Django 发送电子邮件。

到目前为止,我们在 Django 管理中所做的大部分编辑都是基于每个对象的。您选择一个对象,编辑它,然后保存它,然后重新开始。除了最后一个功能(快速编辑)之外,我们一直在逐个编辑对象。然而,有时您希望能够对多个对象执行常见操作,就像我们在电子邮件功能中所需的那样。为了实现这样的功能,Django 管理提供了管理操作

管理操作是ModelAdmin类上的方法,它们接收用户选择的对象列表。然后,这些方法可以对这些对象执行一些操作,然后将用户返回到更改列表页面。

注意

实际上,我稍微简化了一下。管理操作不需要是ModelAdmin上的方法。它们也可以是独立的函数。然而,通常最好的编程实践是在使用它们的ModelAdmin中声明它们,所以我们将在这里这样做。您可以在docs.djangoproject.com/en/stable/ref/contrib/admin/actions/的管理操作文档中找到更多详细信息。

Django 管理员默认提供了一个操作:删除。如果你打开预订列表顶部的操作下拉菜单,你应该会看到这个菜单:

管理员操作

要定义管理员操作,首先需要在ModelAdmin类上创建一个方法,然后将方法的名称添加到类的actions属性中。actions属性是一个列表,就像我们到目前为止看到的所有其他属性一样。修改BookingModelAdmin以匹配以下代码:

class BookingModelAdmin(admin.ModelAdmin):
    list_display = ['customer_name', 'booking_start_date', 'booking_end_date', 'is_approved']
    list_filter = ['is_approved']
    list_editable = ['is_approved']
    search_fields = ['customer_name']

    actions = ['email_customers']

    def email_customers(self, request, queryset):
        for booking in queryset:
            if booking.is_approved:
                email_body = """Dear {},
    We are pleased to inform you that your booking has been approved.
Thanks
""".format(booking.customer_name)
            else:
                email_body = """Dear {},
    Unfortunately we do not have the capacity right now to accept your booking.
Thanks
""".format(booking.customer_name)

            print(email_body)

让我们在查看代码功能之前先试一下。刷新 Booking 模型的changelist页面,查看操作下拉菜单。应该会有一个新选项,给顾客发送邮件

管理员操作

要测试它,从列表中选择一些预订对象,从下拉菜单中选择给顾客发送邮件操作,然后单击下拉菜单旁边的Go按钮。页面加载后,查看控制台。你应该会看到类似于这里显示的内容:

Dear Jibran,
    We are pleased to inform you that your booking has been approved.
Thanks

[18/Jan/2016 09:58:05] "POST /admin/frontend/booking/ HTTP/1.1" 302 0

让我们看看我们在这里做了什么。正如我之前所说,管理员操作只是ModelAdmin类上的一个方法,接受request对象和queryset作为参数,然后对queryset执行所需的操作。在这里,我们为每个预订对象创建了一个电子邮件正文,并将其打印到控制台。

UX 改进

虽然系统现在已经足够好让我们的客户使用,但肯定还有改进的空间。首先,用户没有得到任何关于给顾客发送邮件操作是否执行的反馈。让我们先解决这个问题。在email_customers方法的末尾添加这一行:

self.message_user(request, 'Emails were send successfully')

再次尝试使用电子邮件操作。现在页面重新加载后,你会看到一个很好的成功消息,向用户保证他们想要的操作已经完成。在用户体验方面的小改进在帮助用户导航和成功使用产品方面可以走很长的路。

其次,让我们来看看如何命名这个操作。对于这个操作,Django 提供了一个相当不错的名称——给顾客发送邮件。这个名称简单明了。然而,它并不像应该的那样清晰。它没有向用户传达正在发送的电子邮件是什么。在一个更大的系统中,客户可能会发送多种类型的电子邮件,我们的操作名称应该清楚地说明我们在谈论哪一封电子邮件。

为了改变管理员操作的名称,我们需要给方法添加一个名为short_description的属性。由于在 Python 中方法也是对象,所以这很容易实现。修改BookingModelAdmin类以匹配以下代码。需要添加的新行已经标出:

class BookingModelAdmin(admin.ModelAdmin):
    list_display = ['customer_name', 'booking_start_date', 'booking_end_date', 'is_approved']
    list_filter = ['is_approved']
    list_editable = ['is_approved']
    search_fields = ['customer_name']

    actions = ['email_customers']

    def email_customers(self, request, queryset):
        for booking in queryset:
            if booking.is_approved:
                email_body = """Dear {},
    We are pleased to inform you that your booking has been approved.
Thanks
""".format(booking.customer_name)
            else:
                email_body = """Dear {},
    Unfortunately we do not have the capacity right now to accept your booking.
Thanks
""".format(booking.customer_name)

            print(email_body)

        self.message_user(request, 'Emails were send successfully')
    email_customers.short_description = 'Send email about booking status to customers'

请注意,新的行(最后一行)不是函数体的一部分。它与函数定义的缩进级别相同,实际上是类的一部分,而不是函数的一部分。刷新列表页面,再次查看操作下拉菜单:

UX 改进

总结

这一章可能是本书中编写的代码最少的一章。然而,我们在这里构建的功能可能比大多数章节中构建的功能更复杂。我在本章的开头说过,Django 框架受欢迎的原因之一是管理员应用程序。我希望到现在为止你同意我的观点。

不到 20 行代码,我们就能够创建一个与大多数 CMS 系统相媲美的系统,而且仍然更符合我们客户的需求。与大多数 CMS 系统不同,我们不将CarBooking对象视为页面或节点。在我们的系统中,它们是一流的对象,每个对象都有自己的字段和独特的功能。然而,就客户而言,管理员的工作方式与大多数 CMS 一样,可能更容易,因为没有像大多数 CMS 解决方案中那样有额外的字段。

我们几乎只是开始了解定制管理员的表面。管理员提供了许多功能,适用于管理面板所需的大多数场景。通过在ModelAdmin上更改一些设置,所有这些功能都很容易使用。在我开发的所有 Django 应用程序中,我只需要创建定制的管理面板一次。Django 管理员是如此可定制,您只需配置它以满足您的需求。

我强烈建议您查看 Django 管理员的文档[https://docs.djangoproject.com/en/stable/ref/contrib/admin/]。如果您需要为您的 Web 应用程序创建管理项目,请检查管理员是否提供您想要的功能。往往情况是如此,并且可以节省大量精力。

第五章:多语言电影数据库

互联网可能是世界上增长最快的现象。廉价的互联网手机进一步加速了这种增长,据一些估计,今天世界上有 40%的人口可以接入互联网。我们开发的任何网络应用都可以真正成为全球性的。然而,英语用户只占互联网用户的大约 30%。如果你的网站只有英文,你就错过了一个巨大的受众。

为了解决这个问题,近年来已经做出了许多努力,使网站也能够为非英语用户提供访问。Django 本身包括可靠的方法,将网站内容翻译成多种语言。

然而,翻译内容只是过程的第一部分。语言并不是世界各地不同之间唯一的不同之处。货币代码、时区和数字格式只是一些例子。将这些适应到用户所在地的过程称为本地化。你经常会看到这个缩写为l10n。这是本地化的第一个l,然后是一个数字10,后面是最后一个n10指的是两者之间的字符数!你可能也会遇到国际化(i18n)这个术语。国际化是确保你的应用在多个地区都能正常运行,不会出现错误。例如,确保你从用户那里接受的任何输入可以是多种语言,而不仅仅是你开发应用的那种语言。

在本章中,我们将制作一个受到非常有用的IMDB互联网电影数据库)网站启发的应用程序。如果你从未听说过它,它是一个提供有关电影的大量信息的网络应用程序,无论是新的还是旧的。我们将创建一个类似于 IMDB 的应用程序,提供一些非常基本的功能。由于我们的应用程序是多语言的(顺便说一句,IMDB 也是),我将把它称为多语言电影数据库MMDB)。

本章的代码包含了一个工作的非本地化应用程序副本。我们的工作是为法国用户添加本地化和国际化,以便其能够正常使用。

要求

让我们来看看本章结束时我们想要实现的目标:

  • 了解 Django 提供的所有功能,以允许本地化

  • 将网站内容翻译成法语

  • 给用户选择他们想要在网站中使用的语言的能力

  • 在多次访问中保持用户的语言偏好

  • 翻译模型的内容

在我们开始之前,有一件事我想提一下。由于我们是第一次学习这些东西,我们将从一个已经存在的 Django 应用程序开始。然而,与大多数真实项目相比,我们的应用程序非常小。对于更大的应用程序,在完成项目后添加本地化通常更加困难。

在开始项目时考虑本地化需求并在首次开发应用程序时将这些功能纳入其中总是一个好主意,而不是在应用程序开发后的后期阶段这样做。

启动项目

和往常一样,一旦你下载了代码包,解压它。然后,为这个项目创建一个新的虚拟环境并安装 Django。最后,激活它并在项目根目录中运行迁移命令。这应该为项目设置数据库,并让你可以启动应用程序。现在你需要创建一个新的超级用户,这样你就可以添加一些测试数据。在项目根目录中(虚拟环境处于活动状态),运行以下命令:

> python manage.py createsuperuser

回答问题,您将获得一个新用户。现在,使用runserver命令运行应用程序,然后访问http://127.0.0.1:8000/admin/,并向数据库添加一些电影详细对象。一旦您添加了一些测试数据,访问应用程序的主页,您应该看到类似以下屏幕截图的内容:

启动项目

您应该花一些时间来探索这个应用程序。您可以在页面上查看特定电影的详细信息,如下面的屏幕截图所示:

启动项目

最后,您可以点击提交新评论链接,转到下一页,并为电影创建一个新的评论,如下面的屏幕截图所示:

启动项目

这就是我们整个应用程序。在本章的其余部分,我们将探讨如何向这个项目添加 l10n 和 i18n。我们对核心产品功能几乎没有或没有做任何更改。

翻译我们的静态内容

我们想要做的第一件事是翻译网站上的所有静态内容。这包括在前面三个屏幕中看到的所有标题、链接和表单标签。为了翻译模板中使用的字符串,Django 为我们提供了一个trans模板标签。让我们先看看如何在简单的上下文中使用它,然后我会详细介绍它的工作原理。这是一个稍微长一点的部分,因为我们将在这里做很多构成 Django 翻译功能基础的事情。

提示

如果您不理解某些内容,请不要惊慌。只需按照说明进行。我将深入介绍每一步,但首先我想向您展示翻译是如何完成的。

打开main/templates/movies_list.html,并将h2标签中的Movies List替换为以下内容:

{% trans "Movies List" %}

在文件的第二行后面的extends标签之后,添加以下load标签:

{% load i18n %}

这是我们现在需要对模板进行的所有更改。我将在稍后解释这两行的作用,但首先我想完成整个翻译过程,这样您就可以看到整个过程而不仅仅是一小部分。

接下来,让我们从项目根目录运行以下命令:

> python manage.py makemessages -l fr
CommandError: Unable to find a locale path to store translations for file main/__init__.py

如果您运行这个命令,您也应该看到与我一样的错误,即找不到区域设置路径。我们会在演示结束后解释区域设置路径是什么。现在,在main文件夹中创建一个名为locale的新文件夹,然后再次运行该命令:

>mkdir main/locale
> python manage.py makemessages -l fr
processing locale fr

这次命令成功了。如果您查看您创建的locale文件夹,您会看到它下面创建了一个全新的文件夹层次结构。makemessages命令所做的是在main/locale/fr/LC_MESSAGES/django.po文件中创建了一个django.po文件。如果您打开这个文件,您应该能够了解一些关于它的目的。文件的最后三行应该如下所示:

#: main/templates/movies_list.html:5
msgid "Movies List"
msgstr ""

加上这个文件的路径(locale/fr/LC_MESSAGES/django.po)和这三行,您应该能够理解这个文件将包含我们之前用trans标签标记的字符串的法语翻译。在msgstr旁边放在引号中的任何内容都将替换网站的法语翻译中的原始字符串。

我使用 Google 翻译来翻译Movies List字符串,它给了我翻译为 Liste des films。将这个翻译放在msgstr旁边的引号中。现在,django.po文件的最后三行应该与以下内容匹配:

#: main/templates/movies_list.html:5
msgid "Movies List"
msgstr "Liste des films"

接下来,从项目根目录运行以下命令:

> python manage.py compilemessages -l fr
processing file django.po in /Users/asadjb/Programming/Personal/DjangoBluePrints/mmdb/mmdb/main/locale/fr/LC_MESSAGES

如果您现在查看LC_MESSAGES文件夹,您会看到一个新的django.mo文件已经被创建。这是我们的django.po文件的编译版本,我们将翻译的字符串放入其中。出于性能考虑,Django 翻译需要将文件编译成二进制格式,然后才能获取字符串的翻译。

接下来,打开mmdb/settings.py并找到MIDDLEWARE_CLASSES列表。编辑它,使得django.middleware.locale.LocaleMiddleware字符串出现在已安装的SessionMiddlewareCommonMiddleware之间。位置很重要。列表现在应该如下所示:

MIDDLEWARE_CLASSES = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
 'django.middleware.locale.LocaleMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

接下来,在设置文件中添加一个LANGUAGES变量,并给它以下值:

LANGUAGES = (
    ('en', 'English'),
    ('fr', 'French')
)

默认情况下,Django 支持更多的语言列表。对于我们的项目,我们希望将用户限制在这两个选项中。这就是LANGUAGES列表的作用。

最后一步是修改mmdb/urls.py文件。首先,从django.conf.urls.i18n导入i18n_patterns。接下来,更改urlpatterns变量,以便i18n_patterns函数包装所有我们的 URL 定义,如下面的代码所示:

urlpatterns = i18n_patterns(
url(r'^$', MoviesListView.as_view(), name='movies-list'),
url(r'^movie/(?P<pk>\d+)/$', MovieDetailsView.as_view(), name='movie-details'),
url(r'^movie/(?P<movie_pk>\d+)/review/$', NewReviewView.as_view(), name='new-review'),

url(r'^admin/', admin.site.urls),
)

完成这些后,让我们测试一下,看看我们的辛勤工作得到了什么。首先,打开http://127.0.0.1:8000。您应该会看到与之前相同的主页,但是如果您注意地址栏,您会注意到浏览器位于http://127.0.0.1:8000/en/而不是我们输入的内容。我们将在下一步详细了解为什么会发生这种情况,但简而言之,我们打开了主页而没有指定语言,Django 将我们重定向到了站点的默认语言,我们之前指定为英语。

将 URL 更改为http://127.0.0.1:8000/fr/,您应该会再次看到相同的主页,但是这次,Movies List文本应该被我们说的法语翻译所替换,如下面的截图所示:

翻译我们的静态内容

虽然这一切可能看起来像是为了翻译一个句子而做了很多工作,但请记住,您只需要做一次。既然基础已经建立,让我们看看现在翻译其他内容有多容易。让我们将单词Stars翻译成法语,Etoiles。打开main/templates/movies_list.html,并将单词Stars替换为以下内容:

{% trans "Stars" %}

接下来,运行makemessages命令:

> python manage.py makemessages -l fr

打开main/locale/fr/LC_MESSAGES/django.po文件。您应该会看到一个新的部分,用于我们标记为翻译的Stars字符串。添加翻译(Étoile)并保存文件。最后,运行compilemessages命令:

> python manage.py compilemessages

再次访问http://127.0.0.1:8000/fr/,打开法语语言主页。您会看到单词Stars已被其法语翻译所替换。所需的工作量很小。您刚刚遵循的工作流程:标记一个或多个字符串进行翻译,制作消息,翻译新字符串,最后运行compilemessages,是大多数 Django 开发人员在翻译项目时遵循的工作流程。准备网站翻译所涉及的大部分工作都是我们之前所做的。让我们更仔细地看看我们到底做了什么来使我们的 Web 应用程序可翻译。

所有这些是如何工作的?

就像我在上一节开始时所承诺的那样,在看到 Django 翻译实际操作后,我们现在将更深入地了解我们所遵循的所有步骤以及每个步骤所做的事情。

我们做的第一件事是加载 i18n 模板标签库,它为我们提供了各种模板标签来翻译模板中的内容。最重要的,也可能是您最常使用的,是trans标签。trans标签接受一个字符串参数,并根据活动的语言输出该字符串的正确翻译。如果找不到翻译,将输出原始字符串。

您在模板中编写的几乎任何字符串最终都将被trans标签包装,然后在您的 Web 应用程序可用的各种语言中进行翻译。有某些情况下trans标签无法使用。例如,如果您必须将某些上下文变量的值添加到已翻译的字符串中,则trans标签无法做到这一点。对于这些情况,我们需要使用块翻译标签blocktrans。我们的应用程序不需要它,但您可以在 Django 文档中了解有关它的信息docs.djangoproject.com/es/stable/topics/i18n/translation/#blocktrans-template-tag

我们的下一步是运行make messages命令。我们的第一次尝试没有成功,所以我们不得不在我们的application文件夹中创建一个locale目录。做完这些后,我们运行了该命令,并创建了一个带有.po扩展名的消息文件。该命令的作用是遍历项目中的每个文件,并提取您标记为翻译的字符串。标记字符串的一种方法是使用trans标签进行包装。还有其他方法,我们稍后会看到。

make messages命令提取字符串后,需要创建文件并将提取的字符串存储在这些文件中。Django 在确定每个提取的字符串应放入哪个文件时遵循一组规则。对于从应用程序文件中提取的字符串,Django 首先尝试在该应用程序的文件夹中找到locale目录。如果找到该文件夹,它将在其中创建适当的层次结构(fr/LC_MESSAGES目录)并将消息文件放在那里。

如果未找到locale文件夹,Django 将查看LOCALE_PATHS设置变量的值。这应该是一个目录位置列表。Django 从此路径列表中选择第一个目录,并将消息文件放在那里。在我们的情况下,我们没有设置LOCALE_PATHS,这就是为什么 Django 会引发错误,找不到我们主要应用程序文件夹中的 locale 目录。

让我们稍微谈谈消息文件的格式。这是我们当前消息文件的样子:

# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2016-02-15 21:25+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"

#: main/templates/movies_list.html:6
msgid "Movies List"
msgstr "Liste des films"

#: main/templates/movies_list.html:10
msgid "Stars"
msgstr "Étoile"

#开头的行是注释。然后是一对空的msgidmsgstr。然后是关于此消息文件的一些元数据。之后,我们得到了主要部分。消息文件,忽略元数据和第一对(在模糊注释之前的那一对),只是一系列msgidmsgstr对。msgid对是您标记为翻译的字符串,msgstr是该字符串的翻译。翻译应用程序的常规方法是首先标记所有字符串以进行翻译,然后生成消息文件,最后将其提供给翻译人员。然后翻译人员将带有填充翻译的文件返回给您。使用简单文本文件的好处是,翻译人员不需要使用任何特殊软件。如果他可以访问简单的文本编辑器,他就可以翻译消息文件。

一旦我们翻译了消息文件中的字符串,我们需要在 Django 能够使用这些翻译之前运行编译消息命令。如前所述,编译命令将文本消息文件转换为二进制文件。二进制文件格式对 Django 来说读取速度更快,在具有数百或数千个可翻译字符串的项目中,这些性能优势会非常快速地累积起来。编译消息文件的输出是一个.mo文件,与.po文件在同一个文件夹中。

一旦我们完成并编译了翻译,我们需要设置一些 Django 配置。我们首先要做的是将LocaleMiddleware添加到应用程序使用的中间件列表中。LocaleMiddleware的工作是允许用户根据一些请求参数选择站点的语言。您可以在文档中阅读有关语言确定方式的详细信息docs.djangoproject.com/es/stable/topics/i18n/translation/#how-django-discovers-language-preference。我们稍后会回到这个问题,讨论它如何通过示例确定语言。

然后我们需要定义两个设置变量,LANGUAGESLANGUAGELANGUAGE已经在代码包中定义了,所以我们只设置了LANGUAGES变量。LANGUAGES是 Django 可以为站点提供翻译的语言选择列表。默认情况下,这是一个包含 Django 可以翻译的所有语言的巨大列表。然而,对于大多数项目,您希望用户仅限于使用站点的少数语言。通过为LANGUAGES列表提供我们自己的值,我们确保 Django 不会为除定义的语言之外的任何语言提供页面。

LANGAUGE变量定义了要使用的默认语言。如果您记得,当我们打开主页时没有任何语言代码(http://127.0.0.1:8000/),英语语言会被默认选择。LANGUAGE变量决定了站点的默认语言是什么。

使应用程序可翻译的下一步是修改url.py文件。我们将 URL 配置的简单列表替换为i18n_patterns函数。这个函数允许我们匹配在 URL 前面加上语言代码的 URL。对于每个进来的请求,这个函数会尝试匹配我们在其中包装的模式,然后从 URL 路径中移除语言代码。这有点复杂,所以让我们看一个例子。

假设我们有以下的 URL 模式:

url(r'^example/$', View.as_view(), name='example')

这将匹配DOMAIN.COM/example/,但如果我们尝试DOMAIN.com/en/example/,模式将不会匹配,因为/en/部分不是正则表达式的一部分。然而,一旦我们将其包装在i18n_patterns中,它将匹配第二个示例。这是因为i18n_patterns函数会移除语言代码,然后尝试匹配我们在其中包装的模式。

在一些应用程序中,您不希望所有的 URL 都匹配语言前缀。一些 URL,比如 API 端点,不会根据语言而改变。在这种情况下,您可以将i18n_patterns和普通的 URL 模式列表结合在一起:

urlpatterns = i18n_patterns(url(r'^example/$', ExampleView.as_view(), name='example')) + [url(r'^api/$', ApiView.as_view(), name='api')]

这样,您可以创建一些混合了翻译和非翻译视图的应用程序。

添加了i18n_urlpatterns后,我们已经完成了 Django 需要的基本国际化配置,我们可以访问我们用法语编写的页面并查看翻译版本。

我要解释的最后一件事是LocaleMiddleware。区域设置中间件是 Django 的一部分,允许用户使用 URL 中的语言代码来决定使用哪种语言。因此,即使是i18n_patterns根据语言代码匹配模式,中间件也会为每个请求激活正确的语言。除了在 URL 路径中使用语言前缀之外,LocaleMiddleware还提供了其他几种选择语言的方式:

  • 一个会话变量

  • 一个 cookie 值

  • 用户浏览器发送的Accept-Language

  • 如果一切都失败了,就会使用LANGUAGE设置变量的默认语言

这是我们如何使我们的应用程序适应可翻译的概述。然而,我们还没有完成。

让用户决定使用哪种语言

虽然这不是 Django 的一部分,但几乎所有国际化的项目都使用这种模式;因此我认为您了解这一点很重要。大多数具有多种语言选项的网站都会向用户提供一个菜单,让他们选择要以哪种语言查看网站。让我们创建一个。修改templates/base.html模板以匹配以下内容:

{% load i18n %}

<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=utf-8" />

<title>MMDB</title>
</head>
<body>
<h1>MMDB</h1>
<div>
<span>Select Language</span>
<ul>
 {% get_available_languages as available_languages %}
 {% for lang_code, lang_name in available_languages %}
 {% language lang_code %}<li><a href="{% url "movies-list" %}">{{ lang_name }}</a></li>{% endlanguage %}
 {% endfor %}
</ul>
</div>
    {% block content %}
    {% endblock %}
</body>
</html>

新部分已经突出显示。我们首先导入 i18n 模板库。然后,我们创建一个新的div元素来保存我们的语言选择列表。接下来,为了将语言选择作为模板的一部分,我们使用get_available_languages模板标签,并将选择分配给available_languages变量。

接下来,我们根据语言选择创建一个链接列表。get_available_languages的返回值是我们在LANGUAGES变量的设置文件中设置的元组。

在我们的链接列表中,我们需要一种方法来获取每种语言的 URL。Django 在这方面表现出色,它与国际化功能和框架的其他部分深度集成。如果您启用了国际化并反转 URL,它会自动获取正确的语言前缀。

然而,我们不能在这里对 URL 进行反转,因为那样会创建当前活动语言的 URL。因此,我们的语言切换链接列表实际上只会指向当前语言。相反,我们必须暂时切换到我们想要创建链接的语言,然后生成 URL。我们使用language标签来实现这一点。在language标签之间,我们传递的语言参数会被激活。因此,我们反转的 URL 正好符合我们的要求。

最后要注意的是我们反转的 URL。对于我们的应用程序,movies-list URL 是主页,因此我们反转它。对于大多数应用程序,您将做同样的事情,并反转主页 URL,以便切换语言时将用户带到指定语言的主页。

提示

有一种高级的方法可以让用户保持在当前页面并切换语言。一种方法是在每个页面上生成链接,而不是在base.html中,就像我们在这里做的一样。这样,由于您知道模板将呈现的 URL,您可以反转适当的 URL。然而,这样做的缺点是需要重复很多次。您可以在 Google 上搜索Django reverse current URL in another language,并获得一些其他建议。我还没有找到一个好的方法来使用,但您可以决定是否认为其中一个建议的选项符合您的需求。

一旦您进行了更改,通过访问http://127.0.0.1:8000/en/再次打开电影列表页面,您现在应该在顶部看到语言切换链接。参考以下截图:

让用户决定使用哪种语言

您可以尝试切换语言,看到页面上的字符串立即反映出变化。

保持用户选择

让我们尝试一个实验。将语言切换为法语,然后关闭浏览器窗口。再次打开浏览器并访问http://127.0.0.1:8000/。注意 URL 中没有语言前缀。您将被重定向到该网站的英语版本。如果一旦您选择了要使用的语言,它能够在访问时保持不变,那不是很好吗?

Django 提供了这样一个功能,您只需添加一些代码来使用它。如果您记得LocaleMiddleware确定当前请求的语言所采取的步骤列表,那么在查看 URL 前缀之后的第二步是查看会话。如果我们可以将语言选择放入会话字典中,Django 将在随后的访问中自动为用户选择正确的语言。

在哪里放置更新会话字典的代码是正确的位置?如果您考虑一下,每当用户更改其语言选择时,我们都会将其重定向到主页。因为他们在语言偏好更改时总是访问主页,让我们把我们的代码放在那里。修改MoviesListView以匹配以下代码:

class MoviesListView(ListView):
    model = MovieDetails
    template_name = 'movies_list.html'

    def get(self, request, *args, **kwargs):
        current_language = get_language()
        request.session[LANGUAGE_SESSION_KEY] = current_language

        return super(MoviesListView, self).get(request, *args, **kwargs)

您还需要导入get_languageLANGUAGE_SESSION_KEY。将其放在main/views.py的顶部:

from django.utils.translation import LANGUAGE_SESSION_KEY
from django.utils.translation import get_language

现在,再次访问网站并将语言更改为法语。接下来,关闭浏览器窗口,然后再次打开。打开http://127.0.0.1:8000/,并注意不要在 URL 中添加语言前缀,您应该被重定向到法语页面。

让我们看看这里发生了什么。在 URL 中没有语言代码的情况下,LocaleMiddleware会查看会话,看看保存语言选择的键是否有任何值。如果有,中间件会将其设置为请求的语言。我们通过首先使用get_language方法获取当前活动的语言,然后将其放入会话中,将用户的语言选择放入会话中。中间件使用的键名称存储在LANGUAGE_SESSION_KEY常量中,因此我们使用它来设置语言选择。

正确设置会话后,用户下次访问网站时,如果没有语言前缀,中间件会在会话中找到他们的选择并使用它。

翻译我们的模型

我们要看的最后一件事是如何翻译我们的模型数据。打开网站并切换到法语。您的主页应该类似于以下截图:

翻译我们的模型

您会注意到,即使静态内容——我们自己放在模板中的内容——已被翻译,电影的动态名称却没有被翻译。虽然这对一些网站来说是可以接受的,但您的模型数据也应该被翻译,以便真正国际化。Django 默认没有任何内置方法来实现这一点,但这很容易。

提示

我将要向您展示的是 Django modeltranslation库已经提供的内容。我在一个大型项目中使用过它,效果非常好,所以如果您想跳过这一部分,可以直接使用该库。但是,了解如何在没有任何外部帮助的情况下实现它也是很好的。

您可以在github.com/deschler/django-modeltranslation找到该库。

我们需要的是一种方法来为我们模型中的每个文本字段存储多种语言。您可以想出一种方案,在其中使用某种分隔符将字符串的英文和法文翻译存储在同一个字段中,然后在显示模型时将两者分开。

另一种实现相同结果的方法是为每种语言添加一个额外的字段。对于我们当前的示例,这意味着为每个要翻译的字段添加一个额外的字段。

这两种方法都有其利弊。第一种方法难以维护;随着需要翻译的语言不止一种,数据格式变得难以维护。

第二种方法添加了数据库字段,这可能并非总是可能的。此外,它需要根本性地改变数据访问方式。但是,如果您有选择,我总是建议选择结果更清晰易懂的代码,这种情况下意味着为每种语言添加额外字段。

对于我们的MovieDetails模型,这意味着为标题和描述字段各添加一个额外的字段来存储法语翻译。编辑您的main/models.py文件,使MovieDetails模型与以下代码匹配:

class MovieDetails(models.Model):
    title = models.CharField(max_length=500)
    title_fr = models.CharField(max_length=500)

    description = models.TextField()
    description_fr = models.TextField()

    stars = models.PositiveSmallIntegerField()

    def __str__(self):
        return self.title

接下来,创建并运行迁移以将这些新字段添加到数据库中:

> python manage.py makemigrations
You are trying to add a non-nullable field 'description_fr' to moviedetails without a default; we can't do that (the database needs something to populate existing rows).
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows)
 2) Quit, and let me add a default in models.py
Select an option: 1
Please enter the default value now, as valid Python
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now()
>>> ''
You are trying to add a non-nullable field 'title_fr' to moviedetails without a default; we can't do that (the database needs something to populate existing rows).
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows)
 2) Quit, and let me add a default in models.py
Select an option: 1
Please enter the default value now, as valid Python
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now()
>>> ''
Migrations for 'main':
 0002_auto_20160216_2300.py:
 - Add field description_fr to moviedetails
 - Add field title_fr to moviedetails
 - Alter field movie on moviereview

如你在前面的 CLI 会话中所看到的,当我创建迁移时,我被要求为新字段提供默认值。我只输入了空字符串。我们可以稍后从管理员中修复这个值。

最后,运行新的迁移:

> python manage.py migrate
Operations to perform:
 Apply all migrations: admin, contenttypes, main, auth, sessions
Running migrations:
 Rendering model states... DONE
 Applying main.0002_auto_20160216_2300... OK

完成后,打开管理员,查看数据库中一个对象的编辑页面。它应该看起来类似于以下截图:

翻译我们的模型

在前面的截图中,你可以看到管理员添加了两个新字段。我使用 Google 翻译翻译了这些字段的英文值,并填写了法语语言字段的值。点击保存。现在,我们的模型在英语值旁边有了法语语言数据;但如何显示它们呢?

你可以在模板代码中放置一些 if/else 条件来决定使用哪种语言字段。然而,这很快就会变得混乱。现在,我们的模型只有两个被翻译的字段,但想象一下一个有 10 个这样字段的模型。你会有多少条件?我们只讨论支持一种语言。最后,我们只需要修改两个模板,列表视图和详细视图。在一个真实的、更复杂的应用程序中,你的模型可能会被用在一百个不同的地方。if/else 方法很快就变得难以维护。

相反,我们将给我们的模型方法,以智能地返回正确的字段值,取决于当前的语言。让我们再次修改我们的 main/models.py 文件。首先,在顶部导入 get_language 方法:

from django.utils.translation import get_language

接下来,再次修改 MovieDetails 模型,并添加这三个新方法(在代码中突出显示):

class MovieDetails(models.Model):
    title = models.CharField(max_length=500)
    title_fr = models.CharField(max_length=500)

    description = models.TextField()
    description_fr = models.TextField()

    stars = models.PositiveSmallIntegerField()

 def get_title(self):
 return self._get_translated_field('title')

 def get_description(self):
 return self._get_translated_field('description')

 def _get_translated_field(self, field_name):
 original_field_name = field_name

 lang_code = get_language()

 if lang_code != 'en':
 field_name = '{}_{}'.format(field_name, lang_code)
 field_value = getattr(self, field_name)

 if field_value:
 return field_value
 else:
 return getattr(self, original_field_name)

    def __str__(self):
        return self.title

新方法中没有任何 Django 特定的内容。主要的工作是 _get_translated_field 方法。给定一个字段名,它查看当前的语言,如果语言不是英语,就将语言代码附加到字段名。然后从对象中获取新字段名的值。如果值为空,因为我们没有翻译该字段,它遵循 Django 的约定,只返回原始未翻译字段的值。

现在,修改 main/templates/movies_list.html 来使用这些新方法:

{% extends "base.html" %}
{% load i18n %}

{% block content %}
<h2>{% trans "Movies List" %}</h2>

<ul>
    {% for movie in object_list %}
<li><a href="{% url 'movie-details' pk=movie.pk %}">{{ movie.get_title }}</a> | {{ movie.stars }} {% trans "Stars" %}</li>
    {% endfor %}
</ul>
{% endblock %}

唯一的改变是,现在不再直接使用 movie.title 的值,而是使用 movie.get_title。这是这种方法的一个主要缺点。现在在你的项目中,无论何处你需要 titledescription 的值,你都必须使用 get_titleget_description 方法,而不是直接使用字段值。

注意

保存字段也是一样的。你必须弄清楚要写入哪个字段名,这取决于激活的语言。虽然这两者都不复杂,但它们确实给整个过程增加了一些不便。然而,这就是你为这种功能付出的代价。

我之前提到的 django-modeltranslation 包对这个问题有一个很好的解决方案。它使用模型中的代码来自动决定每当你访问任何字段时应该返回哪种语言。所以,你不再使用 obj.get_title(),而是直接写 obj.title,就可以得到当前激活语言的正确字段。对于你的项目,你可能需要研究一下这个。我在本章中没有使用这个,因为我想给你一个使用基本的 Django 的方法,并向你展示一种自己处理事情的可能方式,而不是依赖第三方库。

再次打开网站的法语版本,你会看到我们翻译的一个对象应该有标题的翻译版本,而其他的则只显示未翻译的版本:

翻译我们的模型

对于详情模板做同样的事情应该很简单,留给你来完成!

总结

虽然这是一个相对较小的章节,但我们看到的信息将在您的网络开发职业生涯中派上用场。虽然并非您开发的所有网站都需要被翻译成多种语言,但一些最重要的网站会需要。当您有机会参与这样的项目时,您将拥有如何创建一个真正国际化的网络应用程序的信息。

我们只是初步了解了 Django 国际化和本地化的可能性。当您开始一个需要这些功能的项目时,请务必查阅文档。

我们现在已经完成了简单的仅使用 Django 的应用程序。在学习了 Django 的基础知识之后,下一章将让我们开始开发一个更复杂的网络应用程序——一个涉及使用强大的 Elasticsearch 进行搜索的应用程序!

第六章:戴恩特里 - 电子商务网站

在前几章中,我们创建了一些稳健的网络应用。它们很简单,但具有足够的功能,可以在真实项目中使用。通过一些前端工作,我们的应用很可能可以部署在互联网上,并解决真实问题。现在是时候看一些更复杂的东西了。

我相信你已经使用过,或者至少听说过,电子商务领域的一些大名鼎鼎的公司,比如亚马逊和阿里巴巴。虽然这些网站非常复杂,但在内部,一个基本的电子商务网站是相当简单的。电子商务网站也是许多客户想要创建的东西,因此了解如何制作一个好的电子商务网站对你的职业生涯将非常有用。

一个基本的电子商务网站有一个主要目的:帮助用户从在线商店找到并购买产品。Django 可以单独用于快速构建电子商务网站,使用数据库查询来允许跨产品范围进行搜索,但这并不适合扩展。数据库被设计为快速保存和检索数据行,但它们并不是为了跨整个数据集(或子集)进行搜索而进行优化的。一旦您的网站流量开始增加,您会发现搜索速度会迅速下降。除此之外,还有一些很难用数据库构建的功能。

相反,我们将使用搜索服务器。搜索服务器非常类似于数据库。您可以给它一些数据来存储,然后以后可以检索它。它还具有专门为帮助您向应用程序添加搜索而构建的功能。您可能会想,如果搜索服务器可以像数据库一样存储我们的数据,那么我们不是可以摆脱数据库吗?我们可以,但通常不建议这样做。为什么?因为搜索服务器是为不同的用例而设计的。虽然它可以存储您的数据,但数据库提供了许多关于存储的保证,搜索服务器通常不提供。例如,一个好的数据库(如 MySQL 或 PostgreSQL)会保证,如果您尝试保存某些内容并且数据库返回成功的响应,那么在发生崩溃、停电或其他问题时,您的数据不会丢失。这被称为耐久性。搜索服务器不提供此保证,因为这不是它们的设计目的。通常最好将我们的数据保存在数据库中,并使用搜索服务器来搜索我们的数据。

在本章中,我们将使用Elasticsearch,这是最受欢迎、可能也是最易于使用的搜索服务器之一。它也是开源的,可以免费使用。所以让我们开始吧。这将是一个令人兴奋的章节!

代码包

本章的代码包含了一个基本的网络应用程序,其中包含了一个简单电子商务网站的模型和视图。现在还没有搜索,只有一个列出所有可用产品的页面。我还提供了一个数据转储,其中包含大约 1,000 个产品,这样我们的数据库就有一些可以玩耍的数据。与往常一样,下载代码包,创建一个新的虚拟环境,安装 Django,运行迁移命令,然后发出run server命令来启动开发服务器。你现在应该已经掌握了如何在没有任何指导的情况下做这些事情。

要加载测试数据,请在迁移命令之后运行以下命令:

> python manage.py loaddata main/fixtures/initial.json

这应该会用一千个样品产品填满你的数据库,并为我们提供足够的数据来玩耍。

探索 Elasticsearch

在我们将 Elasticsearch 与 Django 应用程序集成之前,让我们花点时间来探索 Elasticsearch。我们将研究如何将数据导入其中,并使用搜索功能来获取我们想要的结果。我们不会详细讨论搜索,因为我们将在构建应用程序的搜索页面时再进行研究,但我们将对 Elasticsearch 的工作原理和它对我们有用的地方进行基本概述。

首先,从www.elastic.co/downloads/elasticsearch下载最新版本的 Elasticsearch。你需要在系统上安装 Java 才能运行 Elasticsearch,所以如果你还没有安装,就去安装吧。你可以从java.com/en/download/获取 Java。下载完 Elasticsearch 后,将压缩存档中的文件提取到一个文件夹中,打开一个新的终端会话,并cd到这个文件夹。接下来,cd进入bin文件夹,并运行以下命令:

> ./elasticsearch
.
.
.
[2016-03-06 17:53:53,091][INFO ][http                     ] [Marvin Flumm] publish_address {127.0.0.1:9200}, bound_addresses {[fe80::1]:9200}, {[::1]:9200}, {127.0.0.1:9200}
[2016-03-06 17:53:53,092][INFO ][node                     ] [Marvin Flumm] started
[2016-03-06 17:53:53,121][INFO ][gateway                  ] [Marvin Flumm] recovered [0] indices into cluster_state

运行 Elasticsearch 二进制文件应该会产生大量的输出,它会与我粘贴的内容不同。然而,你应该仍然能看到输出的最后出现两条消息startedrecovered [0] indices into cluster_state。这意味着 Elasticsearch 现在正在你的系统上运行。这并不难!当然,在生产环境中运行 Elasticsearch 会有些不同,Elasticsearch 文档提供了大量关于如何为几种不同的用例部署它的信息。

在本章中,我们只涵盖了 Elasticsearch 的基础知识,因为我们的重点是查看 Django 和 Elasticsearch 之间的集成,但如果你发现自己陷入困境或需要解答一些问题,可以查看文档——它真的非常广泛和详尽。你可以在www.elastic.co/guide/en/elasticsearch/reference/current/index.html找到它。如果你真的想花时间学习 Elasticsearch,还有一本书式指南可供参考,地址是www.elastic.co/guide/en/elasticsearch/guide/current/index.html

Elasticsearch 的第一步

既然我们已经运行了 Elasticsearch,我们可以用它做些什么呢?首先,你需要知道 Elasticsearch 通过一个简单的 HTTP API 公开其功能。因此,你不需要任何特殊的库来与其通信。大多数编程语言,包括 Python,都包含了进行 HTTP 请求的手段。然而,有一些库提供了另一层对 HTTP 的抽象,并使得与 Elasticsearch 的工作更加容易。我们稍后会详细介绍这些。

现在,让我们在浏览器中打开这个 URL:

http://localhost:9200/?pretty

这应该会给你一个类似于这样的输出:

{
  "name" : "Marvin Flumm",
  "cluster_name" : "elasticsearch",
  "version" : {
    "number" : "2.2.0",
    "build_hash" : "8ff36d139e16f8720f2947ef62c8167a888992fe",
    "build_timestamp" : "2016-01-27T13:32:39Z",
    "build_snapshot" : false,
    "lucene_version" : "5.4.1"
  },
  "tagline" : "You Know, for Search"
}

虽然大部分值都会不同,但响应的结构应该大致相同。这个简单的测试让我们知道 Elasticsearch 在我们的系统上正常工作。

现在我们将快速浏览一下,我们将插入、检索和搜索一些产品。我不会详细介绍,但如果你感兴趣,你应该查看我之前提到的 Elasticsearch 文档。

注意

在本节中,你需要在你的机器上安装一个工作的 curl 命令行实用程序的副本才能执行这些步骤。它应该默认在 Linux 和 Unix 平台上,包括 Mac OS X 上可用。如果你在 Windows 上,你可以从curl.haxx.se/download.html获取一个副本。

打开一个新的终端窗口,因为我们当前的窗口中已经运行了 Elasticsearch。接下来,输入以下内容:

> curl -XPUT http://localhost:9200/daintree/products/1 -d '{"name": "Django Blueprints", "category": "Book", "price": 50, "tags": ["django", "python", "web applications"]}'
{"_index":"daintree","_type":"products","_id":"1","_version":1,"_shards":{"total":2,"successful":1,"failed":0},"created":true}      
                                                > curl -XPUT http://localhost:9200/daintree/products/2 -d '{"name": "Elasticsearch Guide", "category": "Book", "price": 100, "tags": ["elasticsearch", "java", "search"]}'
{"_index":"daintree","_type":"products","_id":"2","_version":1,"_shards":{"total":2,"successful":1,"failed":0},"created":true}

大多数 Elasticsearch API 接受 JSON 对象。在这里,我们要求 Elasticsearch 将两个文档,id 为 1 和 2,放入其存储中。这可能看起来很复杂,但让我解释一下这里发生了什么。

在数据库服务器中,你有数据库、表和行。你的数据库就像一个命名空间,所有的表都驻留在其中。表定义了你想要存储的数据的整体形状,每一行都是这些数据的一个单元。Elasticsearch 有一种稍微不同的处理数据的方式。

在数据库的位置,Elasticsearch 有一个索引。表被称为文档类型,并且存在于索引内。最后,行,或者正如 Elasticsearch 所称的那样,文档存储在文档类型内。在我们之前的例子中,我们告诉 Elasticsearch 在daintree索引中的products文档类型中PUT一个 Id 为1的文档。我们在这里没有做的一件事是定义文档结构。这是因为 Elasticsearch 不需要固定的结构。它会动态更新表的结构(文档类型),当你插入新的文档时。

让我们尝试检索我们插入的第一个文档。运行这个命令:

> curl -XGET 'http://localhost:9200/daintree/products/1?pretty=true'
{
  "_index" : "daintree",
  "_type" : "products",
  "_id" : "1",
  "_version" : 1,
  "found" : true,
  "_source" : {
    "name" : "Django Blueprints",
    "category" : "Book",
    "price" : 50,
    "tags" : [ "django", "python", "web applications" ]
  }
}

正如你可能猜到的那样,Elasticsearch 的 API 非常简单和直观。当我们想要插入一个文档时,我们使用PUT HTTP 请求。当我们想要检索一个文档时,我们使用GET HTTP 请求类型,并且我们给出了与插入文档时相同的路径。我们得到的信息比我们插入的要多一些。我们的文档在_source字段中,其余字段是 Elasticsearch 与每个文档存储的元数据。

现在我们来看看搜索的主角——搜索!让我们看看如何对标题中包含 Django 的书进行简单搜索。运行以下命令:

> curl -XGET 'http://localhost:9200/daintree/products/_search?q=name:Django&pretty'
{
  "took" : 4,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 0.19178301,
    "hits" : [ {
      "_index" : "daintree",
      "_type" : "products",
      "_id" : "1",
      "_score" : 0.19178301,
      "_source" : {
        "name" : "Django Blueprints",
        "category" : "Book",
        "price" : 50,
        "tags" : [ "django", "python", "web applications" ]
      }
    } ]
  }
}

结果是你对这次搜索的预期。Elasticsearch 只返回了一个包含 Django 一词的文档,并跳过了其他的。这被称为 lite 搜索或查询字符串搜索,因为我们的查询作为查询字符串参数的一部分发送。然而,对于具有多个参数的复杂查询,这种方法很快变得难以使用。对于这些查询,Elasticsearch 提供了完整的查询 DSL,它使用 JSON 来指定查询。让我们看看如何使用查询 DSL 进行相同的搜索:

> curl -XGET 'http://localhost:9200/daintree/products/_search?pretty' -d '{"query": {"match": {"name": "Django"}}}'
{
  "took" : 3,
  "timed_out" : false,
  "_shards" : {
    "total" : 5,
    "successful" : 5,
    "failed" : 0
  },
  "hits" : {
    "total" : 1,
    "max_score" : 0.19178301,
    "hits" : [ {
      "_index" : "daintree",
      "_type" : "products",
      "_id" : "1",
      "_score" : 0.19178301,
      "_source" : {
        "name" : "Django Blueprints",
        "category" : "Book",
        "price" : 50,
        "tags" : [ "django", "python", "web applications" ]
      }
    } ]
  }
}

这一次,我们不再传递查询参数,而是发送一个带有 GET 请求的主体。主体是我们希望执行的 JSON 查询。我不会解释查询 DSL,因为它有很多功能,非常强大,需要另一本书来正确解释它。事实上,已经有几本书完全解释了 DSL。然而,对于像这样的简单用法,你可以很容易地猜到发生了什么。如果你想了解更多细节,我再次建议查看 Elasticsearch 文档。

从 Python 中搜索

现在我们已经基本了解了如何使用 Elasticsearch 来插入和搜索我们的文档,让我们看看如何从 Python 中做同样的事情。我们可以使用 Python 中的 Elasticsearch 的 HTTP API 并查询文档,但有更好的方法。有许多库提供了对 Elasticsearch 的 HTTP API 的抽象。在底层,它们只是简单地使用 HTTP API,但它们提供的抽象使我们更容易与 Elasticsearch 通信。我们将在这里使用的库是elasticsearch_dsl。确保你的虚拟环境已激活,并使用pip安装它:

> pip install elasticsearch_dsl

接下来,让我们启动一个 Django shell,这样我们就可以玩耍并弄清楚如何使用它:

> python manage.py shell
> from elasticsearch_dsl import Search
> from elasticsearch_dsl.connections import connections
> connections.create_connection(hosts=['localhost:9200'])
<Elasticsearch([{u'host': u'localhost', u'port': 9200}])>
> Search(index='daintree').query('match', name='django').execute().to_dict()
{u'_shards': {u'failed': 0, u'successful': 5, u'total': 5},
 u'hits': {u'hits': [{u'_id': u'1',
    u'_index': u'daintree',
    u'_score': 0.19178301,
    u'_source': {u'category': u'Book',
     u'name': u'Django Blueprints',
     u'price': 50,
     u'tags': [u'django', u'python', u'web applications']},
    u'_type': u'products'}],
  u'max_score': 0.19178301,
  u'total': 1},
 u'timed_out': False,
 u'took': 2}

让我们来看看每一行。前两行只是导入库。第三行很重要。它使用create_connection方法来定义一个默认连接。这是每当我们尝试使用这个库进行搜索时使用的连接,使用默认设置。

接下来,我们执行搜索并打印结果。这是重要的部分。这一行代码做了几件事情,让我们来分解一下。首先,我们构建了一个Search对象,传入了我们之前创建的daintree索引的索引名称。由于我们没有传入自定义的 Elasticsearch 连接,它使用了我们之前定义的默认连接。

接下来,我们在Search对象上使用query方法。这种语法很简单。第一个参数是我们想要使用的查询类型的名称。就像我们使用curl一样,我们使用match查询类型。查询方法的所有其他参数都需要是关键字参数,这些参数将是查询的元素。在这里,这生成了与我们之前使用curl示例相同的查询:

{
    "query": {
        "match": {
            "name": "django"
        }
    }
}

Search对象中添加查询后,我们需要显式执行它。这是通过execute方法完成的。最后,为了查看响应,我们使用响应的辅助to_dict方法,该方法打印出 Elasticsearch 对我们的搜索做出的响应;在这种情况下,它类似于我们之前使用curl时得到的内容。

现在我们已经看到了如何搜索,下一步将是看看如何向我们的 Elasticsearch 索引添加数据。在我们这样做之前,我们需要了解 Elasticsearch 映射。

映射

我之前提到过,Elasticsearch 不需要为文档类型定义数据结构。但是,Elasticsearch 在内部会弄清楚我们插入的数据的结构。我们有能力手动定义这个结构,但不一定需要这样做。当 Elasticsearch 使用自己猜测的数据结构时,它被称为使用文档类型的动态映射。让我们看看 Elasticsearch 为我们的product文档类型猜测了什么。使用命令行,使用 curl 发出以下请求:

> curl 'http://localhost:9200/daintree/products/_mapping?pretty'
{
  "daintree" : {
    "mappings" : {
      "products" : {
        "properties" : {
          "category" : {
            "type" : "string"
          },
          "name" : {
            "type" : "string"
          },
          "price" : {
            "type" : "long"
          },
          "tags" : {
            "type" : "string"
          }
        }
      }
    }
  }
}

Elasticsearch 已经相当准确地猜测了我们的文档结构。正如您所看到的,它正确猜测了所有字段的类型。但是,如果您注意到 tags 字段的类型,您会发现它是一个字符串。如果您查看我们之前检索到的文档,您会发现 tags 字段是一个字符串数组。这是怎么回事?

嗯,在 Elasticsearch 中,数组没有任何特殊的映射。每个字段可以有一个或多个值;因此,每个字段都可以是一个数组,而无需将其映射为这样。这种情况的一个重要含义是,Elasticsearch 中的数组只能具有一种数据类型。因此,您不能有一个同时包含日期值和字符串的数组。如果您尝试插入这样的内容,Elasticsearch 将只是将日期存储为字符串。

您可能会想知道,如果 Elasticsearch 足够智能,可以弄清楚我们的数据结构,那么为什么我们要关心映射呢?嗯,我们使用的用于与Elasticsearch一起工作的库elasticsearch_dsl需要定义自定义映射才能将文档插入索引。

在将数据插入索引时,明确指定要插入的数据类型也是一个好主意。您可以在设置自定义映射时设置许多选项,例如定义字段为整数。这样,即使您插入值“123”,Elasticsearch 也会在插入文档之前将其转换为整数,并在无法转换时引发错误。这提供了数据验证。有某些类型的数据,例如日期格式与 Elasticsearch 默认使用的格式不同,只有在设置了自定义映射时才能正确索引。

定义映射

要使用elasticsearch_dsl定义映射,我们创建一个DocType子类。这类似于定义 Django 数据库模型的方式。创建一个新的main/es_docs.py文件,并键入以下代码:

from elasticsearch_dsl import DocType
from elasticsearch_dsl import Long
from elasticsearch_dsl import String

class ESProduct(DocType):
    name = String(required=True)
    description = String()
    price = Long(required=True)

    category = String(required=True)
    tags = String(multi=True)

    class Meta:
        doc_type = 'products'

这里不应该有任何意外,因为语法非常简单易懂。我喜欢在我的文档类型类的开头添加 ES,以区分 ES 文档类型类和同名的 Django 模型。请注意,我们明确指定了文档类型名称。如果我们没有这样做,elasticsearch_dsl将根据类名自动提出一个名称——ESProduct。但是,由于我们只是想为现有的文档类型定义映射,因此我们在Meta类中设置了doc_type属性。

注意我们的数据类型与我们之前在询问 Elasticsearch 关于映射时看到的数据类型是一样的。这是有原因的。您不能更改现有字段的数据类型。否则,现有文档将具有错误的数据类型,搜索将返回不一致的结果。虽然这个映射已经存在于我们的 Elasticsearch 中,让我们看看如何使用这个类来定义一个新的文档类型映射。再次打开 Django shell,输入以下内容:

> python manage.py shell
> from elasticsearch_dsl.connections import connections
> from main.es_docs import ESProduct
> connections.create_connection()
<Elasticsearch([{}])>
> ESProduct.init(index='daintree')

我们使用ESProduct.init(index='daintree')方法在 Elasticsearch 中创建映射。由于我们的映射已经存在并且完全相同,这个函数没有改变任何东西。但是,如果我们正在创建一个新的映射,这个函数将配置 Elasticsearch 与新的文档类型。

请注意,这次我们没有向connections.create_connection()方法传递任何参数,这意味着它使用了默认的主机列表,假设默认端口 9200 上运行的本地实例 Elasticsearch。由于我们的 Elasticsearch 在同一端口上本地运行,我们可以跳过create_connection()方法的主机参数。

从 Python 将文档插入 Elasticsearch

现在我们有了一个DocType子类,并且已经看到了如何创建映射,剩下的就是插入文档到 Elasticsearch。本节假设您已经加载了我提供的代码片段的 fixtures 数据。

再次打开 Django shell 并输入以下命令:

> python manage.py shell
> from elasticsearch_dsl.connections import connections
> from main.es_docs import ESProduct
> from main.models import Product
> connections.create_connection()
<Elasticsearch([{}])>
> p = Product.objects.get(pk=200)
> esp = ESProduct(meta={'id':p.pk}, name=p.name, description=p.description, price=p.price, category=p.category.name)
> for tag in p.tags.all():
>     esp.tags.append(tag.name)
>
> esp.save(index='daintree')
True

注意

注意在 for 循环体之后的空行。在 shell 中,这个空行是必需的,告诉交互式 shell 循环体已经结束,可以继续执行循环。

直到我们从数据库中获取 ID 为200的产品为止,一切都应该很正常。我只是随机选择了一个 ID,因为我知道在加载我提供的 fixtures 后,您的数据库中将存在 ID 为200的产品。

接下来,我们创建一个新的ESProduct实例,并从我们的 Django 模型中分配值。ID 字段需要使用特殊的 meta 关键字参数分配一个值,因为它是 Elasticsearch 文档的元数据,而不是文档主体的一部分。如果我们没有提供 ID,Elasticsearch 将自动生成一个随机的 ID。我们明确指定它,以便我们可以将我们的数据库模型与我们的 Elasticsearch 文档联系起来。

接下来,我们循环遍历我们的Product对象中的所有标签,并将其附加到我们的ESProduct对象中的tags字段。我们不需要将tags字段的值设置为空数组。当我们定义tags字段时,我们向构造函数传递了multi=True参数。对于elasticsearch_dsl字段,多字段具有默认的空值,即一个空列表。因此,在我们的循环中,我们确信esp.tags是一个我们可以附加的列表。

在我们使用正确的值设置了ESProduct模型实例之后,我们调用 save 方法,传递要插入的索引名称。一旦保存调用返回,Elasticsearch 将保存我们的新数据。我们可以使用curl来检索这个新文档:

> curl 'http://localhost:9200/daintree/products/_search?pretty'

在这个命令的输出中,您现在应该看到三个产品,而不是我们最初插入的两个。

将所有数据导入 Elasticsearch

我们不能一直从控制台向 Elasticsearch 插入数据。我们需要一种自动化的方法。正如我们之前所看到的,Django 管理命令是创建一个脚本的完美方式。创建将保存我们命令文件的文件夹,main/management/commands,在main/managementmain/management/commands中创建一个空的__init__.py文件,并将以下代码添加到main/management/commands/index_all_data.py中:

import elasticsearch_dsl
import elasticsearch_dsl.connections

from django.core.management import BaseCommand

from main.models import Product
from main.es_docs import ESProduct

class Command(BaseCommand):
    help = "Index all data to Elasticsearch"

    def handle(self, *args, **options):
        elasticsearch_dsl.connections.connections.create_connection()

        for product in Product.objects.all():
            esp = ESProduct(meta={'id': product.pk}, name=product.name, description=product.description,
                            price=product.price, category=product.category.name)
            for tag in product.tags.all():
                esp.tags.append(tag.name)

            esp.save(index='daintree')

这里没有什么新的。我们只是循环遍历数据库中的所有产品对象,并将它们添加到 Elasticsearch 中。运行如下:

> python manage.py index_all_data

它将成功运行而不输出任何内容,现在您应该在 Elasticsearch 中拥有所有文档。为了确认这一点,我们可以从 Elasticsearch 获取我们的daintree索引的统计信息。从您的 shell 运行以下命令:

> curl 'localhost:9200/daintree/_stats?pretty=1'

这应该输出有关daintree索引的大量数据。您需要向上滚动,您会找到总文档数。它应该类似于这样:

.
.
.
"total" : {
        "docs" : {
          "count" : 1000,
          "deleted" : 0
        },
.
.
.

如您所见,我们的所有数据现在都已被索引。接下来,我们将使用 Elasticsearch 在我们的主页上添加搜索。

添加搜索

如果您现在查看我们的主页,它应该是从我们的数据库中随机选择的 50 个产品的列表。您可以在http://127.0.0.1:8000打开它,它应该看起来类似于这样:

添加搜索

我们想要做的是在这个页面上添加一个基本的搜索表单。表单将只是一个接受搜索词的字段和执行搜索的按钮。搜索词将在我们产品列表的名称字段上执行搜索。

让我们创建一个简单的 Django 表单并将其添加到我们的页面上。创建一个新的main/forms.py文件,并添加以下代码:

from django import forms

class SearchForm(forms.Form):
    name = forms.CharField(required=False)

接下来,我们需要在主页上显示我们的搜索表单。在home.html模板中添加以下内容,就在content块的开头标签之后:

<h2>Search</h2>
<form action="" method="get">
    {{ form.as_p }}
    <input type="submit" value="Search" />
</form>

最后,我们需要修改我们的HomeView,以便它使用用户的查询来生成结果列表,而不是从数据库中获取 50 个随机的结果。更改main/view.py以匹配以下代码:

import random

from django.shortcuts import render
from django.template.response import RequestContext
from django.views.generic import View

from elasticsearch_dsl import Search
from elasticsearch_dsl.connections import connections

from main.forms import SearchForm

class HomeView(View):
    def get(self, request):
        form = SearchForm(request.GET)

        ctx = {
            "form": form
        }

        if form.is_valid():
            connections.create_connection()

            name_query = form.cleaned_data["name"]
            s = Search(index="daintree").query("match", name=name_query)
            result = s.execute()

            ctx["products"] = result.hits

        return render(request, "home.html", ctx)

首先让我们测试一下,然后我会解释这段代码的作用。在字段中输入搜索词并点击搜索按钮。由于我们的示例数据在所有字段中都有通常的Lorem Ipsum文本,因此搜索一个词如lorem。您应该会看到类似于这样的东西:

添加搜索

尝试使用一些不同的搜索词来玩耍,看看它的反应。如果您输入的内容在我们的产品列表中找不到,您应该会看到一个空页面。我们将更改它,以便用户看到一条消息,告诉他们他们的搜索查询没有结果。此外,类别名称已经消失了。这是因为product.category.name模板中使用的属性名称与我们的 Elasticsearch 文档包含的内容不同。虽然我们的 Elasticsearch 文档中大多数字段名称与我们的 Django 模型中的字段名称相同,但类别名称需要以不同的方式访问,因为它不再是外键,而是一个简单的字符串。在main/templates/home.html中,请注意以下行:

<i>Category: {{ product.category.name }}</i> <br />

将其更改为以下内容:

<i>Category: {{ product.category }}</i> <br />

我们的产品的类别名称将重新出现。

如果您进行了一些实验,您会注意到如果您将字段留空并单击搜索按钮,您将不会收到任何结果。这是因为如果您给匹配查询一个空字符串进行匹配,它会返回零结果。我们可以通过查询用户是否指定了搜索词来解决这个问题。从视图代码中删除这行:

s = Search(index="daintree").query("match", name=name_query)

将其替换为以下条件:

if name_query:
    s = Search(index="daintree").query("match", name=name_query)
else:
    s = Search(index="daintree")

这样,如果用户没有输入任何查询,我们要求 Elasticsearch 进行没有指定查询的搜索,Elasticsearch 只返回它拥有的前十个文档。这类似于如果我们使用数据库,执行Product.objects.all()[:10]

现在,让我们更改我们的模板,以便如果没有结果,用户会看到一个漂亮的消息来解释,而不是一个空页面,用户可能会认为这是我们应用程序中的一个错误。更改我们的main/templates/home.html模板中的{% for product in products %}循环,并将其替换为以下内容:

{% if products %}                                             
    {% for product in products %}
    <li>
        Name: <b>{{ product.name }}</b> <br />
        <i>Category: {{ product.category.name }}</i> <br />
        {% if product.tags.all %}
            Tags: (
            {% for tag in product.tags.all %}
                {{ tag.name }}
                {% if not forloop.last %}
                ,
                {% endif %}
            {% endfor %}
            )
        {% endif %}
    </li>
    {% endfor %}
{% else %}
    No results found. Please try another search term
{% endif %}

现在,如果您输入一个没有结果的搜索词,您应该会看到一条消息,而不是一个空页面。

现在,表单和模板代码应该很容易让您理解。最有趣的是视图代码。让我们看看发生魔术的get方法:

def get(self, request):
        form = SearchForm(request.GET)

        ctx = {
            "form": form
        }

        if form.is_valid():
            connections.create_connection()

            name_query = form.cleaned_data.get("name")
            if name_query:
                s = Search(index="daintree").query("match", name=name_query)
            else:
                s = Search(index="daintree")
            result = s.execute()

            ctx["products"] = result.hits

        return render(request, "home.html", ctx)

首几行只是用请求中的 GET 参数实例化表单。我们还将它添加到稍后传递给模板的上下文字典中。然后,我们检查表单是否有效。如果有效,我们首先使用elasticsearch_dsl库中的create_connection()方法。我们需要在这里这样做,因为如果没有这样做,我们以后将无法进行搜索。

注意

你们中的一些人可能会说,在我们的视图代码中配置 Elasticsearch 连接的方法感觉像是糟糕的代码。我同意!以后,我们会解决这个问题,不用担心。

设置好 Elasticsearch 连接后,我们检查用户是否实际输入了一些搜索词。如果他们输入了,我们就创建Search对象并将我们的查询添加到其中。我们指定我们需要match查询类型,并且我们希望获取name字段中包含用户输入的查询词的文档。如果用户没有输入任何搜索查询,我们需要将我们的搜索对象s设置为默认搜索。如前所述,我们这样做是因为如果查询词为空,Elasticsearch 会返回一个空的结果列表。

最后,我们执行搜索并将结果存储在result变量中。然后,我们从result变量的hits参数中提取结果,并将其分配给上下文字典中的products键。

最后,我们只需使用我们准备好的上下文字典来呈现模板。正如你所看到的,使用 Elasticsearch 与 Django 并不是非常复杂的事情。elasticsearch_dsl库特别使这变得非常简单。

配置管理

在前面的代码中,我们在视图代码中使用connections.create_connection()方法来设置我们的 Elasticsearch 连接。由于几个原因,这是一个不好的做法。首先,你必须记住在每个想要使用 Search 对象的视图中初始化连接。我们的示例只有一个视图,所以我们没有遇到这个问题。但是,想象一下,如果你有三个使用 Elasticsearch 的视图。现在你的create_connection()方法调用必须在这三个视图中都有,因为你永远不知道用户会以什么顺序访问网站,哪个视图会首先运行。

其次,更重要的是,如果你需要改变连接配置的方式——也许是改变 Elasticsearch 服务器的地址或设置其他连接参数——你需要在所有初始化连接的地方进行更改。

由于这些原因,将初始化外部连接的代码放在一个地方总是一个好主意。Django 为我们提供了一个很好的方法来使用AppConfig对象来做到这一点。

当 Django 启动时,它将导入settings.INSTALLED_APPS列表中列出的所有应用程序。对于每个应用程序,它将检查应用程序的__init__.py是否定义了default_app_config变量。这个变量需要是一个字符串,其中包含指向AppConfig类的子类的 Python 路径。

如果定义了default_app_config变量,Django 将使用指向的子类作为该应用程序的配置选项。如果没有,Django 将创建一个通用的AppConfig对象并使用它。

AppConfig子类有一些有趣的用途,比如为应用程序设置详细名称和获取应用程序中定义的模型。对于我们的情况,AppConfig子类可以定义一个ready()方法,Django 在首次导入应用程序时将调用该方法一次。我们可以在这里设置我们的 Elasticsearch 连接,然后只需在整个应用程序中使用Search对象,而不需要关心连接是否已配置。现在让我们来做这个。

首先,编辑main/apps.py文件并更改代码以匹配以下内容:

from __future__ import unicode_literals

from django.apps import AppConfig

from elasticsearch_dsl.connections import connections

class MainConfig(AppConfig):
    name = 'main'

    def ready(self):
        connections.create_connection()

接下来,打开main/__init__.py并添加以下行:

default_app_config = "main.apps.MainConfig"

最后,从main/views.py中删除导入:

from elasticsearch_dsl.connections import connections

HomeViewget方法中删除connections.create_connection()方法调用。

再次打开主页并进行几次搜索。您会发现即使在我们的视图中没有create_connection()方法调用,搜索也能正常工作。如果您想了解有关AppConfig的更多信息,我建议您查看 Django 文档docs.djangoproject.com/en/stable/ref/applications/

更多搜索选项

虽然我们的基本搜索很有用,但我们的用户肯定也需要一些按价格范围搜索的方法。让我们看看如何将其添加到我们的搜索表单中。我们将使用range Elasticsearch 查询类型来添加此功能。首先,让我们更改main/forms.py以添加我们需要的两个字段-最低价格和最高价格:

from django import forms

class SearchForm(forms.Form):
    name = forms.CharField(required=False)
    min_price = forms.IntegerField(required=False, label="Minimum Price")
    max_price = forms.IntegerField(required=False, label="Maximum Price")

接下来,更改HomeView代码以接受并使用我们搜索查询中的这些新字段:

class HomeView(View):
    def get(self, request):
        form = SearchForm(request.GET)

        ctx = {
            "form": form
        }

        if form.is_valid():
            name_query = form.cleaned_data.get("name")
            if name_query:
                s = Search(index="daintree").query("match", name=name_query)
            else:
                s = Search(index="daintree")

            min_price = form.cleaned_data.get("min_price")
            max_price = form.cleaned_data.get("max_price")
            if min_price is not None or max_price is not None:
                price_query = dict()

                if min_price is not None:
                    price_query["gte"] = min_price

                if max_price is not None:
                    price_query["lte"] = max_price

                s = s.query("range", price=price_query)

            result = s.execute()

            ctx["products"] = result.hits

        return render(request, "home.html", ctx)

在视图中,我们首先检查用户是否为最低价格或最高价格提供了值。如果用户没有为任何字段输入任何值,那么添加空查询就没有意义。

如果用户为两个价格范围字段中的任何一个输入了值,我们首先实例化一个空字典(稍后我们将看到为什么需要字典)。然后,根据用户在两个价格范围字段中输入数据的情况,我们向字典添加大于或等于和小于或等于子句。最后,我们添加一个范围查询,将我们创建的字典作为字段名称关键字参数的值传递,price在我们的情况下。以下是相关的代码行:

s = s.query("range", price=price_query)

我们在这里需要一个字典而不是在上一个示例中需要的原因是因为一些 Elasticsearch 查询不仅仅有一个选项。在范围查询的情况下,Elasticsearch 支持gtelte选项。但是,我们正在使用的库elasticsearch_dsl只能接受任何查询类型的一个参数,并且此参数需要作为字段名称的关键参数传递,我们的情况下是price。因此,我们创建一个字典,然后将其传递给我们的范围查询。

现在您应该在我们的主页上看到这两个字段,并且能够使用它们进行查询。您会注意到我们没有向用户提供有关产品价格的任何反馈。它没有显示在任何地方。因此,我们无法确认搜索是否实际起作用。让我们现在添加它。更改main/templates/home.html以在我们显示产品类别的下方添加这行:

<i>Price: {{ product.price }}</i> <br />

现在,如果您查看主页,它将为您显示每个产品的价格,并且您会感到它提供了更好的用户体验。此外,您现在还可以测试最低和最高价格搜索代码。到目前为止,我们的主页看起来是这样的:

更多搜索选项

到目前为止,我们在 Elasticsearch 中还没有做任何数据库无法轻松完成的事情。我们可以使用 Django ORM 构建所有这些查询,并且它将起到相同的作用。也许我们获得了一些性能优势,但在我们的应用程序操作的小规模中,这些收益几乎可以忽略不计。接下来,我们将添加一个使用仅仅数据库很难创建的功能,并且我们将看到 Elasticsearch 如何使它变得更容易。

聚合和过滤器

如果您曾经使用过亚马逊(或任何其他大型电子商务网站),您可能会记得在搜索结果的左侧,这些网站提供了一个用户可以轻松选择和浏览搜索结果的过滤器列表。这些过滤器是根据显示的结果动态生成的,选择一个过滤器会进一步缩小搜索结果。通过截图更容易理解我的意思。在亚马逊上,如果您进行搜索,您会在屏幕左侧看到类似于以下内容:

聚合和过滤器

如果您选择这里列出的任何选项,您将进一步细化您的搜索,并只看到与该选项相关的结果。它们还为用户提供了即时反馈,让他们一目了然地知道如果他们选择其中一个可用选项,他们可以期望看到多少结果。

我们想在我们的应用程序中实现类似的功能。Elasticsearch 提供了一个名为聚合的功能来帮助我们做到这一点。让我们先看看什么是聚合。

聚合提供了一种获取有关我们搜索结果的统计信息的方法。有两种类型的聚合可用于获取有关搜索结果的两种不同类型的数据:bucket 聚合和度量聚合。

Bucket 聚合类似于GROUP BY SQL查询。它们根据某些维度将文档聚合到组或桶中,并为这些组中的每个计算一些指标。最简单的聚合是terms聚合。您给它一个字段名,对于该字段的每个唯一值,Elasticsearch 返回包含该值的字段的文档计数。

例如,假设您的索引中有五个文档:

{"name": "Book 1", "category": "web"}
{"name": "Book 2", "category": "django"}
{"name": "Book 3", "category": "java"}
{"name": "Book 4", "category": "web"}
{"name": "Book 5", "category": "django"}

如果我们根据类别字段对这些数据运行 terms 聚合,我们将得到返回结果,这些结果给出了每个类别中书籍的数量:web 中有两本,Django 中有两本,Java 中有一本。

首先,我们将为产品列表中的类别添加聚合,并允许用户根据这些类别筛选他们的搜索。

类别聚合

第一步是向我们的搜索对象添加一个聚合,并将来自此聚合的结果传递给我们的模板。更改main/views.py中的HomeView以匹配以下代码:

class HomeView(View):
    def get(self, request):
        form = SearchForm(request.GET)

        ctx = {
            "form": form
        }

        if form.is_valid():
            name_query = form.cleaned_data.get("name")
            if name_query:
                s = Search(index="daintree").query("match", name=name_query)
            else:
                s = Search(index="daintree")

            min_price = form.cleaned_data.get("min_price")
            max_price = form.cleaned_data.get("max_price")
            if min_price is not None or max_price is not None:
                price_query = dict()

                if min_price is not None:
                    price_query["gte"] = min_price

                if max_price is not None:
                    price_query["lte"] = max_price

                s = s.query("range", price=price_query)

            # Add aggregations
 s.aggs.bucket("categories", "terms", field="category")

            result = s.execute()

            ctx["products"] = result.hits
 ctx["aggregations"] = result.aggregations

        return render(request, "home.html", ctx)

我已经突出显示了新代码,只有两行。第一行如下:

s.aggs.bucket("categories", "terms", field="category")

这一行向我们的搜索对象添加了一个 bucket 类型的聚合。在 Elasticsearch 中,每个聚合都需要一个名称,并且聚合结果与响应中的此名称相关联。我们给我们的聚合起名为categories。方法的下一个参数是我们想要的聚合类型。因为我们想要计算每个不同类别术语的文档数量,所以我们使用terms聚合。正如我们将在后面看到的,Elasticsearch 有许多不同的聚合类型,几乎可以满足您能想到的所有用例。在第二个参数之后,所有关键字参数都是聚合定义的一部分。每种类型的聚合需要不同的参数。terms聚合只需要要聚合的字段的名称,这在我们的文档中是category

下一行如下:

ctx["aggregations"] = result.aggregations

这一行将我们的聚合结果添加到我们的模板上下文中,我们将在模板中使用它进行渲染。聚合结果的格式类似于这样:

{
    "categories": {
        "buckets": [
            {
                "key": "CATEGORY 1",
                "doc_count": 10
            },

            {
                "key": "CATEGORY 2",
                "doc_count": 50
            },

            .
            .
            .
        ]
    }
}

顶层字典包含我们添加的每个聚合的键,与我们添加的名称相同。在我们的情况下,名称是categories。每个键的值是该聚合的结果。对于 bucket 聚合,就像我们使用的terms一样,结果是一个桶的列表。每个桶都有一个键,这是一个不同的类别名称,以及具有该类别的文档数量。

让我们首先在模板中显示这些数据。更改main/templates/home.html以匹配以下代码:

{% extends "base.html" %}

{% block content %}
<h2>Search</h2>
<form action="" method="get">
    {{ form.as_p }}
    <input type="submit" value="Search" />
</form>

{% if aggregations.categories.buckets %}
<h2>Categories</h2>
<ul>
{% for bucket in aggregations.categories.buckets %}
 <li>{{ bucket.key }} ({{ bucket.doc_count }})</li>
{% endfor %}
</ul>
{% endif %}

<ul>
    {% if products %}
        {% for product in products %}
        <li>
            Name: <b>{{ product.name }}</b> <br />
            <i>Category: {{ product.category }}</i> <br />
            <i>Price: {{ product.price }}</i> <br />
            {% if product.tags.all %}
                Tags: (
                {% for tag in product.tags.all %}
                    {{ tag.name }}
                    {% if not forloop.last %}
                    ,
                    {% endif %}
                {% endfor %}
                )
            {% endif %}
        </li>
        {% endfor %}
    {% else %}
        No results found. Please try another search term
    {% endif %}
</ul>
{% endblock %}

再次,我已经突出显示了新代码。看到了前面输出的格式,这个新代码对你来说应该很简单。我们只是循环遍历每个桶项,并在这里显示类别的名称和具有该类别的文档数量。

让我们来看看结果。在浏览器中打开主页并进行搜索;您应该会看到类似于这样的结果:

类别聚合

现在我们有一个显示的类别列表。但等等,这是什么?如果你仔细看,你会发现没有一个类别名称是有意义的(除了它们是拉丁文)。我们看到的类别都不符合我们的产品类别。为什么呢?

这里发生的是 Elasticsearch 获取了我们的类别列表,将它们分解成单个单词,然后进行了聚合。例如,如果三个产品的类别是web developmentdjango developmentweb applications,这个聚合将给我们以下结果:

  • 网络(2)

  • 开发(2)

  • django(1)

  • 应用程序(1)

然而,这对我们的用例没有用。我们的类别名称应该被视为一个单位,而不是分解成单个单词。此外,当我们索引数据时,我们从未要求 Elasticsearch 做任何这样的事情。那么发生了什么?要理解这一点,我们需要了解 Elasticsearch 如何处理文本数据。

全文搜索和分析

Elasticsearch 基于 Lucene,这是一个非常强大的库,用于创建全文搜索应用程序。全文搜索有点像在自己的文档上使用 Google。您一生中可能已经使用过类似 Microsoft Word 这样的文字处理器中的查找功能,或者在网页上几次。这种搜索方法称为精确匹配。例如,您有一段文本,就像从《一千零一夜故事》的序言中摘取的这段:

《一千零一夜故事》中的女主人公沙赫拉萨德(Scheherazadè)与世界上伟大的讲故事者一样,就像佩内洛普(Penelope)与织工一样。拖延是她艺术的基础;尽管她完成的任务是辉煌而令人难忘的,但她的发明量远远超过了质量——在长时间的表演中,本来可以更简短地完成的任务——这使她成为戏剧性兴趣的人物。

如果您使用精确匹配搜索术语memorable quantity,它将不会显示任何结果。这是因为在这段文本中没有找到确切的术语memorable quantity

然而,全文搜索会返回这段文本,因为即使确切术语memorable quantity在文本中没有出现,但memorablequantity这两个词确实出现在文本中。即使搜索memorable Django,这段文本仍然会返回,因为memorable这个词仍然出现在文本中,即使Django没有。这就是大多数用户期望在网络上进行搜索的方式,特别是在电子商务网站上。

如果您在我们的网站上搜索Django web development图书,但我们没有确切标题的书,但我们有一本名为Django Blueprints的书,用户会期望在搜索结果中看到它。

这就是当您使用全文搜索时 Elasticsearch 所做的。它会将您的搜索词分解成单词,然后使用这些单词来查找包含这些词的搜索结果。但是,为了做到这一点,Elasticsearch 还需要在索引文档时分解您的文档,以便以后可以更快地进行搜索。这个过程称为分析文档,并且默认情况下对所有字符串字段在索引时间进行。

这就是为什么当我们为我们的类别字段获取聚合时,我们得到的是单个单词,而不是结果中完整的类别名称。虽然全文搜索在大多数搜索情况下非常有用,例如我们拥有的名称查询搜索,但在类别名称等情况下,它实际上给我们带来了意想不到的结果。

正如我之前提到的,导致 Elasticsearch 分解(这个技术术语称为标记化)的分析过程是在索引时间完成的。为了确保我们的类别名称不被分析,我们需要更改我们的ESProduct DocType子类并重新索引所有数据。

首先,让我们在main/es_docs.py中更改我们的ESProduct类。注意以下行:

category = String(required=True)

将其更改如下:

category = String(required=True, index="not_analyzed")

然而,如果我们现在尝试更新映射,我们将遇到问题。Elasticsearch 只能为字段创建映射,而不能更新它们。这是因为如果允许在索引中有一些数据之后更改字段的映射,旧数据可能再也不符合新的映射了。

要删除我们现有的 Elasticsearch 索引,请在命令行中运行以下命令:

> curl -XDELETE 'localhost:9200/daintree'
{"acknowledged":true}

接下来,我们想要创建我们的新索引并添加ESProduct映射。我们可以像以前一样从 Python shell 中创建索引。相反,让我们修改我们的index_all_data命令,在运行时自动创建索引。更改main/management/commands/index_all_data.py中的代码以匹配以下内容:

import elasticsearch_dsl
import elasticsearch_dsl.connections

from django.core.management import BaseCommand

from main.models import Product
from main.es_docs import ESProduct

class Command(BaseCommand):
    help = "Index all data to Elasticsearch"

    def handle(self, *args, **options):
        elasticsearch_dsl.connections.connections.create_connection()
        ESProduct.init(index='daintree')

        for product in Product.objects.all():
            esp = ESProduct(meta={'id': product.pk}, name=product.name, description=product.description,
                            price=product.price, category=product.category.name)
            for tag in product.tags.all():
                esp.tags.append(tag.name)

            esp.save(index='daintree')

我已经突出显示了更改,只是添加了一行调用ESProduct.init方法。最后,让我们运行我们的命令:

> python manage.py index_all_data

运行命令后,让我们确保我们的新映射被正确插入。让我们通过在命令行中运行以下命令来查看 Elasticsearch 现在有什么映射:

> curl "localhost:9200/_mapping?pretty=1"
{
  "daintree" : {
    "mappings" : {
      "products" : {
        "properties" : {
          "category" : {
            "type" : "string",
            "index" : "not_analyzed"
          },
          "description" : {
            "type" : "string"
          },
          "name" : {
            "type" : "string"
          },
          "price" : {
            "type" : "long"
          },
          "tags" : {
            "type" : "string"
          }
        }
      }
    }
  }
}

如果您查看category字段的映射,现在它不再被分析。让我们再试一次最后的搜索,看看这是否解决了我们的类别聚合问题。现在你应该看到类似于这样的东西:

全文搜索和分析

如您所见,我们不再将我们的类别名称拆分为单独的单词。相反,我们得到了一个唯一类别名称的列表,这正是我们从一开始想要的。现在让我们让我们的用户能够选择其中一个类别,将他们的搜索限制为所选的类别。

使用聚合进行搜索

我们希望用户交互是这样的:用户打开搜索页面或进行搜索并看到类别链接列表。然后用户点击其中一个链接,只看到来自这些类别的产品,并应用用户之前的搜索。因此,如果用户搜索价格在 100 到 200 之间的产品,然后点击一个类别链接,新的搜索应该只显示来自该类别的产品,同时仍然应用价格过滤。

为了实现这一点,我们需要一种方法来创建类别链接,以便保留当前搜索。我们可以将类别作为另一个 GET 参数传递给HomeView。因此,我们需要获取当前的 GET 参数(构成当前搜索)并将我们的类别名称添加到其末尾作为另一个参数。

不幸的是,Django 没有内置的方法来实现这一点。有许多解决方案。您可以构建一个自定义模板标签,将参数添加到当前 URL 的末尾,或者您可以在模板中使用一些 if 条件将类别名称添加到 URL 的末尾。还有另一种方法,我更喜欢,因为它更清晰。我们将在 Python 代码中生成 URL,而不是在模板中生成 URL,我们有很多实用程序来处理 URL GET 参数,只需将类别列表与 URL 一起传递到模板中显示。

让我们更改main/views.py的代码以匹配以下内容:

import random

from django.core.urlresolvers import reverse
from django.shortcuts import render
from django.template.response import RequestContext
from django.views.generic import View
from elasticsearch_dsl import Search
from elasticsearch_dsl.connections import connections

from main.forms import SearchForm

class HomeView(View):
    def get(self, request):
        form = SearchForm(request.GET)

        ctx = {
            "form": form
        }

        if form.is_valid():
            name_query = form.cleaned_data.get("name")
            if name_query:
                s = Search(index="daintree").query("match", name=name_query)
            else:
                s = Search(index="daintree")

            min_price = form.cleaned_data.get("min_price")
            max_price = form.cleaned_data.get("max_price")
            if min_price is not None or max_price is not None:
                price_query = dict()

                if min_price is not None:
                    price_query["gte"] = min_price

                if max_price is not None:
                    price_query["lte"] = max_price

                s = s.query("range", price=price_query)

            # Add aggregations
            s.aggs.bucket("categories", "terms", field="category")

            if request.GET.get("category"):
 s = s.query("match", category=request.GET["category"])

            result = s.execute()

            ctx["products"] = result.hits

            category_aggregations = list()
 for bucket in result.aggregations.categories.buckets:
 category_name = bucket.key
 doc_count = bucket.doc_count

 category_url_params = request.GET.copy()
 category_url_params["category"] = category_name
 category_url = "{}?{}".format(reverse("home"), category_url_params.urlencode())

 category_aggregations.append({
 "name": category_name,
 "doc_count": doc_count,
 "url": category_url
 })

 ctx["category_aggs"] = category_aggregations

        return render(request, "home.html", ctx)

我已经突出显示了我们添加的新代码。首先,我们从 Django 导入了reverse方法。接下来,在进行搜索查询时,我们检查用户是否选择了一个类别(通过查看类别查询参数)。如果用户确实选择了某些内容,我们将其添加到我们的搜索中作为对类别字段的match查询。

更重要的部分接下来,我们要为类别链接构建 URL。我们循环遍历聚合结果中的每个桶。对于每个桶,我们提取类别名称和文档计数。然后,我们复制请求的 GET 参数。我们复制是因为我们想要通过添加我们的类别名称来修改参数,但request.GET dict是不可变的,不能被改变。如果你尝试改变request.GET中的内容,你会得到一个异常。所以我们复制一份,并在其中添加当前桶的类别名称。

接下来,我们为使用该类别进行搜索创建一个 URL。首先,我们要反转主页的 URL,然后添加查询参数——我们通过复制当前请求参数并添加我们的类别名称而得到的参数。

最后,我们将所有这些信息添加到一个列表中,然后传递给模板。我们的模板也需要改变以适应这种新的数据格式。以下是main/templates/home.html的新代码:

{% extends "base.html" %}

{% block content %}
<h2>Search</h2>
<form action="" method="get">
    {{ form.as_p }}
    <input type="submit" value="Search" />
</form>

{% if category_aggs %}
<h2>Categories</h2>
<ul>
{% for agg in category_aggs %}
 <li>
 <a href="{{ agg.url }}">{{ agg.name }}</a> ({{ agg.doc_count }})
 </li>
{% endfor %}
</ul>
{% endif %}

<h2>Results</h2>
<ul>
    {% if products %}
        {% for product in products %}
        <li>
            Name: <b>{{ product.name }}</b> <br />
            <i>Category: {{ product.category }}</i> <br />
            <i>Price: {{ product.price }}</i> <br />
            {% if product.tags.all %}
                Tags: (
                {% for tag in product.tags.all %}
                    {{ tag.name }}
                    {% if not forloop.last %}
                    ,
                    {% endif %}
                {% endfor %}
                )
            {% endif %}
        </li>
        {% endfor %}
    {% else %}
        No results found. Please try another search term
    {% endif %}
</ul>
{% endblock %}

我已经突出显示了代码更改。鉴于我们现在已经格式化了我们的类别过滤器,我们所做的应该是清楚的。一个不相关的小改变是添加了<h2> Results </h2>。那是因为我之前忘记添加它,后来才意识到聚合过滤器和结果之间没有分隔符。所以我在这里添加了它。

你应该尝试玩一下类别过滤器。选择其中一个显示的类别,你应该只能看到该类别的产品。你的屏幕应该看起来类似于这样:

使用聚合进行搜索

我想要添加的最后一个功能是取消类别过滤器的方法。如果你仔细想想,我们只需要删除类别查询参数来取消类别过滤器,这样我们就会得到只包括搜索表单参数的原始查询。这样做非常简单,让我们来看一下。

main/views.py中,在get() HomeView方法的render()调用之前,添加以下代码:

if "category" in request.GET:
    remove_category_search_params = request.GET.copy()
    del remove_category_search_params["category"]
    remove_category_url = "{}?{}".format(reverse("home"), remove_category_search_params.urlencode())
    ctx["remove_category_url"] = remove_category_url

main/templates/home.html中,在类别ul标签结束后添加以下内容:

{% if remove_category_url %}
<a href="{{ remove_category_url }}">Remove Category Filter</a>
{% endif %}

就是这样。现在尝试使用搜索,选择一个类别。你应该会看到一个删除类别过滤器链接,你可以用它来删除任何类别搜索条件。它应该看起来类似于这样:

使用聚合进行搜索

你可能已经注意到的一件事是,当选择了任何类别后,我们就不再看到其他类别了。这是因为 Elasticsearch 的聚合默认是限定于主查询的。因此,任何术语聚合只会计算已经存在于主查询结果中的文档。当搜索包括类别查询时,我们拥有的类别聚合只能找到所选类别中的文档。要改变这种行为并显示所有类别,无论用户选择了什么,超出了本书的范围。然而,我会指引你正确的方向,通过一些工作,你应该能够自己实现这一点。看一下www.elastic.co/guide/en/elasticsearch/guide/current/_scoping_aggregations.html

摘要

哇!这是一个相当深奥的章节。我们看了很多东西,获得了很多知识。特别是在涉及到 Elasticsearch 的时候,我们很快地从 0 到 60,在前 10 页内就设置好并运行了搜索。

然而,我相信到现在你应该能够轻松掌握复杂的概念。我们首先看了如何在本地系统上启动 Elasticsearch。然后我们看了如何使用它的 HTTP API 轻松地与 Elasticsearch 进行交互。我们了解了 Elasticsearch 的基本概念,然后向我们的第一个索引插入了一些文档。

然后我们使用 HTTP API 来搜索这些文档并获取结果。一旦我们了解了 Elasticsearch 是什么以及它是如何工作的,我们就开始将其与我们的 Django 应用程序集成。

我们再次看到了使用 Django shell 快速测试库并找出如何处理各种任务的能力,就像我们在使用elasticsearch_dsl库对文档进行索引和搜索时所做的那样。然后我们创建了一个 Django 命令,基本上只是复制了我们之前在 Django shell 中所做的事情。

然后我们真正开始处理我们的搜索视图。我们将主页更改为使用 Elasticsearch 而不是数据库来显示我们的产品,并添加了对名称字段的基本搜索。接下来,我们看了如何从一个中央位置AppConfig管理我们应用的配置选项。我们还学习了如何使用elasticsearch_dsl来执行更复杂的查询,比如范围查询。

最后,我们了解了 Elasticsearch 聚合是什么,以及我们如何将它们整合到我们的应用程序中,为用户提供出色的搜索体验。总的来说,这是一个复杂的章节,完成后,你现在应该有信心去处理更大型和功能丰富的应用程序。

第七章:表单梅森-你自己的猴子

在我们学习 Django 的旅程中,我们走了很远的路。我们从小步开始,学习设置数据库、基本视图和模板。然后我们转向更困难的东西,比如管理命令和 Django shell。这是我们旅程的最后一章,在这里我们将使用我们所学到的所有知识,并用它来创建迄今为止最复杂的应用程序之一。以一个轰轰烈烈的方式结束事情总是很有趣,这就是我们将在这里做的事情!

您可能听说过 SurveyMonkey(www.surveymonkey.com)或 Wufoo(www.wufoo.com)。如果没有,这些都是允许您创建自定义表单以从受众那里收集数据的网络应用程序。您可以使用这些网站上提供的控制面板设置表单,定义所有字段以及它们应该如何验证,并配置一些基本的东西,比如表单应该看起来像什么,应该使用什么主题等等。配置表单后,您将获得一个显示该表单的网页链接。

然后,您将此链接发送给预期的受众,他们填写表格,他们的回答将被保存。作为您的控制面板的一部分,您还可以获得一个网页,您可以在其中查看和过滤这些回答。

这些服务使得即使是最不懂计算机的人也能轻松创建在线调查,并从广泛的受众那里收集任何目的的数据。这似乎是一个很酷的服务,我们将在本章中复制它。这也将是我们创建的最复杂的应用程序,因为它要求我们深入了解 Django,并了解 Django 表单在幕后是如何工作的。在某种意义上,您将了解使 Django 运行的魔法。令人兴奋,不是吗!

代码包

本章没有代码包,因为我们即将要做的几乎所有东西都是新的。此外,在本章中我们也将进行大量的探索。这既有趣,又向您展示了解决一个艰巨项目的许多方法之一。

由于没有代码包可供使用,您需要自己启动项目。首先,像往常一样,为新项目创建一个新的虚拟环境。接下来,在激活环境的情况下,安装 Django 并运行以下命令:

> django-admin.py startproject formmason

这将创建一个带有我们的新 Django 项目的formmason文件夹,准备让我们开始工作。使用cd命令进入此文件夹,并创建一个新的应用程序,我们将使用它来保存我们的视图、模型等等:

> python manage.py startapp main

最后,在formmason/settings.py中将main应用程序添加到我们的INSTALLED_APPS列表中。完成后,我们准备开始了!

查看 Django 表单

由于我们的目标是允许用户根据数据库中存储的参数创建动态表单,因此一个很好的起点是看看 Django 表单在幕后是如何工作的,以及我们有哪些选项可以自定义它。首先,让我们创建一个基本表单。创建一个新的main/forms.py文件,并将以下代码添加到其中:

from django import forms

class SampleForm(forms.Form):
    name = forms.CharField()
    age = forms.IntegerField()
    address = forms.CharField(required=False)
    gender = forms.ChoiceField(choices=(('M', 'Male'), ('F', 'Female')))

这是一个非常基本的表单。但是,它有各种我们可以查看的表单字段。让我们在 shell 中玩一下,您可以按照以下方式启动:

> python manage.py shell

提示

我通常喜欢安装另一个叫做 ipython 的包。当您安装了 ipython 并启动 Django shell 时,您将获得增强版的基本 shell,具有许多很酷的功能,如自动完成和更好看的界面。我总是在任何 Django 项目中安装它,因为我几乎总是在项目开始时使用 shell 来玩耍。我强烈建议您在开始新的 Django 项目时也安装它。

在 shell 中导入我们的表单,如下所示:

> from main.forms import SampleForm

在同一个 shell 中,输入以下内容:

> form = SampleForm()
> form.fields
OrderedDict([('name', <django.forms.fields.CharField at 0x10fc79510>),
             ('age', <django.forms.fields.IntegerField at 0x10fc79490>),
             ('address', <django.forms.fields.CharField at 0x10fc79090>),
             ('gender', <django.forms.fields.ChoiceField at 0x10fc792d0>)])

第一行代码只是创建了我们表单类的一个实例。第二行才是开始有趣的地方。不过,我先解释一下OrderedDict数据结构。

顾名思义,OrderedDict是一个保持其元素插入顺序的字典。Python 中的普通字典没有固定的顺序。如果你向一个字典插入三个元素,键为ABC,然后你使用字典实例的keys()方法要求返回键,你得到它们的顺序是不确定的,这就是为什么普通内置字典被称为无序的。

相比之下,OrderedDict中的键是有序的,它来自collections库(Python 标准库的一部分)。因此,如果你使用keys()items()方法迭代键,你总是会按照插入的顺序得到它们。

回到输出,你会发现打印的字典具有与我们创建SampleForm类时使用的字段名称相同的键。这些键的值是我们在表单中使用的Field子类(CharFieldIntegerField等)。

让我们试一下。在 shell 中,输入以下内容并查看输出:

> form.name
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
<ipython-input-16-1174e5d9164a> in <module>()
----> 1 form.name

AttributeError: 'SampleForm' object has no attribute 'name'

看起来我们在SampleForm类上定义的name属性不再存在了。奇怪,对吧?

到目前为止,你已经了解了关于 Django 表单的两个事实。首先,表单实例的fields属性包含字段名称到Field类的映射。其次,在创建SampleForm类时定义的字段属性在实例上是不可访问的。

将这两个事实结合起来,我们可以得出一些关于 Django 在创建SampleForm类的实例时的做法。它会删除字段属性并将它们添加到fields字典中。创建表单实例后,查找定义在其上的字段的唯一方法是使用fields字典属性。

因此,如果我们想在类定义后向表单添加另一个字段,而且无法更改类本身的代码,我们可以将Field子类的实例添加到表单实例的fields属性中。让我们试一试,看看这是否有效。

注意

当我说 Django 在创建SampleForm类的实例时会删除字段属性时,我有点撒谎。实际上,SampleForm类也删除了它的字段属性。如果你在 shell 中输入SampleForm.name,你会得到类似的AttributeError。然而,这些信息与我们当前的任务无关,而且发生这种情况的原因很复杂,所以我不会在本书中详细介绍。如果你想了解所有细节,请查看django.forms.Form类的源代码。

向 SampleForm 实例添加额外字段

我们现在将进行一个实验。我们将使用一些有效的测试数据创建SampleForm的实例,然后我们将在实例的fields属性上添加另一个字段。然后我们将调用is_valid()方法,看看我们的表单实例在验证先前提供的数据时是否考虑了动态添加的字段。让我们看看会发生什么。在 Django shell 中,输入以下命令:

> from main.forms import SampleForm
> test_data = {'name': 'Jibran', 'age': 27, 'gender': 'M'}
> form = SampleForm(test_data)
> from django import forms
> form.fields['country'] = forms.CharField()
> form.is_valid()
False
> form.errors
{'country': [u'This field is required.']}

不错!看起来我们的想法奏效了。尽管我们在SampleForm实例化并提供数据后添加了country字段,但表单验证确实考虑了我们的新字段。由于country字段不是test_data字典的一部分,并且该字段是必需的,表单验证失败,并且errors列表包含了适当的错误。

现在我们有了一种实现目标的技术,让我们创建一些视图和模板来呈现动态表单。

生成动态表单

在我们开始编写代码之前,我们必须决定一个数据格式来指定我们动态表单的结构。JSON是当前网络上最受欢迎的,如果不是最受欢迎的数据存储和交换格式之一。你可能已经知道 JSON 是什么,它是如何工作的,以及它是什么样子。不过,如果你不知道,这里有一个快速介绍。

JSON 代表 JavaScript 对象表示法。它使用与 JavaScript 用于声明对象相同的格式。对于我们 Python 程序员来说,最大的好处是它几乎与 Python 中的字典声明语法完全相同。假设我们想要存储有关用户的一些详细信息。在 JSON 中,这些信息看起来是这样的:

{
    "name": "Jibran",
    "age": 27,
    "country": "Pakistan"
}

正如你所看到的,我们甚至可以将其复制粘贴到我们的 Python 代码中,它将是一个有效的字典定义,这使得我们很容易使用。

JSON 相对于其他数据存储/交换格式有许多优点:

  • 它只是文本,所以不需要专门的工具来查看和解析它。

  • 解析 JSON 非常容易,大多数语言都有用于此的库。Python 自带了一个标准库来解析 JSON。

  • 这个格式很容易手写。

  • 它可以以多种方式存储,而无需进行任何特殊处理。我们稍后将利用这一事实将 JSON 存储在我们的数据库中作为一个简单的文本字段。

现在你已经了解了一些关于 JSON 的知识,让我们看看如何使用它。我们将存储有关如何构建动态表单的信息。然后我们将使用 Python 标准库json将 JSON 字符串转换为 Python 字典,然后我们将迭代并创建我们的表单。

注意

在本节中,我们将硬编码我们用来生成表单的 JSON。稍后,我们将允许用户从我们为他们制作的控制面板中创建和存储 JSON 到数据库中。

我们将看看如何在 JSON 中定义表单字段,并从此模式生成动态表单。我们还将创建一个 HTML 表单来渲染我们的表单,以便我们可以测试一切是否符合预期。

让我们看看一个用于收集有关人们基本人口统计信息的简单表单的格式:

{
    "name": "string",
    "age": "number",
    "city": "string",
    "country": "string",
    "time_lived_in_current_city": "string"
}

这是我们在本节中将使用的示例 JSON,用于生成和显示我们的动态表单。这个结构是一个简单的带有字符串值的字典。我们必须编写代码来解析这个字典,并根据这些信息创建一个 Django 表单。

从 JSON 生成表单

我们需要做的第一件事是将我们的视图获取的 JSON 数据转换为 Python 字典,以便我们可以对其进行迭代。Python 自带了json模块作为标准库的一部分,它将为我们处理解析。你可以在docs.python.org/3/library/json.html阅读它的文档。不过,它非常简单易用,所以让我们直接开始吧。

打开main/views.py并添加以下代码:

import json

from django import forms
from django.views.generic import FormView

class CustomFormView(FormView):
    template_name = "custom_form.html"

    def get_form(self):
        form_structure_json = """{
"name": "string",
"age": "number",
"city": "string",
"country": "string",
"time_lived_in_current_city": "string"
}"""
        form_structure = json.loads(form_structure_json)

        custom_form = forms.Form(**self.get_form_kwargs())
        for key, value in form_structure.items():
            field_class = self.get_field_class_from_type(value)
            if field_class is not None:
                custom_form.fields[key] = field_class()
            else:
                raise TypeError("Invalid field type {}".format(value))

        return custom_form

    def get_field_class_from_type(self, value_type):
        if value_type == "string":
            return forms.CharField
        elif value_type == "number":
            return forms.IntegerField
        else:
            return None

这里有几件事情需要解释。不过,让我们先让视图工作起来,看看它是否符合我们的要求。然后我会解释这段代码的作用。接下来,让我们创建main/templates/custom_form.html模板。你需要先在main/文件夹下创建templates文件夹。将这段代码放在那里:

<html>
<head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8" />

    <title>Custom Form Demo</title>
</head>

<body>
    <h1>Custom Form</h1>
    <form action="" method="post">{% csrf_token %}
        {{ form.as_p }}
        <input type="submit" value="Submit" />
    </form>
</body>
</html>

最后,在formmason/urls.py中放入以下代码以包含我们的新视图:

from django.conf.urls import url

from main.views import CustomFormView

urlpatterns = [
    url(r'^$', CustomFormView.as_view(), name='custom-form'),
]

就这些了。要测试它,使用runserver命令运行开发服务器,然后在任何浏览器中打开http://127.0.0.1:8000。你应该会看到一个类似这样的屏幕:

从 JSON 生成表单

尝试提交一个不完整的表单,页面应该呈现出正确的错误消息。尝试完成表单并提交。现在你应该看到一个错误页面,显示ImproperlyConfigured: No URL to redirect to. Provide a success_url错误。这个错误来自我们使用的FormView通用视图。当表单有效时,它期望success_url被定义,以便用户可以被重定向到它。我们没有任何地方可以重定向用户,所以我们现在会忽略这个。让我们看看代码,看看我们在这里做了什么。

提示

这个例子也突出了通用视图的强大之处。通过重新定义一个函数get_form,我们能够使用FormView的所有功能,而这个表单是动态生成的。由于 Django 通用视图是以模块化为目的设计的,视图的其余部分并不关心表单是如何创建的,只要get_form方法返回一个有效的表单。

我们的CustomFormViewget_form方法中发生了大部分操作,所以让我们从那里开始。代码的第一行定义了我们将用来生成表单的 JSON 数据结构。正如我之前提到的,这些数据最终将来自数据库后端;然而,出于测试目的,我们现在将其硬编码。接下来,我们使用json.loads方法将 JSON 字符串转换为 Python 对象。这个转换过程非常直观。我们拥有的 JSON 字符串是一个带有nameagecitycountrytime_lived_in_current_city键的字典。我们从json.loads得到的 Python 对象也是一个带有相同键/值对的字典。你也可以在你的 JSON 字符串中有数组,并且加载它会给你一个 Python 列表。JSON 支持嵌套对象,所以你可以有一个字典数组或一个值为数组的字典。

接下来,我们创建了基础django.forms.Form类的一个实例。正如我们之前的实验中看到的,我们可以取一个 Django 表单的实例并向其添加字段。我们不是创建一个空的表单类并使用它,而是直接使用 Django 的基础Form类。对于表单类的构造函数,我们传递了self.get_form_kwargs()接收到的任何内容。这个方法是 Django FormView类的一部分,根据请求类型创建正确的关键字参数传递给Form类。例如,如果请求是 POST/PUT 请求,get_form_kwargs()将返回一个包含data关键字参数的字典,以便我们可以使用表单来验证并对数据采取进一步的操作。

接下来发生有趣的事情,当我们循环遍历我们自定义字段数据的项目列表时。我们从 JSON 加载的数据上使用items()方法返回一个列表,如下所示:

[('name', 'string'), ('age', 'number'), ('city', 'string'), ('country', 'string'), ('time_lived_in_city', 'string')]

我们循环遍历这个列表,将这些项目对分配给for循环中的变量keyvalue。然后我们将这些值传递给get_field_class_from_type方法,该方法决定使用哪个可用的表单字段类来处理传递的数据类型。该方法的代码是一个简单的if/else,如果传递了无效的类型,则返回一个字段类或None

我们使用这个方法的返回值,这是一个类,并将其实例分配给表单的fields属性字典。请注意,我们分配的是字段类的实例,而不是类本身。字段的名称是我们的 JSON 字典中的键。我们还进行了一些基本的错误处理,如果我们在 JSON 数据字典中找不到匹配的字段类,则引发TypeError

最后,我们返回我们定制的表单类。从那里开始,Django FormView接管并且要么呈现表单,如果需要的话带有错误,要么如果用户提交的表单数据有效,就重定向用户到成功的 URL。因为我们没有定义任何成功的 URL,所以当我们提交一个有效的表单时会出现错误。

就是这样。这就是我们需要创建动态生成表单的所有代码。我们可以添加一些其他功能,比如自定义验证或支持具有有限选择集的选择字段的高级功能,但我会把这些作为一个有趣的项目留给你自己去尝试。

接下来,让我们看看如何在数据库中存储定义我们自定义表单的 JSON 数据。我们将为其创建一个模型,并允许用户从管理面板创建具有不同字段的多个表单。

我们的 JSON 模型

正如我之前提到的,使用 JSON 作为表单定义格式的最大好处之一是它只使用简单的文本数据来编码复杂对象的定义。虽然一些数据库,如 PostgreSQL,有一个用于 JSON 的列类型,但其他数据库没有。然而,因为我们处理的是简单的文本数据,我们不需要一个!我们可以将我们的 JSON 数据存储在一个简单的TextField中,然后在需要时对数据进行编码和解码为 Python 字典。事实上,Django 社区中有许多人已经处理过这个问题,并为我们开源了他们的解决方案供我们使用。

我过去使用过的一个这样的包是django-jsonfield。你可以在github.com/bradjasper/django-jsonfield找到它,我们将在我们的项目中使用它。首先,在命令行中输入以下命令来安装所需的包。确保首先激活虚拟环境,以便它安装在正确的位置。

> pip install jsonfield

安装了这个包后,我们可以为我们的表单创建一个模型。打开main/models.py并将其更改为以下代码:

from __future__ import unicode_literals

from django.db import models

from jsonfield import JSONField

class FormSchema(models.Model):
    title = models.CharField(max_length=100)
    schema = JSONField()

保存文件,然后创建和运行迁移,以便 Django 为我们的新模型创建表。在命令行中运行以下命令:

> python manage.py makemigrations main
Migrations for 'main':
  0001_initial.py:
    - Create model FormSchema
> python manage.py migrate
Operations to perform:
  Apply all migrations: sessions, contenttypes, admin, main, auth
Running migrations:
  Rendering model states... DONE
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying sessions.0001_initial... OK

迁移完成后,让我们看看如何使用这个模型。我喜欢从在 Django shell 中进行一些实验开始,而不是直接编写视图的代码。输入以下内容打开它:

> python manage.py shell

然后输入以下内容来测试我们的新模型:

> from main.models import FormSchema
> fs = FormSchema()
> fs.title = 'My First Form'
> fs.schema = {'name': 'string', 'age': 'number', 'city': 'string', 'country': 'string', 'time_lived_in_current_city': 'string'}
> fs.save()
> FormSchema.objects.get(pk=1).schema
{u'age': u'number',
 u'city': u'string',
 u'country': u'string',
 u'name': u'string',
 u'time_lived_in_current_city': u'string'}

我们现在所做的对你来说应该很简单理解。这里要注意的重要事情是我们分配给schema字段的值。我们没有使用 JSON 数据的字符串表示,而是简单地分配了一个 Python 字典。我们在模型中使用的JSONField类在保存到数据库时会把 Python 字典转换为 JSON 字符串。

反之亦然。注意,在我们的 shell 会话的最后一行中,我们直接访问了模式字段,并得到了一个 Python 字典,而不是实际保存在数据库中的字符串 JSON 数据。这使得我们对 JSON 的处理对我们来说是透明的。

提示

你可能会想知道为什么我要求你在本章中进行实验并在 shell 中玩耍,而在之前的章节中,我直接向你展示了相关的视图/模型/模板代码。

就像我在本章开头提到的,本章是关于向你展示如何自己找到解决方案,而不是我牵着你的手直接向你展示最终结果。

我认识的所有优秀的 Django 开发者都有一种类似的方法来为他们所工作的项目开发解决方案。他们会进行一些实验,并通过这样做找到解决问题的方法。不过,每个人的实验方式都不同。有些人,比如我,经常使用 Django shell。其他人在视图和模型中编写测试代码。其他人可能创建简单的 Django 管理命令来做同样的事情。然而,每个人都经历了同样的过程。

我们发现一个需要解决的问题,我们稍微研究一下,然后尝试各种解决方法,最后选择我们最喜欢的方法。您最终会开发出一种您感到舒适的方法。与此同时,我将带您了解我的方法,如果您喜欢,可以使用这个方法。

现在我们的数据库中有一个FormSchema对象,让我们创建一个视图,可以使用这个对象来生成一个表单。在main/views.py中,首先在顶部导入我们的新模型:

from main.models import FormSchema

然后将我们的CustomFormView中的get_form方法更改为匹配这个:

def get_form(self):
    form_structure = FormSchema.objects.get(pk=1).schema

    custom_form = forms.Form(**self.get_form_kwargs())
    for key, value in form_structure.items():
        field_class = self.get_field_class_from_type(value)
        if field_class is not None:
            custom_form.fields[key] = field_class()
        else:
            raise TypeError("Invalid field type {}".format(value))

    return custom_form

我已经突出显示了新行。我们已经删除了我们使用的硬编码 JSON 字符串,并将数据库对象的schema字段的值分配给了form_structure变量。其余代码保持不变。尝试再次打开应用程序的主页。您会发现前端保持不变。交互也将保持不变。您可以尝试提交无效或不完整的数据,它将像以前一样显示错误。尝试提交有效的表单仍将导致有关未定义成功 URL 的错误。

接下来,让我们为我们的用户创建一个更好的前端。我们将创建一个列表视图,用户可以在该视图中看到站点上所有可用表单的列表,以及一个表单视图,显示实际的表单并处理交互。

创建更好的用户界面

我们将在这里做的事情并不复杂。您应该很容易跟上,所以我将给您写代码并留下解释,因为我们在之前的章节中已经做过这些很多次了。

首先,在项目根目录中创建一个templates目录。接下来,在我们的formmason/settings.py文件中的TEMPLATES配置变量的DIRS列表中添加它。settings.py文件已经配置了一个TEMPLATES变量,所以请继续用这里看到的值替换这个字典中的DIRS列表:

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [
            os.path.join(BASE_DIR, 'templates'),
        ],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
        },
    },
]

接下来,在您刚刚创建的新的templates目录中创建一个base.html模板,并将以下代码放入其中:

<html>
<head>
    <meta http-equiv="content-type" content="text/html; charset=utf-8" />

    <title>Form Mason</title>
</head>

<body>
    <a href="{% url 'home' %}">Home</a>
    {% block content %}
    {% endblock %}
</body>
</html>

修改main/templates/custom_form.html以匹配这个:

{% extends "base.html" %}

{% block content %}
    <h1>Custom Form</h1>
    <form action="" method="post">{% csrf_token %}
        {{ form.as_p }}
        <input type="submit" value="Submit" />
    </form>
{% endblock %}

main/views.py更改为以下内容:

from django import forms
from django.views.generic import FormView
from django.views.generic import ListView

from main.models import FormSchema

class HomePageView(ListView):
    model = FormSchema
    template_name = "home.html"

class CustomFormView(FormView):
    template_name = "custom_form.html"

    def get_form(self):
        form_structure = FormSchema.objects.get(pk=self.kwargs["form_pk"]).schema

        custom_form = forms.Form(**self.get_form_kwargs())
        for key, value in form_structure.items():
            field_class = self.get_field_class_from_type(value)
            if field_class is not None:
                custom_form.fields[key] = field_class()
            else:
                raise TypeError("Invalid field type {}".format(value))

        return custom_form

    def get_field_class_from_type(self, value_type):
        if value_type == "string":
            return forms.CharField
        elif value_type == "number":
            return forms.IntegerField
        else:
            return None

main/templates/home.html中创建一个新的主页模板,并给出以下代码:

{% extends "base.html" %}

{% block content %}
    <h1>Available Forms</h1>
    {% if object_list %}
    <ul>
        {% for form in object_list %}
        <li><a href="{% url 'custom-form' form_pk=form.pk %}">{{ form.title }}</a></li>
        {% endfor %}
    </ul>
    {% endif %}
{% endblock %}

最后,将formmason/urls.py更改为匹配这个:

from django.conf.urls import url

from main.views import CustomFormView
from main.views import HomePageView

urlpatterns = [
    url(r'^$', HomePageView.as_view(), name='home'),
    url(r'^form/(?P<form_pk>\d+)/$', CustomFormView.as_view(), name='custom-form'),
]

完成所有这些后,再次打开应用程序的主页http://127.0.0.1:8000,您应该会看到一个类似于这样的页面:

创建更好的用户界面

单击表单链接应该将您带到之前的相同表单页面;唯一的区别是现在它是在 URL http://127.0.0.1:8000/form/1/ 上提供的。

就像我说的,所有这些都是我们在过去几章中反复做过的基本事情。可能新的一件事是我们在CustomFormView.get_form方法中使用了self.kwargs['form_pk']。以下是相关行:

form_structure = FormSchema.objects.get(pk=self.kwargs["form_pk"]).schema

对于 Django 提供的任何通用视图(除了基本的View类),self.kwargs是与 URL 模式匹配的所有命名参数的字典。如果您查看我们的formmason/urls.py文件,我们定义了自定义表单页面的 URL 如下:

url(r'^form/(?P<form_pk>\d+)/$', CustomFormView.as_view(), name='custom-form')

在我们的视图中,URL 的正则表达式模式中定义的form_pk参数在self.kwargs中可用。同样,URL 模式中的任何非关键字参数都可以在self.args中使用。

现在我们有了一个可用的用户界面,我们将继续将表单响应存储在我们的数据库中,并为我们的客户提供一个页面来查看这些响应。

保存响应

我们希望保存用户填写我们动态表单之一的响应。由于动态表单的数据也是动态的,我们需要在数据库中存储未知数量的字段及其值。正如我们已经看到的,JSON 是存储这种数据的合理方式。让我们在main/models.py中创建一个新模型来存储响应:

class FormResponse(models.Model):
    form = models.ForeignKey(FormSchema)
    response = JSONField()

接下来,创建并运行迁移以将这个新模型添加到我们的数据库中:

> python manage.py makemigrations main
Migrations for 'main':
  0002_formresponse.py:
    - Create model FormResponse
> python manage.py migrate main
Operations to perform:
  Apply all migrations: main
Running migrations:
  Rendering model states... DONE
  Applying main.0002_formresponse... OK

现在我们有了我们的模型,我们需要将我们表单的有效响应保存到这个模型中。CustomFormViewform_valid方法是添加此逻辑的正确位置。首先,我们需要在main/views.py文件的顶部从 Django 和我们的新模型中导入一些东西:

from django.core.urlresolvers import reverse
from django.http.response import HttpResponseRedirect

from main.models import FormResponse

然后,在CustomFormView类中添加一个form_valid方法,其中包含以下代码:

def form_valid(self, form):
    custom_form = FormSchema.objects.get(pk=self.kwargs["form_pk"])
    user_response = form.cleaned_data

    form_response = FormResponse(form=custom_form, response=user_response)
    form_response.save()

    return HttpResponseRedirect(reverse('home'))

就是这样。现在尝试提交我们之前创建的自定义表单,如果数据有效,您应该在响应保存后被重定向到主页。目前,我们无法在前端看到这些响应;但是,我们可以使用 Django shell 来确保我们的数据已经保存。使用以下命令启动 shell:

> python manage.py shell

然后使用以下代码行来查看保存的响应:

> from main.models import FormResponse
> FormResponse.objects.all()[0].response
{u'age': 27,
 u'city': u'Dubai',
 u'country': u'UAE',
 u'name': u'Jibran',
 u'time_lived_in_current_city': u'3 years'}

当保存表单时,您应该看到输入的数据。现在让我们创建一个屏幕,让我们的客户可以看到他们定制表单的响应。

显示响应

本节中的代码非常简单,对您来说应该没有任何意外。首先,在main/views.py中创建视图:

class FormResponsesListView(ListView):
    template_name = "form_responses.html"

    def get_context_data(self, **kwargs):
        ctx = super(FormResponsesListView, self).get_context_data(**kwargs)
        ctx["form"] = self.get_form()

        return ctx

    def get_queryset(self):
        form = self.get_form()
        return FormResponse.objects.filter(form=form)

    def get_form(self):
        return FormSchema.objects.get(pk=self.kwargs["form_pk"])

接下来,创建main/templates/form_responses.html模板:

{% extends "base.html" %}

{% block content %}
<h1>Responses for {{ form.title }}</h1>
{% if object_list %}
<ul>
    {% for response in object_list %}
    <li>{{ response.response }}</li>
    {% endfor %}
</ul>
{% endif %}
{% endblock %}

formmason/urls.py中,导入我们的新视图:

from main.views import FormResponsesListView

将此 URL 模式添加到urlpatterns列表中:

url(r'^form/(?P<form_pk>\d+)/responses/$', FormResponsesListView.as_view(), name='form-responses'),

最后,编辑main/templates/home.html以添加到这个新视图的链接:

{% extends "base.html" %}

{% block content %}
    <h1>Available Forms</h1>
    {% if object_list %}
    <ul>
        {% for form in object_list %}
        <li>
            <a href="{% url 'custom-form' form_pk=form.pk %}">{{ form.title }}</a><br />
 <a href="{% url 'form-responses' form_pk=form.pk %}">See Responses</a>
        </li>
        {% endfor %}
    </ul>
    {% endif %}
{% endblock %}

新的代码行已经突出显示。在进行所有这些更改后,打开主页,您应该看到现有表单链接旁边的查看响应链接,用于新视图:

显示响应

单击链接应该会带您到以下页面:

显示响应

虽然它可以完成工作,但它非常粗糙。我们可以做得更好。让我们改进一下。

改进的响应列表

我们希望以表格的形式显示响应,字段名称作为标题,字段的响应值显示在它们的下方。让我们首先修改我们的视图代码。在main/views.py中,首先导入TemplateView,因为我们将不再使用ListView作为FormResponsesListView的基类:

from django.views.generic import TemplateView

接下来,修改FormResponsesListView以匹配以下代码:

class FormResponsesListView(TemplateView):
    template_name = "form_responses.html"

    def get_context_data(self, **kwargs):
        ctx = super(FormResponsesListView, self).get_context_data(**kwargs)

        form = self.get_form()
        schema = form.schema
        form_fields = schema.keys()
        ctx["headers"] = form_fields
        ctx["form"] = form

        responses = self.get_queryset()
        responses_list = list()
        for response in responses:
            response_values = list()
            response_data = response.response

            for field_name in form_fields:
                if field_name in response_data:
                    response_values.append(response_data[field_name])
                else:
                    response_values.append('')
            responses_list.append(response_values)

        ctx["object_list"] = responses_list

        return ctx

    def get_queryset(self):
        form = self.get_form()
        return FormResponse.objects.filter(form=form)

    def get_form(self):
        return FormSchema.objects.get(pk=self.kwargs["form_pk"])

这里的主要更改在get_context_data方法中。我们还将基类从ListView更改为TemplateView。让我们看看我们在get_context_data方法中做了什么。

首先,我们使用 JSON 表单模式中的keys方法来创建字段标题的列表,该模式保存在FormSchema模型中。我们将这个列表传递给模板中的headers上下文变量。

稍微复杂的部分接下来。我们循环遍历数据库中的每个FormResponse对象。对于每个响应对象,我们然后循环遍历表单字段名称,并从响应数据中获取该属性。我们这样做是因为在 Django 模板中,没有办法使用变量键名从字典中获取值。我们可以创建一个自定义模板标签;但是,那会增加不必要的复杂性。相反,我们在视图中做同样的事情,这样更容易。对于每个响应对象,我们创建一个与字段标题顺序相同的值列表。然后,我们将这个字段值列表添加到我们的响应列表中。当我们查看我们的模板时,我们的数据结构方式变得清晰。

最后,我们将响应列表分配给object_list模板上下文变量并返回上下文。接下来,让我们修改main/templates/form_responses.html模板以匹配以下内容:

{% extends "base.html" %}

{% block content %}
<h1>Responses for {{ form.title }}</h1>
{% if object_list %}
<table border="1px">
    <tr>
        {% for field_name in headers %}
        <th>{{ field_name }}</th>
        {% endfor %}
    </tr>
    {% for response in object_list %}
    <tr>
        {% for field_value in response %}
        <td>{{ field_value }}</td>
        {% endfor %}
    </tr>
    {% endfor %}
</table>
{% endif %}
{% endblock %}

如果您理解了我们在视图中的数据结构方式,那么模板应该很容易理解。这是我们现在需要做的所有更改。

如果您再次打开响应页面,您现在应该看到一个整齐排列的表格,如下截图所示:

改进的响应列表

到目前为止,我们已经创建了一个页面来列出可用的表单,一个页面来提交动态表单的数据,以及一个页面来显示这些响应。我们的应用程序几乎完成了。然而,我们仍然缺少一个页面,允许客户创建自定义表单。让我们接下来解决这个问题。

设计表单创建界面

我们希望为用户提供一个友好的界面,以编辑他们动态表单的结构。理想的界面应该是允许用户在网页上进行拖放操作,并使用简单的点击和输入操作设置这些字段的属性。然而,创建这样的界面是一个重大的工作,需要一个前端开发人员和设计师团队来创建用户友好的界面。

不幸的是,我们无法创建这样的界面,因为这样的界面更多的是一个前端项目,而不是与 Django 相关的项目。然而,如果你愿意,这是一个很好的练习,如果你想提高你的前端技能。

对于这个项目,我们将创建一个简单的 Django 表单,用户可以手动输入 JSON 来定义他们的表单结构。我们将提供基本的验证和编辑功能。所以让我们从创建我们的表单开始。更改main/forms.py文件以匹配以下代码:

import json

from django import forms

class NewDynamicFormForm(forms.Form):
    form_pk = forms.CharField(widget=forms.HiddenInput(), required=False)
    title = forms.CharField()
    schema = forms.CharField(widget=forms.Textarea())

    def clean_schema(self):
        schema = self.cleaned_data["schema"]
        try:
            schema = json.loads(schema)
        except:
            raise forms.ValidationError("Invalid JSON. Please submit valid JSON for the schema")

        return schema

表单本身非常简单。然而,我们需要仔细研究clean_schema方法。当在 Django 表单上调用is_valid()方法时,它经过了几个步骤。确切的细节可以在文档中找到[https://docs.djangoproject.com/en/stable/ref/forms/validation/]。然而,一个很好的近似值是 Django 在表单类上调用两种不同类型的清理方法。首先,它查找每个字段定义在表单上的clean_<fieldname>方法。这个方法应该返回字段的清理值,然后存储在表单的cleaned_data字典中。接下来,Django 在表单类上调用clean方法。这个方法用于清理相互依赖的字段的数据,因为依赖的清理不应该在单独的clean_<fieldname>方法中完成。clean方法应该返回一个字典,它将完全替换表单的cleaned_data字典。

任何一种清理方法都可以引发forms.ValidationError异常,如果数据不符合预期的格式。如果在clean_<fieldname>方法中引发ValidationError,则错误与该字段绑定,并在表单呈现时显示在其旁边。如果错误是在一般的clean方法中引发的,错误将显示在表单错误列表中,在表单字段在 HTML 中开始之前显示。

由于我们希望确保用户在模式字段中输入的数据是有效的 JSON,我们在用户输入的数据上使用json.loads,如果调用失败则引发异常。一个重要的事情要注意的是,如果我们的clean_schema调用成功,我们将返回模式的修改版本。这是因为在我们的模型中,我们需要保存一个 Python 字典而不是一个 JSON 字符串。当保存到数据库时,我们的JSONField模型字段处理了从 Python 对象到 JSON 字符串的必要转换。

另一个需要注意的是clean_schema方法没有给出任何参数。它必须从表单的cleaned_data字典中获取模式字段的值。有了我们创建的表单,让我们在main/views.py中添加一个视图来显示这个表单。首先,在顶部导入我们的新表单和json库:

import json
from main.forms import NewDynamicFormForm

然后在视图中添加以下代码:

class CreateEditFormView(FormView):
    form_class = NewDynamicFormForm
    template_name = "create_edit_form.html"

    def get_initial(self):
        if "form_pk" in self.kwargs:
            form = FormSchema.objects.get(pk=self.kwargs["form_pk"])
            initial = {
                "form_pk": form.pk,
                "title": form.title,
                "schema": json.dumps(form.schema)
            }
        else:
            initial = {}

        return initial

    def get_context_data(self, **kwargs):
        ctx = super(CreateEditFormView, self).get_context_data(**kwargs)
        if "form_pk" in self.kwargs:
            ctx["form_pk"] = self.kwargs["form_pk"]

        return ctx

    def form_valid(self, form):
        cleaned_data = form.cleaned_data

        if cleaned_data.get("form_pk"):
            old_form = FormSchema.objects.get(pk=cleaned_data["form_pk"])
            old_form.title = cleaned_data["title"]
            old_form.schema = cleaned_data["schema"]
            old_form.save()
        else:
            new_form = FormSchema(title=cleaned_data["title"], schema=cleaned_data["schema"])
            new_form.save()

        return HttpResponseRedirect(reverse("home"))

接下来,创建main/templates/create_edit_form.html文件,并将以下代码放入其中:

{% extends "base.html" %}

{% block content %}
<h1>Create/Edit Form</h1>

{% if form_pk %}
<form action="{% url 'edit-form' form_pk=form_pk%}" method="post">{% csrf_token %}
{% else %}
<form action="{% url 'create-form' %}" method="post">{% csrf_token %}
{% endif %}

{{ form.as_p }}
<input type="submit" value="Create Form" />
</form>
{% endblock %}

最后,在我们的formmason/urls.py中导入这个新视图,并为它添加两个模式到urlpatterns文件。这是最终的formmason/urls.py文件:

from django.conf.urls import url

from main.views import CreateEditFormView
from main.views import CustomFormView
from main.views import FormResponsesListView
from main.views import HomePageView

urlpatterns = [
    url(r'^$', HomePageView.as_view(), name='home'),
    url(r'^form/(?P<form_pk>\d+)/$', CustomFormView.as_view(), name='custom-form'),
    url(r'^form/(?P<form_pk>\d+)/responses/$', FormResponsesListView.as_view(), name='form-responses'),

    url(r'form/new/$', CreateEditFormView.as_view(), name='create-form'),
    url(r'form/(?P<form_pk>\d+)/edit/$', CreateEditFormView.as_view(), name='edit-form'),
]

我们两次使用相同的视图——一次作为创建视图,一次作为编辑视图。如果你看一下视图的代码,我们会根据 URL 中的form_pk关键字参数是否匹配来为表单提供来自FormSchema对象的初始数据。如果没有匹配,我们知道我们正在创建一个新表单,我们的初始数据字典是空的。需要注意的一点是,在初始数据中,我们使用json.dumps(form.schema)来为模式字段提供初始值。这与我们在表单的clean_schema方法中所做的相反。这是因为form.schema是一个 Python 对象;然而,我们的前端需要显示结构的 JSON。因此,我们使用json.dumps方法将 Python 对象转换为 JSON 字符串。

我们在视图的form_valid方法中处理两种情况——新表单创建和表单编辑。成功保存或创建我们的表单后,我们将用户重定向到主页。

我们需要做的最后一件事是在我们的基本模板和主页上添加链接来创建和编辑表单。首先,在templates/base.html中的Home链接后添加此链接:

<a href="{% url 'create-form' %}">Create New Form</a>

main/templates/home.html中,在查看响应链接后添加这个:

<br /><a href="{% url 'edit-form' form_pk=form.pk %}">Edit Form</a>

就是这样。让我们来测试一下。首先打开主页http://127.0.0.1:8000/,你应该看到类似下面截图的内容:

设计表单创建界面

点击编辑表单按钮,你应该看到下面的页面:

设计表单创建界面

让我们将标题更改为新的内容,然后在模式字段中输入这个:

{"years_of_experience": "number", "occupation": "string"}

点击创建新表单链接。这应该保存表单并将您带回主页。主页上的表单标题应该更改为您编辑的内容。点击链接查看表单,你应该看到类似这样的屏幕:

设计表单创建界面

太棒了!我们的表单已经更改以匹配我们在编辑表单时输入的模式。让我们看看我们的响应页面是什么样子的。使用新的表单模式提交一些测试数据,然后打开响应页面。你应该看到类似这样的内容:

设计表单创建界面

这个页面按预期工作,无需更改任何代码。你会发现以前的响应不再可见;然而,在最新的响应上方似乎有一些空单元格。我们将在接下来解决这个问题和其他一些小问题。然而,我们的应用程序除了需要做一些小修复外已经完成了。

你也应该在页面顶部使用创建新表单链接来创建一个新表单并进行测试。它应该按预期工作。

小修复

我在最后一节工作时注意到了三个小错误,我想要修复。首先,如果你仔细看表单提交页面(用户可以在自定义表单中输入数据的页面),你会注意到顶部的标题是自定义表单。这是我们的第一个测试,当我们的表单模式是在硬编码的 JSON 字符串中定义的,并且没有标题。由于我们的表单模型现在有一个标题字段,编辑main/templates/custom_form.html并更改h1标签以匹配以下内容:

<h1>{{ form_schema.title }}</h1>

接下来,在main/views.py中编辑CustomFormView并将this get_context_data方法添加到类中:

def get_context_data(self, **kwargs):
    ctx = super(CustomFormView, self).get_context_data(**kwargs)

    form_schema = FormSchema.objects.get(pk=self.kwargs["form_pk"])
    ctx["form_schema"] = form_schema

    return ctx

再次查看表单详细页面。这次,标题应该反映表单标题,如下面的截图所示:

小修复

我注意到的下一个错误是在表单编辑页面上。现在,无论你是创建新表单还是编辑现有表单,标题总是说创建/编辑表单提交按钮总是说创建新表单。让我们修改我们的main/templates/create_edit_form.html模板,明确告诉用户他们正在执行什么操作。以下是模板的最终代码:

{% extends "base.html" %}

{% block content %}
{% if form_pk %}
    <h1>Edit Form</h1>
{% else %}
    <h1>Create Form</h1>
{% endif %}

{% if form_pk %}
<form action="{% url 'edit-form' form_pk=form_pk%}" method="post">{% csrf_token %}
{% else %}
<form action="{% url 'create-form' %}" method="post">{% csrf_token %}
{% endif %}
    {{ form.as_p }}
    {% if form_pk %}
        <input type="submit" value="Save Form" />
    {% else %}
        <input type="submit" value="Create Form" />
    {% endif %}
</form>
{% endblock %}

现在,如果你编辑一个现有表单或创建一个新表单,标题和按钮应该与该操作匹配:

小修复

我看到的最后一个问题是在响应页面上。如果你编辑一个已经有响应的表单,并且新表单没有旧表单的任何字段,你会看到一些空的表格行。这是因为即使我们的视图代码不包括这些行的任何数据,它仍然包括一个空行。让我们通过修改main/views.pyFormResponsesListViewget_context_data方法来解决这个问题。找到这段代码:

for field_name in form_fields:
    if field_name in response_data:
        response_values.append(response_data[field_name])
    else:
        response_values.append('')
responses_list.append(response_values)

将其更改为以下内容:

for field_name in form_fields:
    if field_name in response_data:
        response_values.append(response_data[field_name])
    else:
        response_values.append('')
if any(response_values):
 responses_list.append(response_values)

最后两行,被标记的那些,是更改。any函数是一个内置的 Python 方法,如果给定列表中的任何值求值为True,则返回True。如果我们的responses_list包含所有空字符串,也就是说,如果我们的新表单结构没有与旧表单结构重叠的字段,if条件将失败,我们将不会在我们的响应列表中包含一个完全空的行。再次查看响应列表页面,你会发现空行现在已经消失了:

小修复

就是这样。我们最后一个,也是最雄心勃勃的应用程序已经完成。恭喜!你已经从我们在本书开头创建的简单博客中走了很长的路。现在你应该准备好应对任何网页应用项目了。

总结

本章更多的是关于探索而不是新概念。你学到的唯一重要的新概念是关于 Django 表单的内部工作原理,而这一部分不到五页。本章的其余部分是关于找到我们在路上遇到的问题的解决方案。

正如我在本章开头所说的,这是我们在书中创建的最复杂的应用程序。这也很可能是你在职业生涯初期创建的更复杂的应用程序之一。这并不是一个完美的产品;这更多是因为我们没有做任何前端工作,而不是 Django 后端功能的缺失。

虽然我们在本章对 Django 表单的了解将对你非常有价值,但我希望你从中主要学到的是解决问题的方法。我们发现了一个需要解决的问题,我们在 Django shell 中进行了实验,然后找到了解决方案。有时候情况会比这更复杂。你可能需要搜索互联网上的现有解决方案或向同事求助。然而,在找到似乎与你的问题相关的答案之后,你总是需要回到实验中,确保解决方案确实解决了你的问题。

我向你展示的在 Django shell 中玩耍的方式只是你可以选择尝试不同解决方案的众多方式之一。正如我之前提到的,每个人都有自己的技巧。有些人使用管理命令,有些人使用简单的 Python 脚本,有些人在视图中编写测试代码。最终,你会找到自己最喜欢的方式。

我希望你从本章中带走的另一件事是使用外部库来帮助解决我们的问题。当我们需要一种方法来在数据库中存储 JSON 时,我们使用了jsonfield库,这是作为开源软件可用的,而不是自己定制的解决方案。这有利有弊。好处是,如果你找到一个广泛使用的包,比如jsonfield,你将得到一个经过许多人测试的解决方案。由于经过测试,该库将比你或任何人自己想出的东西稳定得多。使用外部内容的缺点是你无法控制项目的发展方向,在某些项目中,你将不得不仔细检查外部库的代码,以确保它符合项目可能对外部代码使用的任何规定。

然而,就我个人而言,我更喜欢每次都使用经过验证的第三方库,而不是自己创建东西。对我来说,优点通常大于缺点。

就是这样!这是我们从一个简单的博客开始的史诗般旅程的最后一章!我们已经走了很长的路;然而,我相信你作为一名 Django 网络开发者的旅程才刚刚开始。你还有许多令人期待的事情,我祝你在这个旅程中一切顺利。希望你和我写作时一样享受阅读这本书。一路顺风!

附录 A.开发环境设置详细信息和调试技巧

本附录将更详细地介绍我们在整本书中一直在使用的 Django 开发环境设置。我们将深入了解设置的细节,并解释我们采取的每个步骤。我还将向您展示一种调试 Django 应用程序的技术。在本附录中,我们将假设我们正在设置的项目是第一章中的 Blueblog 项目。

我们将首先创建一个根目录,然后cd到该目录,以便所有命令都在其中运行:

> mkdir blueblog
> cd blueblog

这没有技术原因。我只是喜欢将与项目相关的所有文件放在一个目录中,因为当您必须添加与项目相关的其他文件(如设计和其他文档)时,这样做会更容易组织。

接下来,我们将创建一个虚拟环境用于该项目。虚拟环境是一个功能,允许您创建 Python 的轻量级安装,以便每个项目都可以拥有自己使用的所有库的安装。当您同时在多个项目上工作,并且每个项目需要某个库的单独版本时,这将非常有用。例如,在工作中,我曾经不得不同时处理两个项目。一个需要 Django 1.4;另一个需要 Django 1.9。如果我没有使用虚拟环境,将很难同时保留 Django 的两个版本。

虚拟环境还可以让您保持 Python 环境的清洁,这在最终准备将应用程序部署到生产服务器时非常重要。当将应用程序部署到服务器时,您需要能够准确地复制与开发机器中相同的 Python 环境。如果您不为每个项目使用单独的虚拟环境,您将需要准确确定项目使用的 Python 库,然后仅在生产服务器上安装这些库。有了虚拟环境,您不再需要花时间弄清楚安装的 Python 库中哪些与您的项目相关。您只需创建虚拟环境中安装的所有库的列表,并在生产服务器上安装它们,确信您不会错过任何内容或安装任何多余的内容。

如果您想了解更多关于虚拟环境的信息,可以阅读官方文档docs.python.org/3/library/venv.html

要创建虚拟环境,我们使用pyvenv命令:

> pyvenv blueblogEnv 

这将在blueblogEnv文件夹内创建一个新的环境。创建环境后,我们激活它:

> 
source blueblogEnv/bin/activate

激活环境可以确保我们运行的任何 Python 命令或我们安装的任何库都将使用激活的环境。接下来,在我们的新环境中安装 Django 并启动我们的项目:

> pip install django
> django-admin.py startproject blueblog src

这将创建一个名为src的目录,其中包含我们的 Django 项目。您可以将目录命名为任何您喜欢的名称;这只是我喜欢的约定。

这就是我们开发环境的设置。

使用 pdb 调试 Django 视图

在 Django 应用程序中,您经常会遇到一些不太清楚的问题。当我遇到棘手的错误,特别是在 Django 视图中时,我会使用 Python 调试器来逐步执行我的视图代码并调试问题。为此,您需要在认为问题存在的地方的视图中放入这行代码:

import pdb; pdb.set_trace()

然后,下次加载与该视图相关的页面时,你会发现你的浏览器似乎没有加载任何内容。这是因为你的 Django 应用现在已经暂停了。如果你在运行runserver命令的控制台中查看,你应该会看到一个pdb的提示。在提示符中,你可以输入当前 Python 范围内(通常是你正在调试的视图的范围)可用的任何变量的名称,它会打印出该变量的当前值。你还可以运行一系列其他调试命令。要查看可用功能的完整列表,请查看 Python 调试器的文档docs.python.org/3/library/pdb.html

一个很好的 Stack Overflow 问题,列出了一些其他有用的答案和调试技巧,链接是stackoverflow.com/questions/1118183/how-to-debug-in-django-the-good-way

在 Windows 上开发

如果你在阅读本书时要使用 Windows 操作系统,请注意有一些事情需要做出不同的处理。首先,本书中提供的所有指令都是基于 Linux/Mac OS X 环境的,有些指令可能无法直接使用。最重要的变化是 Windows 如何处理文件路径。在 Linux/OS X 环境中,路径是用正斜杠写的。书中提到的所有路径都是类似格式的,例如,PROJECT_DIR/main/settings.py。在 Windows 上引用这些路径时,你需要将正斜杠改为反斜杠。这个路径将变成PROJECT_DIR\main\settings.py

其次,虽然 Python 通常包含在 Linux/OS X 中,或者很容易安装,但你需要按照https://www.python.org/downloads/windows/上的说明在 Windows 上安装 Python。安装了 Python 之后,你可以按照docs.djangoproject.com/en/stable/howto/windows/上的说明安装 Django。

有一些其他的东西需要在 Windows 上进行修改。我在书中提到了这些,但可能会漏掉一些。如果是这样,通过谷歌搜索通常会找到答案。如果找不到,你可以在 Twitter 上找到我,我的用户名是@theonejb,我会尽力帮助你。

posted @ 2024-05-20 16:49  绝不原创的飞龙  阅读(5)  评论(0编辑  收藏  举报