Django2-和-Channel2-实践教程-全-
Django2 和 Channel2 实践教程(全)
一、Django 简介
在过去的十年里,互联网对我们社会的影响非常深刻。它改变了一切,从我们社交和工作的方式到我们约会和度假的方式。互联网已经从让我们能够虚拟旅行并与大洋彼岸的人们联系的工具,扩展到让我们能够实际旅行的工具,包括打车服务,这是它变得多么有影响力的一个主要例子。
这一趋势没有停止的迹象。未来可能会更有影响力。如今,传统机构不仅需要在线业务,还需要出色的在线业务,为客户提供在线和线下同样的服务。不这样做的传统机构最终会被以互联网为骨干的更精简的组织所瓦解。
用户生成的数据也越来越普遍。在线用户评论现在是决定如何花费我们的时间和金钱的一个关键因素。在线社区数量众多且欣欣向荣。最近,机器学习取得了很多进展,如果没有网络作为一种为这些系统生成数据的方式,这一切都不会发生。
作为软件工程师,我们如何参与所有这些激动人心的活动?传统的计算机科学教育只会给你基础知识。考虑到用户期望和技术工具发展的速度,这种差距是不可避免的。但是,考虑到网上有那么多工具和教程,如何弥合这种差距并不明显。
如果您正在阅读这本书,我假设您熟悉 Python,并且已经选择使用它进行 web 开发。Python 是一种非常流行的编程语言。它是一种解释性语言,具有动态、强类型系统。在我看来(可能也是你的),它在速度和安全之间取得了很好的平衡。Django 是用 Python 编写的,它是使用最广泛的 Python web 框架,尽管不是唯一的。
Django 的结构使得创建复杂的、数据库驱动的网站变得尽可能容易。它提供了许多组件,这些组件都经过了实战测试,可以根据您的使用情况进行定制。现代网站中有许多常见的模式可以重用,本着实用的精神,这样做是明智的。
页状构造
这本书将使用大量实际例子来解释 Django。我选择电子商务作为整本书的主题,因为现在网上购物非常普遍,因此你可能已经知道术语和这些网站是如何构建的。
本书的每一章都将介绍足够理解和建立一个电子商务网站的概念。我们将从基础、一些演示页面开始,展示产品、结账流程和更多高级功能。这几章是我个人电子商务经验和用来以越来越复杂的方式介绍 Django 组件的起点之间的折衷。
这不会是一本电子商务圣经;这不是本书的目的。这本书的目的是让你在熟悉概念的帮助下学习。我希望它能成功地帮助你这样做,我期待着听到你的反馈。
Django 是什么
Django 是一个设计用于协同工作的组件集合,它们涵盖了 web 开发的许多公共领域。当你在网上浏览时,你会遇到许多不同的网站,表面上看起来不同,但实际上,它们包含相同的模式。
每个网站都提供 HTML(表示网站内容的语言)、CSS(声明内容呈现方式的语言)、图像等等。
大多数网站都可以登录到一个私人区域,在那里你可以看到为用户做的定制和可能不公开的数据。Django 用它的组件支持所有这些。
Django 的一个重要方面是,它的许多组件都是独立的,因此可以单独使用,而不必加载所有组件。也就是说,一些更高级别的组件利用了现有的组件。
Django 框架诞生于堪萨斯州劳伦斯市出版的《劳伦斯世界日报》的办公室。你可以在 Django 网站的在线教程中看到这种影响。它提供了许多按日期和时间过滤的内容视图。诚然,在第一个版本中,设计者(阿德里安·霍洛瓦蒂和西蒙·威廉森)可能专注于解决特定于报纸的问题,但如今该框架能够处理任何领域。
我个人与 Django 有多年的交往。我在许多领域使用过它——从可视化财务数据、创建在线医疗问卷、建立电子商务商店,到抓取仪表盘、在线目录和商业智能工具——并且一直认为它的价值无可置疑。每次我使用它,它都让我专注于业务问题,而不是重新发明轮子。
Django 不是一个微观框架;它带有行李。如果你的在线业务增长到有多个工程团队在软件的不同领域工作,并且有非常具体的需求,Django 可能不再适合你。但是对于大多数网站来说,它工作得很好。
Django 是一个遵循模型-模板-视图模式的框架,这与许多其他框架的模型-视图-控制器模式非常相似。模型层代表数据库层,用于数据存储。Django 将您从编写 SQL 查询中抽象出来。不使用 SQL,而是使用 Python 对象,加载/保存操作是为您处理的。
模板层负责编写 HTML 响应。它允许您将 HTML 分成几个部分,将这些部分合并到多个页面中,进行动态转换和数据生成,以及许多其他操作。
视图层位于数据库和 HTML 之间,通常是应用中最大的一层。它是大多数业务逻辑所在的地方。它负责告诉模板层要生成什么输出,将需要返回的数据传递给用户,处理表单提交,并与模型层接口以持久化数据。
领域驱动设计
领域驱动设计(DDD)是一种编写软件的技术。它关注领域,领域是软件需要建模和支持的知识或活动。在开发过程中,来自该领域的概念将在业务专家和工程师的帮助下进行整合。
Django 能带来的是专注。由于工程师可以利用现有代码实现许多非特定领域的功能,因此交付特性通常会更快。
Django 有 DDD 教的一些概念。实体、存储库、集合、值对象在某种程度上类似于 ORM 模型、管理器或 HTTP 请求对象的抽象。Django 缺少的代码是您的业务特有的代码,这正是 DDD 可以提供帮助的地方。
开始前你需要什么
在进入 Django 之前,您需要确保安装了一些工具,包括 Python、数据库和 Pipenv。
计算机编程语言
Django 2.0 和所有后续版本都需要 Python 3。Django 2.0 特别要求至少安装 Python 3.4。因此,你首先需要检查的是 Python 是否安装,版本是否正确。如果您还没有这样做,请到 Python 主站点的下载页面( www.python.org/downloads )下载最新的 3.x 版本。在撰写本文时,Python 3.7 已经推出,并且得到了 Django 2 的支持。
如果你用的是 Linux 或者 macOS,不用从官方网站下载,你可以用你的 OS 包管理器下载 Python。如果操作系统存储库中有 Python 的最新版本,我建议您通过这种方法安装它。它更干净,因为它将检查冲突,并且,如果需要,它使卸载 Python 更容易。
如果你不确定,我建议你在网上搜索如何安装 Python 3 的教程,最好使用你的发布工具,比如brew、apt或者yum。
数据库ˌ资料库
虽然不是绝对必要的,但我推荐安装的第二个东西是数据库。Django 可以使用 SQLite,这是一个嵌入式数据库引擎,但是不建议在生产环境中使用,因为这不是它的目的。我建议你从一开始就使用你在网上发布作品时会用到的工具。通过使用相同的工具,您将降低只出现在产品中的错误的几率。
谈到数据库,您有几个选择。最常用的都是开源的:PostgreSQL 和 MySQL。从历史上看,它们经历了不同的演变。PostgreSQL 更注重提供许多特性,而 MySQL 更注重核心功能的性能。如今,两者之间没有明确的赢家。
然而,在 Django 社区,人们更喜欢 PostgreSQL。一些核心提交者已经创建了依赖于标准 SQL 上的 PostgreSQL 扩展的 Django contrib包(虽然它们被包含在内,但不被认为是核心的)。除此之外,Heroku 提供了一个 PostgreSQL 免费层,对我们来说很方便,Heroku 是主机提供商,将在关于生产部署的章节中使用(第十章)。
Pipenv
Pipenv 是一个管理 Python 包的新工具,是这一系列工具中最新的一个。与其前身 Pip 不同,Pipenv 与 Python virtualenv有着更紧密的集成。它还会自动跟踪您安装了什么,并为您锁定版本,因此您总是知道哪些 Python 库正在运行。
为了进行跟踪,Pipenv 生成两个文件。Pipfile是一个文件,其中列出了项目的所有直接依赖项。它还生成另一个名为Pipfile.lock的文件,其中列出了所有依赖项的所有版本和散列。后一个文件用于生成确定性的构建,这意味着 Pipenv 每次都能准确地复制环境。它也不意味着手动编辑。
您可以使用操作系统软件包管理器或使用 Pip 来安装 Pipenv。就我个人而言,我总是更喜欢 OS 软件包管理器,但鉴于 Pipenv 是一个年轻的项目,在这一点上,我建议使用 Pip。
如果你喜欢坚持使用标准的pip,你没有理由不使用它。Python 包格式是相同的。我将在本书中使用pipenv作为参考,但同样可以使用pip。
入门指南
第一步是安装 Django。我们将在接下来的章节中继续使用这个项目,所以我们需要一个好的项目名称。因为我们要卖书,所以将这个项目命名为 BookTime。转到您的个人文件夹(或您想要的任何文件夹)并键入以下内容:
$ mkdir booktime
$ cd booktime
$ pipenv --three install Django
我们现在有了一个安装了 Django 的环境。因为 virtualenvs 在使用前需要激活,所以我们必须这样做:
$ pipenv shell
现在它是活动的,我们将创建项目的初始框架。我们将重新使用之前选择的名称,因为这是我们将在配置文件和文件夹结构中使用的名称:
$ django-admin startproject booktime .
此时,我们应该有一个初始文件夹结构(在当前文件夹中),如下所示:
-
manage.py:允许您与 Django 项目交互的命令行实用程序。在整本书中,你会非常频繁地用到它。 -
booktime:包含每个 Django 项目需要的文件的 Python 包,这些文件是-
booktime/__init__.py:这是一个空文件,只需要使其他文件可以导入。 -
booktime/settings.py:这个文件包含了我们项目的所有配置,可以随意定制。 -
booktime/urls.py:这个文件包含了所有到 Python 函数的 URL 映射。项目需要处理的任何 URL 都必须在这里有一个条目。 -
这是将我们的站点部署到生产环境时将使用的入口点。
-
-
Pipfile:项目正在使用的 Python 库列表。在这一点上只有 Django。 -
Pipfile.lock:内部 Pipenv 文件。
启动开发服务器
此时,我们应该能够看到 Django 的初始网页。为此,我们需要启动开发服务器,如下所示。这就是我们将在本书中看到的任何编码的结果。
$ ./manage.py runserver
如果一切正常,您将看到如下输出:
Performing system checks...
System check identified no issues (0 silenced).
You have 14 unapplied migration(s). Your project may not ...
Run 'python manage.py migrate' to apply them.
March 19, 2018 - 18:16:54
Django version 2.0.3, using settings 'booktime.settings'
Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C.
在 Django 中开发时,您将广泛地使用和重用这个命令,所以不要忘记它!
如果您导航到输出中显示的链接,您将看到类似于图 1-1 所示的初始页面。这是一个临时页面。我们的目标将是改变这是我们的项目。

图 1-1
Django 初始页面
Django 项目与应用
如果你在网上读过任何关于 Django 的介绍,你应该已经看到了这两个概念。Django 项目是我们刚刚创建的东西。例如,它是一个完整的网站。
Django 应用代表了一个独立的网站部分。例如,当考虑电子商务时,订单管理可以被认为是相当独立的。然而,实际上,由于不断变化的需求,很难预见哪些部门在未来会有帮助,哪些部门会由于认知开销而减缓开发。
现在,我们将开始创建一个“主”应用,如下所示,它将包含我们构建的所有内容。在开发过程中,将会清楚是否有足够有界的上下文值得更清晰的分离。
$ ./manage.py startapp main
下面将创建一个名为main的附加文件夹,以及上一节列出的所有现有文件。
main/:
admin.py apps.py __init__.py migrations models.py tests.py views.py
main/migrations:
__init__.py
上面列出的每个文件都使用了相应的同名 Django 组件。在整本书中,我们将逐一介绍它们。除此之外,我们将在书中做的大部分编辑都将在这个文件夹中完成;所以,只提文件名的时候,文件就会在这个main文件夹里。
设置
Django 的许多部分可以配置,或者需要配置,这取决于组件。当您稍早创建 Django 项目时,一个名为settings.py的文件被初始化。这是一个定义了许多常量的 Python 文件。这里所有大写的都被认为是配置,并且通过简单地导入django.conf.settings就可以在项目的每个部分使用。
向该文件添加 Python 代码时请小心。这不是添加函数或复杂代码的地方。这个文件的加载需要保持快速,所以请记住将代码的复杂性限制在不超过赋值操作。
另一件要避免的事情是在运行时改变它的任何内容。每次你在settings.py中改变任何东西,应用都应该被重新部署(或者至少重新启动)。
下面是您在继续操作之前应该知道的最重要的配置变量。在说 Django 之前,把这些看作是你应该知道的一些基本单词。
调试
这是一个打开/关闭调试模式的布尔值。调试模式在开发过程中提供了许多额外的信息。当我们编写最终产品时,我们将不可避免地编写一些不工作的代码。我们的代码中会有错误,调试模式非常有用,可以告诉我们在代码的什么地方触发了错误条件。
当出现错误时,将会出现一个调试页面,而不是我们期望的浏览器输出。这个页面将可视化失败的代码,并提供大量的上下文信息,比如当前变量、HTTP 请求信息、当前数据库、会话信息等等。
图 1-2 显示了在我写了一些试图将一个整数除以零的代码之后的调试页面的例子。

图 1-2
决哥调试页
正如您所看到的,调试页面顶部的第一件事是异常类型 ZeroDivisionError,后面是一些基本信息。第二个也是最有用的部分是回溯部分。
除了您在命令行中使用traceback看到的内容之外,就像您在命令行中使用 Python 一样,在调试页面上,您还可以单击并查看所有帧的所有局部变量。
在 Traceback 部分之后,有一个很长的部分,包含所有 HTTP 请求变量、所有元信息(运行开发服务器的 shell 的环境变量)和所有当前的 Django 设置。
已安装的应用
这是在启动时加载的应用列表,既有 Django 内部的,也有外部库的。Django 将初始化它们,加载和管理它们的模型,并使它们在应用注册中心可用。
对于只有一个“主”应用的 Django 项目,它应该是这样的:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'main.apps.MainConfig',
]
过去您可以只输入main,现在您应该指定AppConfig子类。通常只有一个,并且已经由startapp命令为你创建好了,它位于 Django 应用的apps.py文件中。
正如我们所看到的,Django 本身有一些可以随意启用和禁用的应用。例如,如果我们对消息框架不感兴趣,我们可以通过从列表中删除条目来禁用它。
记录
日志记录是应用的基础部分。日志记录的目的是在问题发生时节省时间,为了做到这一点,您需要能够跟踪运行时发生了什么。日志对于开发和生产站点都很重要。
如果您不熟悉 Python 中日志记录的概念,我建议您查看日志记录模块文档。在整本书中,我们将使用 logger 对象来记录正在发生的事情,并且我们将使用适当的日志级别。
首先,这个日志 Django 设置是关于使用logging.config.dictConfig()配置日志系统的。如果您想了解这是如何构成的,请考虑以下示例:
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'simple': {
'format': '%(levelname)s %(message)s'
},
},
'handlers': {
'console': {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
'formatter': 'simple'
},
},
'loggers': {
'main': {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': True,
},
'booktime': {
'handlers': ['console'],
'level': 'DEBUG', 'propagate': True,
},
},
}
这将打印来自名为booktime和main的记录器的任何记录语句。以 Python 模块名命名日志记录器是常见的做法,这是我们将使用的约定。
不管配置如何,配置可以快速改变,但是很难将日志记录实践改进到现有项目中。我们将在向项目添加代码时添加日志记录。
静态根目录/静态 URL
每个站点都需要一种方式来提供静态内容,无论是图像、CSS、JavaScript 等等。开发和生产环境之间的工作方式是不同的。在开发模式中,您放在 Django 应用的static文件夹中的所有东西(在我们的例子中是main)都将在STATIC_URL下自动可用。这是该环境中唯一重要的配置变量。
对于性能和安全性更为重要的生产环境,Django 不会服务于静态资产。相反,Django 将通过在一个唯一的目录中收集所有静态文件来支持更有效的 HTTP 服务器(如 Nginx)或类似的服务器。这个唯一的目录是在STATIC_ROOT中指定的目录。
媒体根目录/媒体 URL
Django 区分了与 Django 项目捆绑在一起的静态内容和由网站用户上传的内容。用户生成的内容是单独管理的,其配置类似于静态文件的配置。MEDIA_ROOT是本地驱动器上所有用户文件上传的位置。所有这些文件也将自动可供下载,它们的 URL 将以MEDIA_URL为前缀。
上一节中的推理同样适用于这里:如果您配置开发服务器,它将向您提供媒体文件,但是在生产环境中不建议这样做。稍后将对此进行更多介绍。
从现在开始,我们将使用以下配置:
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
MEDIA_URL = '/media/'
中间件
中间件是 Django 的一个强大特性。它允许您插入额外的代码,这些代码将在 HTTP 请求/响应周期的特定点执行。这方面的一个例子是SessionMiddleware,它用于将会话与用户相关联。
指定中间件组件的顺序很重要。一些中间件组件依赖于其他一些组件的结果,它们只能在一个方向上工作。
中间件组件的例子将在本书的后面介绍。就目前而言,了解什么是最重要的就足够了。
SessionMiddleware和AuthenticationMiddleware为有用户概念的项目提供基本功能。如果网站上没有用户自定义,您可以删除它们。
如果您想在视图层使用缓存,您可能想看看缓存中间件。如果您将提供多种语言的内容,LocaleMiddleware类将在客户机和服务器之间进行语言协商。还有很多其他的;更多细节请看 Django 中间件文档。
模板
该变量用于配置 Django 的模板引擎。如果您没有特定的互操作性需求,您可能会继续使用 Django 模板系统。在这种情况下,您可以配置一些东西。我平时改动最多的设置是context_processors。
上下文处理器是在模板范围内注入额外变量的一种方式。通过这样做,您将不必在需要这些变量的每个视图中都这样做。
一个很好的例子是在settings.py中将一些值定义为常量,并在模板中使用。在这种情况下,一种方便的方法是定义一个上下文处理器,从全局配置中读取它们,并将它们推送到模板中。这方面的一个例子将在本书的第十章中介绍。
数据库
您的项目可能会使用数据库来存储数据。使用 Django 而不是较小的框架的一个主要好处是,这方面完全是为您管理的。这是使用 PostgreSQL 进行存储的初始配置:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'mydatabase',
'USER': 'mydatabaseuser',
'PASSWORD': 'mypassword',
'HOST': '127.0.0.1',
'PORT': '5432',
}
}
请记住,用您在配置数据库时选择的细节替换上面的细节。如果您继续进行此配置,安装 PostgreSQL 驱动程序是很重要的。在Pipfile的同一个目录中,您需要运行pipenv install psycopg2,其示例输出如下所示:
Installing psycopg2...
Collecting psycopg2
Downloading psycopg2-2.7.4-cp36-cp36m-manylinux1_x86_64.whl(2.7MB)
Installing collected packages: psycopg2
Successfully installed psycopg2-2.7.4
Adding psycopg2 to Pipfile's [packages]...
Pipfile.lock (374a8f) out of date, updating to (843434)...
Locking [dev-packages] dependencies...
Locking [packages] dependencies...
Updated Pipfile.lock (843434)!
Installing dependencies from Pipfile.lock (843434)...
如果您现在不想安装 PostgreSQL,您可以随时使用 SQLite。在这种情况下,您不需要安装额外的驱动程序:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': 'mydatabase',
}
}
但是,如前所述,不建议将 SQLite 用于生产。
电子邮件 _ 后端
Django 有一个发送电子邮件的图书馆。它提供了几个后端,这是它的配置。一般来说,我使用控制台后端进行开发,使用 SMTP 后端进行生产。
在开发过程中,使用控制台后端允许您查看电子邮件,而不用担心电子邮件被您指定为接收者的人接收。在生产中,这将是您想要的行为。
秘密密钥
这是在安全敏感的上下文中使用的随机字符串。目前,知道这需要保密就足够了。
基于类和函数的视图
历史上,Django 使用函数来表示视图。基于函数的视图(FBV)只是一个 Python 函数,它将一个request object作为参数,并返回一个响应对象。当请求一个特定的 URL 时,这个函数将被调用。
很长一段时间,当其他 Python 框架在使用类的时候,Django 一直在使用函数。函数的美妙之处在于它们是最小的。没有隐藏的行为——所见即所得。可以用 decorators 添加额外的行为,但是代码仍然保持可读性。
Django 1.7 中引入了基于类的视图(cbv)。这种添加背后的原因是,有了文档化的结构和继承,开发人员将能够定制基于类的视图,而不是基于函数的视图。
对于基于函数的视图,您必须添加代码来处理您想要的所有行为,而对于基于类的视图,方法会有所不同。Django 提供了许多 cbv 作为进一步定制的起点,您可以使用继承来实现。
可以想象,方法是完全不同的。函数是纯加法的。您需要添加代码来添加行为。另一方面,对于 CBVs,您可能必须添加代码来更改默认行为,或者添加代码来禁用扩展。
基于函数的视图和基于类的视图之间没有明显的赢家。如果您想编写一个不像 Django 中的 CBV 那样的视图,请使用基于函数的视图。如果您正在修改的视图代码变得超出了 CBV 的目的,也许是时候重新考虑用基于函数的视图来编写它了。
总是使用你的判断是否你应该写一个函数或一个类。在本书中,我将介绍这两种情况。如果在任何时候你意识到你用 cbv 构建了你的代码,这使得代码难以阅读,请记住拥有自动化测试将使你能够以有限的风险改变 FBV 的视图。
测试
在完成开发后,我们应该渴望向世界展示我们的工作,如果我们关心其他人使用我们的软件,它需要工作。为了确保它能正常工作,在 web 开发中,你只需要打开你的浏览器并检查它。如果你对互动感到满意,你会把它标记为完成。
自动化测试是介于“它有效”和“它总是有效”之间的东西如果你想让人们依赖你的软件,你需要确保任何新的修改不会破坏现有的功能。虽然理论上您可以手动完成,但实际上您很少在每次发布时手动测试所有的东西。
除了添加功能,自动化测试是确信你的新版本不会破坏网站关键部分的唯一方法。类似地,自动化测试是确保你的重构没有出错的唯一方法。
测试很容易做到,但是要让它发挥作用,它需要成为我们编码程序的一部分。重要的是,我们的测试要覆盖尽可能多的代码,并且足够细粒度,以便在出现问题时能够告诉我们问题的确切位置。
在本书中,我们将使用标准的unittest包。使用这个包编写的测试适用于每一个测试运行者。除了 Python 默认的测试运行器之外,还有其他的测试运行器,比如pytest,但它们不会在本书中讨论。就我们的目的而言,标准方式对我们来说已经足够好了。
摘要
这一章阐述了 Django 的基础知识:它是什么以及安装和使用它需要什么。建议该项目安装 Python 3.7 和 PostgreSQL 10.5。
我们已经讨论了 Django 最重要的设置和如何配置它们,以及一些其他的主题,比如不同类型的视图和测试方法,我们将贯穿全书。
在下一章中,我们将从一个用例开始,一个虚构的销售书籍的公司网站。
二、从简单的公司网站开始
在这一章中,我们将开始建立网站的第一页。这些页面将提供网站应有的基本功能,如展示页面和收集线索的表单。
我们将讨论
-
基本模板
-
提供模板
-
服务于 CSS 和 JS
-
如何添加 URL
-
创建发送电子邮件的表单
从主页开始
我们要做的第一步是为我们的网站写主页。它将为我们提供一些具体的开始,我们将利用这个机会介绍一些 Django 概念。
为了提供主页,我们将从在模板中编写 HTML 开始。我们将使用在第一章中创建的应用。我们需要将templates文件夹添加到其中:
$ mkdir main/templates/
为了从比基本 HTML 更时尚的东西开始,我们将使用 Bootstrap 实现一个基本模板。Bootstrap 是一个前端框架,它为我们提供了一套良好的风格和组件。如果您不在乎,可以忽略这一点,但是本着使用框架的精神,我们将把它作为一个例子。
让我们给main/templates/home.html添加一些 HTML:
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link
rel="stylesheet"
href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css">
<title>Hello, world!</title>
</head>
<body>
<h1>Hello, world!</h1>
<script
src="https://code.jquery.com/jquery-3.2.1.slim.min.js">
</script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js">
</script>
<script
src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js">
</script>
</body>
</html>
这只是 Bootstrap 文档中的一个“Hello world”示例。它的内容并不重要,因为我们很快就会改变它,但这是一个开始!
这个页面还不可见,因为它需要一个 URL。考虑到这是主页,我们希望这个页面能够满足任何对/的请求。为此,我们需要配置 URL 路由层,将它添加到booktime/urls.py:
from django.contrib import admin
from django.urls import path
from django.views.generic import TemplateView
urlpatterns = [
path('admin/', admin.site.urls),
path(", TemplateView.as_view(template_name="home.html")),
]
现在我们终于可以进入浏览器并看到我们的结果了,如图 2-1 所示。

图 2-1
你好,世界!例子
这足以看到页面,但我们还有两个问题要解决:
-
文件从外部内容交付网络(CDN)加载,这可能对生产站点有好处,但在开发中,我们希望能够完全离线工作。
-
它没有自动测试。
我们的存储库中需要 Bootstrap 的副本。为了快速做到这一点,我们可以从 HTML 中已经有的链接下载。我们可以使用curl(或wget)来完成这项任务:
$ mkdir main/static/
$ mkdir main/static/css/
$ mkdir main/static/js/
$ curl -o main/static/css/bootstrap.min.css \ https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css
...
$ curl -o main/static/js/jquery.min.js \ https://code.jquery.com/jquery-3.2.1.slim.min.js
...
$ curl -o main/static/js/popper.min.js \ https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js
...
$ curl -o main/static/js/bootstrap.min.js \ https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js
...
所有链接的资产现在都可以脱机使用。最后要做的是用本地链接替换外部网站的链接。为此,我们将更改main/templates/home.html以使用static模板标签:
{% load static %}
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet"href="{% static "css/bootstrap.min.css" %}">
<title>Hello, world!</title>
</head>
<body>
<h1>Hello, world!</h1>
<script src="{% static "js/jquery.min.js" %}"></script>
<script src="{% static "js/popper.min.js" %}"></script>
<script src="{% static "js/bootstrap.min.js" %}"></script>
</body>
</html>
测试
现在唯一缺少的就是测试。我们想确保我们的主页一直正常运行。
编写测试时要记住的一个关键概念是,您想要测试的是行为而不是内部实现。不过,在这个具体的例子中,我们将重点测试 HTTP 级别的行为,而不是浏览器级别的行为。
我们想确保
-
该页面的 HTTP 状态代码是 200。
-
模板
home.html已被使用。 -
响应包含我们商店的名称。
我们在 Django 中的测试最初将存储在main/tests.py中。这将使我们很快开始,但我们将改变它,一旦我们写了更多的代码。
from django.test import TestCase
class TestPage(TestCase):
def test_home_page_works(self):
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'home.html')
self.assertContains(response, 'BookTime')
在 Django 中,自动化测试类似于标准的 Python 单元测试,但是它们继承自一组不同的基类。有相当多的基类,每一个都为基类unittest.TestCase增加了更多的功能。现在,我们将坚持使用django.test.TestCase。
您现在可以使用命令./manage.py test运行这个测试,它会产生以下输出:
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
F
===============================================================
FAIL: test_home_page_works (main.tests.TestPage)
---------------------------------------------------------------
Traceback (most recent call last):
File "/home/flagz/workspace/django-book/booktime/main/tests.py",...
self.assertContains(response, 'Booktime')
File "/home/flagz/.local/share/virtualenvs/booktime-8-1qP2a4/lib/...
self.assertTrue(real_count != 0, msg_prefix + "Couldn't find %s...
AssertionError: False is not true : Couldn't find 'BookTime' in response
---------------------------------------------------------------
Ran 1 test in 0.011s
FAILED (failures=1)
Destroying test database for alias 'default'...
糟糕,我们失败了。该测试期望找到单词“BookTime”,这在前面的 HTML 文件中是没有的。这需要改变。
如果你遵循本书中的例子,这种活动循环——编写代码、编写测试、运行测试、失败和修复——将会是非常常见的。对于不习惯这种方法的开发人员来说,一开始可能会显得很慢。然而,一旦你熟悉了机制,你可能会发现你在编码时付出的认知努力会减少,你会走得更快。
更改了main/templates/home.html的内容后,您的测试将会成功:
Creating test database for alias 'default'... System check identified no issues (0 silenced).
.
---------------------------------------------------------------
Ran 1 test in 0.010s
OK
Destroying test database for alias 'default'...
在我们开始创建下一个网页之前,让我们回顾一下到目前为止我们已经涉及到的领域。
模板
以下是模板的默认 Django 配置,您可以在booktime/settings.py中看到:
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'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',
],
},
},
]
在这种配置中,模板加载的工作方式是在INSTALLED_APPS中列出的所有应用中寻找一个templates目录,并试图在其中找到home.html。这是因为APP_DIRS就是True。
通过使用默认的DjangoTemplates后端,你将有一个可用的“标签”列表,由{%和%}包围的单词,你可以在模板中使用它们。其中一些标签是内置的,而另一些标签只有在加载外部模块后才可用。
在上一节中,我们看到了两个标签:{% load %}和{% static %}。在我们的例子中,我们必须将 URL 组合到我们的main/static/文件夹中的 CSS 文件。为此,正确的方法是使用static标签。然而这个标签在默认情况下是不会被加载的,这就是为什么我们必须使用load的原因。在本书的后面,我们会看到我们可以定义自己的标签,它们也是可加载的。
模板视图
TemplateView是我们在这个项目中看到的第一个基于类的视图。非常简单:它呈现一个模板。它可以接受几个关键字参数,但最重要的是template_name。该参数指定要呈现的模板的路径。其他可能的关键字参数可以是extra_content或content_type。
我们可以对这个视图进行一定程度的定制,而不需要对它进行子类化。这就是使用 cbv 的好处:在某些情况下,它允许您编写比 fbv 更少的代码。
为了完整起见,如果您想在一个函数中实现它,您不必编写比这更多的内容:
def home(request):
return render(request, "home.html", {})
这实际上就是TemplateView所做的。
资源定位符
Django 中的 URL 匹配机制从加载settings.py中的ROOT_URLCONF变量中引用的文件开始。一旦加载,它将遍历一个名为urlpatterns的变量中列出的所有条目。
遵循之前显示的文件内容,urlpatterns中有两个条目:admin/是为了使用内置的 Django admin,这将在本书后面介绍,第二个是空条目。空条目表示没有路径,因此是主页。
在需要表达的模式比第一个函数所允许的更复杂的情况下,urlpatterns中的每个条目都是django.urls.path()或django.urls.re_path()的一个实例。
Django 将遍历模式,尝试将 HTTP 请求中的请求路径与现有路径进行匹配,在我们的例子中,第一个匹配将被接受。一旦匹配,遍历就完成了。
如果我们需要,这可以让我们进行一些基于优先级的匹配:
urlpatterns = [
path('product/95/', views.product_95),
path('product/<int:id>/', views.product),
re_path(r'^product/(?P<id>[^/]+)/$', views.product_unknown),
]
第一条路径只与产品 95 匹配。第二个路径将匹配任何以整数作为标识符的产品。由于正则表达式的要求,第三个路径将匹配长度至少为一个字符的任何内容。如果你不知道什么是正则表达式,不要担心,因为我们不会在本书中用到它们。
添加“关于我们”页面
我们想要建立的网站将是一个电子商务网站,但在我们开始之前,我们需要添加一个网页来描述该公司。每个值得尊敬的公司都有一个关于我们的页面,这就是我们在这里要谈论的。
这是我们网站的第二页,这是重要的一步,因为它将允许我们引入更多的结构。从模板开始,我们希望新页面和现有页面的初始部分相同,只有核心内容不同。
为了实现这个目标,我们将开始使用模板继承。模板可以从基础模板继承,并用新数据覆盖某些部分。在 Django 的术语中,这些部分被称为“块”。在处理新页面之前,我们需要更改现有的模板。
这将是新的结构,一个名为main/templates/base.html的新文件:
{% load static %}
<!doctype html>
<html lang="en">
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="{% static "css/bootstrap.min.css" %}">
<title>BookTime</title>
</head>
<body>
<h1>BookTime</h1>
{% block content %}
{% endblock content %}
<script src="{% static "js/jquery.min.js" %}"></script>
<script src="{% static "js/popper.min.js" %}"></script>
<script src="{% static "js/bootstrap.min.js" %}"></script>
</body>
</html>
这个文件将包含以前在main/templates/home.html中的大部分 HTML。main/templates/home.html将使用上面的文件并扩展它:
{% extends"base.html" %}
{% block content %}
<h2>Home</h2>
<p>this will be the content of home</p>
{% endblock content %}
新的main/templates/about_us.html文件将如下所示:
{% extends"base.html" %}
{% block content %}
<h2>About us</h2>
<p>BookTime is a company that sells books online.</p>
{% endblock content %}
这是一个足够好的开始,但是仍然缺少一些东西:一个导航栏。我们将在这里使用引导 navbar,但是这个概念在任何前端框架中都很常见。将这段 HTML 代码插入到main/templates/base.html中,替换h1头:
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<a class="navbar-brand" href="/">BookTime</a>
<button
class="navbar-toggler" type="button"
data-toggle="collapse"
data-target="#navbarSupportedContent">
<span class="navbar-toggler-icon"></span>
</button>
<div
class="collapse navbar-collapse"
id="navbarSupportedContent">
<ul class="navbar-nav mr-auto">
<li
class="nav-item">
<a class="nav-link" href="/">Home</a>
</li>
<li
class="nav-item">
<a class="nav-link" href="/about-us/">About us</a>
</li>
</ul>
</div>
</nav>
这将给我们一个导航栏,使我们的网站更接近普通网站。现在,我们想在导航栏中突出显示当前页面。有许多方法可以做到这一点。我们将选择一个允许我们探索 Django 的另一个概念,即上下文处理器:
...
<li
class="nav-item {% if request.path == "/" %}active{% endif %}">
<a class="nav-link" href="/">Home</a>
</li>
<li
class="nav-item {% if request.path =="/about-us/" %}active{% endif %}">
<a class="nav-link" href="/about-us/">About us</a>
</li>
...
因为我们的 context_processors 中有django.template.context_processors.request,所以变量request可以在模板中使用。它是代表当前 HTTP 请求的django.http.HttpRequest的一个实例。它有很多属性,比如使用的 HTTP 方法、请求的路径、GET 参数等等。
另一个我们之前没有见过的是{% if %}模板标签。它的行为与 Python 中的if语句完全一样。
请注意,前面的代码片段使用了大量硬编码的 URL。有一个更好的方法,你将在下一章看到。
现在我们需要用新模板连接 URL /about-us/。与此同时,我们将看到 Django 的另一个新特点。正如我们对模板所做的那样,我们将进行一些重组。下面是新的booktime/urls.py:
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
path('admin/', admin.site.urls),
path(", include('main.urls')),
]
include()函数允许嵌套各种 URL 模式。在我们的例子中,我们希望将 Django URLs 与应用 URL 分开。同时,我们还为新创建的模板添加了一个新的 URL。函数的参数是下面文件的路径,将是main/urls.py。
from django.urls import path
from django.views.generic import TemplateView
urlpatterns = [
path(
"about-us/",
TemplateView.as_view(template_name="about_us.html")),
path(
"",
TemplateView.as_view(template_name="home.html")),
]
这足以看到我们的新创作,如图 2-2 所示。

图 2-2
关于我们页面
现在是时候确保它总是工作了,我们通过向现有的测试类添加一个测试来做到这一点。该测试与上一个类似:
...
def test_about_us_page_works(self):
response = self.client.get("/about-us/")
self.assertEqual(response.status_code,200)
self.assertTemplateUsed(response, 'about_us.html')
self.assertContains(response, 'BookTime')
最后,值得介绍一种更好的管理 URL 的方法,而不是对它们进行硬编码,这种方法将在以后给我们更多的自由来改变 URL 的结构。为此,我们必须命名urls.py文件中的所有 URL:
from django.urls import path
from django.views.generic import TemplateView
urlpatterns = [
path(
"about-us/",
TemplateView.as_view(template_name="about_us.html"),
name="about_us",
),
path(
"",
TemplateView.as_view(template_name="home.html"),
name="home",
),
]
这将允许我们使用一个名为reverse()的函数,它将我们刚刚插入的名称映射到实际的 URL 路径。如果将来您想要调整 URL,而不改变页面包含的信息,这是非常有用的。
我们需要删除测试中的硬编码 URL。这将是main/tests.py的最终版本:
from django.test import TestCase
from django.urls import reverse
class TestPage(TestCase):
def test_home_page_works(self):
response = self.client.get(reverse("home"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'home.html')
self.assertContains(response, 'BookTime')
def test_about_us_page_works(self):
response = self.client.get(reverse("about_us"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'about_us.html')
self.assertContains(response, 'BookTime')
创建联系我们表单
在这一点上,我们已经有两个网页在网站上,我们想增加一点互动性。许多公司都有联系方式,我们想在网站上添加一个。
在 Django 中,表单由forms库管理,这是一组管理 HTML 表单呈现和处理的函数。要使用这个库,第一步是在从基窗体继承的类中声明窗体的字段。我们将在一个新文件main/forms.py中这样做:
from django import forms
class ContactForm(forms.Form):
name = forms.CharField(label='Your name', max_length=100)
message = forms.CharField(
max_length=600, widget=forms.Textarea
)
这个类声明了一个带有两个名为name和message的文本字段的表单。当这个表单呈现为 HTML 时,它将生成两个输入小部件。在我们的例子中,将会有一个<input type="text">和一个<textarea>。
我们希望这个表单负责处理通信,在这种情况下,使用电子邮件是最简单的方法。还有其他方法,比如触发内部聊天系统的消息,但是电子邮件是无处不在的,而且 Django 非常支持它。为此,我们需要向上面的类添加一个额外的方法:
from django.core.mail import send_mail
import logging
logger = logging.getLogger(__name__)
class ContactForm(forms.Form):
...
def send_mail(self):
logger.info("Sending email to customer service")
message = "From: {0}\n{1}".format(
self.cleaned_data["name"],
self.cleaned_data["message"],
)
send_mail(
"Site message",
message,
"site@booktime.domain",
["customerservice@booktime.domain"],
fail_silently=False,
)
这里有两个新功能:Django 电子邮件库和日志。如前所述,日志记录是一种实践,如果操作正确,可以显著减少故障发生时的恢复时间。这里我们触发了一个电子邮件发送,这可能会导致崩溃。我们希望记录足够的信息来快速定位。
Django 提供了一个名为send_mail()(以及其他一些)的功能来发送电子邮件。这与 Django 紧密集成,我建议使用它,而不是使用其他 Python 邮件函数。这样做的一个主要优点是它被集成到了 Django 测试库中。
测试
和其他代码一样,contact 表单也需要进行集成测试。在继续之前,我们必须进行一些重组。我们将把main/tests.py文件分成多个文件,每个 Django 层一个。
请执行以下操作:
-
创建一个
main/tests/__init__.py空文件来表示它是一个包。 -
将当前文件
main/tests.py移动到main/tests/test_views.py。 -
在主应用中添加一个名为
main/tests/test_forms.py的新文件。
这个新的文件结构还是会被 Django 发现并运行,而且会更清晰一点。
在继续之前,还要注意关于如何命名测试的一些规则:
-
测试文件需要以前缀
test_命名。 -
出现在继承自
TestCase的类中的测试方法需要test_前缀。
注意到这一点,是时候写形式测试了。表单测试将在main/tests/test_forms.py中进行,需要测试有效和无效数据:
from django.test import TestCase
from django.core import mail
from main import forms
class TestForm(TestCase):
def test_valid_contact_us_form_sends_email(self):
form = forms.ContactForm({
'name': "Luke Skywalker",
'message': "Hi there"})
self.assertTrue(form.is_valid())
with self.assertLogs('main.forms', level="INFO") as cm:
form.send_mail()
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(mail.outbox[0].subject, 'Site message')
self.assertGreaterEqual(len(cm.output), 1)
def test_invalid_contact_us_form(self):
form = forms.ContactForm({
'message': "Hi there"})
self.assertFalse(form.is_valid())
使用./manage.py test -v 2运行测试套件,现在应该显示 Django 中构建的测试发现正在查找视图测试和表单测试:
Creating test database for alias 'default' ('test_booktime')...
...
System check identified no issues (0 silenced).
test_invalid_contact_us_form (main.tests.test_forms.TestForm) ... ok
test_valid_contact_us_form_sends_email (main.tests.test_f...) ... ok
test_about_us_page_works (main.tests.test_views.TestPage) ... ok
test_home_page_works (main.tests.test_views.TestPage) ... ok
---------------------------------------------------------------
Ran 4 tests in 0.028s
OK
Destroying test database for alias 'default' ('test_booktime')...
这就结束了我们在联系表单上要做的工作。在使该表单在站点中可见之前,我们将更详细地回顾一下我们所看到的概念。
测试发现
默认情况下,Django 使用对unittest发现功能的一些定制来查找要运行的测试。它将遍历当前文件夹中的所有子文件夹,寻找名为test*.py的文件。在这些文件中,包含在其中的unittest.TestCase的所有子类都将被包含在内。
测试套件可以以几种方式运行。默认情况下,它会运行所有的测试,当我们想要对一些特定的重构有快速的反馈时,这并不理想。以下是一些例子:
$ # Run all the views tests
$ ./manage.py test main.tests.test_views.TestPage
$ # Run the homepage test
$ ./manage.py test main.tests.test_views.TestPage.test_home_page_works
$ # Run all tests
$ ./manage.py test
除了位置参数之外,您还可以传递一些选项:--failfast如果您希望测试套件在失败时立即停止,以及--keepdb如果您希望在运行之间保留测试数据库。它们都非常有助于加速前面列出的测试命令的执行。
表单和字段
Django 表单可能非常复杂和吓人。在本节中,我们将更详细地讨论它们。
正如您在前面看到的,要创建一个从django.forms.Form继承的表单,您需要添加django.forms.fields.Field的实例。这些字段代表一条信息。在一个典型的 HTML 表单中有许多输入,因此在 Django 表单中通常会发现许多字段。
Django 表单中一些最常见的字段如下:
-
BooleanField:典型的复选框 -
CharField:文本输入框(一般为<input type="text">或<textarea>) -
ChoiceField:一组选项之间的选择器 -
DecimalField:小数 -
EmailField:一个文本输入,使用一个特殊的小部件只接受电子邮件地址 -
FileField:文件输入 -
ImageField:类似 FileField,但仅验证图像格式 -
IntegerField:整数
还有其他字段,但是这些字段为我们提供了足够多的选项来处理最常见的情况。如果您需要一个或多个这里没有列出的字段,请查阅 Django 文档,它比这个列表更全面。
每个领域都有一套被接受的核心论点。值得记住的论点是
-
required(真/假):该值是必需的还是可选的? -
label(字符串):输入的友好名称。 -
help_text(字符串):较长的描述。 -
widget:用于呈现字段的django.forms.Widget子类。有些字段可以用多种方式呈现。
除了这个参数列表,还有一些方法可以添加到 form 子类中:
-
clean_<fieldname>():如果定义了这些函数,将会调用这些函数在相应的字段上运行自定义验证。例如,如果您必须检查 fieldname 是否是一个有效的 VAT 号,那么您应该在这里进行检查。 -
clean():这是您可以执行跨字段验证规则的唯一地方,跨字段验证规则是跨多个字段验证表单的规则。
在声明表单时,您可以指定更多选项;同样,如果您感兴趣,网上有很多 Django 文档。
数据提交后,下面是一些标准方法,您可以使用这些方法从视图层与表单进行交互。根据您使用的是基于函数的视图还是基于类的视图,有些工作可能会由 Django 隐式完成。
-
运行所有的验证器和清理函数,检查所有的东西是否正确验证。
-
errors:如果表单无效,这个数组将包含所有的错误。
您也可以在模板中使用这些方法,以及所有声明的字段。有时,为了精确控制模板的呈现方式,您需要这样做。稍后将详细介绍。
发送电子邮件
我们已经介绍了函数send_mail()。此功能可用于向一个或多个收件人发送普通电子邮件,邮件正文和可选的 HTML 版本。对于比这更复杂的事情,比如附加文件,类django.core.mail.EmailMessage是可用的。
电子邮件可以通过几种方式发送,最常见的是 SMTP。大多数时候,这是您想要使用的后端,尽管在运行开发服务器时发送电子邮件可能并不理想。你最终会向人们发送垃圾邮件。
在开发过程中,最好使用console后端。或者,您可以继续使用 SMTP,但使用专门用于测试的服务,如 MailHog 或其他服务。
对我有用的一个配置片段是基于DEBUG的值选择后端:
if not DEBUG:
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
EMAIL_HOST_USER = "username"
EMAIL_HOST = 'smtp.domain.com'
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_PASSWORD = "password"
else:
EMAIL_BACKEND = (
"django.core.mail.backends.console.EmailBackend"
)
请将前面的代码添加到您的booktime/settings.py中。
将表单添加到“联系我们”页面
声明了这个表单之后,现在我们需要为联系我们页面创建一个模板和一个 URL 映射。
联系我们模板将被命名为main/templates/contact_form.html,其内容如下所示。我们将依靠表单的默认呈现方法,只需添加{{ form }}。
{% extends "base.html" %}
{% block content %}
<h2>Contact us</h2>
<p>Please fill the form below to contact us</p>
<form method="POST">
{% csrf_token %}
{{ form }}
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock content %}
{% csrf_token %}标签有助于防止跨站点请求伪造(CSRF)攻击,在这种攻击中,用户被误导到一个站点,该站点具有提交到外部站点的表单,该站点可能是我们的。默认的 Django 配置需要这个标签。我鼓励您在 Django 在线文档中阅读更多关于它的内容。
现在我们需要一个 URL 映射。我们将在main/urls.py中添加另一个条目,但在此之前,还有一些基础工作要做。这个页面不仅需要呈现一个表单,还需要处理表单提交。
为此,我们可以使用名为FormView的基于类的视图,但是 Django 要求我们指定一些过多的参数,以便直接在 URL 文件中使用它。让我们在main/views.py中创建第一个定制视图:
from django.views.generic.edit import FormView
from main import forms
class ContactUsView(FormView):
template_name = "contact_form.html"
form_class = forms.ContactForm
success_url = "/"
def form_valid(self, form):
form.send_mail()
return super().form_valid(form)
完成这些之后,是时候添加 URLs 条目了。我们将把我们的main/views.py文件导入到带有urlpatterns的文件中。为简洁起见,此处仅显示发生变化的部分:
...
from main import views
urlpatterns = [
path(
"contact-us/",
views.ContactUsView.as_view(),
name="contact_us",
),
...
]
此时,我们有了一个带有表单的工作页面。你可以自己打开浏览器查看http://localhost:8000/contact-us/。
为了完成这一部分,我们最不需要的就是一个测试。表单本身已经有一个测试。剩下要测试的是点击 URL 时视图的呈现,页面包含联系人表单。
from main import forms
...
def test_contact_us_page_works(self):
response = self.client.get(reverse("contact_us"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, 'main/contact_form.html')
self.assertContains(response, 'BookTime')
self.assertIsInstance(
response.context["form"], forms.ContactForm
)
现在,我们应该能够通过其 URL 导航到联系人表单。或者,我们可以将它添加到基本模板的导航栏中,该链接将出现在所有页面中。参见图 2-3 。

图 2-3
联系我们页面
填写网站上的表单并单击 Submit 将按预期触发电子邮件。您可以在日志中看到:
INFO Sending email to customer service
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: Site message
From: site@booktime.domain
To: customerservice@booktime.domain
Date: Sat, 07 Apr 2018 17:37:14 -0000
Message-ID: <152312263451.9182.16061866787963579063@computer>
From: hello
just want to say hello world.
---------------------------------------------------------------
在本节中,我们已经看到了许多新的 Django 函数。接下来依次对它们进行描述。
FormView(形状视图)
FormView是一个非常重要的基于类的视图,它也是与模型交互的更高级视图的基础,这些视图将在后面介绍。
除了最初呈现模板之外,FormView还使用 Post/Redirect/Get 模式处理提交。1POST 请求一进来,就运行验证,然后调用几个函数:如果表单验证,就调用form_valid(),反之则调用form_invalid()。默认情况下form_valid()重定向到success_url。
这个类也实现了和TemplateView一样的功能,就是模板渲染。它可以采用相同的关键字参数。template_name指定要渲染的模板的路径。
为了完整起见,如果您想在函数中实现联系我们视图,您将不得不多写一点:
def contact_us(request):
if request.method == 'POST':
form = forms.ContactForm(request.POST)
if form.is_valid():
form.send_mail()
return HttpResponseRedirect('/')
else:
form = forms.ContactForm()
return render(request, 'contact_form.html', {'form': form})
表单渲染
历史上,表单只提供了几种在 HTML 中呈现自己的方式;{{ form.as_p }}和{{ form.as_ul }}就是很好的例子,除了默认。
如今,许多网站用 JavaScript 和 CSS 来增强表单,这需要更注重语义而不是布局的呈现。这意味着有时您可能需要单独呈现字段。
假设我们使用 Bootstrap 作为 CSS 框架,那么可以使用 Bootstrap 表单的样式对前面的示例进行更多的定制:
<form method="POST">
{% csrf_token %}
<div class="form-group">
{{ form.name.label_tag }}
<input
type="text"
class="form-control {% if form.name.errors %}is-invalid{% endif %}"
id="id_name"
name="name"
placeholder="Your name"
value="{{ form.name.value|default:"" }}" >
{% if form.name.errors %}
<div class="invalid-feedback">
{{ form.name.errors }}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.message.label_tag }}
<textarea
class="form-control {% if form.message.errors %}is-invalid{% endif %}"
id="id_message"
name="message"
rows="3">
{{ form.message.value|default:""}}
</textarea>
{% if form.message.errors %}
<div class="invalid-feedback">
{{ form.message.errors }}
</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
在 Django 中呈现表单时有很大的灵活性。您可以使用{{ form.name }}直接访问和呈现字段,或者使用{{ form.name.label_tag }}呈现特定的内容,比如标签标记。
当一个表单被提交并且无效时,Django 将用一个包含无效状态信息的表单呈现原始页面。在 Bootstrap 中,有显示错误状态的约定,您可以在前面的清单中看到如何构造这些约定。
摘要
在这一章中,我们开始创建每个人都会在公司网站上看到的基本页面:主页、关于我们的页面、联系表单等等。为了创造这些,我们采取了循序渐进的过程。
本章举例说明了基本的基于类的视图,如TemplateView和FormView,URL 路由系统,以及 Django 的电子邮件发送功能。
大多数情况下,我们还会对项目中添加的特性进行测试,因为这是良好软件开发的重要一步。
对于联系我们页面,我们介绍了 Django 表单以及如何使用它们来管理数据提交和电子邮件发送。
在下一章,我们将讨论数据库以及如何利用它们来构建一个更复杂的 web 应用。
三、将产品目录添加到网站
在这一章中,我们将开始为我们的 BookTime 网站构建产品页面,这些页面将由数据库中包含的数据驱动。我们还将看到如何操作数据以及如何从 CSV 文件导入数据。
本章介绍
-
Django 奥姆
-
迁移
-
管理命令
-
信号
-
ListView和DetailView -
上传文件管理
创建第一个模型
Django 有一个叫做 ORM 的层,代表对象关系映射器。这是软件中的一种已知模式,已经存在多年了。它包括将从数据库加载的所有行包装到一系列模型中。模型是 Python 对象,其属性对应于数据库行中的列。
模型有与底层数据库行交互的方法:save()将模型属性的任何更改写回数据库,delete()删除数据库行。
这些模型首先被声明为 Python 类,继承自django.db.models.Model。为了让 Django 检测添加到系统中的任何新模型,它们必须包含在文件models.py中(或者包含在模型包中,就像我们在第二章中对测试所做的那样)。
产品
我们公司需要可视化关于其产品的数据,所以我们将从一个Product模型开始。要创建它,请在空文件models.py中写入以下内容:
from django.db import models
class Product(models.Model):
name = models.CharField(max_length=32)
description = models.TextField(blank=True)
price = models.DecimalField(max_digits=6, decimal_places=2)
slug = models.SlugField(max_length=48)
active = models.BooleanField(default=True)
in_stock = models.BooleanField(default=True)
date_updated = models.DateTimeField(auto_now=True)
分析前面的模型,您会注意到它是一个具有许多属性的类。那里的所有属性最终都是从django.db.models.fields.Field继承的,它们大致映射到数据库类型。例如,CharField映射到 SQL 类型VARCHAR,IntegerField映射到 SQL 整数,BooleanField映射到 SQL 布尔值,等等。
传递给字段构造函数的许多属性表示进一步的 SQL 说明符。以CharField为参考,max_length是允许的最大字符数,它作为参数传递给VARCHAR:这个最大值是在数据库级强制的。
Django 的其他部分也使用这些属性。例如,blank由 Django admin 使用。auto_now(仅适用于日期/日期时间字段)由 Django 管理:它自动使用模型的最后修改时间来更新字段。每个字段都有自己可能的配置。
一旦我们在文件中声明了模型,它仍然不能工作。我们需要首先创建一个迁移。迁移是一些特殊的文件,包含一系列创建表、添加列、删除列等数据库指令。所有数据定义语言(DLL)命令(创建和更改模式的命令)都封装在迁移文件中使用的 Python API 中。
虽然这些迁移文件可以手动创建,但是 Django 提供了makemigrations命令来自动创建它们。在大多数情况下,它像预期的那样工作,如下所示。对于一些特别困难的情况,自动创建可能无法创建正确的迁移。在这些情况下,必须手动纠正。
$ ./manage.py makemigrations
Migrations for 'main':
main/migrations/0001_initial.py
- Create model Product
为了说明我之前提出的关于包含 DDL 命令的迁移的观点,下面是我们刚刚运行的一个例子:
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Product',
fields=[
('id', models.AutoField(auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID')),
('name', models.CharField(max_length=32)),
('description', models.TextField(blank=True)),
('price', models.DecimalField(decimal_places=2,
max_digits=6)),
('slug', models.SlugField(max_length=48)),
('active', models.BooleanField(default=True)),
('in_stock', models.BooleanField(default=True)),
('date_updated', models.DateTimeField(auto_now=True)),
],
),
]
注意文件中有一个额外的id字段。当您的模型中没有字段明确具有属性primary_key=True时,这是由 Django 自动添加的。ORM 需要一个主键来将 Python 对象映射到数据库行。
产品图像
对于任何产品目录来说,每个产品都必须有一个图片。在我们的例子中,我们希望每个产品可以有任意数量的图像。为了实现这一点,关于图像的信息需要放在一个单独的表中,我们可以通过外键关系将它链接回Product模型:
class ProductImage(models.Model):
product = models.ForeignKey(
Product, on_delete=models.CASCADE
)
image = models.ImageField(upload_to="product-images")
与之前的模型相比,这里有几个更复杂的字段。ForeignKey是存储链接Product模型主键的字段。ORM 使用它在被访问时自动运行连接操作。
ImageField是FileField的子类,它专门为上传的图像提供了一些额外的功能。这些额外的功能需要一个叫做Pillow的额外的库。要安装它,需要运行pipenv install Pillow。
此时,我们可以再次运行 Django ./manage.py makemigrations命令。它将为这个模型生成一个新的迁移。迁移将包含一个CreateModel指令和三个字段,两个刚刚引入的字段和一个id字段。
ProductTag(产品标签)
我们要介绍的最后一个模型是作为类别概括的“标签”概念:一个产品可能有一个或多个标签,一个标签可能包含一个或多个产品。
class ProductTag(models.Model):
products = models.ManyToManyField(Product, blank=True)
name = models.CharField(max_length=32)
slug = models.SlugField(max_length=48)
description = models.TextField(blank=True)
active = models.BooleanField(default=True)
Django 提供了一种特殊类型的字段ManyToManyField,它自动在两个表之间创建一个链接表,在本例中是 Product Tags 和 Products。这个链接表允许您创建任何标签可以与任何产品相关联的关系,反之亦然。
这是目前的最后一种型号。记住要为此生成迁移,然后您可以通过运行migrate命令来运行它们:
$ ./manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, main, sessions
Running migrations:
...
Applying main.0001_initial... OK
Applying main.0002_productimage... OK
Applying main.0003_producttag... OK
后面的章节将介绍更多的模型,但是在本章的剩余部分,我们将关注在这三个模型之上实现功能。
缩略图生成
在这个阶段,我们已经有足够的东西来构建新的网页,但在此之前,我们将为ProductImage模型添加一些额外的功能:缩略图。
我们不想为网络客户提供太大的图像,因为加载时间太长,客户会因为我们的网站无响应而离开。
我们需要向模型添加一个新的ImageField字段,它将保存缩略图:
class ProductImage(models.Model):
...
thumbnail = models.ImageField(
upload_to="product-thumbnails", null=True
)
在其上运行makemigrations命令将生成一个带有AddField指令的新迁移:
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('main', '0003_producttag'),
]
operations = [
migrations.AddField(
model_name='productimage',
name='thumbnail',
field=models.ImageField(null=True,
upload_to='product-thumbnails'),
),
]
添加之后,这个字段的内容需要自动填充,为此,我们将使用 Django 信号。信号是在特定事件发生时运行代码的一种方式,Django 提供了许多钩子将它们连接到 ORM 操作。
信号在某些情况下非常有用,但你应该谨慎使用。使用大量信号的应用可能会变得更难调试,因为它们的执行顺序是不确定的。
编写信号处理程序时的惯例是将它们放在相关 Django 应用中的一个名为signals.py的文件中,在我们的例子中是main。这里我们将放入生成缩略图的代码:
from io import BytesIO
import logging
from PIL import Image
from django.core.files.base import ContentFile
from django.db.models.signals import pre_save
from django.dispatch import receiver
from .models import ProductImage
THUMBNAIL_SIZE = (300, 300)
logger = logging.getLogger(__name__)
@receiver(pre_save, sender=ProductImage)
def generate_thumbnail(sender, instance, **kwargs):
logger.info(
"Generating thumbnail for product %d",
instance.product.id,
)
image = Image.open(instance.image)
image = image.convert("RGB")
image.thumbnail(THUMBNAIL_SIZE, Image.ANTIALIAS)
temp_thumb = BytesIO()
image.save(temp_thumb, "JPEG")
temp_thumb.seek(0)
# set save=False, otherwise it will run in an infinite loop
instance.thumbnail.save(
instance.image.name,
ContentFile(temp_thumb.read()),
save=False,
)
temp_thumb.close()
一旦我们完成了这些,我们需要确保当 Django 应用由内部的 Django 应用注册中心启动时,这个文件被初始化。建议的方法是在main/apps.py内的应用配置中添加一个名为ready()的方法:
from django.apps import AppConfig
class MainConfig(AppConfig):
name = 'main'
def ready(self):
from . import signals
这足以确保信号被记录。现在,将为上传到网站的每个新产品图像调用处理程序。
鉴于这是我们第一次管理用户上传的文件,我们需要确保 Django 知道在哪里存储这些文件,以及从哪里提供这些文件。这两个值需要在booktime/settings.py中指定:
...
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
MEDIA_URL = "/media/"
为了测试信号,我们将编写一个 Django 测试。我们将把这个测试放在一个名为main/tests/test_signals.py的文件中。
下面的测试,以及本章中的许多测试,将依赖于一些引导数据(图像和 CSV)。这些数据包含在本书的代码库中。
from django.test import TestCase
from main import models
from django.core.files.images import ImageFile
from decimal import Decimal
class TestSignal(TestCase):
def test_thumbnails_are_generated_on_save(self):
product = models.Product(
name="The cathedral and the bazaar",
price=Decimal("10.00"),
)
product.save()
with open(
"main/fixtures/the-cathedral-the-bazaar.jpg", "rb"
) as f:
image = models.ProductImage(
product=product,
image=ImageFile(f, name="tctb.jpg"),
)
with self.assertLogs("main", level="INFO") as cm:
image.save()
self.assertGreaterEqual(len(cm.output), 1)
image.refresh_from_db()
with open(
"main/fixtures/the-cathedral-the-bazaar.thumb.jpg",
"rb",
) as f:
expected_content = f.read()
assert image.thumbnail.read() == expected_content
image.thumbnail.delete(save=False)
image.image.delete(save=False)
在测试中,我们正在创建一些数据库内容,假设测试基类继承自TransactionTestCase,那么任何创建的模型都会在测试结束时被重置。对于需要手动删除的文件,情况就不一样了。
查询集和管理器
在上一节中,我们讨论了模型,包括我们如何定义它们以及如何用它们创建数据库行。我们将研究的下一组 SQL 操作包括选择和更新,它们依赖于查询集和管理器。
管理器是负责针对模型构建查询的类。每个模型至少需要一个经理。默认情况下,django.db.models.Manager的实例通过模型中名为objects的属性连接到每个模型。管理器的所有标准方法都返回查询集。
QuerySet 是从数据库加载的模型的集合。查询集由管理器实例构造和填充。QuerySets 也有可以用来进一步过滤模型的方法,比如filter()或exclude()。查询集类似于 Python 列表:它们可以被切片,并且可以用一个for循环进行迭代。
这两个方法接受表示字段查找的关键字参数,Django 在查询时将其转换为 WHERE 子句。字段查找以field__lookuptype=value的形式表示。如果您省略了lookuptype部分,则假设您想要检查相等性。
下面是一些使用默认管理器对之前创建的模型进行查询的示例:
-
Product.objects.all():返回所有产品 -
Product.objects.filter(price__gt=Decimal("2.00")):返回价格大于 2.00 的产品 -
Product.objects.filter(price__lte=Decimal("2.00")):返回价格小于(或等于)2.00 的产品 -
Product.objects.exclude(price__gt=Decimal("12.00")):返回价格大于 12.00 的产品 -
Product.objects.filter(price=Decimal("2.00")):返回价格正好为 2.00 的产品 -
Product.objects.filter(name__icontains="cathedral"):返回名称中包含单词“cathedral”的产品(不区分大小写) -
...filter(name__startswith="The",price__gt=Decimal("9.00")):返回带有 AND 的两个子句的组合
所有前面的查询都限于一个表。如果需要匹配其他表中的字段,Django 提供了一种在查询时构建连接的方法。Product.objects.filter(producttag__name="sci-fi")就是这样的一个例子。它将返回与标签“sci-fi”相关联的产品,为了进行计算,它构建了两个额外的连接:一个连接到链接表,另一个连接到标签表。
如果您想构建更高级的查询,如 OR 查询或引用其他字段的查询,您需要使用django.db.models.Q对象或django.db.models.F对象。以下是一些例子:
-
...filter(Q(name__startswith="The") | Q(price__gt=Decimal("9.00"))):返回名称以“The”开头或价格大于 9.00 的产品。 -
...filter(price__gt=F("price") – 1):返回价格大于价格本身减 1 的产品。这是一个愚蠢的例子来证明它是如何工作的。
现在我们知道了经理是如何工作的,我们将创建一个。这样做的原因是为了添加额外的方法来返回经过筛选的查询集。假设我们在Product模型中有一个active字段,我们将添加一个带有过滤器的管理器:
class ActiveManager(models.Manager):
def active(self):
return self.filter(active=True)
声明之后,我们将通过覆盖一个由约定objects调用的属性来将其连接到模型:
class Product(models.Model):
...
objects = ActiveManager()
这个方法将可以像前面的例子一样使用:Product.objects.active()只返回活动的产品。为了完成这个功能,我们需要编写测试。这里有一个有效的方法:
from decimal import Decimal
from django.test import TestCase
from main import models
class TestModel(TestCase):
def test_active_manager_works(self):
models.Product.objects.create(
name="The cathedral and the bazaar",
price=Decimal("10.00"))
models.Product.objects.create(
name="Pride and Prejudice",
price=Decimal("2.00"))
models.Product.objects.create(
name="A Tale of Two Cities",
price=Decimal("2.00"),
active=False)
self.assertEqual(len(models.Product.objects.active()), 2)
迁移
考虑到数据库模式管理的影响力,它应该有自己的章节。我们已经看到,迁移是 Django 在模型更改后应用数据库更改的方式。这些迁移文件一旦生成,就是项目的一部分。它们需要被置于源代码控制之下,并在参与项目的每个人之间共享。
执行迁移时,需要了解一些命令:
-
makemigrations:生成移植文件(Python 语言),以后可以修改 -
migrate:运行未按顺序应用的迁移文件 -
showmigrations:列出已应用或未应用的迁移 -
sqlmigrate:显示迁移的 SQL
记住这些命令,值得再看一看我们到目前为止生成的迁移文件,并讨论迁移依赖关系。如果您打开任何迁移文件,您会注意到一个名为dependencies的属性。这用于构建依赖图。我们的如图 3-1 所示。

图 3-1
迁移
方向性
使用命令migrate,迁移可以向前或向后运行,这意味着应用更改或恢复已经应用的更改。为了让这种双向性发挥作用,在创建迁移时,应该以可逆的方式构建迁移。反向操作的一些示例如下
-
添加字段时,与删除字段相反。
-
添加表格时,与删除表格相反。
-
当删除一个可空的字段时,反过来就是将它添加回去。
然而,有些情况是不可逆转的。一些例子是不可空字段移除和将文本数据转换回数字。进行迁移时,我们应该避免进行不可逆的迁移。
作为双向性给我们的可能性的概述,我们将从分析当前数据库状态开始:
$ ./manage.py showmigrations main
main
[X] 0001_initial
[X] 0002_productimage
[X] 0003_producttag
[X] 0004_productimage_thumbnail
我们已经应用了所有迁移,但是如果我们愿意,我们可以恢复它们。使用migrate命令可以恢复它们,或者向后应用它们:
$ ./manage.py migrate main 0003_producttag
Operations to perform:
Target specific migration: 0003_producttag, from main
Running migrations:
Rendering model states... DONE
Unapplying main.0004_productimage_thumbnail... OK
在多个分支上工作时,恢复/应用迁移非常有用。如果不同版本的代码需要不同的数据库状态,这允许您切换它。要重新应用,我们只需使用migrate命令,无需指定迁移:
$ ./manage.py migrate main
Operations to perform:
Apply all migrations: main
Running migrations:
Applying main.0004_productimage_thumbnail... OK
合并迁移
当使用 Git 这样的版本控制系统时,创建分支并独立地处理相同代码库的副本是非常容易的。鉴于 Django 中的数据库结构是通过 Python 代码管理的,两个分支可能包含两组不同的模型。
两个源代码控制系统都有合并代码的方法,但是在这样做之后,我们可能会遇到这样的情况:多个迁移位于依赖图的顶部。一个例子是说明这一点的最好方法。假设在分支“A”上,有人将产品的名称字段编辑为 40 个字符,而不是当前的 32 个字符。makemigrations的结果将是
./manage.py makemigrations -n productname_40
Migrations for 'main':
main/migrations/0005_productname_40.py
- Alter field name on product
但是在另一个分支“B”上,其他人编辑了同一个 Django 应用的模型(在我们的例子中,main)。此人将标签的名称改为 40 个字符长:
$ ./manage.py makemigrations -n producttagname_40
Migrations for 'main':
main/migrations/0005_producttagname_40.py
- Alter field name on producttag
当需要将其合并到分支时,我们将有两个“0005”迁移,它们都依赖于同一个基础。如果我们试图在这种情况下运行migrate,我们会得到一个错误:
$ ./manage.py migrate
CommandError: Conflicting migrations detected;...
...multiple leaf nodes in the migration graph:
(0005_productname_40, 0005_producttagname_40 in main).
To fix them run 'python manage.py makemigrations --merge'
这是因为 Django 不允许迁移图有两个头部,这是当前状态(如图 3-2 所示)。

图 3-2
双重移民
如果我们运行推荐的makemigrations --merge命令,我们将通过一个特殊的空迁移来纠正这个问题,该迁移依赖于我们在分支 A 和 B 中创建的每个迁移(参见图 3-3 )。

图 3-3
合并后的迁移
数据迁移
除了模式更改之外,还可以使用迁移来加载数据或转换现有数据。例如,在通过三步过程更改字段类型时,通常会进行类型转换:
-
添加目标字段的模式迁移。
-
数据迁移,将数据转换并保存到新字段。
-
架构迁移以删除原始字段。
为了说明它们的用法,我们将关注一个更简单的用例。让我们创建一个将产品名称大写的数据迁移。为此,我们从空迁移开始:
$ ./manage.py makemigrations main --empty -n productname_capitalize
生成的文件需要填充我们的代码:
from django.db import migrations
def capitalize(apps, schema_editor):
Product = apps.get_model('main', 'Product')
for product in Product.objects.all():
product.name = product.name.capitalize()
product.save()
class Migration(migrations.Migration):
...
operations = [
migrations.RunPython(
capitalize,
migrations.RunPython.noop
),
]
我们没有使用像CreateModel或AlterField这样的操作,而是使用一个叫做RunPython的底层操作,它采用一个向前函数和一个向后函数。在我们的例子中,我们指定了一个 noop backward 函数,它将允许我们不做任何事情而返回,而不是引发一个异常。
一旦我们应用这一点,所有当前产品的名称都将大写。认识到这种迁移只应用一次是很重要的。如果我们的数据转换必须独立于数据库更改来应用,那么迁移可能不是最好的方法。
需要注意的一点是,在向前/向后函数中,我们必须使用那种特殊的方式来导入模型。这是因为在执行迁移时,数据库模式将不同于模型文件中声明的模式。apps.get_model()方法将返回旧模型,从它的自定义方法中剥离出来。
通过 Django admin 管理数据
Django 经常被引用的杀手级特性之一是它的管理界面。这是一个创建、更新和删除系统中任何模型的 UI。它还提供了一个认证和许可系统,用于为不同的用户分配不同级别的权限。
Admin 是一个非常有用的免费工具;它只需要被激活。为此,我们将首先创建一个名为main/admin.py的文件:
from django.contrib import admin
from . import models
admin.site.register(models.Product)
admin.site.register(models.ProductTag)
admin.site.register(models.ProductImage)
一旦我们完成了这些,我们需要创建 Django 项目的第一个用户。它将是管理员用户。Django 没有任何初始数据,因此这一步需要手动完成。为此,有一个名为createsuperuser的 Django 命令:
$ ./manage.py createsuperuser
Username (leave blank to use 'flagz'): admin
Email address: me@site.domain
Password:
Password (again):
Superuser created successfully.
这个应该够开始用了。默认情况下,可以在http://localhost:8000/admin/访问管理界面。
当使用管理界面时,用户将登陆其登录页面,一旦登录,将看到 Django 应用划分的模型列表,如图 3-4 所示。可以单击每个模型名称,这将导致一个视图,其中列出了模型的所有实例。

图 3-4
Django 管理初始页面
在这个阶段,您还没有任何数据。要完全尝试管理界面,您应该至少插入一个产品。继续插入你最喜欢的书的书名。您可以在产品列表视图中执行此操作。单击产品,然后单击右上角的添加按钮。
对于任何模型列表视图,页面上都有一些东西。右上角有一个按钮,用于添加所选模型的新实例。在左侧,有一个模型列表,以及每个模型的复选框。单击复选框后,可以对选定的模型应用操作。
管理操作是可以应用于一组模型(同类)的操作。型号列表上方的下拉框中列出了可用的操作。默认情况下,唯一可用的操作是删除。
简单定制
在这一点上,再多一点努力,我们就可以让管理界面对我们更有用。以下是列表视图的一些简单自定义:
-
list_display是字段列表,如果指定,将用于在模型列表视图中创建列。除了字段,这些也可以是函数。 -
list_filter是将用作该列表过滤器的字段列表。选中时,将只显示所有模型的子集。 -
search_fields是一个选项,当它出现时,会指示 Django 添加一个搜索框,可以用来搜索指定的字段。 -
list_editable是一个字段列表,它将使list_display中指定的一些列可编辑。
当可视化单个项目而不是项目列表时,请使用以下内容:
-
如果您有一个字段引用了另一个包含许多实体的表,那么
autocomplete_fields非常有用。由于选项的数量,这可能会使使用标准的选择框变得困难。 -
prepopulated_fields对段塞油田有用。它告诉管理界面从另一个字段自动创建 slug。 -
readonly_fields是不可编辑的字段列表。
使用所有这些定制,我们可以组合出比默认配置更友好的东西,其结果如图 3-5 所示:

图 3-5
我们的产品管理员
from django.contrib import admin
from django.utils.html import format_html
from . import models
class ProductAdmin(admin.ModelAdmin):
list_display = ('name', 'slug', 'in_stock', 'price')
list_filter = ('active', 'in_stock', 'date_updated')
list_editable = ('in_stock', )
search_fields = ('name',)
prepopulated_fields = {"slug": ("name",)}
admin.site.register(models.Product, ProductAdmin)
class ProductTagAdmin(admin.ModelAdmin):
list_display = ('name', 'slug')
list_filter = ('active',)
search_fields = ('name',)
prepopulated_fields = {"slug": ("name",)}
autocomplete_fields = ('products',)
admin.site.register(models.ProductTag, ProductTagAdmin)
class ProductImageAdmin(admin.ModelAdmin):
list_display = ('thumbnail_tag', 'product_name')
readonly_fields = ('thumbnail',)
search_fields = ('product__name',)
def thumbnail_tag(self, obj):
if obj.thumbnail:
return format_html(
'<img src="%s"/>' % obj.thumbnail.url
)
return "-"
thumbnail_tag.short_description = "Thumbnail"
def product_name(self, obj):
return obj.product.name
admin.site.register(models.ProductImage, ProductImageAdmin)
这种配置将
-
列出产品时,显示列
name, slug, in_stock,和price。在这四列中,in_stock将是可编辑的。更改产品是否有库存后,单击保存按钮保存更改。 -
使产品在指定的字段上可过滤。这些过滤器列在右侧。
-
在产品表上方添加搜索框。无论您搜索什么,Django 都会查找包含在指定字段中的字符串,忽略字母的大小写。
-
添加产品时,在输入产品名称的同时,即时计算 slug。
-
While working on the product tags, provide a similar configuration to the one of products (see Figure 3-6).
![img/466106_1_En_3_Fig6_HTML.jpg]()
图 3-6
标签管理
-
在选择属于特定标签的产品时添加自动完成功能。
-
在两列中显示图像,其内容将是 admin 类中指定的两个函数的返回值,其中一个函数返回 HTML。
-
Add a search box in the images list view. This search will be applied on the linked product table (using a JOIN) rather than the productimage table (see Figure 3-7).
![img/466106_1_En_3_Fig7_HTML.jpg]()
图 3-7
图像管理
此时,有几个问题需要解决。Django admin 在构建选择器或一般可视化项目时,依赖于它的字符串表示。在我们的例子中,自动完成需要一个好的字符串表示。要解决这个问题,我们需要向所有模型添加方法。这里有一个例子:
class Product(models.Model):
...
def __str__(self):
return self.name
最后要解决的是提供用户上传的图片。此时,虽然提供了正确的图像和缩略图 URL,但是这些 URL 返回 404。我们需要在主 URL 文件booktime/urls.py中解决这个问题。
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('main.urls')),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
这就是我们现在要定制的。在接下来的章节中,我们将讨论更高级的特性。
管理命令
到目前为止,你已经在书中看到了几个命令,如./manage.py runserver或./manage.py migrate。在 Django,这些工具被称为管理命令。这些可以随意添加,但是有一些已经包含在内,随时可以使用。您可以使用./manage.py打印可用命令的完整列表。
makemessages和compilemessages用于管理 Django 中的翻译文件。如果您的项目的用户界面需要支持多种语言,您可以使用这些。
check是 Django 系统检查框架的接口,在启动任何 Django 命令时使用,以确保满足项目运行所需的所有条件。例如,如果您的项目依赖于环境中指定的一些配置变量,这将非常有用。
dbshell和shell是交互式命令行提示。dbshell将启动数据库命令行客户端,而shell将启动 Python 解释器,Django 项目已经初始化。
loaddata和dumpdata作为简单的数据加载机制非常有用。它们被用来引导一个数据库,其中包含来自设备的数据,这些数据是 Django 能够理解的格式的简单数据文件。它们可以是 JSON 或 XML,只要结构符合 Django 的要求,就可以不用任何额外的代码来管理它们。
我们将利用loaddata和dumpdata命令来管理标签。标签将是一个固定的集合,由开发人员管理,并且这个固定装置将被提交到存储库中。我们还将介绍 Django 的另一个功能,叫做自然键。
我们可以使用 Django admin 来创建它们。添加六个标签后(见图 3-8 ,我们将使用dumpdata命令导出它们。

图 3-8
标签列表
序列化和自然键
序列化,在 Django 中,意思是将存储在数据库表中的数据转换成可以写入文件的东西。Django 支持 XML、JSON 和 YAML 文件格式。反序列化是相反的操作,从文件到填充的数据库表。
Django 通过助手函数和管理命令提供序列化和反序列化功能。我们现在将集中讨论管理命令。以下是如何使用dumpdata命令的示例:
$ ./manage.py dumpdata --indent 2 main.ProductTag
[{
"model": "main.producttag",
"pk": 2,
"fields": {
"name": "Finance",
"slug": "finance",
"description": "",
"active": true,
"products": [
16
]
}
},
...
不幸的是,这不适合与我们的库一起发布。它包含许多特定于数据库实例的内部 id。
解决这个问题的一个方法是使用独立的 id,比如 UUIDs,但是它们对人不太友好。相反,我们将使用自然键,因此我们需要对当前模型做一些事情:
-
将
ManyToManyField移出标签并移入Product模型 -
确保定义了标签的
__str__()方法 -
定义标签
natural_key()的方法
通过删除ProductTag模型中的products字段,并将其重新添加到Product模型中,可以很容易地完成第一个要点:
class Product(models.Model):
...
tags = models.ManyToManyField(ProductTag, blank=True)
...
在数据库级别,没有太大的变化:我们仍然有一个链接表。现在的区别是,这个链接更容易从产品中穿过。这个变化影响了 Django 管理,特别是autocomplete_fields设置,它也需要移动:
class ProductAdmin(admin.ModelAdmin):
...
autocomplete_fields = ('tags',)
第二个调整是添加__str__()方法,我们这样做的原因和我们已经做的一样,那就是可用性。
作为最后一步,我们将添加一个名为natural_key()的方法,它将返回标签自然键。在我们的例子中,我们将使用 slug 作为自然键。这背后的基本原理是,作为 URL 的一部分,slugs 不太可能改变(这里简化了一点)。
按照所有建议的更改,该类现在看起来应该是这样的:
class ProductTag(models.Model):
name = models.CharField(max_length=40)
slug = models.SlugField(max_length=48)
description = models.TextField(blank=True)
active = models.BooleanField(default=True)
def __str__(self):
return self.name
def natural_key(self):
return (self.slug,)
有了这个模型,就有可能使用自然键而不是内部数据库键来运行dumpdata。我们还将把下面的内容输出到一个文件中,供以后使用。生成的文件将独立于内部标识符,因此在不同的数据库环境之间更容易移植。
$ ./manage.py dumpdata --indent 2 --natural-primary main.ProductTag
[
{
"model": "main.producttag",
"fields": {
"name": "Finance",
"slug": "finance",
"description": "",
"active": true
}
},
...
$ ./manage.py dumpdata --indent 2 --natural-primary main.ProductTag \
> main/fixtures/producttags.json
如何使用自然键加载
当使用自然键加载数据时,Django 不能使用我们已经定义的natural_key()方法,因为模型加载是通过管理器进行的,而不是模型本身。为了能够加载回标签,我们需要为该模型创建一个管理器,并实现get_by_natural_key()方法:
class ProductTagManager(models.Manager):
def get_by_natural_key(self, slug):
return self.get(slug=slug)
class ProductTag(models.Model):
...
objects = ProductTagManager()
...
如果您遵循了前面的所有步骤,加载(或重新加载)它应该只是一个命令操作。为了测试这一点,我们可以在文件中添加一些标签描述并重新加载它们。如果数据已经存在于数据库中,它将被更新。
$ ./manage.py loaddata main/fixtures/producttags.json
Installed 6 object(s) from 1 fixture(s)
使用管理命令导入数据
除了已经包含的管理命令之外,项目还可以定义新的命令。一旦创建,就可以使用manage.py脚本启动它们。
我们将创建一个专用命令来导入产品数据。
为了创建管理命令,您需要在main/management/commands/中添加一个文件。您选择的文件名将成为命令名。每个文件将有一个管理命令;如果您想要创建多个命令,您将需要多个文件。
每个管理命令都可以接受选项:这些选项是用argparse解析的,Django 在指定这些选项时也有一些约定。
要开始创建管理命令的任务,我们首先需要设置一些基本文件夹:
$ mkdir main/management
$ touch main/management/__init__.py
$ mkdir main/management/commands
$ touch main/management/commands/__init__.py
这些命令将使这些文件夹成为 Python 模块,使 Django 能够检查和执行它们的内容。接下来我们将创建main/management/commands/import_data.py,它将包含进口商:
from django.core.management.base import BaseCommand
class Command(BaseCommand):
help = 'Import products in BookTime'
def handle(self, *args, **options):
self.stdout.write("Importing products")
这足以让命令执行,这里的约定清晰可见。这些文件需要包含一个继承自django.core.management.base.BaseCommand并实现handle()功能的 Python 类。这也会让 Django 发现它。
以下是一些交互示例:
$ ./manage.py
Type 'manage.py help <subcommand>' for help on a specific subcommand.
Available subcommands:
...
[main]
import_data
...
$ ./manage.py import_data
Importing products
handle()方法的主体是导入逻辑所在的地方。假设我们需要从 CSV 文件中填充数据,如下所示:
name,description,tags,image_filename,price
The cathedral and the bazaar,A book about open source methodologies,Open source|Programming,cathedral-Siddhartha,A novel by Hermann Hesse,Religion|Narrative,siddhartha.jpg,6.00
Backgammon for dummies,How to start playing Backgammon,Games|Manual,backgammon.jpg,13.00
要在本节稍后测试该命令,请将前面的数据保存在一个文件中。
这个文件包含我们数据库结构中分散在不同表中的字段。因此,我们不能在这里使用loaddata命令。另一个原因是 CSV 不是 Django 处理的序列化格式之一。这是一个导入前面文件的实现:
from collections import Counter
import csv
import os.path
from django.core.files.images import ImageFile
from django.core.management.base import BaseCommand
from django.template.defaultfilters import slugify
from main import models
class Command(BaseCommand):
help = "Import products in BookTime"
def add_arguments(self, parser):
parser.add_argument("csvfile", type=open)
parser.add_argument("image_basedir", type=str)
def handle(self, *args, **options):
self.stdout.write("Importing products")
c = Counter()
reader = csv.DictReader(options.pop("csvfile"))
for row in reader:
product, created = models.Product.objects.get_or_create(
name=row["name"], price=row["price"]
)
product.description = row["description"]
product.slug = slugify(row["name"])
for import_tag in row["tags"].split("|"):
tag, tag_created = models.ProductTag.objects.get_or_create(
name=import_tag
)
product.tags.add(tag)
c["tags"] += 1
if tag_created:
c["tags_created"] += 1
with open(
os.path.join(
options["image_basedir"],
row["image_filename"],
),
"rb",
) as f:
image = models.ProductImage(
product=product,
image=ImageFile(
f, name=row["image_filename"]
),
)
image.save()
c["images"] += 1
product.save()
c["products"] += 1
if created:
c["products_created"] += 1
self.stdout.write(
"Products processed=%d (created=%d)"
% (c["products"], c["products_created"])
)
self.stdout.write(
"Tags processed=%d (created=%d)"
% (c["tags"], c["tags_created"])
)
self.stdout.write("Images processed=%d" % c["images"])
这个新的import_data命令的代码中有很多需要解释的地方。首先,add_arguments函数:管理命令可以接受命令行选项,Django 提供了一些对所有命令都可用的选项(例如 verbosity),但是这个列表可以扩展。
我们还在所有 Django 选项之上添加了两个位置参数。第一个位置参数是要导入的 CSV 文件的路径,第二个是 images 目录的路径。add_argument的语法在argparse模块文档中有解释,这是 Django 正在使用的 Python 模块。
脚本参数的使用,而不是硬编码的变量,为这个脚本提供了灵活性。当在不同于您的机器环境的环境中运行这些导入时,您可以将它与其他命令(如wget或gunzip)结合使用,以下载和解压缩具有动态生成名称的临时文件夹中的档案。
打开 CSV 文件后,脚本循环遍历这些行,并尝试加载(或生成)具有相同名称/价格组合的产品。get_or_create函数返回两个值:一个模型和一个指示它是否是新模型的布尔标志。
一旦我们加载了一个产品,我们就通过循环浏览 CSV 文件中tags字段的所有标签来更新它的标签列表。鉴于 CSV 是一种平面格式,这个列表需要从其压缩形式(管道分隔列表)扩展。
一旦标签被插入,脚本试图通过用os.path.join()连接basedir和指定的文件名来打开图像。通过传递包装在一个ImageFile对象中的产品和打开的文件,创建了一个ProductImage模型的新实例,它添加了关于文件的额外信息。
在命令中,有几个对self.stdout.write的调用。这会写入标准输出。类似地,如果标准误差更可取,也可以使用self.stderr.write。
至于到目前为止出现的所有相当大的程序代码,这段代码需要测试,以确保它永远不会出错:
from io import StringIO
import tempfile
from django.conf import settings
from django.core.management import call_command
from django.test import TestCase, override_settings
from main import models
class TestImport(TestCase):
@override_settings(MEDIA_ROOT=tempfile.gettempdir())
def test_import_data(self):
out = StringIO()
args = ['main/fixtures/product-sample.csv',
'main/fixtures/product-sampleimages/']
call_command('import_data', *args, stdout=out)
expected_out = ("Importing products\n"
"Products processed=3 (created=3)\n"
"Tags processed=6 (created=6)\n"
"Images processed=3\n")
self.assertEqual(out.getvalue(), expected_out)
self.assertEqual(models.Product.objects.count(), 3)
self.assertEqual(models.ProductTag.objects.count(), 6)
self.assertEqual(models.ProductImage.objects.count(), 3)
如您所见,Django 提供了函数call_command()从 Python 本身调用管理命令,这在测试中非常方便。
在我们将示例 CSV 文件(以及一些图像)放在指定的位置后,就可以运行这个测试了。该测试断言 stdout 等于预期的结果,并且在导入之后出现的模型数量是应该的。
在测试中,使用了装饰器override_settings。它的目的是覆盖特定测试的 Django 设置。在本例中,我们创建了一个新的临时文件夹MEDIA_ROOT,因为我们要处理很多上传的文件。与数据库不同,Django 不清理这些文件。使用临时文件夹可以确保它最终会被操作系统清除。
对于所有涉及数据库的测试,Django 在运行这些测试之前,会创建一个临时使用的测试数据库。在测试运行结束时,此测试数据库将被删除。在我们的例子中,测试数据库将是一个新的 PostgreSQL 数据库,其名称与指定名称相同,只是添加了一个前缀test_。这种管理是自动的。
这个测试并不详尽:它没有测试 csv 文件是否存在,图像是否出现在basedir中,等等。这是留给你的练习。
添加产品列表页面
在这一章中,到目前为止,我们还没有做任何网站的工作,只有数据基础。现在是时候创建我们的第一个数据库驱动的网页了,从列出产品开始。我们可以利用另一个名为ListView的基于类的视图。Django 有很多 cbv 来帮助构建数据库驱动的视图,我们将在适当的时候使用它们。下面是我们将要使用的视图:
from django.views.generic.list import ListView
from django.shortcuts import get_object_or_404
from main import models
class ProductListView(ListView):
template_name = "main/product_list.html"
paginate_by = 4
def get_queryset(self):
tag = self.kwargs['tag']
self.tag = None
if tag != "all":
self.tag = get_object_or_404(
models.ProductTag, slug=tag
)
if self.tag:
products = models.Product.objects.active().filter(
tags=self.tag
)
else:
products = models.Product.objects.active()
return products.order_by("name")
我们的视图利用了ListView,但是增加了一个定制:一个额外的过滤参数(tag)。根据kwargs的内容,它返回属于该标签的活动产品列表,或者如果指定了标签all,则返回所有活动产品。
ListView和我们目前看到的 cbv 一样,使用template_name进行渲染。它将在呈现视图时寻找该模板。请注意,与最初的视图不同,我们添加了一个额外的main。这是一个惯例,我们将开始使用它来反映其他数据库视图所做的事情。
这个视图还使用paginate_by参数透明地管理分页。
当这个视图的实例被创建时,属性args和kwargs被来自 URL 路由的信息填充。在我们的例子中,这个视图期望用 URL 路径中指定的标记来调用,而不是在 GET 参数中。另一方面,如果标签是一个 GET 参数,可以使用self.request.GET字典访问它。
函数get_object_or_404是一个非常有用的快捷方式:它返回一个对应于指定过滤器的对象,或者引发一个 404 异常(django.http.Http404)。Django 将捕获这个消息,并创建一个 404 状态的 HTML 响应(404 是“未找到”HTTP 状态代码),如果可用的话,使用404.html模板。
要使用这个新视图,它需要 urlpatterns 中的一个条目:
from main import views
...
urlpatterns = [
path(
"products/<slug:tag>/",
views.ProductListView.as_view(),
name="products",
),
...
]
这是项目中第一个具有可变路径的 URL。Django 将尝试使用接受字母、数字、连字符和下划线的slug转换器来转换由<和>包围的部分中的任何内容。
产品列表页面需要的最后一样东西是一个模板,它将存储在main/templates/main/product_list.html中:
{% extends "base.html" %}
{% block content %}
<h1>products</h1>
{% for product in page_obj %}
<p>{{ product.name }}</p>
<p>
<a href="{% url "product" product.slug %}">See it here</a>
</p>
{% if not forloop.last %}
<hr>
{% endif %}
{% endfor %}
<nav>
<ul class="pagination">
{% if page_obj.has_previous %}
<li class="page-item">
<a
class="page-link"
href="?page={{page_obj.previous_page_number}}">Previous</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#">Previous</a>
</li>
{% endif %}
{% for pagenum in page_obj.paginator.page_range %}
<li
class="page-item{% if page_obj.number == pagenum %} active{% endif %}">
<a class="page-link" href="?page={{pagenum}}">{{pagenum}}</a>
</li>
{% endfor %}
{% if page_obj.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{page_obj.next_page_number}}">Next</a>
</li>
{% else %}
<li class="page-item disabled">
<a class="page-link" href="#">Next</a>
</li>
{% endif %}
</ul>
</nav>
{% endblock content %}
这个模板中有一些新的东西值得解释。我们在已经看到的现有基础模板和标签的基础上构建,并使用一个新的for标签。这个标记做 Python 中的等效标记所做的事情:它重复一个部分。我们将生成与列表中的元素一样多的 HTML 部分。
在for循环中,我们可以使用一个名为forloop的变量,它包含一系列我们可以使用的动态值。这个变量由 Django 自动设置。在我们的例子中,除了最后一个周期,我们为每个周期打印一个水平分隔符。
模板中的最后一部分打印分页部分。视图填充的page_obj变量包含一部分需要可视化的结果,以及一些页面信息,比如当前页面是什么,是否有下一个页面。图 3-9 显示了上面的模板在渲染时的样子。

图 3-9
产品列表视图
作为产品列表视图的最后一步,我们将添加它的测试。我们可以将它们添加到test_views.py文件中:
...
from decimal import Decimal
from main import models
class TestPage(TestCase):
...
def test_products_page_returns_active(self):
models.Product.objects.create(
name="The cathedral and the bazaar",
slug="cathedral-bazaar",
price=Decimal("10.00"),
)
models.Product.objects.create(
name="A Tale of Two Cities",
slug="tale-two-cities",
price=Decimal("2.00"),
active=False,
)
response = self.client.get(
reverse("products", kwargs={"tag": "all"})
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "BookTime")
product_list = models.Product.objects.active().order_by(
"name"
)
self.assertEqual(
list(response.context["object_list"]),
list(product_list),
)
def test_products_page_filters_by_tags_and_active(self):
cb = models.Product.objects.create(
name="The cathedral and the bazaar",
slug="cathedral-bazaar",
price=Decimal("10.00"),
)
cb.tags.create(name="Open source", slug="opensource")
models.Product.objects.create(
name="Microsoft Windows guide",
slug="microsoft-windows-guide",
price=Decimal("12.00"),
)
response = self.client.get(
reverse("products", kwargs={"tag": "opensource"})
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "BookTime")
product_list = (
models.Product.objects.active()
.filter(tags__slug="opensource")
.order_by("name")
)
self.assertEqual(
list(response.context["object_list"]),
list(product_list),
)
假设产品列表页面总是返回数据库数据,那么每个测试都需要填充这些表。两个主要的执行路径是是否有带标签的查询,每个都有一个测试。
两个测试的最后一个断言是测试是否将正确的产品传递给模板进行呈现的断言。这里我们用object_list代替page_obj,但原理差不多。
我们使用product_list作为最后一个断言的预期结果。如您所见,在创建时,所有返回 querysets 的方法都是可链接的。这就是为什么可以将active()与filter()和order_by()一起使用。
路径转换器
我在前面简单提到了 URL 路径和转换器,以及slug转换器。路径转换器和path()函数是 Django 在 2.0 版本中的新增功能。它们是一种非常方便的快捷方式,而不是重复指定长而难读的正则表达式。
格式为<type:name>。Django 已经包含了一些路径转换器:
-
str匹配除字符/以外的任何非空字符串。 -
int匹配任何大于或等于零的整数。 -
slug匹配字母、数字、连字符和下划线。 -
uuid匹配 UUIDs。 -
path像str但也配/。
如果您有一些经常出现在项目中的标识符,并且不属于任何列出的类,那么您可以创建自己的转换器。在线文档解释了在需要时如何创建它们。
添加单个产品页面
为了完善这一章,我们将为单个产品创建一个可视化效果,比产品列表页面更详细。与ListView类似,我们可以利用另一个名为DetailView的视图。
与之前的视图不同,我们的详细视图版本不需要很多定制。我们将只定制模板和连接网址。我们将依赖它的约定,包括它构造模板名称的方式。在我们的例子中是main/templates/main/product_detail.html。
{% extends "base.html" %}
{% block content %}
<h1>products</h1>
<table class="table">
<tr>
<th>Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th>Cover images</th>
<td>
{% for image in object.productimage_set.all %}
<img src="{{ image.thumbnail.url }}" alt="cover"/>
{% endfor %}
</td>
</tr>
<tr>
<th>Price</th>
<td>{{ object.price }}</td>
</tr>
<tr>
<th>Description</th>
<td>{{ object.description|linebreaks }}</td>
</tr>
<tr>
<th>Tags</th>
<td>{{ object.tags.all|join:","|default:"No tags available" }}</td>
</tr>
<tr>
<th>In stock</th>
<td>{{ object.in_stock|yesno|capfirst }}</td>
</tr>
<tr>
<th>Updated</th>
<td>{{ object.date_updated|date:"F Y" }}</td>
</tr>
</table>
<a href="{% url "add_to_basket" %}?product_id={{ object.id }}">Add to basket</a>
{% endblock content %}
模板本身非常简单:它只显示变量object的一些属性,这是DetailView为模型实例定义的。然而,这个模板展示了模板过滤器的一个很好的例子。
模板过滤器是将一些值转换成另一个值的操作,这在数据有一些原始形式不能满足的可视化需求的情况下很有帮助。
linebreaks获取description字段的内容,并为字段中的每一个新行生成一个<p>标签。join用指定的分隔符连接字符串列表。default在当前输出为 None 的情况下输出其参数。
yesno将 true/false 转换为字符串 yes/no。capfirst将第一个单词大写。date在将日期对象转换成字符串时很有用,并且可以指定格式。这种情况下的格式仅为月和年。
使用过滤器的常见格式是variable|filtername:arg1,arg2...。有些过滤器接受参数,有些不接受。
模板中另一个值得一提的是相关模型是如何被遍历的。如果一个模型有另一个表的外键,只需指定相关管理器的名称就可以调用它们的方法。例如,模板中的object.tags.all相当于 Python 中的object.tags.all()。
Django 还有很多其他的过滤器。也有创建我们自己的过滤器的方法,我们将在本书的后面看到。
为了连接起来,下面是正确的 urlpattern:
from django.views.generic.detail import DetailView
from main import models
urlpatterns =
path(
"product/<slug:slug>/",
DetailView.as_view(model=models.Product),
name="product",
),
...
使用没有子类化的DetailView对于我们的用例来说已经足够好了。正因为如此,我们避免了编写任何 Python 代码,因此,没有必要为它编写自动化测试。图 [3-10 显示了该页面的外观。

图 3-10
产品明细视图
摘要
在这一章中,我们定义了我们的第一个模型。我们对虚构公司在线销售的产品进行建模,包括描述性标签和所有链接到特定产品的图像。除了模型之外,我们还包括视图和 URL 来向网站用户显示这些数据。
我们谈到了移民。在 Django 中,迁移是数据库管理的一个重要部分。它曾经是一个独立的库,但在最近的版本中已经集成到框架中。
本章还介绍了 Django admin,这是一个供内部用户管理数据库内容的管理仪表板。在我们的例子中,我们创建面板来添加、更改和删除关于我们产品的数据。
我们还讨论了管理命令、它们是什么以及它们如何工作,以及模型序列化和反序列化。我们利用这两个概念从 CSV 文件创建了一个产品导入器。
在下一章中,我们将讨论如何在网上列出这些待售产品,以便可以通过网络直接下订单。
四、用于在线接收订单的结账系统
在第三章为我们的项目打下了数据库基础,在本章中,我们将在此基础上构建剩余的模型,以存储用户生成的数据。在我们的例子中,它是一个结账系统,这对电子商务网站非常重要。
我们将讨论以下主题:
-
定制
User模型 -
注册和登录流程
-
Django 中的 CRUD 1 视图
-
中间件组件
-
决哥小部件
-
在管理界面中显示复杂数据
用户模型
无论我们要创建什么样的模型来存储用户生成的数据,都很可能与User模型相关联。User模型包含网站用户登录所需的所有数据。这个模型也是通常在注册阶段创建的模型。
Django 有一个内置的User模型,位于django.contrib.auth.models.User。有几个字段可用:
-
username和password作为登录系统的凭证。 -
first_name、last_name、email均为可选项,为描述性字段。 -
groups是与用户所属的所有Group模型的关系。 -
user_permissions是与用户拥有的所有Permission模型的关系。 -
is_staff是表示用户是否可以使用 Django admin 的标志。 -
is_superuser是一个标志,如果为True,则允许用户做任何事情。 -
is_active代表,在最简单的情况下,用户是否可以登录。
User模型也有一些常用的方法:
-
set_password()和check_password()用于设置和检查密码。 -
has_perm()和has_perms()用于检查权限。 -
objects.create_user()创建用户。
这个模型还有许多其他的方法、属性和特性,但是这些对于最常见的操作来说已经足够了。在 Django 文档中你会找到所有的细节。
Django 还提供了用您在 Django 项目中指定的模型覆盖内置的User模型的选项,只要它继承自基类User并实现了一些必需的功能。我们现在要做的是在继续之前,因为
-
我们产品的数据可以很容易地重新导入。
-
我们现有的模型还没有一个与
User模型相关联。 -
Django 默认拥有一个
username字段并不是我们所需要的。我们希望email字段成为每个人的用户标识符,没有例外。
鉴于我们的意图是移除username字段,除了重新定义User模型,我们还需要重新定义内置的UserManager。下面是我们将要使用的代码:
from django.contrib.auth.models import (
AbstractUser,
BaseUserManager,
)
...
...
class UserManager(BaseUserManager):
use_in_migrations = True
def _create_user(self, email, password,**extra_fields):
if not email:
raise ValueError("The given email must be set")
email = self.normalize_email(email)
user = self.model(email=email, **extra_fields)
user.set_password(password)
user.save(using=self._db)
return user
def create_user(self, email, password=None, **extra_fields):
extra_fields.setdefault("is_staff", False)
extra_fields.setdefault("is_superuser", False)
return self._create_user(email, password, **extra_fields)
def create_superuser(self, email, password, **extra_fields):
extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True)
if extra_fields.get("is_staff") is not True:
raise ValueError(
"Superuser must have is_staff=True."
)
if extra_fields.get("is_superuser") is not True:
raise ValueError(
"Superuser must have is_superuser=True."
)
return self._create_user(email, password, **extra_fields)
class User(AbstractUser):
username = None
email = models.EmailField('email address', unique=True)
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = []
objects = UserManager()
通过将它添加到models.py文件中,我们能够使用我们自己版本的User模型,并确保 Django 可以对它进行操作。最后,我们需要给settings.py添加一个配置指令:
AUTH_USER_MODEL = "main.User"
这足够所有的基本系统工作了。然而,Django admin 将不起作用,因为标准配置要求存在username字段。为了解决这个问题,我们需要为自定义用户定义一个 Django 管理处理程序。将此添加到您的main/admin.py文件中:
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
...
...
@admin.register(models.User)
class UserAdmin(DjangoUserAdmin):
fieldsets = (
(None, {"fields": ("email", "password")}),
(
"Personal info",
{"fields": ("first_name", "last_name")},
),
(
"Permissions",
{
"fields": (
"is_active",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
)
},
),
(
"Important dates",
{"fields": ("last_login", "date_joined")},
),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("email", "password1", "password2"),
},
),
)
list_display = (
"email",
"first_name",
"last_name",
"is_staff",
)
search_fields = ("email", "first_name", "last_name")
ordering = ("email",)
这里,我们重新定义了 Django admin 的配置,以适应我们的定制User模型。具体来说,我们修改了一些类变量的内容。在前一章中,我们已经熟悉了其中的一些变量,但是fieldsets和add_fieldsets是新的。
这两个元组指定了在“change model”页面和“add model”页面中显示哪些字段,以及页面部分的名称。如果这些都不存在,对于任何其他模型,Django 将使每个字段都可变。然而,内置的DjangoUserAdmin引入了一些需要撤销的对默认行为的定制。
最后,我们需要重置我们的数据库和迁移。这是一个破坏性的操作,但是,不幸的是,改变User模型需要我们应用这些不可逆的改变。如果你在一个团队中工作,这将影响到每个人,因此在这样做之前找到一致意见是很重要的。
我们要做的是删除所有迁移:
$ rm main/migrations/0*
rm 'booktime/main/migrations/0001_initial.py'
rm 'booktime/main/migrations/0002_productimage.py'
rm 'booktime/main/migrations/0003_producttag.py'
rm 'booktime/main/migrations/0004_productimage_thumbnail.py'
rm 'booktime/main/migrations/0005_productname_40.py'
rm 'booktime/main/migrations/0005_producttagname_40.py'
rm 'booktime/main/migrations/0006_merge_40.py'
rm 'booktime/main/migrations/0007_productname_capitalize.py'
rm 'booktime/main/migrations/0008_move_m2m.py'
直接使用 psql 客户端重置数据库,如下所示。如果您正在使用另一个数据库,请使用适当的命令行客户端。如果您使用的是 SQLite,删除数据库文件就可以了。在运行以下命令之前,用您的数据库名称替换单词booktime。
$ psql postgres
psql (9.6.8)
Type "help" for help.
postgres=# DROP DATABASE booktime;
DROP DATABASE
postgres=# CREATE DATABASE booktime;
CREATE DATABASE
postgres=# \q
重新创建初始迁移,然后应用它:
$ ./manage.py makemigrations
Migrations for 'main':
main/migrations/0001_initial.py
- Create model User
- Create model Product
- Create model ProductImage
- Create model ProductTag
- Add field tags to product
$ ./manage.py migrate
Operations to perform:
Apply all migrations: admin, auth, contenttypes, main, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0001_initial... 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 auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying main.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying sessions.0001_initial... OK
为了测试前面所有的步骤都工作了,并且能够再次使用 Django admin,您需要再次创建一个超级用户。请注意,与第三章不同,不需要用户名。
$ ./manage.py createsuperuser
Email address: admin@admin.com
Password:
Password (again):
Superuser created successfully.
创建注册页面
现在我们已经有了我们想要的用户模型类型,是时候向世界展示它了。从外部来看,我们想要构建的功能类似于我们在第二章中看到的联系人表单:一堆字段将被填充并提交,一封电子邮件将被发送。还有更多的内容:
-
验证提交
-
在我们的用户表中创建新用户
-
发送“欢迎”电子邮件
-
登录用户
-
在页面上显示成功消息
对于列表中的前三个动作,我们将创建一个表单。这三个动作不需要在视图级别执行,因为它们不与请求对象交互。仅使用表单层将使该功能更容易被其他视图重用。
一般来说,拥有可重用的代码是一件好事。请记住,Django 存在于更广泛的 Python 环境中。有时候,使用普通的 Python 函数是封装代码时唯一需要做的事情。
将以下代码添加到main/forms.py:
...
from django.contrib.auth.forms import (
UserCreationForm as DjangoUserCreationForm
)
from django.contrib.auth.forms import UsernameField
from . import models
...
class UserCreationForm(DjangoUserCreationForm):
class Meta(DjangoUserCreationForm.Meta):
model = models.User
fields = ("email",)
field_classes = {"email": UsernameField}
def send_mail(self):
logger.info(
"Sending signup email for email=%s",
self.cleaned_data["email"],
)
message = "Welcome{}".format(self.cleaned_data["email"])
send_mail(
"Welcome to BookTime",
message,
"site@booktime.domain",
[self.cleaned_data["email"]],
fail_silently=True,
)
我们刚刚向这个表单添加了一个发送电子邮件的方法。这将与我们在第二章中看到的ContactForm表单中已经使用的方法非常相似。
对于注册,我们可以重用一个名为UserCreationForm的 Django 类。它为我们提供了一个基本的注册表单,其中密码被询问两次(第二次用于确认)。在最新的 Django 2.0 版本中,当有一个定制的User模型时,内部的Meta类需要被覆盖。Django 2.1 将改变这一点。
在我们创建表单之后,需要一些更高级的功能。让我们在main/views.py文件中创建一个基于类的视图:
...
import logging
from django.contrib.auth import login, authenticate
from django.contrib import messages
...
logger = logging.getLogger(__name__)
class SignupView(FormView):
template_name = "signup.html"
form_class = forms.UserCreationForm
def get_success_url(self):
redirect_to = self.request.GET.get("next", "/")
return redirect_to
def form_valid(self, form):
response = super().form_valid(form)
form.save()
email = form.cleaned_data.get("email")
raw_password = form.cleaned_data.get("password1")
logger.info(
"New signup for email=%s through SignupView", email
)
user = authenticate(email=email, password=raw_password)
login(self.request, user)
form.send_mail()
messages.info(
self.request, "You signed up successfully."
)
return response
这个视图重用了FormView,一个我们已经用于 contact 表单的类。不同之处在于,我们在方法form_valid()中做了更多的事情。
首先,我们创建的注册表单是一种特殊类型的表单,叫做ModelForm。按照 Django 的说法,这是对Form的专门化,提交的数据可以自动存储在模型中,表单的字段默认为相关模型中的字段。当窗体上的方法save()被调用时,存储动作发生。
后来有几个函数叫做:authenticate()和login()。这些方法用于与 Django 认证系统交互。如果用户信息存储在应用数据库中,Django 身份验证可以基于数据库,或者以其他方式(例如 LDAP/Active Directory)进行。无论在幕后使用什么抽象,这两种方法总是以相同的方式工作。
authenticate()根据认证后端,确保传递的凭证有效。如果是,它返回一个User模型的实例。如果不是,则不返回任何值。然后将结果传递给login(),后者通过一个会话将用户与当前请求和未来请求相关联。
在方法form_valid()结束之前,有一个对messages.info()的调用,一个来自 Django 消息框架的方法。这对于在浏览器的下一次 HTTP 请求时向用户显示“flash”消息非常有用。
这个方法返回一个从超类form_valid()方法获得的响应对象,它通常是一个到 URL 的重定向,要么在类变量success_url中指定,要么由方法get_success_url()返回。我们在这里实现了get_success_url()来反映内置的 Django 登录视图所做的事情:如果 GET 请求中传递了一个名为next的参数,它将使用该参数进行重定向。
这个视图现在需要一个模板(main/templates/signup.html):
{% extends"base.html" %}
{% block content %}
<h2>Sign up</h2>
<p>Please fill the form below.</p>
<form method="POST">
{% csrf_token %}
<div class="form-group">
{{ form.email.label_tag }}
<input
type="email"
class="form-control {% if form.email.errors %}is-invalid{% endif %}"
id="id_email"
name="email"
placeholder="Your email"
value="{{ form.email.value|default:"" }}" >
{% if form.email.errors %}
<div class="invalid-feedback">
{{ form.email.errors }}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.password1.label_tag }}
<input
type="password"
class="form-control {% if form.password1.errors %}is-invalid{% endif %}"
id="id_password1"
name="password1"
value="{{ form.password1.value|default:"" }}">
{% if form.password1.errors %}
<div class="invalid-feedback">
{{ form.password1.errors }}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.password2.label_tag }}
<input
type="password"
class="form-control{% if form.password2.errors %}is-invalid{% endif %}"
id="id_password2"
name="password2"
value="{{ form.password2.value|default:"" }}">
{% if form.password2.errors %}
<div class="invalid-feedback">
{{ form.password2.errors }}
</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock content %}
同样,该模板类似于“联系我们”页面的模板。由于表单字段不同,它会呈现一组不同的字段。
将视图映射到 URL 后,我们将能够看到结果:
urlpatterns = [
path('signup/', views.SignupView.as_view(), name="signup"),
...
]
我们目前所做的已经足够得到一个工作页面,如图 4-1 所示。为了完成这段代码,我们需要测试视图和表单。

图 4-1
注册页面
class TestForm(TestCase):
...
def test_valid_signup_form_sends_email(self):
form = forms.UserCreationForm(
{
"email": "user@domain.com",
"password1": "abcabcabc",
"password2": "abcabcabc",
}
)
self.assertTrue(form.is_valid())
with self.assertLogs("main.forms", level="INFO") as cm:
form.send_mail()
self.assertEqual(len(mail.outbox), 1)
self.assertEqual(
mail.outbox[0].subject, "Welcome to BookTime"
)
self.assertGreaterEqual(len(cm.output), 1)
上面的代码是要去test_forms.py,而下面的是在test_views.py。
from unittest.mock import patch
from django.contrib import auth
...
class TestPage(TestCase):
...
def test_user_signup_page_loads_correctly(self):
response = self.client.get(reverse("signup"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "signup.html")
self.assertContains(response, "BookTime")
self.assertIsInstance(
response.context["form"], forms.UserCreationForm
)
def test_user_signup_page_submission_works(self):
post_data = {
"email": "user@domain.com",
"password1": "abcabcabc",
"password2": "abcabcabc",
}
with patch.object(
forms.UserCreationForm, "send_mail"
) as mock_send:
response = self.client.post(
reverse("signup"), post_data
)
self.assertEqual(response.status_code, 302)
self.assertTrue(
models.User.objects.filter(
email="user@domain.com"
).exists()
)
self.assertTrue(
auth.get_user(self.client).is_authenticated
)
mock_send.assert_called_once()
对于视图,我们测试两种情况,第一种呈现和成功提交。对于失败的提交,Django 不会触发form_valid()函数,也不会重定向到success_url;这只是一个页面重新加载,第一个测试已经涉及了一点。
成功的提交触发了一些值得测试的东西。我们使用mock模块来测试是否调用了send_mail()函数,而不是检查是否有电子邮件发出。对于实际的发送,我们已经在前面的文件中进行了测试。
我们正在测试的其他东西是
-
响应是 HTTP 302 重定向
-
已经为该电子邮件创建了一个新的
User模型 -
新用户已经登录
更详细的模型表单
如前所述,ModelForm 是一种特殊类型的表单。它是通过将一个模型与其相关联来创建的,因此,表单中所有相应的字段都是自动创建和映射的。您还需要指定想要包括(或排除)的字段。
class Lead(models.Model):
name = models.CharField(max_length=32)
class LeadForm(forms.ModelForm):
class Meta:
model = Lead
fields = ('name', )
# Creating a form to add a lead.
>>> form = LeadForm()
# Create a form instance with POST data for a new lead
>>> form = LeadForm(request.POST)
# Creating a form to change an existing lead.
>>> lead = Lead.objects.get(pk=1)
>>> form = LeadForm(instance=lead)
# Create a form instance with POST data for an existing lead
>>> form = LeadForm(request.POST, instance=lead)
# Creates the lead entry in the database, or
# triggers an update if an instance was passed in.
>>> new_lead = form.save()
前面的代码包括一些交互的例子。通过在从forms.ModelForm继承的类的Meta类中指定模型和字段,为您生成一个管理加载、验证和保存的表单。你可以将这个ModelForm模式和任何一个FormView一起使用。
我们上面看到的表单UserCreationForm,只是ModelForm的一个子类,有一些额外的功能。
消息框架和消息级别
如前所示,messages 框架用于向用户显示消息,而不是持久化它们。下次 Django 返回 HTTP 响应时,该消息将不再存在。创建邮件很容易:
messages.debug(request, '%s SQL statements were executed.' % count)
messages.info(request, 'You signed up successfully.')
messages.success(request, 'Profile details updated.')
messages.warning(request, 'Your account expires in three days.')
messages.error(request, 'Product does not exist.')
要显示这些消息,我们所要做的就是将它们显示在呈现给用户的模板中的某个位置。在我们的例子中,我们将在基本模板main/templates/base.html中这样做,就在内容块之前:
...
</nav>
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">{{ message }}</div>
{% endfor %}
{% block content %}
...
这将足以始终在任何页面视图中呈现消息。在我们的例子中,消息标记将对应于 debug、info、success 等,这些标记可用于以不同的方式设置该块的样式。
何时使用表单或视图
尽管模型、视图和模板之间的界限被很好地标记出来,但是视图和表单之间的界限却是模糊的。视图可以做表单可以做的所有事情,但是表单巧妙地包装了一个非常常见的模式:表单呈现和验证。
表单不直接与请求对象交互,因此它们的功能有限。它们通常与request.POST交互,或者在某些特定情况下,与请求的其他属性交互。
在某些情况下,您可能会看到 request 对象被传递到 forms 并在一些 form 方法中使用。如果我们决定这样做,在创建从多个地方修改请求状态的代码时,我们必须小心,因为代码的可读性可能会降低。
创建登录页面
创建注册页面后的下一步是创建登录页面。我们的页面将要求输入电子邮件地址和密码,并尝试验证用户提交的数据。Django 提供了一个名为LoginView的视图,我们可以重用它,但是我们需要为它创建一个定制的表单,因为我们有自己的User模型。
让我们把这个加到main/forms.py:
from django.contrib.auth import authenticate
...
class AuthenticationForm(forms.Form):
email = forms.EmailField()
password = forms.CharField(
strip=False, widget=forms.PasswordInput
)
def __init__(self, request=None, *args, **kwargs):
self.request = request
self.user = None
super().__init__(*args, **kwargs)
def clean(self):
email = self.cleaned_data.get("email")
password = self.cleaned_data.get("password")
if email is not None and password:
self.user = authenticate(
self.request, email=email, password=password
)
if self.user is None:
raise forms.ValidationError(
"Invalid email/password combination."
)
logger.info(
"Authentication successful for email=%s", email
)
return self.cleaned_data
def get_user(self):
return self.user
在这个表单中,与前一个不同,我们需要覆盖__init__()方法来接受请求对象。因为authenticate()函数需要,request对象将被用于验证。
假设 Django 中已经包含了一个视图,我们将在main/urls.py文件中直接使用它,传递一些参数来覆盖默认值:
from django.contrib.auth import views as auth_views
from main import forms
...
urlpatterns = [
path(
"login/",
auth_views.LoginView.as_view(
template_name="login.html",
form_class=forms.AuthenticationForm,
),
name="login",
),
...
]
我们需要创建前面提到的模板。这是我们的版本,结果如图 4-2 所示:

图 4-2
登录页面
{% extends "base.html" %}
{% block content %}
<h2>Login</h2>
<p>Please fill the form below.</p>
<form method="POST">
{% csrf_token %}
{{ form.non_field_errors }}
<div class="form-group">
{{ form.email.label_tag }}
<input
type="email"
class="form-control {% if form.email.errors %}is-invalid{% endif %}"
id="id_email"
name="email"
placeholder="Your email"
value="{{ form.email.value|default:"" }}">
{% if form.email.errors %}
<div class="invalid-feedback">
{{ form.email.errors }}
</div>
{% endif %}
</div>
<div class="form-group">
{{ form.password.label_tag }}
<input
type="password"
class="form-control{% if form.password.errors %}is-invalid{% endif %}"
id="id_password"
name="password"
value="{{ form.password.value|default:"" }}">
{% if form.password.errors %}
<div class="invalid-feedback">
{{ form.password.errors }}
</div>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock content %}
这类似于我们已经看到的标准表单模板。注意form.non_field_errors,在渲染我们的无效用户/密码错误时使用。我们使用它是因为错误不是特定于字段的。
为了完成这个,我们将在 Django 设置中定义一个名为LOGIN_REDIRECT_URL的额外设置。我们将把它设置为一个定义好的 URL,比如/。如果我们没有定义这个,默认的 URL 将是/accounts/profile/,它并不存在。
管理用户的地址
现在系统中有了用户,我们需要开始管理一些用户信息,从地址开始。我们将在稍后构建结账系统时使用这些信息。这是我们将要研究的模型:
class Address(models.Model):
SUPPORTED_COUNTRIES = (
("uk", "United Kingdom"),
("us", "United States of America"),
)
user = models.ForeignKey(User, on_delete=models.CASCADE)
name = models.CharField(max_length=60)
address1 = models.CharField("Address line 1", max_length=60)
address2 = models.CharField(
"Address line 2", max_length=60, blank=True
)
zip_code = models.CharField(
"ZIP / Postal code", max_length=12
)
city = models.CharField(max_length=60)
country = models.CharField(
max_length=3, choices=SUPPORTED_COUNTRIES
)
def __str__(self):
return ", ".join(
[
self.name,
self.address1,
self.address2,
self.zip_code,
self.city,
self.country,
]
)
我们的Address模型包含描述地址的最常见字段。这里唯一值得注意的是country字段上的choices属性:它用于将字段的内容限制为一组给定的条目。
这个修饰符接受一个对列表(或元组)。这些对的结构是,第一个条目是存储的值,第二个条目是显示的值。这样,我们将所需的存储量限制为三个字符,同时保持显示更长字符串的能力。
此时,运行./manage.py makemigrations,如果生成的迁移对于前面的模型是正确的,则运行./manage.py migrate。
下一步是为用户提供添加、更改和删除地址的方法。幸运的是,Django 通过这些基于类的视图:ListView、CreateView、UpdateView和DeleteView,使得这个高级操作变得简单。
from django.urls import reverse_lazy
from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.edit import FormView, CreateView, UpdateView, DeleteView
from django.views.generic.edit import (
FormView,
CreateView,
UpdateView,
DeleteView,
)
...
class AddressListView(LoginRequiredMixin, ListView):
model = models.Address
def get_queryset(self):
return self.model.objects.filter(user=self.request.user)
class AddressCreateView(LoginRequiredMixin, CreateView):
model = models.Address
fields = [
"name",
"address1",
"address2",
"zip_code",
"city",
"country",
]
success_url = reverse_lazy("address_list")
def form_valid(self, form):
obj = form.save(commit=False)
obj.user = self.request.user
obj.save()
return super().form_valid(form)
class AddressUpdateView(LoginRequiredMixin, UpdateView):
model = models.Address
fields = [
"name",
"address1",
"address2",
"zip_code",
"city",
"country",
]
success_url = reverse_lazy("address_list")
def get_queryset(self):
return self.model.objects.filter(user=self.request.user)
class AddressDeleteView(LoginRequiredMixin, DeleteView):
model = models.Address
success_url = reverse_lazy("address_list")
def get_queryset(self):
return self.model.objects.filter(user=self.request.user)
将这些视图添加到我们的main/views.py文件中,我们就有足够的功能为用户提供我们计划的特性。这里有很多要描述的。列出的所有视图都带有一个model参数,它指定了这些视图正在创建、更新、列出或删除的模型。对于创建和更新操作,视图使用的模型字段列表需要是明确的。
然而,每个用户必须只能操作他们自己的地址,这就是为什么前面代码中的get_queryset()方法基于用户所有权对它们进行预过滤。地址创建视图不需要预过滤,但是需要用户在内部设置正确的值,这就是为什么我们在form_valid()方法中使用了self.request.user。
所有这些视图只有在登录时才能访问,因为它们需要用户,这就是 mixin LoginRequiredMixin的功能。
前面代码中的最后三个视图正在更改数据库,它们是包含两个步骤的操作:
-
创建流程包括数据输入步骤和数据提交步骤。
-
更新流程包括数据编辑步骤和数据提交步骤。
-
删除流程有确认步骤和删除步骤。
在第二步结束时,所有这些视图都重定向到在success_url中指定的 URL。这里使用的reverse_lazy()函数类似于url模板标签:它查找指定的命名 URL 并返回它的 URL。
我们将继续为目前为止在main/urls.py中所写的内容分配以下 URL 和名称:
urlpatterns = [
path(
"address/",
views.AddressListView.as_view(),
name="address_list",
),
path(
"address/create/",
views.AddressCreateView.as_view(),
name="address_create",
),
path(
"address/<int:pk>/",
views.AddressUpdateView.as_view(),
name="address_update",
),
path(
"address/<int:pk>/delete/",
views.AddressDeleteView.as_view(),
name="address_delete",
),
...
由于更新和删除操作总是在现有模型上操作,这些视图接受通过 URL 传递的pk参数。如果您想为模型使用 slug,对它的支持已经内置到这个类正在使用的SingleObjectMixin中。
这些视图现在需要模板。如果没有指定模板名称,就像在我们的例子中一样,这些名称是通过使用模型名称和视图类型自动生成的。他们遵循模式<app_name>/<model_name>_<operation_name>.html。
对于ListView,模板为main/templates/main/address_list.html:
{% extends "base.html" %}
{% block content %}
<h1>List of your addresses:</h1>
{% for address in object_list %}
<p>
{{ address.name }}<br>
{{ address.address1 }}<br>
{{ address.address2 }}<br>
{{ address.city }}<br>
{{ address.get_country_display }}<br>
</p>
<p>
<a href="{% url "address_update" address.id %}">Update address</a>
</p>
<p>
<a href="{% url "address_delete" address.id %}">Delete address</a>
</p>
{% if not forloop.last %}
<hr>
{% endif %}
{% endfor %}
<p>
<a href="{% url "address_create" %}">Add new address</a>
</p>
{% endblock content %}
这个非常基本的布局足以让我们可视化所有用户的地址。
下一个是创造。对于 CreateView,模板放入main/templates/main/address_form.html:
{% extends "base.html" %}
{% block content %}
<h2>Add a new address</h2>
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock content %}
与我们目前看到的表单模板不同,我们在这里使用了一个快捷方式。我们使用表单的as_p方法自动呈现所有字段,使用<p>作为分隔符。这对生产站点没有太大的帮助,但是现在已经足够了。
我们下一个操作是更新。UpdateView 将加载main/templates/main/address_update.html。该模板与前一个几乎完全相同:
{% extends "base.html" %}
{% block content %}
<h2>Add a new address</h2>
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock content %}
最后一个模板用于删除操作。这里没有表格,只有一个要求确认的页面。
删除视图的模板是main/templates/main/address_confirm_delete.html:
{% extends "base.html" %}
{% block content %}
<h2>Delete address</h2>
<form method="POST">
{% csrf_token %}
<p>Are you sure you want to delete it?</p>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock content %}
这包括了我们在 Web 上使用该功能所需的所有视图。为了完成这一点,值得为我们对 cbv 所做的覆盖编写测试。我们将测试用户是否只能看到自己的地址,以及地址创建是否与当前用户相关联。我们将这些测试添加到现有的test_views.py文件中:
...
def test_address_list_page_returns_only_owned(self):
user1 = models.User.objects.create_user(
"user1", "pw432joij"
)
user2 = models.User.objects.create_user(
"user2", "pw432joij"
)
models.Address.objects.create(
user=user1,
name="john kimball",
address1="flat 2",
address2="12 Stralz avenue",
city="London",
country="uk",
)
models.Address.objects.create(
user=user2,
name="marc kimball",
address1="123 Deacon road",
city="London",
country="uk",
)
self.client.force_login(user2)
response = self.client.get(reverse("address_list"))
self.assertEqual(response.status_code, 200)
address_list = models.Address.objects.filter(user=user2)
self.assertEqual(
list(response.context["object_list"]),
list(address_list),
)
def test_address_create_stores_user(self):
user1 = models.User.objects.create_user(
"user1", "pw432joij"
)
post_data = {
"name": "john kercher",
"address1": "1 av st",
"address2": "",
"zip_code": "MA12GS",
"city": "Manchester",
"country": "uk",
}
self.client.force_login(user1)
self.client.post(
reverse("address_create"), post_data
)
self.assertTrue(
models.Address.objects.filter(user=user1).exists()
)
创建购物篮功能
购物篮或购物车的概念是任何电子商务网站的基石。如果没有这个功能,用户将无法购买产品,网站也无法在线赚钱。为了构建这个篮子,我们将首先向我们的main/models.py文件添加以下内容:
from django.core.validators import MinValueValidator
...
class Basket(models.Model):
OPEN = 10
SUBMITTED = 20
STATUSES = ((OPEN, "Open"), (SUBMITTED, "Submitted"))
user = models.ForeignKey(
User, on_delete=models.CASCADE, blank=True, null=True
)
status = models.IntegerField(choices=STATUSES, default=OPEN)
def is_empty(self):
return self.basketline_set.all().count() == 0
def count(self):
return sum(i.quantity for i in self.basketline_set.all())
class BasketLine(models.Model):
basket = models.ForeignKey(Basket, on_delete=models.CASCADE)
product = models.ForeignKey(
Product, on_delete=models.CASCADE
)
quantity = models.PositiveIntegerField(
default=1, validators=[MinValueValidator(1)]
)
我们将从这个非常简单的篮子开始。请生成迁移并将其应用到您的数据库。
我们对篮子本身有一个模型,还有许多链接回它的BasketLine模型。然后,每个BasketLine模型将连接到一个特定的产品,并有一个名为quantity的额外字段来存储篮子中有多少这种产品。
我已经在前一节解释了choices参数,但是这里我们将它用于一个整数字段。这个参数可以应用于任何类型的字段,但是整数是最节省空间的,因此我选择了它们。
在第二个模型中,对于 quantity 字段,我们将传递一个名为validators的额外参数。验证器对数据添加额外的检查以防止保存。在这种情况下,我们希望确保 quantity 字段的值永远不会小于 1。将其设置为零的唯一方法是删除模型。
现在我们有了模型,我们将介绍 Django 的一个我们还没有见过的特性,中间件。在 Django 中,中间件是一个函数(更准确地说,是一个可调用的函数),它包装并向视图提供额外的功能。他们能够在请求进入视图时修改请求,在请求离开视图时修改响应。
我们将使用它来自动将网篮连接到 HTTP 请求。我们在中间件中这样做,因为我们将在几个视图和模板中使用篮子,这有助于我们避免对特定代码重复相同的调用。
将此内容写入main/middlewares.py:
from . import models
def basket_middleware(get_response):
def middleware(request):
if 'basket_id' in request.session:
basket_id = request.session['basket_id']
basket = models.Basket.objects.get(id=basket_id)
request.basket = basket
else:
request.basket=None
response = get_response(request)
return response
return middleware
我们可以清楚地看到,每次视图激活都有一些代码在进行,这发生在调用get_response()方法时。在此之前,代码检查来自 Django 提供的另一个中间件的会话是否包含一个basket_id。如果是,它将载入篮子并把它分配给request.basket。
中间件可以依赖于其他中间件,这就是这里正在发生的事情:我们依赖于SessionMiddleware。要使用basket_middleware,我们需要将它添加到 Django settings.py文件中的常量MIDDLEWARES中,在会话中间件之后:
MIDDLEWARE = [
'django.contrib.sessions.middleware.SessionMiddleware',
...
'main.middlewares.basket_middleware',
]
添加了这个中间件之后,我们现在可以在每个模板中呈现篮子了。为了演示这一点,我们将把这个代码片段添加到基本模板main/templates/base.html,就在{% block content %}之前:
...
{% if request.basket %}
<div>
{{ request.basket.count }}
items in basket
</div>
{% endif %}
...
添加到购物篮视图
现在我们准备开始处理视图。我们将创建的第一个视图是将产品添加到购物篮中。我们将使用基于函数的视图,而不是基于类的视图,因此它与您目前看到的语法不同:
from django.http import HttpResponseRedirect
from django.urls import reverse
...
def add_to_basket(request):
product = get_object_or_404(
models.Product, pk=request.GET.get("product_id")
)
basket = request.basket
if not request.basket:
if request.user.is_authenticated:
user = request.user
else:
user = None
basket = models.Basket.objects.create(user=user)
request.session["basket_id"] = basket.id
basketline, created = models.BasketLine.objects.get_or_create(
basket=basket, product=product
)
if not created:
basketline.quantity += 1
basketline.save()
return HttpResponseRedirect(
reverse("product", args=(product.slug,))
)
在这个视图中,我们可以依靠中间件将现有的购物篮放置在request.basket属性中。只有当购物篮存在,并且其 id 已经存在于会话中时,这才会起作用。这个视图还将负责创建一个篮子(如果它还不存在的话),并为中间件处理任何后续请求做必要的步骤。
将它实现为基于类的视图是可能的,但是就内置的 cbv 而言,我们没有多少可以重用的。作为一个类这样做将会导致更多的代码而没有更多的功能,这就是为什么我把它写成一个函数。
要从模板中使用它,需要一个 URL:
urlpatterns = [
...
path(
"add_to_basket/",
views.add_to_basket,
name="add_to_basket",
),
...
]
现在可以从产品详细信息模板中引用该视图。在</table>后增加以下几行:
...
<a
href="{% url "add_to_basket" %}?product_id={{ object.id }}">
Add to basket
</a>
为了最终确定并测试这一点,我们可以使用以下测试:
...
class TestPage(TestCase):
...
def test_add_to_basket_loggedin_works(self):
user1 = models.User.objects.create_user(
"user1@a.com", "pw432joij"
)
cb = models.Product.objects.create(
name="The cathedral and the bazaar",
slug="cathedral-bazaar",
price=Decimal("10.00"),
)
w = models.Product.objects.create(
name="Microsoft Windows guide",
slug="microsoft-windows-guide",
price=Decimal("12.00"),
)
self.client.force_login(user1)
response = self.client.get(
reverse("add_to_basket"), {"product_id": cb.id}
)
response = self.client.get(
reverse("add_to_basket"), {"product_id": cb.id}
)
self.assertTrue(
models.Basket.objects.filter(user=user1).exists()
)
self.assertEquals(
models.BasketLine.objects.filter(
basket__user=user1
).count(),
1,
)
response = self.client.get(
reverse("add_to_basket"), {"product_id": w.id}
)
self.assertEquals(
models.BasketLine.objects.filter(
basket user=user1
).count(),
2,
)
这个测试覆盖了前面视图中的所有代码:购物篮创建、重用和BasketLine操作。
管理购物篮视图
我们网站需要的第二个视图是一个页面,用于更改数量和删除购物篮中的行。我们将使用 Django 的另一个我们还没有用过的功能:表单集。
表单集是处理同一类型的多个表单的一种方式。当在同一个页面上修改一个表单的多个条目时,这非常方便。要创建表单集,有几个“工厂”函数:
-
最简单的方法,最适合正常的形式
-
modelformset_factory():相当于模型表单,但应用于表单集 -
inlineformset_factory():与上述类似,但更具体地说是与基本对象相关的对象(通过外键)
在我们的项目中,我们希望构建一个页面来修改购物篮的内容。我们需要一个连接到篮子的每个篮子线的表单。在我们的例子中,我们可以使用inlineformset_factory()。我们将把它添加到我们的main/forms.py文件中:
from django.forms import inlineformset_factory
...
BasketLineFormSet = inlineformset_factory(
models.Basket,
models.BasketLine,
fields=("quantity",),
extra=0,
)
此表单集将自动为连接到指定购物篮的所有购物篮行构建表单;唯一可编辑的字段将是数量,没有额外的表单来添加新条目,因为我们是通过add_to_basket视图来添加的。
Django 不包含任何基于类的视图和表单集。我们必须自己为它编写整个基于函数的视图。
这是需要放入main/views.py中的视图:
from django.shortcuts import get_object_or_404, render
...
def manage_basket(request):
if not request.basket:
return render(request, "basket.html", {"formset": None})
if request.method == "POST":
formset = forms.BasketLineFormSet(
request.POST, instance=request.basket
)
if formset.is_valid():
formset.save()
else:
formset = forms.BasketLineFormSet(
instance=request.basket
)
if request.basket.is_empty():
return render(request, "basket.html", {"formset": None})
return render(request, "basket.html", {"formset": formset})
如果用户还没有购物篮,或者有一个购物篮但它是空的,前面的视图将不会呈现任何表单集。如果购物篮不为空,将为GET请求呈现表单集,当表单通过POST请求提交回来时,将处理提交。
与上述视图相匹配的模板放入main/templates/basket.html:
{% extends "base.html" %}
{% block content %}
<h2>Basket</h2>
{% if formset %}
<p>You can adjust the quantities below.</p>
<form method="POST">
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
<p>
{{ form.instance.product.name }}
{{ form }}
</p>
{% endfor %}
<button type="submit" class="btn btn-default">Update basket</button>
</form>
{% else %}
<p>You have no items in the basket.</p>
{% endif %}
{% endblock content %}
该模板通过直接通过instance访问模型来呈现被表单排除的产品名称。表单集工作的一个必要条件是在模板中呈现management_form属性。这用于表单集在表单之上提供的额外功能。
为了结束此功能,让我们添加 URL:

图 4-3
管理购物篮视图
urlpatterns = [
path('basket/', views.manage_basket, name="basket"),
...
]
假设视图相当简单,并且大部分功能都是由表单集管理的,那么就不做测试了。
登录时合并篮子
我们的核心篮子功能几乎已经完成,但仍有一些边缘情况可以处理得更好。其中之一是处理用户将书放入购物篮,然后登录,却发现所选的书已经不在购物篮中的情况。出现这种明显奇怪的行为是因为登录用户连接到了另一个 Django 会话。
为了解决这个问题,我们需要实现一些合并篮子的代码。一旦用户登录,我们就会这样做,为此我们可以利用另一个名为user_logged_in的内置信号。下面的代码向您展示了如何(main/signals.py):
from django.contrib.auth.signals import user_logged_in
from .models import Basket
...
@receiver(user_logged_in)
def merge_baskets_if_found(sender, user, request,**kwargs):
anonymous_basket = getattr(request,"basket",None)
if anonymous_basket:
try:
loggedin_basket = Basket.objects.get(
user=user, status=Basket.OPEN
)
for line in anonymous_basket.basketline_set.all():
line.basket = loggedin_basket
line.save()
anonymous_basket.delete()
request.basket = loggedin_basket
logger.info(
"Merged basket to id %d", loggedin_basket.id
)
except Basket.DoesNotExist:
anonymous_basket.user = user
anonymous_basket.save()
logger.info(
"Assigned user to basket id %d",
anonymous_basket.id,
)
这段代码实现了所描述的行为。总之,测试是可取的。
我们将用一个测试来测试这个流,在main/tests/test_views.py:
from django.contrib import auth
from django.urls import reverse
...
def test_add_to_basket_login_merge_works(self):
user1 = models.User.objects.create_user(
"user1@a.com", "pw432joij"
)
cb = models.Product.objects.create(
name="The cathedral and the bazaar",
slug="cathedral-bazaar",
price=Decimal("10.00"),
)
w = models.Product.objects.create(
name="Microsoft Windows guide",
slug="microsoft-windows-guide",
price=Decimal("12.00"),
)
basket = models.Basket.objects.create(user=user1)
models.BasketLine.objects.create(
basket=basket, product=cb, quantity=2
)
response = self.client.get(
reverse("add_to_basket"), {"product_id": w.id}
)
response = self.client.post(
reverse("login"),
{"email": "user1@a.com", "password": "pw432joij"},
)
self.assertTrue(
auth.get_user(self.client).is_authenticated
)
self.assertTrue(
models.Basket.objects.filter(user=user1).exists()
)
basket = models.Basket.objects.get(user=user1)
self.assertEquals(basket.count(),3)
一个更好的篮子数量小部件
我们将应用于购物篮的最后一点是一个更好的小部件,用于管理产品数量的变化。我们希望呈现带有加号和减号的单独的更大的按钮,以方便用户更改数字。
为此,我们需要改变表单中呈现quantity字段的方式。因为该字段继承自IntegerField,Django 默认情况下将使用内置的NumberInput小部件。
让我们从创建main/widgets.py开始,并包含我们第一个小部件的代码:
from django.forms.widgets import Widget
class PlusMinusNumberInput(Widget):
template_name = 'widgets/plusminusnumber.html'
class Media:
css = {
'all': ('css/plusminusnumber.css',)
}
js = ('js/plusminusnumber.js',)
小部件引用其 HTML 的外部模板。这里我们还定义了一个Media子类,包含一些额外的 CSS 和 JavaScript 来输出。虽然template_name是一个特定的小部件功能,但是定义一个Media子类是一个可以应用于小部件和表单的功能。
我们将把这个 HTML 用于小部件(main/templates/widgets/plusminusnumber.html):
<input
type="number"
name="{{ widget.name }}"
class="form-control quantity-number"
value="{{ widget.value }}"
min="1"
max="10"
{% include "django/forms/widgets/attrs.html" %} />
<button
type="button"
class="btn btn-default btn-number btn-minus"
data-type="minus"
data-field="{{ widget.name }}">
-
</button>
<button
type="button"
class="btn btn-default btn-number btn-plus"
data-type="plus"
data-field="{{ widget.name }}">
+
</button>
我们还将使用一些 CSS 自定义输入框,并使用 JavaScript 添加一些交互性。这些变化是非常基本的,但是对于您来说,这是一个很好的起点,您可以吸取这些经验教训,并将它们应用到更复杂的场景中。
这是我们需要的 CSS,放在main/static/css/plusminusnumber.css中:
.quantity-number {
-moz-appearance:textfield;
}
.quantity-number:: -webkit-inner-spin-button,
.quantity-number:: -webkit-outer-spin-button {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
margin: 0;
}
和一些 JavaScript 来添加交互(main/static/js/plusminusnumber.js):
$(function () {
$('.btn-number').click(function (e) {
e.preventDefault();
fieldName = $(this).attr('data-field');
type = $(this).attr('data-type');
var input = $("input[name='" + fieldName + "']");
var currentVal = parseInt(input.val());
if (type == 'minus') {
if (currentVal > input.attr('min')) {
input
.val(currentVal - 1)
.change();
}
if (parseInt(input.val()) == input.attr('min')) {
$(this).attr('disabled', true);
}
} else if (type == 'plus') {
if (currentVal < input.attr('max')) {
input
.val(currentVal + 1)
.change();
}
if (parseInt(input.val()) == input.attr('max')) {
$(this).attr('disabled', true);
}
}
});
});
特别是对于 JavaScript,我们需要确保它的加载发生在任何依赖项的加载之后。在我们的例子中,假设我们在基本模板中包含了 jQuery,我们需要确保它出现在之前。为此,我们将在基础模板main/templates/base.html中添加一个空块。该块将在需要注入额外 JavaScript 的模板中被覆盖。
{% load static %}
<!doctype html>
<html lang="en">
...
<script src="{% static "js/jquery.min.js" %}"></script>
<script src="{% static "js/popper.min.js" %}"></script>
<script src="{% static "js/bootstrap.min.js" %}"></script>
{% block js %}
{% endblock js %}
</body>
</html>
最后,我们将在main/forms.py中指示 formset 工厂使用这个小部件:
from . import widgets
...
BasketLineFormSet = inlineformset_factory(
models.Basket,
models.BasketLine,
fields=("quantity",),
extra=0,
widgets={"quantity": widgets.PlusMinusNumberInput()},
)
我们将从我们的basket.html模板中覆盖这个块的内容:
{% extends "base.html" %}
{% block content %}
...
{% endblock content %}
{% block js %}
{% if formset %}
{{ formset.media }}
{% endif %}
{% endblock js %}
订单和结账
对于任何电子商务网站来说,关键的一步是从一个装满的篮子到系统中有一个订单。用户需要遍历的流程称为“结帐”
我们将创建一个简单的流程,其结构如图 4-4 所示。

图 4-4
结账漏斗
基础模型
从这些特性开始,我们将首先用一系列模型来奠定基础。就像我们对Basket和BasketLine模型所做的那样,我们将添加一个Order模型和一个OrderLine model。虽然这两个模型的基本原理是相似的(将订单/购物篮链接到许多产品),但我们的OrderLine模型将有一个重要的不同。
虽然BasketLine可以包含许多产品,但是对于每个订购的产品,OrderLine只有一个条目。这样做的原因是,我们希望状态字段具有单个订购项目的粒度。
class Order(models.Model):
NEW = 10
PAID = 20
DONE = 30
STATUSES = ((NEW, "New"), (PAID, "Paid"), (DONE, "Done"))
user = models.ForeignKey(User, on_delete=models.CASCADE)
status = models.IntegerField(choices=STATUSES, default=NEW)
billing_name = models.CharField(max_length=60)
billing_address1 = models.CharField(max_length=60)
billing_address2 = models.CharField(
max_length=60, blank=True
)
billing_zip_code = models.CharField(max_length=12)
billing_city = models.CharField(max_length=60)
billing_country = models.CharField(max_length=3)
shipping_name = models.CharField(max_length=60)
shipping_address1 = models.CharField(max_length=60)
shipping_address2 = models.CharField(
max_length=60, blank=True
)
shipping_zip_code = models.CharField(max_length=12)
shipping_city = models.CharField(max_length=60)
shipping_country = models.CharField(max_length=3)
date_updated = models.DateTimeField(auto_now=True)
date_added = models.DateTimeField(auto_now_add=True)
class OrderLine(models.Model):
NEW = 10
PROCESSING = 20
SENT = 30
CANCELLED = 40
STATUSES = (
(NEW, "New"),
(PROCESSING, "Processing"),
(SENT, "Sent"),
(CANCELLED, "Cancelled"),
)
order = models.ForeignKey(
Order, on_delete=models.CASCADE, related_name="lines"
)
product = models.ForeignKey(
Product, on_delete=models.PROTECT
)
status = models.IntegerField(choices=STATUSES, default=NEW)
对于任何main/models.py文件更改,请生成并应用迁移。
就像我们对Basket模型所做的一样,我们将使用状态字段来管理我们的电子商务商店中的工作流。为了简单起见,我们将不构建在线支付系统。客服会将订单标记为已支付,配送经理会将订单行标记为相关状态。
每个订单都将关联一个用户和一个帐单/送货地址。这里我们没有使用任何外键;相反,我们复制了Address模型的内容。这将及时生成订单快照,用户地址的任何后续更改都不会影响现有订单。
订单还有两个与之相关的时间戳:注意auto_now和auto_now_add属性。这些由 Django 管理,并在模型更新和创建时自动更新。
line 模型也有一些新的东西值得解释。参数related_name用于在从Order实例访问订单行时指定一个更好的名称。通过指定这一点,我们可以用order.lines.all()而不是更冗长的缺省值order.orderline_set.all()来访问订单的所有行。
产品线模型中产品的ForeignKey字段有说明符models.PROTECT。这与我们通常的models.CASCADE不同。我们在数据库级别实施的行为阻碍了任何有序产品的删除。
为了生成订单,我们将编写一个附加到购物篮模型的创建方法:
import logging
logger = logging.getLogger(__name__)
...
def create_order(self, billing_address, shipping_address):
if not self.user:
raise exceptions.BasketException(
"Cannot create order without user"
)
logger.info(
"Creating order for basket_id=%d"
", shipping_address_id=%d, billing_address_id=%d",
self.id,
shipping_address.id,
billing_address.id,
)
order_data = {
"user":self.user,
"billing_name": billing_address.name,
"billing_address1": billing_address.address1,
"billing_address2": billing_address.address2,
"billing_zip_code": billing_address.zip_code,
"billing_city": billing_address.city,
"billing_country": billing_address.country,
"shipping_name": shipping_address.name,
"shipping_address1": shipping_address.address1,
"shipping_address2": shipping_address.address2,
"shipping_zip_code": shipping_address.zip_code,
"shipping_city": shipping_address.city,
"shipping_country": shipping_address.country,
}
order = Order.objects.create(**order_data)
c=0
for line in self.basketline_set.all():
for item in range(line.quantity):
order_line_data = {
"order": order,
"product": line.product,
}
order_line = OrderLine.objects.create(
**order_line_data
)
c += 1
logger.info(
"Created order with id=%d and lines_count=%d",
order.id,
c,
)
self.status = Basket.SUBMITTED
self.save()
return order
考虑到这段代码的重要性,我们将为test_models.py添加一个测试:
...
def test_create_order_works(self):
p1 = models.Product.objects.create(
name="The cathedral and the bazaar",
price=Decimal("10.00"),
)
p2 = models.Product.objects.create(
name="Pride and Prejudice", price=Decimal("2.00")
)
user1 = models.User.objects.create_user(
"user1", "pw432joij"
)
billing = models.Address.objects.create(
user=user1,
name="John Kimball",
address1="127 Strudel road",
city="London",
country="uk",
)
shipping = models.Address.objects.create(
user=user1,
name="John Kimball",
address1="123 Deacon road",
city="London",
country="uk",
)
basket = models.Basket.objects.create(user=user1)
models.BasketLine.objects.create(
basket=basket, product=p1
)
models.BasketLine.objects.create(
basket=basket, product=p2
)
with self.assertLogs("main.models", level="INFO") as cm:
order = basket.create_order(billing, shipping)
self.assertGreaterEqual(len(cm.output), 1)
order.refresh_from_db()
self.assertEquals(order.user, user1)
self.assertEquals(
order.billing_address1, "127 Strudel road"
)
self.assertEquals(
order.shipping_address1, "123 Deacon road"
)
# add more checks here
self.assertEquals(order.lines.all().count(), 2)
lines = order.lines.all()
self.assertEquals(lines[0].product, p1)
self.assertEquals(lines[1].product, p2)
你会注意到我们需要建立多少数据。在接下来的章节中,我将介绍一种减轻这种情况的方法,但这只是暂时的。调用create_order()方法将返回一个订单,但是为了确保我们正在处理一个干净的副本,调用了refresh_from_db()方法。
结账流程
为了实现本节开始时描述的结帐流程,我们需要对现有代码进行一些更改,并增加一些新的东西。在连接页面之前,我们将首先构建页面。新页面是地址选择页面,它需要一个表单:
class AddressSelectionForm(forms.Form):
billing_address = forms.ModelChoiceField(
queryset=None)
shipping_address = forms.ModelChoiceField(
queryset=None)
def __init__(self, user, *args, **kwargs):
super(). __init__(*args, **kwargs)
queryset = models.Address.objects.filter(user=user)
self.fields['billing_address'].queryset = queryset
self.fields['shipping_address'].queryset = queryset
这种形式与目前看到的形式不同,它在声明的字段中动态地指定参数。在这种情况下,我们将地址限制为连接到当前用户的地址。原因很简单:我们不希望用户能够选择系统中任何可用的地址。
为了管理这个表单,我们可以创建一个继承自FormView的相应视图,并用我们想要的定制来填充这个类:
class AddressSelectionView(LoginRequiredMixin, FormView):
template_name = "address_select.html"
form_class = forms.AddressSelectionForm
success_url = reverse_lazy('checkout_done')
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs['user'] = self.request.user
return kwargs
def form_valid(self, form):
del self.request.session['basket_id']
basket = self.request.basket
basket.create_order(
form.cleaned_data['billing_address'],
form.cleaned_data['shipping_address']
)
return super().form_valid(form)
这里指定的方法完成了大部分工作:get_form_kwargs()从请求中提取用户并将其返回到字典中。然后这个字典被超类传递给表单。FormView超类自动调用这个函数,因为这是一个非常常见的模式,用于向表单传递额外的变量。
在form_valid()方法中,我们从会话中删除购物篮,并用提交的地址数据对其调用create_order()方法。
我们现在需要填补缺失的部分,从main/templates/address_select.html模板开始:
{% extends "base.html" %}
{% block content %}
<h2>Select the billing/shipping addresses</h2>
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn btn-primary">Submit</button>
<a class="btn btn-primary" href="{% url "address_create" %}">Add a new one</a>
</form>
{% endblock content %}
这是一个非常标准的表单模板。唯一增加的是到我们在本章开始时创建的地址创建页面的链接。此页面需要从篮子模板链接。这是main/templates/basket.html的修改版:
{% extends "base.html" %}
{% block content %}
<h2>Basket</h2>
{% if formset %}
<p>You can adjust the quantities below.</p>
<form method="POST">
{% csrf_token %}
{{ formset.management_form }}
{% for form in formset %}
<p>
{{ form.instance.product.name }}
{{ form }}
</p>
{% endfor %}
<button type="submit" class="btn btn-default">Update basket</button>
{% if user.is_authenticated %}
<a href="{% url "address_select" %}" class="btn btn-primary">Place order</a>
{% else %}
<a
href="{% url "signup" %}?next={% url"address_select" %}"
class="btn btn-primary">Signup</a>
<a
href="{% url "login" %}?next={% url "address_select" %}"
class="btn btn-primary">Login</a>
{% endif %}
</form>
{% else %}
<p>You have no items in the basket.</p>
{% endif %}
{% endblock content %}
{% block js %}
{% if formset %}
{{ formset.media }}
{% endif %}
{% endblock js %}
记下额外的链接以进入下一步。这个漏斗中的下一步是注册或登录(在用户未被认证的情况下),或者是地址选择(在用户已经登录的情况下)(如图 4-5 所示)。

图 4-5
地址选择页面
在本章的前面,我们创建了注册和登录页面,如果指定了名为next的 GET 参数,那么如果表单提交成功,该参数的值将是用户被重定向到的 URL。我们将使用此功能来构建漏斗。
在我们定义 URL(并且有一个功能性的漏斗)之前,我们将定义漏斗的最后一页,也就是在本节前面被命名为checkout_done的 URL。我们将在main/templates/order_done.html中为它添加一个非常简单的模板:
{% extends "base.html" %}
{% block content %}
<p>Thanks for your order.</p>
{% endblock content %}
最后一步是为我们之前创建的两个视图添加 URL:
...
urlpatterns =
path(
"order/done/",
TemplateView.as_view(template_name="order_done.html"),
name="checkout_done",
),
path(
"order/address_select/",
views.AddressSelectionView.as_view(),
name="address_select",
),
...
我们的结帐流程现在完成了。这个漏斗现在需要的是一点造型。我们在第 [2 章中说过我们正在使用 Bootstrap,HTML 中有几个 CSS 类是其中的一部分,但是剩下的就看你自己了。
在这一章的最后,我们将讨论 Django Admin 以及如何在其中可视化结帐数据。
在 Django 管理中显示签出数据
Basket模型和Order模型都有一个与之链接的相应的“lines”模型。当显示一个购物篮或一个订单时,如果 Django 管理员能够显示所有这些链接的模型和原始模型,将会很有帮助。Django 通过使用“内联”来支持这一点。
首先,将这些添加到您的main/admin.py文件中:
class BasketLineInline(admin.TabularInline):
model = models.BasketLine
raw_id_fields = ("product",)
@admin.register(models.Basket)
class BasketAdmin(admin.ModelAdmin):
list_display = ("id", "user", "status", "count")
list_editable = ("status",)
list_filter = ("status",)
inlines = (BasketLineInline,)
class OrderLineInline(admin.TabularInline):
model = models.OrderLine
raw_id_fields = ("product",)
@admin.register(models.Order)
class OrderAdmin(admin.ModelAdmin):
list_display = ("id", "user", "status")
list_editable = ("status",)
list_filter = ("status", "shipping_country", "date_added")
inlines = (OrderLineInline,)
fieldsets = (
(None, {"fields": ("user", "status")}),
(
"Billing info",
{
"fields": (
"billing_name",
"billing_address1",
"billing_address2",
"billing_zip_code",
"billing_city",
"billing_country",
)
},
),
(
"Shipping info",
{
"fields": (
"shipping_name",
"shipping_address1",
"shipping_address2",
"shipping_zip_code",
"shipping_city",
"shipping_country",
)
},
),
)
上面我们声明了嵌套在其他实例中的ModelAdmin的实例(通过inlines属性)。对于像Basket和Order这样的模型,它们的外键指向它们相关的line模型,我们需要使用内联来显示相关数据。
属性raw_id_fields用于改变外键的呈现方式。默认情况下,外键小部件用选择框呈现,所有可能的关系都在选择选项中呈现。如果像在我们的例子中,有许多可能的关系,列表会变得很长,这会对性能产生影响,甚至触发超时,给定足够的记录。
其他大多数属性已经在本章或上一章中介绍过了。如果你不记得了,我鼓励你回去找到它们的用途。显然,所有这些信息都可以在 Django 的官方文档中找到。
我们使用@admin.register()装饰器而不是直接调用admin.register()来注册这些新类。两种方式都有效。
摘要
在这一章中,我们首先重新定义了User模型,删除用户名并强制发送电子邮件。然后,我们创建了结账所需的模型,包括购物篮和订单。
我们还为用户添加了站点登录和注册页面,以及添加和管理用户购物篮中的项目的视图。
中间件用于管理模板中的购物篮渲染和相关视图中的使用。Django widgets 功能也引入了购物篮管理页面。
我们在本章的某些部分使用了 ModelForms 和 Django 消息,作为自动生成表单和管理短暂消息的一种方式,这些消息向用户展示了在被执行的视图中发生了什么。
这一章是书中最长的一章,介绍了许多功能。在下一章中,我们将从数据库的深度退一步,讨论静态资产。
五、将 CSS/JavaScript 添加到 Django 项目中
如今,每个网站都使用 CSS 和 JavaScript,有些比其他网站用得更多。本章概述了这些技术,以及它们如何与 Django 集成。
我们对这些技术的调查不会很广泛——前端生态系统中的大多数工具发展得太快,以至于一本书无法保持更新。相反,我们将采取一种极简的方法,只使用几个相当稳定的库。但是,请记住,您可能仍然需要将您在这里读到的内容应用到所展示的工具的未来版本中。
管理静态文件
静态文件是作为 HTTP 请求的结果直接提供给浏览器的任何东西。以下是静态文件的一些示例:
-
CSS 文件
-
JavaScript 文件
-
形象
-
录像
-
字体
-
前端的数据文件
Django 以同样的方式管理所有这些资源。静态资源可以特定于 Django 应用,也可以存储在项目级别。当使用其开发服务器时,Django 将提供STATIC_URL下每个应用的每个static文件夹的内容,如第一章所述,不管它是什么类型。
我们在第四章中已经看到了这样一个例子,当时我们引入了一个小部件来更新购物篮数量。我们已经将支持 CSS 和 JavaScript 分别放在了main/static/css和main/static/js中,如果我们的项目设置是正确的,它将与小部件一起提供这些文件。
对于简单的 JavaScript 和 CSS,这就是你所需要的。只要这些文件准备好被客户端解释,就没有更多的事情要做了。不过,在过去几年中,预处理 CSS 和 JavaScript 的工具变得越来越常见,允许您使用更高级的功能,比如最新版本的 JavaScript。
这些预处理器可以与 Django 集成。我们将在接下来的章节中探讨其中的一些。
CSS 预处理程序
CSS 预处理程序给 CSS 世界带来了更多的复杂性。从历史上看,CSS 一直是一种极其简单的语言,只有选择器和属性,仅此而已。出于可维护性的考虑,这可能还不够。
本章的目的不是强迫你选择一个 CSS 预处理器,或者任何预处理器,而是向你展示预处理器有多强大,以及如何与 Django 一起使用。如果你的项目不需要这些,你可以安全地跳到下一章。
Sass ( https://sass-lang.com )是最常用的预处理器之一。最初是用 Ruby 写的,现在有了更容易与现有 Django 项目集成的 C 版本。在 CSS 之上,它增加了对变量、混合、内联导入、继承等的支持。
少( http://lesscss.org )是另一个著名的预处理器。语法从一开始就更接近 CSS。与 SASS 不同,它是用 JavaScript 编写的,可以在 Node 和浏览器中运行。
Stylus ( http://stylus-lang.com )是另一种预处理器,尽管它没有 Sass 和 less 那么常用。它是更复杂的一种,有许多功能。它也是用 JavaScript 编写的,需要 Node 才能运行。
使用或不使用 JavaScript 框架
毫无疑问,JavaScript 在前端开发中占有重要地位。近年来,这种语言经历了一些重大的发展,浏览器中的解释器变得非常复杂。
与 Python 不同,JavaScript 不附带“电池”当你安装 Python 时,你安装了一个相当大的标准库。JavaScript 没有标准库。在正常的前端开发工作流程中,您可能需要引入外部库。
JavaScript 是 web 浏览器中唯一可用的语言,而 web 浏览器又可用于几乎任何类型的计算机,所以这种语言本身是无处不在的。它的使用将虚拟的 HTML 和 CSS 转化为给网站增加逻辑和交互的东西。
你的网站需要逻辑和动态交互吗?如果是这样,无论如何,从这一章中学习并做你自己的研究,并广泛使用它。请注意,并不是所有的网站都需要这种复杂程度:有时简单的 HTML 是最好的选择。另一方面,用户希望现代网站表现出一定程度的速度和智能。使用 JavaScript 可能是满足用户期望的唯一方法。
我们网站上 JavaScript 的使用量可以根据我们的需要而增减。我们可以使用 JavaScript 为特定页面的各个部分添加动态性,并且可以使用它完全覆盖通常驻留在后端的功能:URL 路由、模板、表单验证等等。它的使用范围由我们决定。
我们想要集成的 JavaScript 的范围和复杂性应该决定我们是否使用框架。框架的概念既适用于 JavaScript,也适用于 Python。这关系到时间效率,也关系到不要重新发明轮子。然而,它也必须坚持框架的约定。
以下是目前可以用来管理我们网站 UI 的常见 JavaScript 项目的(非详尽)列表:
-
React (
https://reactjs.org):脸书创建的用于构建用户界面的库。它的范围比以下任何一个库都小,它提供了一个很好的项目生态系统来扩展它的核心功能。这是一个普遍的选择。 -
Vue.js (
https://vuejs.org):一个非常灵活的 JavaScript 框架,提供的不仅仅是现成的 React。这是一个年轻的项目,但已经有很多用户。 -
Angular (
https://angular.io):比较老也比较完整的 JavaScript 框架之一。它特别适合用于单页应用(spa)和非常复杂的用户界面。根据我的经验,它更侧重于企业。 -
jQuery (
https://jquery.com):可能是第一个非常成功的 JavaScript 库。它不是为了管理 UI 而诞生的,而是为了包装当时没有标准化的语言部分(和文档对象模型),当时浏览器还没有今天这么先进。尽管年代久远,它仍被广泛使用。与列表中的其他库相比,围绕这个库构建的生态系统是最大的。
spa 和 Django 的后果
单页应用(SPA) 是一种 web 应用(或网站),它不依赖于标准的浏览器页面重新加载,而是使用 JavaScript 动态重写活动页面内容。这样一来,SPA 通常会为最终用户带来更流畅的体验。
当决定是否在我们的网站上使用这种方法时,我们需要考虑其后果。我们将把所有的用户界面问题转移到客户端,客户端将包含比纯 HTML 网站更多的逻辑。
这些架构在服务器上将会变得更薄。大多数 spa 的结构是直接与 REST APIs(或者最近的 GraphQL)一起工作,并直接在浏览器中组成这些数据的视图。将所有的模板和 URL 路由转移到客户端使得后端更可能包含返回 JSON 或 XML 数据的视图,而不是 HTML。
水疗也有一些缺点。使用 spa 使得网站很难针对搜索引擎进行优化。谷歌最近开始抓取和执行网页上的 JavaScript,但有很多限制;因此,仅仅依靠这一点可能是不明智的。
谷歌不是唯一的搜索引擎。其他搜索引擎也可能采取类似的行动,但同样,在这种规模上执行 JavaScript 不是一件容易的事情,所以我们不应该假设搜索引擎会正确地抓取 SPA。
实际上,在 Django 完全有可能开设水疗中心。除了 SPA 的 JS 和 CSS 文件包之外,您还需要一个 bootstrap HTML 页面,它加载一个带有根容器的空页面,通常是一个<div>标签。
除此之外,spa 处理路由客户端。我们需要确保路由到特定的部分不会发生在服务器端。对此的解决方案是为客户端管理的所有链接提供相同的引导页面。
这是一组典型的 URL 示例:
from django.views.generic import TemplateView
urlpatterns = [
path('api/', include(api_patterns)),
...
path(' ', TemplateView.as_view(template_name="spa.html")),
# Catchall URL
re_path(r'^.*/$', TemplateView.as_view(template_name="spa.html")),
引导页面spa.html将包含如下内容:
{% load staticfiles }
<!DOCTYPEhtml>
<html>
<head>
<title>SPA</title>
</head>
<body>
<div id="main-app">
Loading...
</div>
<script src="{% static 'js/spa.bundle.js' %}"></script>
</body>
</html>
JavaScript 测试如何适用
在一个简单的 Django 项目中,您会发现大部分的复杂性都在后端,这也是大部分自动化测试发生的地方。然而,如果前端的复杂性增加了,自动化测试也在那里发生是很重要的。
前端的复杂性通常存在于 UI 交互中,这是后端所不具备的。在更高级的用例中,前端还需要在浏览器中存储状态。虽然无状态交互更容易测试,但是随着前端变得更有状态,测试技术也需要相应地改变。
最后,Django 还配备了功能/系统测试。在这个层面上,我们需要小心行事。话虽如此,对最重要的用户流进行系统测试,可能会让我们不必解释为什么在线销售突然停止了。
单元、集成和端到端测试
根据测试中涉及的代码量,测试通常分为三类:
-
单元测试:单个组件的测试,可以是 Python 函数,也可以是 Django 视图激活。这里的目的是尽可能详细地说明要测试的代码。如果您的测试涉及一些外部资源,比如数据库或网络,那么它就不是单元测试。
-
集成测试:测试多个组件的交互。我们在前面章节中编写的大多数测试都与在数据库中设置数据有关。考虑到这个过程涉及多个系统,在我们的例子中是 Postgres 和 Django,可以肯定地说这些测试是集成测试。
-
端到端(E2E)测试:有时被称为功能测试,包括对整个系统的整体测试。这包括我们的项目数据库和用户在浏览器中所做的一切。这种类型的测试很难编写并且运行缓慢;因此,项目应该限制 E2E 测试的次数。
注意,参与端到端测试的浏览器可以是真实的,也可以是无头的。无头浏览器是一种在开发者的显示器上不显示任何窗口的浏览器,但是仍然执行处理和呈现页面所需的动作。无头浏览器比真正的浏览器更快,因为它们不将呈现的站点呈现在屏幕上,但它们不是最终用户所拥有的。
一般来说,除非你有一些性能要求,否则使用真正的浏览器。当测试正在运行时,你将能够看到比你的代码认为的失败更多的东西,即使你的代码没有测试它。你会在屏幕上看到错误。
在产品页面上添加图像切换器
我们现在正通过应用我们已经获得的一些知识从理论走向实践。在我们的项目中,我们有可能为每个产品上传许多图像,但我们还没有建立一个智能的方式来显示这些图像。我们将构建一个非常简单的图像切换器,当点击相关缩略图时,它将显示原始图像。
我们将使用 React 来编写 JavaScript,但是任何框架都可以。请不要关注我对框架的选择,而是关注最终的解决方案。现在,关于 Django 集成,我们也将尽可能保持简单。
让我们在产品页面上添加 React 组件。我们将通过修改main/templates/main/product_detail.html来做到这一点:
{% extends"base.html" %}
{% block content %}
<h1>products</h1>
<table class="table">
<tr>
<th>Name</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th>Cover images</th>
<td>
<div id="imagebox">
Loading...
</div>
</td>
</tr>
...
</table>
{% endblock content %}
{% block js %}
<script
src="https://unpkg.com/react@16/umd/react.production.min.js">
</script>
<script
src="https://unpkg.com/react-dom@16/umd/react-dom.production.min.js">
</script>
<style type="text/css" media="screen">
.image{
margin: 10px;
display: inline-block;
}
</style>
<script>
const e=React.createElement;
class ImageBox extends React.Component{
constructor(props){
super(props);
this.state = {
currentImage: this.props.imageStart
}
}
click(image){
this.setState({
currentImage: image
});
}
render(){
const images = this.props.images.map((i)=>
e('div', {className: "image", key: i.id},
e('img', {onClick: this.click.bind(this, i),
width: "100",
src: i.thumbnail}),
),
);
return e('div', {className: "gallery"},
e('div', {className: "current-image"},
e('img',{src: this.state.currentImage.image})
),
images)
}
}
document.addEventListener("DOMContentLoaded",
function(event) {
var images = [
{% for image in object.productimage_set.all %}
{"image": "{{ image.image.url|safe }}",
"thumbnail": "{{ image.thumbnail.url|safe }}"},
{% endfor %}
]
ReactDOM.render(
e(ImageBox, {images: images, imageStart: images[0]}),
document.getElementById('imagebox')
);
});
</script>
{% endblock %}
这足以增加切换功能。如果你点击页面上的任何一个小图片,它的完整版本会显示在上面的大框中。也有一些基本的 CSS 样式。结果页面如图 5-1 所示。

图 5-1
图像切换器
用硒进行 E2E 测试
我们准备开始构建第一个端到端测试。为此,我们必须先安装一些软件包。在主项目文件夹中,Pipfile所在的位置,键入以下内容:
$ pipenv install selenium
这将安装 Selenium 驱动程序。Selenium 是 web 应用的测试框架。它用于自动化用户可以在网站上进行的所有操作,例如键盘和鼠标事件。Selenium 将创建一个浏览器副本(例如 Firefox ),并在没有我们干预的情况下进行试验。
我们将使用 Firefox 进行测试。请确保您安装了 Firefox。你还需要安装 Geckodriver,这是一个充当 Firefox 和 Selenium 之间桥梁的软件。你可以在 Mozilla GitHub 页面的https://github.com/mozilla/geckodriver/releases找到这个。
确保您从 GitHub 下载的 Geckodriver 二进制文件安装在您的可执行文件路径中。在 Linux 上,一个好地方是/usr/local/bin。
在 Mac 上,整个安装可以通过命令brew install geckodriver来完成。
一旦我们完成了所有这些设置(我们只需要做一次),我们就可以开始我们的第一个端到端测试了。将以下内容放入main/tests/test_e2e.py:
from decimal import Decimal
from django.urls import reverse
from django.core.files.images import ImageFile
from django.contrib.staticfiles.testing import (
StaticLiveServerTestCase
)
from selenium.webdriver.firefox.webdriver import WebDriver
from main import models
class FrontendTests(StaticLiveServerTestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.selenium = WebDriver()
cls.selenium.implicitly_wait(10)
@classmethod
def tearDownClass(cls):
cls.selenium.quit()
super().tearDownClass()
def test_product_page_switches_images_correctly(self):
product = models.Product.objects.create(
name="The cathedral and the bazaar",
slug="cathedral-bazaar",
price=Decimal("10.00"),
)
for fname in ["cb1.jpg", "cb2.jpg", "cb3.jpg"]:
with open("main/fixtures/cb/ s" %fname, "rb") as f:
image = models.ProductImage(
product=product,
image=ImageFile(f, name=fname),
)
image.save()
self.selenium.get(
"%s%s"
% (
self.live_server_url,
reverse(
"product",
kwargs={"slug":"cathedral-bazaar"},
),
)
)
current_image = self.selenium.find_element_by_css_selector(
".current-image > img:nth-child(1)"
).get_attribute(
"src"
)
self.selenium.find_element_by_css_selector(
"div.image:nth-child(3) > img:nth-child(1)"
).click()
new_image = self.selenium.find_element_by_css_selector(
".current-image > img:nth-child(1)"
).get_attribute("src")
self.assertNotEqual(current_image, new_image)
该测试将启动 Firefox,创建一个包含三个图像的产品,加载产品页面,并单击图像切换器底部的一个缩略图。最后一个断言测试完整的图像是否已经更改为测试所点击的图像。
在运行前面的测试之前,确保在main/fixtures/cb/中有三个样本图像,并相应地命名。
与我们目前看到的测试不同,这个测试继承了StaticLiveServerTestCase。虽然标准的TestCase使用一个非常简单的 HTTP 客户端,但是它将提供足够的功能让真正的浏览器连接并使用它。这个测试服务器将在它自己的独立数据库中运行——它不会重用现有的数据库。
这里有需要改进的地方,从 JavaScript 集成开始。我们现在将着眼于集成一个 JavaScript 构建工具。
CSS/JavaScript 构建工具
在现代开发工作流程中,通常有一个 CSS/JS 构建工具,它将转换应用于原始 JavaScript。有许多类型的转换,最常见的是编译和缩小。这些工具执行的另一个常见步骤是预处理 CSS 文件。
有许多构建工具可用,但最近,一个似乎受到很多关注的构建工具是 Webpack ( https://webpack.js.org )。Webpack 获取具有依赖关系的 JavaScript 模块,并生成包含代码运行所需的所有依赖关系的包。它还能够缩小和取出未使用的代码。
Webpack 是用 Node.js 编写的,为了使用这个工具,你需要确保 Node 和 Npm 都安装了。一旦完成,我们将把它整合到我们的项目中。从顶层文件夹(manage.py所在的位置),启动以下命令:
$ # create a package.json file
$ npm init -y
$ # install webpack as dev dependencies
$ npm install webpack webpack-cli --save-dev
$ # install webpack-bundle-tracker as dev dependencies
$ npm install webpack-bundle-tracker --save-dev
$ # install react dependencies
$ npm install react react-dom --save
$ # install Django package connected to webpack-bundle-tracker
$ pipenv install django-webpack-loader
发出这些命令后,我们将改变package.json的几个部分,如下所示。我们还将创建一个 Webpack 配置,声明多个条目并添加我们的 bundle tracker 插件。
我们的package.json文件如下:
{
"name": "booktime",
"version": "1.0.0",
"description": "",
"private": true,
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "webpack"
},
"keywords": [],
"author": "",
"license": "ISC",
"devDependencies": {
"webpack": "⁴.12.0",
"webpack-bundle-tracker": "⁰.3.0",
"webpack-cli": "³.0.8"
},
"dependencies": {
"react": "¹⁶.4.1",
"react-dom": "¹⁶.4.1"
}
}
这是webpack.config.js的内容:
const path = require('path');
const BundleTracker = require('webpack-bundle-tracker')
module.exports = {
mode: 'development',
entry: {
imageswitcher: './frontend/imageswitcher.js'
},
plugins:[
new BundleTracker({filename: './webpack-stats.json'}),
],
output:{
filename: '[name].bundle.js',
path: path.resolve(dirname, 'main/static/bundles')
}
};
在 Django 端,我们将在settings.py中集成 Webpack loader 库:
...
WEBPACK_LOADER = {
'DEFAULT': {
'BUNDLE_DIR_NAME': 'bundles/',
'STATS_FILE': os.path.join(BASE_DIR, 'webpack-stats.json'),
}
}
INSTALLED_APPS = [
...
'webpack_loader',
...
]
现在是时候将产品详细信息模板中的所有 JavaScript 代码移动到单独的文件中了。我们将其命名为imageswitcher.js,并将其放在一个名为frontend/的新顶级文件夹中,如 Webpack 配置文件中所示。
const React = require("react");
const ReactDOM = require("react-dom");
const e = React.createElement;
var imageStyle = {
margin: "10px",
display: "inline-block"
}
class ImageBox extends React.Component{
constructor(props) {
super(props);
this.state = {
currentImage: this.props.imageStart
}
}
click(image){
this.setState({
currentImage: image
});
}
render(){
const images = this.props.images.map((i) =>
e('div', {style: imageStyle, className: "image", key:i.image},
e('img', {onClick: this.click.bind(this,i), width: "100", src: i.thumbnail})
)
);
return e('div', {className: "gallery"},
e('div', {className: "current-image"},
e('img', {src: this.state.currentImage.image})
),
images)
}
}
window.React = React
window.ReactDOM = ReactDOM
window.ImageBox = ImageBox
module.exports = ImageBox
前面的代码与我们之前的代码相似,但有一些重要的区别。先前版本中没有import语句,以及内嵌样式和对window和module.exports的最终赋值。从页面中的其他位置使用这段代码需要分配给window对象。
此时,您应该能够通过键入以下命令来运行webpack
$ npm run build
> booktime@1.0.0 build /..../booktime
> webpack
(node:23880) DeprecationWarning: Tapable.plugin is deprecated. Use new API on `.hooks` instead
Hash: 828b5178a3e3974adde0
Version: webpack 4.12.0
Time: 488ms
Built at: 07/30/2018 8:54:08 AM
Asset Size Chunks Chunk Names
imageswitcher.bundle.js 713 KiB imageswitcher [emitted] imageswitcher
[./frontend/imageswitcher.js] 931 bytes {imageswitcher} [built]
+ 21 hidden modules
现在您已经生成了一个 Webpack 包,并且在webpack-stats.json文件中添加了一些新条目。django-webpack-loader将使用这最后一个文件在模板中插入包引用。
在包含包之后,前面提到的所有window分配将在product_detail.html模板中可用:
{% extends"base.html" %}
{% load render_bundle from webpack_loader %}
{% block content %}
...
{% endblock content %}
{% block js %}
{% render_bundle 'imageswitcher" js' %}
<script>
document.addEventListener("DOMContentLoaded", function (event){
var images = [
{% for image in object.productimage_set.all %}
{
"image":"{{ image.image.url }}",
"thumbnail": "{{ image.thumbnail.url }}"
},
{% endfor %}
]
ReactDOM.render(React.createElement(ImageBox,{
images: images,
imageStart: images[0]
}),document.getElementById( 'imagebox'));
});
</script>
{% endblock %}
这是将 Webpack 与 Django 集成的可能方式之一。React 组件是相当独立的,数据是通过组件实例化直接在页面上加载的,不需要 API 的帮助。呈现位置也是直接在页面模板中指定的,因此如果组件是从不同的页面加载的,这可以很容易地更改。
为了验证我们没有破坏这个功能,我们可以运行我们在上一节中编写的端到端测试。如果有效,您就正确地遵循了所有步骤。
JavaScript 单元测试
我们现在依靠一个完整的浏览器来测试我们创建的图像切换器,尽管有一个更好、更快的方法。考虑到这个组件的自包含性,我们可以在一个比成熟的浏览器简单得多的环境中单独测试它。
为此,我们将使用一个名为 Jest ( https://jestjs.io )的 JavaScript 工具。Jest 是如今越来越普遍的一种测试手段。同样,工具的具体选择在这里并不重要。还有其他的试跑者比如 QUnit ( https://qunitjs.com )、茉莉( https://jasmine.github.io )、摩卡( https://mochajs.org )。它们都使用 Node.js 作为它们的运行时,因此它们都以相似的方式执行。
从顶层文件夹中键入以下命令:
$ npm install jest enzyme enzyme-adapter-react-16 --save-dev
现在我们可以将我们的第一个测试集成到我们的项目中了。假设我们从将所有 JavaScript 资产放在frontend/目录中的惯例开始,我们将把我们的测试放在名为imageswitcher.test.js的目录中:
const React = require("react");
const ImageBox = require('./imageswitcher');
const renderer = require('react-test-renderer');
const Enzyme = require('enzyme');
const Adapter = require('enzyme-adapter-react-16');
Enzyme.configure({ adapter: new Adapter() });
test('ImageBox switches images correctly', ()=>{
var images = [
{"image": "1.jpg",
"thumbnail": "1.thumb.jpg"},
{"image": "2.jpg",
"thumbnail": "2.thumb.jpg"},
{"image": "3.jpg",
"thumbnail": "3.thumb.jpg"}
]
const wrapper = Enzyme.shallow(
React.createElement(ImageBox, {images: images, imageStart: images[0]})
);
const currentImage = wrapper.find('.current-image > img').first().prop('src');
wrapper.find('div.image').at(2).find('img').simulate('click');
const newImage = wrapper.find('.current-image > img').first().prop('src');
expect(currentImage).not.toEqual(newImage);
});
该测试从frontend/imageswitcher.js加载组件并渲染它,然后通过点击第三个图像来模拟 a。最后的断言测试点击改变了当前图像。
Jest 需要一点配置来进行测试发现,我们将把它添加到package.json:
...
"scripts": {
"test": "jest",
"build": "webpack"
},
...
"jest": {
"moduleDirectories": [
"node_modules",
"frontend"
],
"testURL": "http://localhost/"
},
...
有了这个配置,我们可以用命令npm test运行测试:
$ npm test
> jest
PASS frontend/imageswitcher.test.js
v ImageBox switches images correctly (24ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.65s, estimated 2s
Ran all test suites.
这对我们来说是一个足够的开始。这个测试运行程序不同于 Django 测试运行程序,它需要与后端测试分开运行。如果我们有一个持续集成系统,这将需要与 Django 测试一起集成到管道中。
摘要
在现代网站中,前端发生了很多事情。许多网站对 CSS 和 JavaScript 都使用资产预处理程序,在这一章中,我们看到了一种集成它们的可能方法。
我们还研究了如何使用 Webpack、React 和 Selenium 等工具来增加站点的交互性,同时不忽略测试部分。然而,Selenium 测试很慢,因此最好将测试限制在 web 应用最关键的部分。Jest,另一方面,测试自己作出反应,它要快得多。
我为这一章挑选的库绝不是这个空间中唯一的库。有许多可用的工具,你应该决定哪一个适合你的需要和愿望。这里工具的选择主要是基于 Django 社区的使用。
在下一章中,我们将讨论一些外部的 Django 库,它们通常被用来给 Django 增加更多的功能。
六、在我们的项目中使用外部库
本章介绍了在我们的项目中包含外部 Django 库是多么容易,以及我们如何使用其中的一些库来加速开发或添加在普通 Django 中不可用的特性。
使用 Django 扩展
django Extensions1库提供了一些我们的项目可以使用的有用的附加命令。要了解它们是什么,我们需要首先安装库:
$ pipenv install django-extensions
$ pipenv install pydotplus # for graph_models
$ pipenv install ipython # for shell_plus
$ pipenv install werkzeug # for runserver_plus
我们还需要将库添加到INSTALLED_APPS:
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
'webpack_loader',
'django_extensions',
"main.apps.MainConfig",
]
完成后,我们应该能够在标准命令中看到新命令:
$ ./manage.py
Type 'manage.py help <subcommand>' for help on a specific subcommand.
Available subcommands:
[auth]
changepassword
createsuperuser
[contenttypes]
remove_stale_contenttypes
[django]
check
compilemessages
createcachetable
dbshell
...
test
testserver
[django_extensions]
admin_generator
clean_pyc
clear_cache
compile_pyc
...
sync_s3
syncdata
unreferenced_files
update_permissions
validate_templates
[main]
import_data
[sessions]
clearsessions
[staticfiles]
collectstatic
findstatic
runserver
这个库提供了很多命令。在这一章中,我不会一一介绍,只介绍一些比较常用的。您可以在在线 Django 文档中找到这些管理命令的完整列表。 2
第一个命令通过外键生成一个包含所有模型以及它们之间的连接的图:
$ ./manage.py graph_models -a -o booktime_models.png
你可以在图 6-1 中看到我们项目的图表。

图 6-1
graph_models 命令输出
另一个有用的命令是shell_plus,它启动一个 shell(使用本节开始时安装的 IPython 库),为我们的模型提供历史、自动完成、自动导入等等:
$ ./manage.py shell_plus
# Shell Plus Model Imports
from django.contrib.admin.models import LogEntry
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.contrib.sessions.models import Session
from main.models import Address, Basket, BasketLine, ...
# Shell Plus Django Imports
from django.core.cache import cache
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import Avg, Case, Count, F, ...
from django.utils import timezone
from django.urls import reverse
Python 3.6.3 (default, Oct 3 2017, 21:45:48)
Type 'copyright', 'credits' or 'license' for more information
IPython 6.4.0 -- An enhanced Interactive Python. Type '?' for help.
In [1]:
除了这个库,还有一个增强版的runserver命令,我们用它来启动开发服务器。这个命令是runserver_plus:
$ ./manage.py runserver_plus
Performing system checks...
System check identified no issues (0 silenced).
Django version 2.0.7, using settings 'booktime.settings'
Development server is running at http://[127.0.0.1]:8000/
Using the Werkzeug debugger (http://werkzeug.pocoo.org/)
Quit the server with CONTROL-C.
* Debugger is active!
* Debugger PIN: 130-358-807
当使用这个来浏览站点时,Django 错误页面会被 Werkzeug ( http://werkzeug.pocoo.org )替代,它提供了一个交互式调试器,对于快速调试问题非常有用,如图 6-2 所示。

图 6-2
带有 runserver_plus 示例的 Werkzeug 调试器
使用 factory_boy 进行更好的测试
factory_boy 库 3 简化了测试数据的生成。历史上,在 Django 中,测试数据要么从称为 fixtures 的文件中加载,要么直接嵌入到代码中。对于需要大量设置数据的情况,对数据进行硬编码可能会产生维护问题,尤其是对于夹具。
为了解决这个问题,factory_boy 库为我们提供了一种基于测试中指定的约束自动生成测试数据的方法。这个库在每次运行时都会为姓名、地址等生成假数据。除非您另外指定,否则它还会在所有字段上生成数据。
我们可以安装这个库
$ pipenv install factory_boy
为了展示这个库的强大,我们将修改一个测试文件,这个文件在我们的项目中在数据设置方面特别繁重:main/tests/test_models.py。在此之前,我们将在main/factories.py创建一些工厂:
import factory
import factory.fuzzy
from . import models
class UserFactory(factory.django.DjangoModelFactory):
email="user@site.com"
class Meta:
model = models.User
django_get_or_create = ('email',)
class ProductFactory(factory.django.DjangoModelFactory):
price = factory.fuzzy.FuzzyDecimal(1.0, 1000.0, 2)
class Meta:
model = models.Product
class AddressFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.Address
工厂是用于为特定模型生成数据的类。这些被称为测试。这是我们更新版的main/tests/test_models.py:
from decimal import Decimal
from django.test import TestCase
from main import models
from main import factories
class TestModel(TestCase):
def test_active_manager_works(self):
factories.ProductFactory.create_batch(2, active=True)
factories.ProductFactory(active=False)
self.assertEqual(len(models.Product.objects.active()), 2)
def test_create_order_works(self):
p1 = factories.ProductFactory()
p2 = factories.ProductFactory()
user1 = factories.UserFactory()
billing = factories.AddressFactory(user=user1)
shipping = factories.AddressFactory(user=user1)
basket = models.Basket.objects.create(user=user1)
models.BasketLine.objects.create(
basket=basket, product=p1
)
models.BasketLine.objects.create(
basket=basket, product=p2
)
with self.assertLogs("main.models", level="INFO") as cm:
order = basket.create_order(billing, shipping)
self.assertGreaterEqual(len(cm.output), 1)
order.refresh_from_db()
self.assertEquals(order.user, user1)
self.assertEquals(
order.billing_address1, billing.address1
)
self.assertEquals(
order.shipping_address1, shipping.address1
)
self.assertEquals(order.lines.all().count(), 2)
lines = order.lines.all()
self.assertEquals(lines[0].product, p1)
self.assertEquals(lines[1].product, p2)
正如您所看到的,我们从这两个测试的设置中提取了很多额外的信息,比如产品名称、航运公司等等。如果在将来,我们决定向任何一个被调用的模型(例如,Address)添加另一个字段,测试仍然会通过,不需要更新。
Django 调试工具栏
Django 调试工具栏 4 是一个众所周知的库,它显示了许多关于加载的网页的有用信息。它包括关于 HTTP 请求/响应、Django 内部设置、触发的 SQL 查询、使用的模板、缓存调用和其他细节的信息。
这个库还可以使用插件进行扩展,以防包含的内容没有涵盖您想要显示的信息。网上有几个插件。
要安装 Django 调试工具栏,您可以使用以下命令:
$ pipenv install django-debug-toolbar
除了安装之外,还需要一些额外的设置,从对booktime/settings.py的一些更改开始:
...
INSTALLED_APPS = [
....
'debug_toolbar',
"main.apps.MainConfig",
]
MIDDLEWARE = [
"debug_toolbar.middleware.DebugToolbarMiddleware",
...
]
INTERNAL_IPS = ['127.0.0.1']
...
在booktime/urls.py的末尾添加以下内容:
...
if settings.DEBUG:
import debug_toolbar
urlpatterns = [
path('__debug__/', include(debug_toolbar.urls)),
] + urlpatterns
这足以开始使用该工具。屏幕右侧会出现一个黑条,带有可点击的部分,打开后可以看到特定类别的信息。图 6-3 显示了显示产品页面时生成的 SQL 查询。

图 6-3
产品页面上的 SQL 查询
其他面板是 Settings(设置),用于查看当前 Django 配置、Request(请求),用于查看关于当前视图的信息,Templates(模板),用于查看哪些模板已用于编写响应以及传入了哪些变量,以及 Cache(缓存),用于查看视图执行的所有缓存调用。
以我们的图书电商系统 BookTime 为例,这个库工作得很好,因为所有页面都是 Django 返回的模板。如果在您的项目中,您有一个带有后端 API 的 SPA 架构,那么这种方法就不能很好地工作,因为 Django Debug Toolbar 依赖于后端将 HTML 返回给浏览器。这样的话,你可能想看看 Django 丝绸。
使用 django-tables2 和 django-filter 可视化订单
django-tables2 5 和 django-filter 6 库是独立的,但经常结合使用,可以帮助我们加快创建非常简单的仪表板的过程。在这里,我们将为内部用户开发一个可过滤的订单列表:
$ pipenv install django-tables2 django-filter
使用这些库非常容易。我们将在main/views.py的视图列表中添加一个经过验证的视图:
from django.contrib.auth.mixins import (
LoginRequiredMixin,
UserPassesTestMixin
)
from django import forms as django_forms
from django.db import models as django_models
import django_filters
from django_filters.views import FilterView
...
...
class DateInput(django_forms.DateInput):
input_type = 'date'
class OrderFilter(django_filters.FilterSet):
class Meta:
model = models.Order
fields = {
'user__email': ['icontains'],
'status': ['exact'],
'date_updated': ['gt', 'lt'],
'date_added': ['gt', 'lt'],
}
filter_overrides = {
django_models.DateTimeField: {
'filter_class': django_filters.DateFilter,
'extra': lambda f:{
'widget': DateInput}}}
class OrderView(UserPassesTestMixin, FilterView):
filterset_class = OrderFilter
login_url = reverse_lazy("login")
def test_func(self):
return self.request.user.is_staff is True
OrderView是一个视图,只有有权访问管理界面的用户才可以使用,因为test_func函数会对此进行检查。这个视图继承自FilterView,带有一个filterset_class来指定页面中有哪些过滤器可用。
FilterSet类的格式类似于 Django 自己的ModelForm,在这里你可以直接定义过滤器或者使用Meta类自动生成过滤器。在前面的示例中,我们定义了要过滤的字段以及在这些字段上活动的查找表达式。我们还希望确保使用 HTML5 日期输入字段来输入日期。
除了这个视图,我们还将添加一个模板(在视图的默认位置,main/templates/main/order_filter.html):
{% extends "base.html" %}
{% load render_table from django_tables2 %}
{% block content %}
<h2>Order dashboard</h2>
<form method="get">
{{ filter.form.as_p }}
<input type="submit"/>
</form>
<p>
{% render_table filter.qs %}
</p>
{% endblock content %}
这个模板利用django-tables2来呈现表格,以及排序控件。它简化了打印标题的工作,只需一个模板标签就可以循环显示结果。但是,需要安装这个标签。我们将在settings.py中这样做:
INSTALLED_APPS = [
...
'django_tables2',
"main.apps.MainConfig",
]
...
DJANGO_TABLES2_TEMPLATE = 'django_tables2/bootstrap.html'
这个库还允许我们指定在呈现表格时使用什么样的模板。在我们的实例中,我们需要引导框架的 CSS 样式。
当任何 Django 库被添加到INSTALLED_APPS列表中时,Django 会将其static和templates文件夹添加到静态文件和模板文件的搜索路径中。这就是这个库如何通过指定一个初始模板来引入它所有的依赖项。
下一个(也是最后一个)操作是使其在main/urls.py中可用:
urlpatterns = [
...
path(
"order-dashboard/",
views.OrderView.as_view(),
name="order_dashboard",
),
]
这个新创建的仪表板现在可以在上面指示的 URL 上获得。在您的项目中输入一些订单后,您将能够在仪表板中看到一些结果,并使用我们设置的过滤器,如图 6-4 所示。

图 6-4
简单订单仪表板
正如你所看到的,用少量的代码,我们已经建立了一个非常实用的仪表板。
django tweak 小部件
这个库 7 在处理需要某种结构的复杂表单模板时很有帮助。例如,Bootstrap CSS 框架要求每个输入都用form-control CSS 类标记。在标准的 Django 中,这可以通过指定完整的 HTML 标记或者在 Python 中注入类来实现。如果您发现自己正在做这些事情,特别是修改form类以输出正确的 CSS 样式,您正在处理代码中的设计问题,这不是这两个领域之间的一个很好的分离。
在这个库的帮助下,我们可以重写和简化 BookTime 项目中的一些模板。首先,让我们安装它:
$ pipenv install django-widget-tweaks
它还需要包含在设置中:
INSTALLED_APPS = [
...
'widget_tweaks',
"main.apps.MainConfig",
]
一旦我们有了这些,这个库中的所有模板标签都可以在模板中使用了。我们可以在内置的 Django 小部件渲染的基础上,添加我们需要的 CSS 类和 HTML 结构。
Django 模板,除了扩展其他,还可以使用include模板标签,我们将开始使用。让我们将所有的字段渲染 HTML 提取到它自己的模板中,称为main/templates/includes/field.html:
{% load widget_tweaks %}
<div class="form-group">
{{ field.label_tag }}
{{ field|add_class:"form-control"|add_error_class:"is-invalid" }}
{% if field.errors %}
<div class="invalid-feedback">
{{ field.errors }}
</div>
{% endif %}
</div>
field变量是一个小部件,它应用了一些修改输出的标签。我们正在添加一些 CSS 类,一个总是和另一个在错误的情况下。
这个片段现在可以被其他模板包含。这是main/templates/login.html模板使用它的方式:
{% extends "base.html" %}
{% block content %}
<h2>Login</h2>
<p>Please fill the form below.</p>
<form method="POST">
{% csrf_token %}
{{ form.non_field_errors }}
{% include "includes/field.html" with field=form.email %}
{% include "includes/field.html" with field=form.password %}
<button type="submit" class="btn btn-primary">Submit</button>
</form>
{% endblock content %}
类似的重组可以在signup.html和contact_form.html(在main/templates/内部)进行。我们其余的模板使用{{ form }}快捷方式。要在那里使用它,模板变量需要通过迭代它的fields来分解。
使用 Django Rest 框架为订单履行构建 API
对于电子商务组织,订单调度通常由第三方调度服务管理。这个外部实体可能正在使用自己的交付管理系统,在这种情况下,需要进行数据集成。
我们将在 API 中涵盖这些用例:
-
调度服务需要能够看到订单和订单行通过系统,但只有在他们被支付后。
-
调度服务需要能够按状态或顺序过滤前面的列表。
-
调度服务会将订单行标记为“处理中”以示确认。
-
调度服务将订单行标记为“已发送”或“已取消”,以通知中心办公室其状态。
-
如果需要,配送服务可以更改订单的送货地址。
Curl 演示流程
我们将使用curl命令来显示 API。Curl 是一个非常简单的命令行工具,可以在许多平台上使用( https://curl.haxx.se )。除了 API,你可以把它看作是浏览器的等价物。如果您还没有安装这个工具,您可以使用软件包管理器来安装它。
这是我们将要实现的 API 流。
要获取准备发货的订单行列表(状态 10),我们将使用以下命令:
$ curl -H 'Accept: application/json; indent=4' \
-u dispatch@booktime.domain:abcabcabc \
http://127.0.0.1:8000/api/orderlines/
{
"count": 2,
"next": null,
"previous": null,
"results": [
{
"id": 10,
"order": "http://127.0.0.1:8000/api/orders/9/",
"product": "Siddhartha",
"status": 10
},
{
"id": 11,
"order": "http://127.0.0.1:8000/api/orders/9/",
"product": "Backgammon for dummies",
"status": 10
}
]
}
要获取订单编号 9 的发货地址:
$ curl -H 'Accept: application/json; indent=4' \
-u dispatch@bookime.domain:abcabcabc \
http://127.0.0.1:8000/api/orders/9/
{
"shipping_name": "John Smith",
"shipping_address1": "1 the road",
"shipping_address2": "",
"shipping_zip_code": "LC11RA",
"shipping_city": "Smithland",
"shipping_country": "uk",
"date_updated": "2018-07-07T11:46:09.367227Z",
"date_added": "2018-07-05T22:22:01.067294Z"
}
一旦调度系统拥有订单行和订单列表,它将能够开始逐步将这些行标记为“处理中”(状态 20)或“已发送”(状态 30):
$ curl -H 'Accept: application/json; indent=4' \
-u dispatch@booktime.domain:abcabcabc -XPUT \
-H 'Content-Type: application/json' \
-d '{"status": 20}' http://127.0.0.1:8000/api/orderlines/10/
{
"id": 10,
"order": "http://127.0.0.1:8000/api/orders/9/",
"product": "Siddhartha",
"status": 20
}
$ curl -H 'Accept: application/json; indent=4' \
-u dispatch@bookime.domain:abcabcabc -XPUT \
-H 'Content-Type: application/json' \
-d '{"status": 30}' http://127.0.0.1:8000/api/orderlines/11/
{
"id": 11,
"order": "http://127.0.0.1:8000/api/orders/9/",
"product": "Backgammon for dummies",
"status": 30
}
$ curl -H 'Accept: application/json; indent=4' \
-u dispatch@bookime.domain:abcabcabc -XPUT \
-H 'Content-Type: application/json' \
-d '{"status": 30}' http://127.0.0.1:8000/api/orderlines/10/
{
"id": 10,
"order": "http://127.0.0.1:8000/api/orders/9/",
"product": "Siddhartha",
"status": 30
}
我们使用数字状态代码,因为这是我们在OrderLine模型的状态选择列表中定义它们的方式。
拥有数字状态给了我们一些优势,比如空间效率和有序性,但是这些数字的含义并不总是很清楚。在 API 文档中记录这一点很重要。
一旦所有行被标记为“已发送”或“已取消”,这些行将不再出现在订单行列表中:
$ curl -H 'Accept: application/json; indent=4' \
-u dispatch@bookime.domain:abcabcabc \
http://127.0.0.1:8000/api/orderlines/
{
"count": 0,
"next": null,
"previous": null,
"results": []
}
框架安装和配置
Django Rest 框架 8 与django-filter集成在一起,我们将使用它进行过滤。确保您已经安装了它。要安装 Django Rest 框架,请键入以下命令:
$ pipenv install djangorestframework
安装完成后,在settings.py中进行配置:
INSTALLED_APPS = [
...
'rest_framework',
"main.apps.MainConfig",
]
...
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES':
('rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.BasicAuthentication'),
'DEFAULT_PERMISSION_CLASSES':
('rest_framework.permissions.DjangoModelPermissions',),
'DEFAULT_FILTER_BACKENDS':
('django_filters.rest_framework.DjangoFilterBackend',),
'DEFAULT_PAGINATION_CLASS':
'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 100
}
这种配置对于我们的用例来说足够灵活,等等。身份验证类用于验证用户/密码组合是否与数据库中存储的内容相对应。权限类用于了解用户在系统上可以或不可以做什么。
在我们的例子中,我们利用了创建模型时默认生成的内置权限。
除此之外,我们将django-filter设置为我们的过滤后端,并设置每页 100 个项目的默认分页。
最后,我们需要添加一些额外的 URL,我们将在booktime/urls.py中完成:
urlpatterns = [
...
path('api-auth/', include('rest_framework.urls')),
path("", include("main.urls")),
] + ...
用户和权限
我们将在系统中创建一个用户,在调用 API 时使用。该用户至少需要两种权限:
-
可以改变顺序
-
可以更改订单行
正如你在图 6-5 中看到的,这就是我们在前面的例子中使用的用户。您可以随意使用您想要的任何用户/密码,但是要记得适当地更新curl命令。

图 6-5
休息用户
API 端点
Django Rest 框架是一个非常灵活的库,有很多功能。有足够的材料写很多页,但这不是这本书的重点。下面是一些使用这个库的代码,涵盖了我们提到的用例;将此代码放入main/endpoints.py:
from rest_framework import serializers, viewsets
from . import models
class OrderLineSerializer(serializers.HyperlinkedModelSerializer):
product = serializers.StringRelatedField()
class Meta:
model = models.OrderLine
fields = ('id', 'order', 'product', 'status')
read_only_fields = ('id', 'order', 'product')
class PaidOrderLineViewSet(viewsets.ModelViewSet):
queryset = models.OrderLine.objects.filter(
order__status=models.Order.PAID).order_by("-order__date_added")
serializer_class = OrderLineSerializer
filter_fields = ('order', 'status')
class OrderSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = models.Order
fields = ('shipping_name',
'shipping_address1',
'shipping_address2',
'shipping_zip_code',
'shipping_city',
'shipping_country',
'date_updated',
'date_added')
class PaidOrderViewSet(viewsets.ModelViewSet):
queryset = models.Order.objects.filter(
status=models.Order.PAID).order_by("-date_added")
serializer_class = OrderSerializer
我们需要为前面的视图分配 URL。我们将在main/urls.py中这样做:
...
from django.urls import path, include
from rest_framework import routers
from main import endpoints
router = routers.DefaultRouter()
router.register(r'orderlines', endpoints.PaidOrderLineViewSet)
router.register(r'orders', endpoints.PaidOrderViewSet)
urlpatterns = [
...
path('api/', include(router.urls)),
]
使用信号完成订单
现在我们有足够的信息让 dispatcher 将订单标记为已发送,我们需要确保标记的订单不再出现在 list api 中。我们将以一种不依赖于它们如何被标记的方式来做这件事,无论是通过 REST API 还是通过 Django admin。
为此,我们将再次使用 Django 信号。这个新信号将被插入main/signals.py:
...
from django.db.models.signals import pre_save, post_save
from .models import ProductImage, Basket, OrderLine, Order
...
@receiver(post_save, sender=OrderLine)
def orderline_to_order_status(sender, instance, **kwargs):
if not instance.order.lines.filter(status__lt=OrderLine.SENT).exists():
logger.info(
"All lines for order %d have been processed. Marking as done.", instance.order.id,
)
instance.order.status = Order.DONE
instance.order.save()
该信号将在保存OrderLine模型的实例后执行。它做的第一件事是检查连接到订单的任何订单行的状态是否低于“已发送”如果有,则终止执行。如果“已发送”状态下没有行,则整个订单被标记为“完成”
要尝试这个流程,您需要使用我们上面列出的curl命令。您需要打开两个终端,一个运行 Django runserver命令,另一个输入curl命令。
使用 DRF 网络客户端
除了使用curl来测试 API 之外,还可以使用 Django Rest Framework (DRF) web 客户端,当直接通过浏览器浏览 API URLs 时,可以使用这个客户端。
图 6-6 是 web 客户端的截图。当比命令行更可取时,展示 API 和快速测试是有用的。

图 6-6
DRF 网络客户端
摘要
在本章中我们已经看到了这些库:
-
django-extensions:包括一些 django 中没有的非常有用的命令
-
工厂男孩:为测试动态生成数据
-
django-debug-toolbar:在调试时给我们很多信息的工具
-
django-widget-tweaks:为定义前端属性提供了更多的灵活性
-
django-rest-framework:完全关于 rest 的框架中的框架
当然,网上还有很多其他的图书馆。本章旨在向您展示一些扩展 Django 的方法。我鼓励你在网上做你的研究,选择适合你需要的。
在下一章,我们将再次讨论 Django admin,这次会更深入。
七、为公司制作内部仪表板
在这一章中,我们将在 Django 管理界面上为 Booktime 公司员工构建一个仪表板。我们将讨论为什么和如何这样做,以及公司中不同类型的用户。
我们将讨论这些主题:
-
配置管理界面
-
添加管理视图
-
配置用户和权限
-
创建内部报告
-
生成 pdf
使用 Django admin 的原因
在这一章中,我们将使用 Django 管理界面来演示这个应用是如何可定制的。正如我们在前面章节中看到的,只需一些基本的定制,我们就已经能够管理我们的产品和订单数据。我们还能够在系统中过滤和搜索产品和订单。
Django 管理接口带有一个内置的认证和许可系统。您可以轻松地将多个用户配置为只能查看和更改部分数据。这个界面还有一个内置的日志来跟踪谁更改了数据库中的哪些模型。
这个应用可以让我们不需要太多的努力就可以获得足够的状态。在本章中,我们将通过创建集成在管理界面中的新视图、修改用户权限、集成报告功能以及定制其外观,继续构建我们已经完成的定制。
所有这些都可以通过重写基类来实现,尽管这种方法有局限性。鉴于此,在定制管理界面时,我们总是用额外的代码覆盖内置行为,建议不要过度定制,因为您的代码会很快变得难以阅读。
这种方法的另一个限制是,你不能从根本上改变应用的用户流。就像在基于类的视图和基于函数的视图之间的选择一样,如果你花更多的时间重写内置的行为而不是编写你自己的行为,定制管理界面不是正确的方法。
在这一章中,我们将尝试将这个接口扩展到它的极限,以实现对一个电子商务公司应该能够做的所有标准操作的支持,或者至少对我们虚构的图书销售公司所需要的那些操作的支持。
管理界面中的视图
要在管理中列出所有公开的视图,我们可以使用来自django-extensions库的show_urls命令。以下是其输出的一小部分:
...
/admin/
django.contrib.admin.sites.index
admin:index
/admin/<app_label>/
django.contrib.admin.sites.app_index
admin:app_list
/admin/auth/user/
django.contrib.admin.options.changelist_view
admin:auth_user_changelist
/admin/auth/user/<id>/password/
django.contrib.auth.admin.user_change_password
admin:auth_user_password_change
/admin/auth/user/<path:object_id>/
django.views.generic.base.RedirectView
/admin/auth/user/<path:object_id>/change/
django.contrib.admin.options.change_view
admin:auth_user_change
/admin/auth/user/<path:object_id>/delete/
django.contrib.admin.options.delete_view
admin:auth_user_delete
/admin/auth/user/<path:object_id>/history/
django.contrib.admin.options.history_view
admin:auth_user_history
/admin/login/
django.contrib.admin.sites.login
admin:login
/admin/logout/
django.contrib.admin.sites.logout
admin:logout
...
正如您所看到的,对于 Django admin 的一个实例,有许多页面(在前面的代码片段中没有全部显示):
-
索引视图:初始页面,列出所有 Django 应用及其模型
-
App 列表视图:单个 Django app 的型号列表
-
变更列表视图:Django 模型的所有条目列表
-
变更视图:变更 Django 模型单个实体的视图
-
添加视图:添加 Django 模型新实体的视图
-
删除视图:删除 Django 模型单个实体的确认视图
-
历史视图:单个实体通过 Django 管理界面完成的所有变更的列表
-
支持视图:登录、注销和更改密码视图
这些视图中的每一个都可以通过在正确的 admin 类中覆盖特定的方法来定制(我们将探索一个这样的例子)。这些视图中的每一个都使用一个可以自定义的模板:
-
索引视图 :
admin/index.html -
应用列表视图 :
admin/app_index.html -
变更列表视图 :
admin/change_list.html -
更改项目视图 :
admin/change_form.html -
添加项目视图 :
admin/change_form.html -
删除一个项目上的项目视图 :
admin/delete_confirmation.html -
删除多个项目上的项目视图 :
admin/delete_selected_confirmation.html -
历史查看 :
admin/object_history.html
有更多的模板表示这些视图的屏幕的特定部分。我鼓励您研究这些模板,了解它们的结构。你可以在你的 Python virtualenv 或者在线的 GitHub 1 上找到它们。
Django 管理界面带有一组内置的视图,但是您可以添加新的视图。您可以在顶层和模型层定义视图。新视图将继承相应管理实例的所有安全检查和 URL 命名空间。例如,这使得可以将所有的报告视图添加到我们的管理实例中,并进行适当的授权检查。
除了前面所有的特性之外,还可以在一个站点上运行多个 Django 管理界面,每个界面都有自己的定制。到目前为止,我们已经使用了django.contrib.admin.site,它是django.contrib.admin.AdminSite的一个实例,但是没有什么可以阻止我们拥有它的许多实例。
为公司配置用户类型和权限
在编写任何代码之前,明确系统中不同类型的用户以及每种用户与系统交互的方式是很重要的。在 BookTime 公司,我们有三种类型的用户:
-
业主
- 可以查看和操作所有有用的模型
-
中心办公室员工
-
可以将订单标记为已支付
-
可以更改订单数据
-
可以查看关于网站性能的报告
-
可以管理产品和相关信息
-
-
调度办公室
-
可以将订单行标记为已装运(或已取消)
-
可以将产品标记为缺货
-
在 Django 中,我们将以这种方式存储会员信息:
-
所有者:is _ super user 字段设置为 True 的任何用户
-
中心局员工:属于“员工”组的任何用户
-
调度室:属于“调度员”组的任何用户
为了在系统中创建这些用户类型,我们将使用数据夹具,这与测试夹具的原理相同。将此内容放入main/data/user_groups.json:
[
{
"model": "auth.group",
"fields": {
"name": "Employees",
"permissions": [
[ "add_address", "main", "address" ],
[ "change_address", "main", "address" ],
[ "delete_address", "main", "address" ],
[ "change_order", "main", "order" ],
[ "add_orderline", "main", "orderline" ],
[ "change_orderline", "main", "orderline" ],
[ "delete_orderline", "main", "orderline" ],
[ "add_product", "main", "product" ],
[ "change_product", "main", "product" ],
[ "delete_product", "main", "product" ],
[ "add_productimage", "main", "productimage" ],
[ "change_productimage", "main", "productimage" ],
[ "delete_productimage", "main", "productimage" ],
[ "change_producttag", "main", "producttag" ]
]
}
},
{
"model": "auth.group",
"fields": {
"name": "Dispatchers",
"permissions": [
[ "change_orderline", "main", "orderline" ],
[ "change_product", "main", "product" ]
]
}
}
]
要加载上述代码,请键入以下内容:
$ ./manage.py loaddata main/data/user_groups.json
Installed 2 object(s) from 1 fixture(s)
我们还将向我们的User模型添加一些辅助函数,以帮助我们识别用户的类型:
class User(AbstractUser):
...
@property
def is_employee(self):
return self.is_active and (
self.is_superuser
or self.is_staff
and self.groups.filter(name="Employees").exists()
)
@property
def is_dispatcher(self):
return self.is_active and (
self.is_superuser
or self.is_staff
and self.groups.filter(name="Dispatchers").exists()
)
为用户实现多个管理界面
我们将从一堆代码开始,我将用代码注释来解释它们。从main/admin.py开始,我们将用一个更高级的版本替换我们所有的产品,支持我们列出的所有用例。
from datetime import datetime, timedelta
import logging
from django.contrib import admin
from django.contrib.auth.admin import (
UserAdmin as DjangoUserAdmin
)
from django.utils.html import format_html
from django.db.models.functions import TruncDay
from django.db.models import Avg, Count, Min, Sum
from django.urls import path
from django.template.response import TemplateResponse
from . import models
logger = logging.getLogger(__name__)
class ProductAdmin(admin.ModelAdmin):
list_display = ("name", "slug", "in_stock", "price")
list_filter = ("active", "in_stock", "date_updated")
list_editable = ("in_stock",)
search_fields = ("name",)
prepopulated_fields = {"slug": ("name",)}
autocomplete_fields = ("tags",)
# slug is an important field for our site, it is used in
# all the product URLs. We want to limit the ability to
# change this only to the owners of the company.
def get_readonly_fields(self, request, obj=None):
if request.user.is_superuser:
return self.readonly_fields
return list(self.readonly_fields) + ["slug", "name"]
# This is required for get_readonly_fields to work
def get_prepopulated_fields(self, request, obj=None):
if request.user.is_superuser:
return self.prepopulated_fields
else:
return {}
class DispatchersProductAdmin(ProductAdmin):
readonly_fields = ("description", "price", "tags", "active")
prepopulated_fields = {}
autocomplete_fields = ()
class ProductTagAdmin(admin.ModelAdmin):
list_display = ("name", "slug")
list_filter = ("active",)
search_fields = ("name",)
prepopulated_fields = {"slug": ("name",)}
# tag slugs also appear in urls, therefore it is a
# property only owners can change
def get_readonly_fields(self, request, obj=None):
if request.user.is_superuser:
return self.readonly_fields
return list(self.readonly_fields) + ["slug", "name"]
def get_prepopulated_fields(self, request, obj=None):
if request.user.is_superuser:
return self.prepopulated_fields
else:
return {}
class ProductImageAdmin(admin.ModelAdmin):
list_display = ("thumbnail_tag", "product_name")
readonly_fields = ("thumbnail",)
search_fields = ("product__name",)
# this function returns HTML for the first column defined
# in the list_display property above
def thumbnail_tag(self, obj):
if obj.thumbnail:
return format_html(
'<img src="%s"/>' % obj.thumbnail.url
)
return "-"
# this defines the column name for the list_display
thumbnail_tag.short_description = "Thumbnail"
def product_name(self, obj):
return obj.product.name
class UserAdmin(DjangoUserAdmin):
# User model has a lot of fields, which is why we are
# reorganizing them for readability
fieldsets = (
(None, {"fields": ("email", "password")}),
(
"Personal info",
{"fields": ("first_name", "last_name")},
),
(
"Permissions",
{
"fields": (
"is_active",
"is_staff",
"is_superuser",
"groups",
"user_permissions",
)
},
),
(
"Important dates",
{"fields": ("last_login", "date_joined")},
),
)
add_fieldsets = (
(
None,
{
"classes": ("wide",),
"fields": ("email", "password1", "password2"),
},
),
)
list_display = (
"email",
"first_name",
"last_name",
"is_staff",
)
search_fields = ("email", "first_name", "last_name")
ordering = ("email",)
class AddressAdmin(admin.ModelAdmin):
list_display = (
"user",
"name",
"address1",
"address2",
"city",
"country",
)
readonly_fields = ("user",)
class BasketLineInline(admin.TabularInline):
model = models.BasketLine
raw_id_fields = ("product",)
class BasketAdmin(admin.ModelAdmin):
list_display = ("id", "user", "status", "count")
list_editable = ("status",)
list_filter = ("status",)
inlines = (BasketLineInline,)
class OrderLineInline(admin.TabularInline):
model = models.OrderLine
raw_id_fields = ("product",)
class OrderAdmin(admin.ModelAdmin):
list_display = ("id", "user", "status")
list_editable = ("status",)
list_filter = ("status", "shipping_country", "date_added")
inlines = (OrderLineInline,)
fieldsets = (
(None, {"fields": ("user", "status")}),
(
"Billing info",
{
"fields": (
"billing_name",
"billing_address1",
"billing_address2",
"billing_zip_code",
"billing_city",
"billing_country",
)
},
),
(
"Shipping info",
{
"fields": (
"shipping_name",
"shipping_address1",
"shipping_address2",
"shipping_zip_code",
"shipping_city",
"shipping_country",
)
},
),
)
# Employees need a custom version of the order views because
# they are not allowed to change products already purchased
# without adding and removing lines
class CentralOfficeOrderLineInline(admin.TabularInline):
model = models.OrderLine
readonly_fields = ("product",)
class CentralOfficeOrderAdmin(admin.ModelAdmin):
list_display = ("id", "user", "status")
list_editable = ("status",)
readonly_fields = ("user",)
list_filter = ("status", "shipping_country", "date_added")
inlines = (CentralOfficeOrderLineInline,)
fieldsets = (
(None, {"fields": ("user", "status")}),
(
"Billing info",
{
"fields": (
"billing_name",
"billing_address1",
"billing_address2",
"billing_zip_code",
"billing_city",
"billing_country",
)
},
),
(
"Shipping info",
{
"fields": (
"shipping_name",
"shipping_address1",
"shipping_address2",
"shipping_zip_code",
"shipping_city",
"shipping_country",
)
},
),
)
# Dispatchers do not need to see the billing address in the fields
class DispatchersOrderAdmin(admin.ModelAdmin):
list_display = (
"id",
"shipping_name",
"date_added",
"status",
)
list_filter = ("status", "shipping_country", "date_added")
inlines = (CentralOfficeOrderLineInline,)
fieldsets = (
(
"Shipping info",
{
"fields": (
"shipping_name",
"shipping_address1",
"shipping_address2",
"shipping_zip_code",
"shipping_city",
"shipping_country",
)
},
),
)
# Dispatchers are only allowed to see orders that
# are ready to be shipped
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.filter(status=models.Order.PAID)
# The class below will pass to the Django Admin templates a couple
# of extra values that represent colors of headings
class ColoredAdminSite(admin.sites.AdminSite):
def each_context(self, request):
context = super().each_context(request)
context["site_header_color"] = getattr(
self, "site_header_color", None
)
context["module_caption_color"] = getattr(
self, "module_caption_color", None
)
return context
# The following will add reporting views to the list of
# available urls and will list them from the index page
class ReportingColoredAdminSite(ColoredAdminSite):
def get_urls(self):
urls = super().get_urls()
my_urls = [
path(
"orders_per_day/",
self.admin_view(self.orders_per_day),
)
]
return my_urls + urls
def orders_per_day(self, request):
starting_day = datetime.now() - timedelta(days=180)
order_data = (
models.Order.objects.filter(
date_added__gt=starting_day
)
.annotate(
day=TruncDay("date_added")
)
.values("day")
.annotate(c=Count("id"))
)
labels = [
x["day"].strftime("%Y-%m-%d") for x in order_data
]
values = [x["c"] for x in order_data]
context = dict(
self.each_context(request),
title="Orders per day",
labels=labels,
values=values,
)
return TemplateResponse(
request, "orders_per_day.html", context
)
def index(self, request, extra_context=None):
reporting_pages = [
{
"name": "Orders per day",
"link": "orders_per_day/",
}
]
if not extra_context:
extra_context = {}
extra_context = {"reporting_pages": reporting_pages}
return super().index(request, extra_context)
# Finally we define 3 instances of AdminSite, each with their own
# set of required permissions and colors
class OwnersAdminSite(ReportingColoredAdminSite):
site_header = "BookTime owners administration"
site_header_color = "black"
module_caption_color = "grey"
def has_permission(self, request):
return (
request.user.is_active and request.user.is_superuser
)
class CentralOfficeAdminSite(ReportingColoredAdminSite):
site_header = "BookTime central office administration"
site_header_color = "purple"
module_caption_color = "pink"
def has_permission(self, request):
return (
request.user.is_active and request.user.is_employee
)
class DispatchersAdminSite(ColoredAdminSite):
site_header = "BookTime central dispatch administration"
site_header_color = "green"
module_caption_color = "lightgreen"
def has_permission(self, request):
return (
request.user.is_active and request.user.is_dispatcher
)
main_admin = OwnersAdminSite()
main_admin.register(models.Product, ProductAdmin)
main_admin.register(models.ProductTag, ProductTagAdmin)
main_admin.register(models.ProductImage, ProductImageAdmin)
main_admin.register(models.User, UserAdmin)
main_admin.register(models.Address, AddressAdmin)
main_admin.register(models.Basket, BasketAdmin)
main_admin.register(models.Order, OrderAdmin)
central_office_admin = CentralOfficeAdminSite(
"central-office-admin"
)
central_office_admin.register(models.Product, ProductAdmin)
central_office_admin.register(models.ProductTag, ProductTagAdmin)
central_office_admin.register(
models.ProductImage, ProductImageAdmin
)
central_office_admin.register(models.Address, AddressAdmin)
central_office_admin.register(
models.Order, CentralOfficeOrderAdmin
)
dispatchers_admin = DispatchersAdminSite("dispatchers-admin")
dispatchers_admin.register(
models.Product, DispatchersProductAdmin
)
dispatchers_admin.register(models.ProductTag, ProductTagAdmin)
dispatchers_admin.register(models.Order, DispatchersOrderAdmin)
代码太多了!首先,Django admin 有三个实例,分别对应于我们在上一节中声明的用户类型。每个实例都注册了一组不同的模型,这取决于与该类型用户相关的内容。
Django 管理网站将被彩色编码。颜色是通过一些自定义的 CSS 注入的。所有者和中心办公室的管理界面也有一些额外的报告视图。额外的视图分三步插入:实际视图(orders_per_day)、URL 映射(在get_urls())和包含在索引模板中(index())。
具体到DispatchersAdminSite,我们为Product和Order专门准备了一个版本的ModelAdmin。DispatchersOrderAdmin覆盖了get_queryset()方法,因为调度办公室只需要看到已经被标记为已支付的订单。在这些网站上,他们只需要看到送货地址。
对于除了所有者之外的任何人,我们也限制了修改 slugs 的能力,因为它们是 URL 的一部分。如果他们被改变,谷歌或任何其他链接到我们网站的实体将会断开链接。
Django 管理接口的新实例现在需要在main/urls.py的urlpatterns中有一个条目,如下所示。不要忘记删除booktime/urls.py中admin/的旧条目。如果你忘记删除它,你会遇到一些路径名冲突的问题。
...
from main import admin
urlpatterns = [
...
path("admin/", admin.main_admin.urls),
path("office-admin/", admin.central_office_admin.urls),
path("dispatch-admin/", admin.dispatchers_admin.urls),
]
要完成这个设置,我们需要覆盖几个管理模板。首先,我们将在顶层文件夹中添加一个名为templates的目录,用于覆盖模板。这意味着booktime/settings.py的变化:
TEMPLATES = [
...
{
"DIRS": [os.path.join(BASE_DIR, 'templates')],
...
然后我们覆盖模板。这是我们新的管理基础模板,它将负责设置 CSS 中的颜色。将以下内容放入templates/admin/base_site.html:
{% extends "admin/base.html" %}
{% block title %}
{{ title }} | {{ site_title|default:_('Django site admin') }}
{% endblock %}
{% block extrastyle %}
<style type="text/css" media="screen">
#header {
background: {{site_header_color}};
}
.module caption {
background: {{module_caption_color}};
}
</style>
{% endblock extrastyle %}
{% block branding %}
<h1 id="site-name">
<a href="{% url 'admin:index' %}">
{{ site_header|default:_('Django administration') }}
</a>
</h1>
{% endblock %}
{% block nav-global %}{% endblock %}
在上面我们看到的代码中,索引视图有一些额外的模板变量。要显示这些内容,需要一个新模板。我们将把这个文件放在templates/admin/index.html中,它将是一个内置管理模板的定制。
让我们为我们的项目复制这个管理模板。从我们的顶层文件夹中,运行以下命令。
cp $VIRTUAL_ENV/lib/python3.6/site-packages/django/contrib/admin/templates/admin/index
新模板需要在content程序块的开头进行更改。下面是修改后的模板内容:
{% extends "admin/base_site.html" %}
...
{% block content %}
<div id="content-main">
{% if reporting_pages %}
<div class="module">
<table>
<caption>
<a href="#" class="section">Reports</a>
</caption>
{% for page in reporting_pages %}
<tr>
<th scope="row">
<a href="{{ page.link }}">
{{ page.name }}
</a>
</th>
<td> </td>
<td> </td>
</tr>
{% endfor %}
</table>
</div>
{% else %}
<p>No reports</p>
{% endif %}
...
{% endblock %}
...
我们现在有三个仪表板,我们想给我们的内部团队。请在以超级用户身份登录后,在浏览器中打开这些 URL。请记住,报告部分还没有完成。
在下一节中,我们将更多地讨论用 Django ORM 进行报告。代码包含在上面(orders_per_day()),但是考虑到它的重要性,它值得在它自己的部分中解释。
订单报告
当涉及到报告时,SQL 查询往往会变得更加复杂,使用聚合函数、GROUP BY子句等。Django ORM 的目的是将数据库行映射到模型对象。它可以用来做报告,但这不是它的主要功能。这可能会导致一些难以理解的 ORM 表达式,所以要小心。
在 Django 中,有两类聚合:一类作用于一个QuerySet中的所有条目,另一类作用于它的每个条目。第一种使用aggregate()方法,第二种使用annotate()。另一种解释方式是aggregate()返回一个 Python 字典,而annotate()返回一个QuerySet,其中每个条目都用附加信息进行了注释。
这个规则有一个例外,那就是当annotate()函数与values()一起使用时。在这种情况下,不是为QuerySet的每一项生成注释,而是在values()方法中指定的字段的每一个唯一组合上生成注释。
如果有疑问,您可以通过检查任何QuerySet上的属性query来查看 ORM 正在生成的 SQL。
接下来的几个部分提供了一些报告,并分解了 ORM 查询。
每天的订单数量
在上面的代码中,有一个名为orders_per_day的视图运行这个聚合查询:
order_data = (
models.Order.objects.filter(
date_added__gt=starting_day
)
.annotate(
day=TruncDay("date_added")
)
.values("day")
.annotate(c=Count("id"))
)
Postgres 中的查询如下:
SELECT DATE_TRUNC('day', "main_order"."date_added" AT TIME ZONE 'UTC') AS "day",
COUNT("main_order"."id") AS "c" FROM "main_order"
WHERE "main_order"."date_added" > 2018-01-16 19:20:01.262472+00:00
GROUP BY DATE_TRUNC('day', "main_order"."date_added" AT TIME ZONE 'UTC')
前面的 ORM 代码做了一些事情:
-
创建一个临时/带注释的
day字段,用基于date_added字段的数据填充它 -
使用新的
day字段作为聚合单位 -
统计特定日期的订单
前面的查询包括两个annotate()调用。第一个作用于订单表中的所有行。第二种方法不是作用于所有行,而是作用于由values()调用生成的GROUP BY子句的结果。
为了完成上一节介绍的报告功能,我们需要在main/templates/orders_per_day.html中创建一个模板:
{% extends "admin/base_site.html" %}
{% block extrahead %}
<script
src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.bundle.min.js"
integrity="sha256-XF29CBwU1MWLaGEnsELogU6Y6rcc5nCkhhx89nFMIDQ="
crossorigin="anonymous"></script>
{% endblock extrahead %}
{% block content %}
<canvas id="myChart" width="900" height="400"></canvas>
<script>
var ctx = document.getElementById("myChart");
var myChart = new Chart(ctx, {
type: 'bar',
data: {
labels: {{ labels|safe }},
datasets: [
{
label: 'No of orders',
backgroundColor: 'blue',
data: {{ values|safe }}
}
]
},
options: {
responsive: false,
scales: {
yAxes: [
{
ticks: {
beginAtZero: true
}
}
]
}
}
});
</script>
{% endblock %}
在模板中,我们使用了一个名为Chart.js的开源库。将图表库用于报告视图是一个常见的主题,您应该熟悉一些图表库以及它们要求的数据格式。
查看购买最多的产品
我们将添加另一个视图,显示购买最多的产品。与orders_per_day()不同的是,我们将通过更多的定制和集成测试来展示你可以将普通视图的相同概念应用于管理视图。
这些是我们将为该视图添加的main/admin.py的片段:
from django import forms
...
class PeriodSelectForm(forms.Form):
PERIODS = ((30, "30 days"), (60, "60 days"), (90, "90 days"))
period = forms.TypedChoiceField(
choices=PERIODS, coerce=int, required=True
)
class ReportingColoredAdminSite(ColoredAdminSite):
def get_urls(self):
urls = super().get_urls()
my_urls = [
...
path(
"most_bought_products/",
self.admin_view(self.most_bought_products),
name="most_bought_products",
),
]
return my_urls + urls
...
def most_bought_products(self, request):
if request.method == "POST":
form = PeriodSelectForm(request.POST)
if form.is_valid():
days = form.cleaned_data["period"]
starting_day = datetime.now() - timedelta(
days=days
)
data = (
models.OrderLine.objects.filter(
order__date_added__gt=starting_day
)
.values("product__name")
.annotate(c=Count("id"))
)
logger.info(
"most_bought_products query: %s", data.query
)
labels = [x["product__name"] for x in data]
values = [x["c"] for x in data]
else:
form = PeriodSelectForm()
labels = None
values = None
context = dict(
self.each_context(request),
title="Most bought products",
form=form,
labels=labels,
values=values,
)
return TemplateResponse(
request, "most_bought_products.html", context
)
def index(self, request, extra_context=None):
reporting_pages = [
...
{
"name": "Most bought products",
"link": "most_bought_products/",
},
]
...
如您所见,我们可以在这个视图中使用表单。我们创建了一个简单的表单来选择我们想要多长时间的报告。
此外,我们将创建main/templates/most_bought_products.html:
{% extends "admin/base_site.html" %}
{% block extrahead %}
<script
src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.2/Chart.bundle.min.js"
integrity="sha256-XF29CBwU1MWLaGEnsELogU6Y6rcc5nCkhhx89nFMIDQ="
crossorigin="anonymous"></script>
{% endblock extrahead %}
{% block content %}
<p>
<form method="POST">
{% csrf_token %}
{{ form }}
<input type="submit" value="Set period" />
</form>
</p>
{% if labels and values %}
<canvas id="myChart" width="900" height="400"></canvas>
<script>
var ctx = document.getElementById("myChart");
var myChart = new Chart(ctx, {
type: 'bar',
data: {
labels: {{ labels|safe }},
datasets: [
{
label: 'No of purchases',
backgroundColor: 'blue',
data: {{ values|safe }}
}
]
},
options: {
responsive: false,
scales: {
yAxes: [
{
ticks: {
beginAtZero: true
}
}
]
}
}
});
</script>
{% endif %}
{% endblock %}
前面的模板与前面的模板非常相似,唯一的区别是我们只在表单提交后才呈现图形。查询需要选定的期间。结果页面如图 7-1 所示。

图 7-1
最常购买的产品视图
为了总结这个功能,我们将添加我们的第一个管理视图测试。我们将创建一个名为main/tests/test_admin.py的新文件:
from django.test import TestCase
from django.urls import reverse
from main import factories
from main import models
class TestAdminViews(TestCase):
def test_most_bought_products(self):
products = [
factories.ProductFactory(name="A", active=True),
factories.ProductFactory(name="B", active=True),
factories.ProductFactory(name="C", active=True),
]
orders = factories.OrderFactory.create_batch(3)
factories.OrderLineFactory.create_batch(
2, order=orders[0], product=products[0]
)
factories.OrderLineFactory.create_batch(
2, order=orders[0], product=products[1]
)
factories.OrderLineFactory.create_batch(
2, order=orders[1], product=products[0]
)
factories.OrderLineFactory.create_batch(
2, order=orders[1], product=products[2]
)
factories.OrderLineFactory.create_batch(
2, order=orders[2], product=products[0]
)
factories.OrderLineFactory.create_batch(
1, order=orders[2], product=products[1]
)
user = models.User.objects.create_superuser(
"user2", "pw432joij"
)
self.client.force_login(user)
response = self.client.post(
reverse("admin:most_bought_products"),
{"period": "90"},
)
self.assertEqual(response.status_code, 200)
data = dict(
zip(
response.context["labels"],
response.context["values"],
)
)
self.assertEqual(data, {"B": 3, "C": 2, "A": 6})
这个测试大量使用工厂来为报告创建足够的数据,以包含一些有用的信息。以下是我们在main/factories.py中添加的新工厂:
...
class OrderLineFactory(factory.django.DjangoModelFactory):
class Meta:
model = models.OrderLine
class OrderFactory(factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
class Meta:
model = models.Order
产品批量更新
在 Django 管理界面中,可以批量应用动作。删除就是一个例子:从 change list 视图中,我们可以选择多个条目,然后从表格顶部的下拉菜单中选择 delete 操作。
这些被称为“动作”,可以向特定的ModelAdmin实例添加定制动作。我们将添加两个标签来标记产品是活动的还是非活动的。
为此,让我们更改main/admin.py:
...
def make_active(self, request, queryset):
queryset.update(active=True)
make_active.short_description = "Mark selected items as active"
def make_inactive(self, request, queryset):
queryset.update(active=False)
make_inactive.short_description = (
"Mark selected items as inactive"
)
class ProductAdmin(admin.ModelAdmin):
...
actions = [make_active, make_inactive]
如你所见,这是一个非常简单的改变。点击列名前左侧的下拉按钮,可以在产品列表页面中看到结果,如图 3-5 所示。
打印订单发票(pdf 格式)
我们要解决的最后一个问题是电子商务商店的常见问题:打印发票。在 Django,没有生成 pdf 的工具,所以我们需要安装一个第三方库。
网上有多个 Python PDF 库可用;在我们的例子中,我们将选择 WeasyPrint。这个库允许我们用 HTML 页面创建 pdf,这就是我们在这里开始的方式。如果您想要更大的灵活性,也许您应该依赖不同的库。
WeasyPrint 需要在系统中安装两个系统库:Cairo 和 Pango。它们都用于呈现文档。你可以用你的软件包管理器来安装它们。您还需要正确呈现 CSS 所需的字体。
让我们安装 WeasyPrint:
$ pipenv install WeasyPrint
我们将为此创建一个管理视图,并将其添加到相关的AdminSite类中:
...
from django.shortcuts import get_object_or_404, render
from django.http import HttpResponse
from django.template.loader import render_to_string
from weasyprint import HTML
import tempfile
...
class InvoiceMixin:
def get_urls(self):
urls = super().get_urls()
my_urls = [
path(
"invoice/<int:order_id>/",
self.admin_view(self.invoice_for_order),
name="invoice",
)
]
return my_urls + urls
def invoice_for_order(self, request, order_id):
order = get_object_or_404(models.Order, pk=order_id)
if request.GET.get("format") == "pdf":
html_string = render_to_string(
"invoice.html", {"order": order}
)
html = HTML(
string=html_string,
base_url=request.build_absolute_uri(),
)
result = html.write_pdf()
response = HttpResponse(
content_type="application/pdf"
)
response[
"Content-Disposition"
] = "inline; filename=invoice.pdf"
response["Content-Transfer-Encoding"] = "binary"
with tempfile.NamedTemporaryFile(
delete=True
) as output:
output.write(result)
output.flush()
output = open(output.name, "rb")
binary_pdf = output.read()
response.write(binary_pdf)
return response
return render(request, "invoice.html", {"order": order})
# This mixin will be used for the invoice functionality, which is
# only available to owners and employees, but not dispatchers
class OwnersAdminSite(InvoiceMixin, ReportingColoredAdminSite):
...
class CentralOfficeAdminSite(
InvoiceMixin, ReportingColoredAdminSite
):
...
这个 Django 视图有两种呈现模式,HTML 和 PDF。两种模式都使用相同的invoice.html模板,但是在 PDF 的情况下,WeasyPrint 用于对模板引擎的输出进行后处理。
当生成 pdf 时,我们不使用普通的render()调用,而是使用render_to_string()方法并将结果存储在内存中。PDF 库然后将使用它来生成 PDF 正文,我们将把它存储在一个临时文件中。在我们的例子中,临时文件将被删除,但是如果我们愿意,我们可以将它保存在一个FileField中。
我们案例中使用的模板是main/templates/invoice.html:
{% load static %}
<!doctype html>
<html lang="en">
<head>
<link
rel="stylesheet"
href="{% static "css/bootstrap.min.css" %}">
<title>Invoice</title>
</head>
<body>
<div class="container-fluid">
<div class="row">
<div class="col">
<h1>BookTime</h1>
<h2>Invoice</h2>
</div>
</div>
<div class="row">
<div class="col-8">
Invoice number BT{{ order.id }}
<br/>
Date:
{{ order.date_added|date }}
</div>
<div class="col-4">
{{ order.billing_name }}<br/>
{{ order.billing_address1 }}<br/>
{{ order.billing_address2 }}<br/>
{{ order.billing_zip_code }}<br/>
{{ order.billing_city }}<br/>
{{ order.billing_country }}<br/>
</div>
</div>
<div class="row">
<div class="col">
<table
class="table"
style="width: 95%; margin: 50px 0px 50px 0px">
<tr>
<th>Product name</th>
<th>Price</th>
</tr>
{% for line in order.lines.all %}
<tr>
<td>{{ line.product.name }}</td>
<td>{{ line.product.price }}</td>
</tr>
{% endfor %}
</table>
</div>
</div>
<div class="row">
<div class="col">
<p>
Please pay within 30 days
</p>
<p>
BookTime inc.
</p>
</div>
</div>
</div>
</body>
</html>
这足以让功能正常工作,但是在管理界面中还看不到它。为了使其可见,我们将向 change order 视图添加按钮,如图 7-2 所示。

图 7-2
发票按钮
Django admin 允许我们覆盖特定于模型视图的模板。我们已经通过在templates/文件夹中创建新的模板覆盖了一些管理模板,我们将遵循类似的方法。我们将创建templates/admin/main/order/change_form.html:
{% extends "admin/change_form.html" %}
{% block object-tools-items %}
{% url 'admin:invoice' original.pk as invoice_url %}
{% if invoice_url %}
<li>
<a href="{{ invoice_url }}">View Invoice</a>
</li>
<li>
<a href="{{ invoice_url }}?format=pdf">
Download Invoice as PDF
</a>
</li>
{% endif %}
{{ block.super }}
{% endblock %}
此时,请继续尝试检索和查看 PDF。如果它不能正确生成,你可能需要去 WeasyPrint 论坛找出原因。您会发现大多数情况下,问题是您的系统中缺少一个依赖项。
测试发票生成
这项功能最不需要的就是测试。我们希望确保,给定一个包含一些特定数据的订单,HTML 和 PDF 版本的结果完全符合我们的预期。
这个测试依赖于两个设备,HTML 发票和 PDF 版本。在运行该测试之前,使用如下所示的测试数据创建一个订单,并将两张发票下载到正确的文件夹中。
from datetime import datetime
from decimal import Decimal
from unittest.mock import patch
...
class TestAdminViews(TestCase):
...
def test_invoice_renders_exactly_as_expected(self):
products = [
factories.ProductFactory(
name="A", active=True, price=Decimal("10.00")
),
factories.ProductFactory(
name="B", active=True, price=Decimal("12.00")
),
]
with patch("django.utils.timezone.now") as mock_now:
mock_now.return_value = datetime(
2018, 7, 25, 12, 00, 00
)
order = factories.OrderFactory(
id=12,
billing_name="John Smith",
billing_address1="add1",
billing_address2="add2",
billing_zip_code="zip",
billing_city="London",
billing_country="UK",
)
factories.OrderLineFactory.create_batch(
2, order=order, product=products[0]
)
factories.OrderLineFactory.create_batch(
2, order=order, product=products[1]
)
user = models.User.objects.create_superuser(
"user2", "pw432joij"
)
self.client.force_login(user)
response = self.client.get(
reverse(
"admin:invoice", kwargs={"order_id": order.id}
)
)
self.assertEqual(response.status_code, 200)
content = response.content.decode("utf8")
with open(
"main/fixtures/invoice_test_order.html", "r"
) as fixture:
expected_content = fixture.read()
self.assertEqual(content, expected_content)
response = self.client.get(
reverse(
"admin:invoice", kwargs={"order_id": order.id}
),
{"format": "pdf"}
)
self.assertEqual(response.status_code, 200)
content = response.content
with open(
"main/fixtures/invoice_test_order.pdf", "rb"
) as fixture:
expected_content = fixture.read()
self.assertEqual(content, expected_content)
摘要
本章深入探讨了 Django 管理界面。我们看到了这款应用的可定制性。我们还谈到了将这个应用推得太远的危险:如果我们想要提供的用户流不同于简单的创建/编辑/删除方法,定制管理可能不值得,相反,在管理之外添加定制视图可能会更好。
我们还讨论了报告以及如何为此构建 ORM 查询。Django 文档在这方面更深入,我鼓励您研究它以获得更高级的查询。
我们还讨论了 PDF 生成。在我们的例子中,这只是为后台人员做的。一些网站为网站用户直接提供发票生成。在这种情况下,很容易改编本章中的代码,以在普通(非管理员)视图中提供它。
在下一章中,我们将讨论 Django 的一个名为 Channels 的扩展,以及我们如何使用它来构建一个聊天页面与我们的客户进行交互。
八、移动客服应用的后端
在这一章中,我们将离开 HTML 和普通的 HTTP。我们将创建必要的基础设施来支持移动应用,包括使用 WebSocket 和 HTTP 服务器端事件的异步通信。
本章以 Django 频道为中心,包括以下主题:
-
如何整合
-
如何构建 WebSocket 消费者
-
如何将其与 Redis 整合
-
如何使用它来异步提供内容
Django 频道
Django Channels 是 Django 生态系统的新成员。它得到了 Django 的官方支持,所有的开发都是和 Django 一起在 GitHub 上进行的。但是,在安装 Django 时不包括它。
Channels 允许我们编写异步代码来处理传入的请求,这在客户端和服务器之间有自然异步交互的情况下很有帮助——例如,聊天会话。
虽然如果实时组件不是关键的话,在技术上可以构建一个具有普通 Django 视图的聊天服务器,但是当客户端数量增加时,同步系统将无法像异步系统那样扩展。
也就是说,在同步系统上开发要容易得多。不要认为异步编程总是一个更好的范例,因为,和计算中的任何事情一样,它也有代价。
异步代码与同步代码
同步编程是一个非常好理解的执行模型。你的代码被从头到尾执行,然后将控制权返回给 Django。另一方面,在异步编程中,情况并非总是如此。您的异步代码与一个asyncio事件循环协同工作。网上有很多文档,我鼓励你去看看,比如 1 。
幸运的是,在使用 Django 通道之前,您不需要理解异步编程的所有内容。这个库隐藏了一些底层细节,简化了您的工作。要理解的最重要的事情之一是,您不能自由地混合同步和异步代码。每次这样做,你都需要跨越一个边界 2 。
我鼓励你花一些时间在线阅读关于异步编程的内容。它将帮助你理解本章中的代码和概念。
安装和配置
为了在我们的系统上安装通道,我们将把它和 Redis 一起安装,Redis 是一个开源的内存数据结构服务器( https://redis.io )。这与我们一开始使用 PostgreSQL 的原因相同:总是在生产部署将使用的相同环境中工作。
继续下载并在您的操作系统上安装 Redis。完成后,继续安装通道:
pipenv install channels
pipenv install channels_redis
我们的项目需要一个新文件routing.py,放在同一个文件夹settings.py中。目前,这个文件除了内置的路由之外,不会声明任何路由。
booktime/routing.py的内容是:
from channels.routing import ProtocolTypeRouter
application = ProtocolTypeRouter({})
我们将使用 Redis 将该文件与通道配置连接起来。应用channels需要成为第一个,因为它覆盖了标准 Django 的runserver命令:
INSTALLED_APPS = [
'channels',
...
]
ASGI_APPLICATION = "booktime.routing.application"
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
"hosts": [('127.0.0.1', 6379)],
},
},
}
您现在可以使用这个库了。这是新的runserver命令的输出:
$ ./manage.py runserver
Performing system checks...
System check identified no issues (0 silenced).
August 15, 2018 - 18:56:30
Django version 2.1, using settings 'booktime.settings' Starting ASGI/Channels version 2.1.2 development server at...
Quit the server with CONTROL-C.
2018-08-15 18:56:30,270 - INFO - server - HTTP/2 support not enabled
2018-08-15 18:56:30,271 - INFO - server - Configuring endpoint...
2018-08-15 18:56:30,272 - INFO - server - Listening on 127.0.0.1:8000
用缰绳
如前所述,Redis 是一个内存数据结构服务器。它可以用作非常简单的数据库、缓存或消息代理。它有一组与普通数据库非常不同的操作,它的用例也非常不同。
在我们的设置中,通道将使用 Redis 在运行的 Django 应用的不同实例之间传递消息。Redis 还将使我们能够在不同机器上运行的实例之间以及在单个服务器上运行的实例之间传递消息。
除了要求 Redis 支持进程间的通信,本章的代码将直接使用 Redis 创建一个简单的存在机制。默认情况下,通道不提供确定某些用户是否连接到我们的端点的方法。
顾客
我们可以把渠道中的消费者看作是基于类别的视图的等价物,区别在于消费者不像 cbv 那样层次高、数量多。有两个基本的消费者类别,SyncConsumer和AsyncConsumer。除了异步代码依赖于 Python async/await语法之外,它们的接口是相同的。
消费者的结构基于使用类方法和send()内置方法的消息处理程序的组合。消费者可以有多个消息处理程序,消息的路由基于消息的type值。
当类初始化时,每个消费者都将收到一个存储在self.scope中的scope对象。它包含关于初始请求的信息,例如:
-
scope["path"]:请求的路径 -
scope["user"]:当前用户(仅在认证启用时) -
scope["url_route"]:包含匹配的路由(如果使用 URL 路由器)
原始消费者有几个子类:
-
WebsocketConsumer和AsyncWebsocketConsumer -
JsonWebsocketConsumer和AsyncJsonWebsocketConsumer -
AsyncHttpConsumer
这些子类中的每一个都添加了一些 helper 方法,例如,管理 JSON 编码/解码或 WebSockets。
频道、频道层和群组
这个库的核心概念之一是通道。通道本质上是一个邮箱,当它作为请求处理的一部分被实例化时,每个使用者会自动获得一个邮箱,当使用者终止时,它会被删除。这就是消息从外部发送给给定消费者的方式。
通道层相当于邮递员。它们需要在 Django 的不同实例之间传输消息。该层可以将消息传递给单个消费者(通过知道其通道名)或一组消费者(通过知道其组通道名)。
向单个消费者发送消息并不是常见的用例。更常见的是向与特定用户相关的所有消费者发送消息(在他或她在多个选项卡上打开站点的情况下很有用),或者向一组特定用户的所有消费者发送消息(就像我们的聊天案例)。
为此,Channels 提供了一个名为组的抽象。群组是您可以向其发送消息的实体,这些消息将被转发到与其连接的所有消费者渠道。通过通道层上的方法group_add()和group_discard()将通道连接到组。
要向群组发送信息,您可以使用group_send()。当通过此方法发送消息时,通道层会将消息转发到所有连接的消费者通道。然后,消费者将通过调用特定消息类型的消息处理程序来自动处理消息。
然后,处理程序负责将消息转发回 HTTP 客户端(通过使用send())或进行所需的计算。
路由和中间件
通道包括各种路由器,用于将请求路由到特定的消费者。路由可以基于协议(HTTP 或 WebSocket)、URL 或通道名称。
在我们的项目中,我们最初将使用ProtocolTypeRouter,因为我们需要将 WebSocket 处理代码与普通的 Django 视图分开。我们将使用路由器和渠道提供的特殊中间件。
这里的中间件不同于标准 Django 中的中间件。这个中间件是完全异步的。它提供了一些原则上类似于 Django 中间件的东西:过滤、阻止和向作用域添加附加信息。
在我们的应用中,我们将使用AuthMiddlewareStack,它是认证、会话和 cookie 中间件组件的组合。这个中间件堆栈将负责加载用户会话,确定连接是否经过身份验证,如果是,则加载消费者范围内的用户对象。
为我们的客服人员聊天
让我们开始用渠道构建一些具体的东西:客服人员的内部聊天页面。我们将从下载一个必备软件reconnecting-websocket开始,它将为我们处理不稳定的连接:
$ curl -o main/static/js/reconnecting-websocket.min.js \
https://raw.githubusercontent.com/joewalnes/reconnecting-websocket/
\master/reconnecting-websocket.min.js
我们将为我们的客服代表提供一个非常简单的聊天页面,您可以随意设计。这是main/templates/chat_room.html的内容:
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Chat Room</title>
<script
src="{% static "js/reconnecting-websocket.min.js" %}"
charset="utf-8"></script>
</head>
<body>
<textarea id="chat-log" cols="100" rows="20"></textarea><br/>
<input id="chat-message-input" type="text" size="100"/><br/>
<input id="chat-message-submit" type="button" value="Send"/>
</body>
<script>
var roomName = {{ room_name_json }};
var chatSocket = new ReconnectingWebSocket(
'ws://' + window.location.host + '/ws/customer-service/' +
roomName + '/'
);
chatSocket.onmessage = function (e) {
var data = JSON.parse(e.data);
var username = data['username'];
if (data['type'] == "chat_join") {
message = (username + ' joined\n ');
} else if (data['type'] == "chat_leave") {
message = (username + ' left\n ');
} else {
message = (username + ': ' + data['message'] + '\n');
}
document
.querySelector('#chat-log')
.value += message;
};
chatSocket.onclose = function (e) {
console.error('Chat socket closed unexpectedly');
};
document
.querySelector('#chat-message-input')
.focus();
document
.querySelector('#chat-message-input')
.onkeyup = function (e) {
if (e.keyCode === 13) { // enter, return
document
.querySelector('#chat-message-submit')
.click();
}
};
document
.querySelector('#chat-message-submit')
.onclick = function (e) {
var messageInputDom = document.querySelector(
'#chat-message-input'
);
var message = messageInputDom.value;
chatSocket.send(
JSON.stringify({'type': 'message', 'message': message})
);
messageInputDom.value = ";
};
setInterval(function () {
chatSocket.send(JSON.stringify({'type': 'heartbeat'}));
}, 10000);
</script>
</html>
这是我们定制的包含在渠道库中的示例版本。它是一个简单的消息可视化器/发送器,通过 WebSocket 连接发送和接收 JSON 消息。
WebSocket 协议格式
我们将为 WebSocket 消息定义一个非常简单的格式,服务器到客户端:
{
type: "TYPE",
username: "who is the originator of the event",
message: "This is the displayed message" (optional)
}
这里,TYPE可以有以下值:
-
chat_join:用户名加入了聊天。 -
chat_leave:用户名离开了聊天。 -
chat_message:用户名发送消息。
这足以定义服务器到客户端。现在,对于客户端到服务器:
{
type: "TYPE",
message: "This is the displayed message" (optional)
}
TYPE在这种情况下可以有以下值:
-
message:用户名发送消息。 -
heartbeat:ping 命令,让服务器知道用户是活动的。
这描述了我们的整个 WebSocket 协议。我们将使用它来为我们公司的客户构建客服部分和移动界面。
Redis 中的心跳机制
我们的聊天需要一个以用户为中心而不是以连接为中心的在线系统。任何用户,无论是客服代表还是最终用户,一旦他们发起的所有 WebSocket 连接被关闭或处于非活动状态超过 10 秒钟,他们都将变得不可用。这将确保以更可靠的方式处理网络问题或浏览器崩溃。
我们将依赖 Redis 的到期功能。Redis 有一种非常有效的方法来设置特定键的值,只是暂时的。当设置值和过期时间时,Redis 会在适当的时候自动删除它们。对我们来说,这是一个完美的机制,因为它可以自动回收密钥。
来自客户端的心跳信号将在名为 customer-service _ ORDERID _ user email 的键上发出 Redis SETEX命令,设置虚拟值 1,10 秒后到期。
这足以支持一个页面来显示谁连接到哪个客服聊天的动态列表。该页面将发出带有前缀customer-service_的 Redis KEYS命令,并从返回的结果中生成信息。
为了直接使用 Redis,我们需要向我们的系统添加一个新的依赖项:
$ pipenv install aioredis
假设我们使用异步消费者,我们将需要异步网络库。aioredis库是 Redis Python 客户端的异步版本。
引导页面
我们将添加一个到main/urls.py的 URL 和一个到main/views.py的视图,以服务前面给出的chat_room.html模板。让我们从视图开始:
...
def room(request, order_id):
return render(
request,
"chat_room.html",
{"room_name_json": str(order_id)},
)
...
urlpatterns = [
...
path(
"customer-service/<int:order_id>/",
views.room,
name="cs_chat",
),
]
聊天引导页面现在已经完成,但是它的 JavaScript 不能工作,因为 WebSocket 端点还不存在。
WebSocket 消费者
消费者是大部分工作发生的地方。我们将把消费者放在main/consumers.py:
import aioredis
import logging
from django.shortcuts import get_object_or_404
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
from . import models
logger = logging.getLogger(__name__)
class ChatConsumer(AsyncJsonWebsocketConsumer):
EMPLOYEE = 2
CLIENT = 1
def get_user_type(self, user, order_id):
order = get_object_or_404(models.Order, pk=order_id)
if user.is_employee:
order.last_spoken_to = user order.save()
return ChatConsumer.EMPLOYEE
elif order.user == user:
return ChatConsumer.CLIENT
else:
return None
async def connect(self):
self.order_id = self.scope["url_route"]["kwargs"][
"order_id"
]
self.room_group_name = (
"customer-service_%s" % self.order_id
)
authorized = False
if self.scope["user"].is_anonymous:
await self.close()
user_type = await database_sync_to_async(
self.get_user_type
)(self.scope["user"], self.order_id)
if user_type == ChatConsumer.EMPLOYEE:
logger.info(
"Opening chat stream for employee %s",
self.scope["user"],
)
authorized = True
elif user_type == ChatConsumer.CLIENT:
logger.info(
"Opening chat stream for client %s",
self.scope["user"],
)
authorized = True
else:
logger.info(
"Unauthorized connection from %s",
self.scope["user"],
)
await self.close()
if authorized:
self.r_conn = await aioredis.create_redis(
"redis://localhost"
)
await self.channel_layer.group_add(
self.room_group_name, self.channel_name
)
await self.accept()
await self.channel_layer.group_send(
self.room_group_name,
{
"type": "chat_join",
"username": self.scope[
"user"
].get_full_name(),
},
)
async def disconnect(self, close_code):
if not self.scope["user"].is_anonymous:
await self.channel_layer.group_send(
self.room_group_name,
{
"type": "chat_leave",
"username": self.scope[
"user"
].get_full_name(),
},
)
logger.info(
"Closing chat stream for user %s",
self.scope["user"],
)
await self.channel_layer.group_discard(
self.room_group_name, self.channel_name
)
async def receive_json(self, content):
typ = content.get("type")
if typ == "message":
await self.channel_layer.group_send(
self.room_group_name,
{
"type": "chat_message",
"username": self.scope[
"user"
].get_full_name(),
"message": content["message"],
},
)
elif typ == "heartbeat":
await self.r_conn.setex(
"%s_%s"
% (
self.room_group_name,
self.scope["user"].email,
),
10, # expiration (in 10 seconds)
"1", # dummy value
)
async def chat_message(self, event):
await self.send_json(event)
async def chat_join(self, event):
await self.send_json(event)
async def chat_leave(self, event):
await self.send_json(event)
我们从AsyncJsonWebsocketConsumer派生出我们的消费者,它负责WebSocket的底层方面和 JSON 编码。我们需要实现receive_json()、connect()和disconnect()来让这个类工作。
connect()做的第一件事是生成一个房间名,它将被用作group_*()呼叫的通道名。在此之后,我们需要确保用户有权限在这里,这需要访问数据库。
从异步消费者访问数据库需要将代码包装在同步函数中,然后使用database_sync_to_async()方法。这是因为 Django 本身,尤其是 ORM,是以同步方式编写的。
前面代码中的get_user_type()方法除了检查用户类型之外,还存储订单中客户与之交谈的最后一个雇员的姓名。
三种主要的方法,receive_json()、connect()和disconnect(),使用通道层方法group_send()、group_add()和group_discard()来管理一个聊天室的所有不同消费者实例之间的通信和同步。
在方法receive_json()中,我们使用group_send()方法来处理我们在 WebSocket 协议格式部分列出的两种类型的消息。“message”类型的消息按原样转发给所有连接的消费者,而“heartbeat”类型的消息用于更新 Redis 密钥的到期时间(如果该密钥尚不存在,则创建该密钥)。
在方法connect()和disconnect()中,我们使用group_send()方法来生成不同用户的加入/离开消息。
记住group_send()不是将数据发送回浏览器的 WebSocket 连接,这一点很重要。它仅用于使用配置的通道层在消费者之间传递信息。每个消费者将通过消息处理程序接收这些数据。
最后,在 ChatConsumer 中有三个处理程序:chat_message()、chat_join()和chat_leave()。它们都将消息直接发送回浏览器的 WebSocket 连接,因为所有的处理都将发生在前端。
将处理消息的消息处理程序的名称来自 type 字段。如果用chat_message的message[ ' type ' ]调用group_send(),则接收消费者将使用chat_message()处理程序处理此事。
根据最后几段,花点时间重读代码,并交叉引用我刚才提到的关于代码功能的内容。阅读代码时还要考虑到,异步编程不是 Python 中的默认风格。
这个消费者需要我们数据库模式中的一个新字段。让我们快速添加到我们的main/models.py:
...
class Order(models.Model):
...
last_spoken_to = models.ForeignKey(
User,
null=True,
related_name="cs_chats",
on_delete=models.SET_NULL,
)
...
添加之后,不要忘记运行管理命令makemigrations和migrate来将其应用到数据库。
在定义了消费者及其所有需求之后,我们将继续为这个消费者定义路由。
选择途径
目前我们在booktime/routing.py中有一个空的{}路由变量。我们需要改变这种情况。
我们将在main/routing.py管理所有特定于我们站点的路线:
from django.urls import path
from . import consumers
websocket_urlpatterns = [
path(
"ws/customer-service/<int:order_id>/",
consumers.ChatConsumer
)
]
我们将管理booktime/routing.py中的所有常规路线:
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
import main.routing
application = ProtocolTypeRouter({
# (http->django views is added by default)
'websocket': AuthMiddlewareStack(
URLRouter(
main.routing.websocket_urlpatterns
)
),
})
这个设置将为我们的 WebSocket 消费者分配一个 URL 路径,并将 WebSocket 流量中继到这个新的“websocket”路由。如果我们不将“http”协议类型添加到主路由器,通道将自动添加它以支持标准 Django 视图。
现在,您可以通过在浏览器中加载聊天 URL 来测试您的页面。您应该能够加载聊天,并在聊天中键入一些句子。如果您在多个浏览器中加载聊天内容,您会看到您在一个窗口中输入的内容会出现在另一个窗口中。
自动化测试
正如本书中的任何内容一样,我将解释如何测试到目前为止公开的代码。Channels 提供了名为communicator的构造。您可以将通信器视为 Django 测试用例中包含的测试客户端的等价物。
与消费者不同,通信者没有同步版本和异步版本。它们只带有一个异步 API。要将它与同步运行的标准 Python Unittest 框架一起使用,我们需要使用一些低级别的 asyncio API。这就是我们在测试中要做的。
我们将把这些测试放在main/tests/test_consumers.py中:
import asyncio
from django.contrib.auth.models import Group
from django.test import TestCase
from channels.db import database_sync_to_async
from channels.testing import WebsocketCommunicator
from main import consumers
from main import factories
class TestConsumers(TestCase):
def test_chat_between_two_users_works(self):
def init_db():
user = factories.UserFactory(
email="john@bestemails.com",
first_name="John",
last_name="Smith",
)
order = factories.OrderFactory(user=user)
cs_user = factories.UserFactory(
email="customerservice@booktime.domain",
first_name="Adam",
last_name="Ford",
is_staff=True,
)
employees, _ = Group.objects.get_or_create(
name="Employees"
)
cs_user.groups.add(employees)
return user, order, cs_user
async def test_body():
user, order, cs_user = await database_sync_to_async(
init_db
)()
communicator = WebsocketCommunicator(
consumers.ChatConsumer,
"/ws/customer-service/%d/" % order.id,
)
communicator.scope["user"] = user
communicator.scope["url_route"] = {
"kwargs": {"order_id": order.id}
}
connected, _ = await communicator.connect()
self.assertTrue(connected)
cs_communicator = WebsocketCommunicator(
consumers.ChatConsumer,
"/ws/customer-service/%d/" % order.id,
)
cs_communicator.scope["user"] = cs_user
cs_communicator.scope["url_route"] = {
"kwargs": {"order_id": order.id}
}
connected, _ = await cs_communicator.connect()
self.assertTrue(connected)
await communicator.send_json_to(
{
"type": "message",
"message": "hello customer service",
}
)
await asyncio.sleep(1)
await cs_communicator.send_json_to(
{"type": "message", "message": "hello user"}
)
self.assertEquals(
await communicator.receive_json_from(),
{"type": "chat_join", "username": "John Smith"},
)
self.assertEquals(
await communicator.receive_json_from(),
{"type": "chat_join", "username": "Adam Ford"},
)
self.assertEquals(
await communicator.receive_json_from(),
{
"type": "chat_message",
"username": "John Smith",
"message": "hello customer service",
},
)
self.assertEquals(
await communicator.receive_json_from(),
{
"type": "chat_message",
"username": "Adam Ford",
"message": "hello user",
},
)
await communicator.disconnect()
await cs_communicator.disconnect()
order.refresh_from_db()
self.assertEquals(order.last_spoken_to, cs_user)
loop = asyncio.get_event_loop()
loop.run_until_complete(test_body())
在这个测试中,我们测试连接是否正常工作,消息是否按照预期的方式被转发。为了测试这一点,我们使用两个通信器,一个代表客服操作员,另一个代表最终用户。
注意,前面的测试函数包含两个子函数,一个用于同步数据库初始化,另一个用于主要的异步主体。然后,通过直接引用 asyncio 循环来运行主异步体。
通信器将消费者要连接的对象和 URL 作为参数。URL 不是绝对必要的,但是 URLRouter 使用它来在使用者范围内注入路由。URLRouter 不支持命名路由,因此在引用 URL 时不能使用reverse()。
我们的消费者还需要一个阻止未授权用户的测试:
...
def test_chat_blocks_unauthorized_users(self):
def init_db():
user = factories.UserFactory(
email="john@bestemails.com",
first_name="John",
last_name="Smith",
)
order = factories.OrderFactory()
return user, order
async def test_body():
user, order = await database_sync_to_async(init_db)()
communicator = WebsocketCommunicator(
consumers.ChatConsumer,
"/ws/customer-service/%d/" % order.id,
)
communicator.scope["user"] = user
communicator.scope["url_route"] = {
"kwargs": {"order_id": order.id}
}
connected, _ = await communicator.connect()
self.assertFalse(connected)
loop = asyncio.get_event_loop()
loop.run_until_complete(test_body())
不可否认,这里的低级 asyncio 函数过于简单。如果您打算开始编写许多异步测试,您可能想看看 Pytest,这是 Django Channels 在内部发布时使用的。
聊天仪表板(带在线状态)
我们的客服代表需要能够看到是否有客户在等待服务。我们需要建立一个仪表板,动态更新聊天室和其中各种人的列表。
我们将采用与之前构建的聊天类似的方法,但是我们将使用更简单的单向方法来代替 WebSockets。我们将使用 HTTP 服务器发送的事件。
服务器发送的事件(SSE)本质上是一个 HTTP 连接,它保持打开,并在事件发生时接收大量信息。每个信息块都以单词“data:”为前缀,并以两个换行符结束。
我邀请你在线阅读一些关于协议格式 3 的文档。SSE 没有 WebSockets 复杂。它很容易实现,所有主流浏览器都支持它。
类似于我们之前所做的,我们将使用reconnecting-eventsource,它将为我们处理不稳定的连接:
$ curl -o main/static/js/reconnecting-eventsource.js \
https://cdn.jsdelivr.net/npm/reconnecting-eventsource@1.0.1/\
dist/ReconnectingEventSource.js
本着与聊天页面相同的精神,这是我们非常简单的仪表板,存储在main/templates/customer_service.html:

图 8-1
客服仪表板
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>Chat Rooms</title>
<script
src="{% static "js/reconnecting-eventsource.js" %}"
charset="utf-8"></script>
</head>
<body>
<h1>Customer chats</h1>
<div id="notification-area"></div>
<script>
var source = new ReconnectingEventSource('/customer-service/notify/');
source.addEventListener('message', function (e) {
document
.getElementById("notification-area")
.innerHTML = "";
var data = JSON.parse(e.data);
var html;
for (var i = 0; i < data.length; i++) {
html = '<div><a href="' + data[i]['link'] + '">' + data[i]['text'] + '</a></div>';
document
.getElementById("notification-area")
.innerHTML += html;
}
}, false);
</script>
</body>
</html>
我们将再次添加一个到main/urls.py的 URL 来提供上面的模板:
...
urlpatterns = [
...
path(
"customer-service/",
TemplateView.as_view(
template_name="customer_service.html"
),
name="cs_main",
),
]
HTTP 服务器发送的事件消费者
上面的页面将从main/consumers.py的一个新消费者那里接收动态数据:
...
import asyncio
import json
from django.urls import reverse
from channels.exceptions import StopConsumer
from channels.generic.http import AsyncHttpConsumer
...
class ChatNotifyConsumer(AsyncHttpConsumer):
def is_employee_func(self, user):
return not user.is_anonymous and user.is_employee
async def handle(self, body):
is_employee = await database_sync_to_async(
self.is_employee_func
)(self.scope["user"])
if is_employee:
logger.info(
"Opening notify stream for user %s and params %s",
self.scope.get("user"),
self.scope.get("query_string"),
)
await self.send_headers(
headers=[
("Cache-Control", "no-cache"),
("Content-Type", "text/event-stream"),
("Transfer-Encoding", "chunked"),
]
)
self.is_streaming = True
self.no_poll = (
self.scope.get("query_string") == "nopoll"
)
asyncio.get_event_loop().create_task(self.stream())
else:
logger.info(
"Unauthorized notify stream for user %s and params %s",
self.scope.get("user"),
self.scope.get("query_string"),
)
raise StopConsumer("Unauthorized")
async def stream(self):
r_conn = await aioredis.create_redis("redis://localhost")
while self.is_streaming:
active_chats = await r_conn.keys(
"customer-service_*"
)
presences = {}
for i in active_chats:
_, order_id, user_email = i.decode("utf8").split(
"_"
)
if order_id in presences:
presences[order_id].append(user_email)
else:
presences[order_id] = [user_email]
data = []
for order_id, emails in presences.items():
data.append(
{
"link": reverse(
"cs_chat",
kwargs={"order_id": order_id}
),
"text": "%s (%s)"
% (order_id, ", ".join(emails)),
}
)
payload = "data: %s\n\n" % json.dumps(data)
logger.info(
"Broadcasting presence info to user %s",
self.scope["user"],
)
if self.no_poll:
await self.send_body(payload.encode("utf-8"))
self.is_streaming = False
else:
await self.send_body(
payload.encode("utf-8"),
more_body=self.is_streaming,
)
await asyncio.sleep(5)
async def disconnect(self):
logger.info(
"Closing notify stream for user %s",
self.scope.get("user"),
)
self.is_streaming = False
这个消费者用handle()和disconnect()方法实现了AsyncHttpConsumer接口。由于我们试图构建的端点的流性质,我们需要保持连接开放。我们将调用send_headers()方法来启动 HTTP 响应,并且我们将调用send_body(),将more_body参数设置为True,在一个独立的异步任务中保持活动状态。
stream()方法激活被添加到事件循环中,并且将保持活动状态,直到disconnect()方法被调用(客户端发起的断开连接)。当这个方法被调用时,它会将一个is_streaming标志设置为False,这将导致运行在不同 asyncio 任务中的stream()内循环退出。
stream()方法将定期从 Redis 中读取未过期的密钥,并将它们发送回客户端。如果在连接时传入了nopoll标志,它将退出循环,而不等待客户端断开连接。
只有授权用户才能进行流式传输。对于未经授权的用户,handle()方法将引发一个StopConsumer异常,该异常将停止消费者并关闭与客户端的当前连接。
选择途径
SSE 消费者是我们创建的第一个 HTTP 消费者,它需要一些额外的配置才能工作。我们将在main/routing.py中定义所有非 websocket HTTP 路由:
from channels.auth import AuthMiddlewareStack
...
http_urlpatterns = [
path(
"customer-service/notify/",
AuthMiddlewareStack(
consumers.ChatNotifyConsumer
)
)
]
我们将用一个定制的路由覆盖booktime/routing.py中的默认 HTTP 路由:
from django.urls import re_path
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.http import AsgiHandler
import main.routing
application = ProtocolTypeRouter(
{
"websocket": AuthMiddlewareStack(
URLRouter(main.routing.websocket_urlpatterns)
),
"http": URLRouter(
main.routing.http_urlpatterns
+ [re_path(r"", AsgiHandler)]
),
}
)
HTTP 路由现在与一个 URLRouter 相关联,该 URL router 包含我们在main/routing.py中定义的http_urlpatterns,对于所有其他路由,它退回到AsgiHandler,后者又转发到标准的 Django 处理程序。
AsgiHandler是从异步服务器网关接口(ASGI)协议到 Web 服务器网关接口(WSGI)协议的翻译器,WSGI 是核心 Django 使用的内部协议。
自动化测试
针对 SSE 消费者的测试将使用不同类型的通信器HttpCommunicator。我们将在建立 WebSocket 连接和连接超时后使用它,我们将检查 SSE 请求是否返回了正确的事件。超时是为了给 Redis 时间从内存中删除条目。
...
import json
from channels.testing import HttpCommunicator
class TestConsumers(TestCase):
...
def test_chat_presence_works(self):
def init_db():
user = factories.UserFactory(
email="user@site.com",
first_name="John",
last_name="Smith",
)
order = factories.OrderFactory(user=user)
cs_user = factories.UserFactory(
email="customerservice@booktime.domain",
first_name="Adam",
last_name="Ford",
is_staff=True,
)
employees, _ = Group.objects.get_or_create(
name="Employees"
)
cs_user.groups.add(employees)
return user, order, cs_user
async def test_body():
user, order, notify_user = await database_sync_to_async(
init_db
)()
communicator = WebsocketCommunicator(
consumers.ChatConsumer,
"/ws/customer-service/%d/" % order.id,
)
communicator.scope["user"] = user
communicator.scope["url_route"] = {
"kwargs": {"order_id": order.id}
}
connected, _ = await communicator.connect()
self.assertTrue(connected)
await communicator.send_json_to(
{"type": "heartbeat"}
)
await communicator.disconnect()
communicator = HttpCommunicator(
consumers.ChatNotifyConsumer,
"GET",
"/customer-service/notify/",
)
communicator.scope["user"] = notify_user
communicator.scope["query_string"] = "nopoll"
response = await communicator.get_response()
self.assertTrue(
response["body"].startswith(b"data: ")
)
payload = response["body"][6:]
data = json.loads(payload.decode("utf8"))
self.assertEquals(
data,
[
{
"link": "/customer-service/%d/" % order.id,
"text": "%d (user@site.com)" % order.id,
}
],
"expecting someone in the room but no one found",
)
await asyncio.sleep(10)
communicator = HttpCommunicator(
consumers.ChatNotifyConsumer,
"GET",
"/customer-service/notify/",
)
communicator.scope["user"] = notify_user
communicator.scope["query_string"] = "nopoll"
response = await communicator.get_response()
self.assertTrue(
response["body"].startswith(b"data: ")
)
payload = response["body"][6:]
data = json.loads(payload.decode("utf8"))
self.assertEquals(
data,
[],
"expecting no one in the room but someone found",
)
loop = asyncio.get_event_loop()
loop.run_until_complete(test_body())
移动 API
在本章的剩余部分,我们将停止使用 Django 通道。我们计划在移动应用中使用的其他 API 将使用 Django REST 框架(DRF)构建。我们将为订单检索构建一个身份验证端点和一个 API。
证明
对于我们的移动应用,我们将使用基于令牌的身份验证。这是非 web 应用的最佳实践,因为它为我们提供了更多的安全性,以防客户端设备受到威胁。除了令牌之外,设备上不存储任何凭据,如果需要,令牌很容易失效。
为此,我们需要在 Django Rest 框架中启用它:
INSTALLED_APPS = [
...
"rest_framework",
"rest_framework.authtoken",
...
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.BasicAuthentication",
),
...
}
这将为我们的系统增加一个额外的模型。前一步需要运行migrate命令。此后,我们将在main/signals.py中为每个有新信号的新用户自动生成一个令牌:
from django.conf import settings
from rest_framework.authtoken.models import Token
...
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_auth_token(
sender, instance=None, created=False, **kwargs
):
if created:
Token.objects.create(user=instance)
从现在开始,除了已经存在的方法之外,每个新用户都可以使用令牌访问经过身份验证的 DRF 端点。接下来我们需要创建登录端点,我们可以将它添加到我们的main/urls.py的底部:
from rest_framework.authtoken import views as authtoken_views
...
urlpatterns = [
...
path(
"mobile-api/auth/",
authtoken_views.obtain_auth_token,
name="mobile_token",
),
]
就是这样。我们有一个工作的移动认证端点。为了完成这项工作,需要进行相关的测试(main/tests/test_endpoints.py):
from django.urls import reverse
from rest_framework.test import APITestCase
from main import models
class TestEndpoints(APITestCase):
def test_mobile_login_works(self):
user = models.User.objects.create_user(
"user1", "abcabcabc"
)
response = self.client.post(
reverse("mobile_token"),
{"username": "user1", "password": "abcabcabc"},
)
jsonresp = response.json()
self.assertIn("token", jsonresp)
检索订单
我们的移动应用需要一种方法来检索当前已验证用户的订单。我们将向我们的main/endpoints.py添加一个新的端点:
from rest_framework.decorators import (
api_view,
permission_classes,
)
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
...
@api_view()
@permission_classes((IsAuthenticated,))
def my_orders(request):
user = request.user
orders = models.Order.objects.filter(user=user).order_by(
"-date_added"
)
data = []
for order in orders:
data.append(
{
"id": order.id,
"image": order.mobile_thumb_url,
"summary": order.summary,
"price": order.total_price,
}
)
return Response(data)
这是一个定义为函数的 Django Rest 框架 API。正如 Django 一样,DRF 允许我们将端点定义为类或函数。因为这是一个自定义的只读端点,所以将其定义为函数比定义为类更容易。
这个视图使用了Order模型的一些新属性:
class Order(models.Model):
...
@property
def mobile_thumb_url(self):
products = [i.product for i in self.lines.all()]
if products:
img = products[0].productimage_set.first()
if img:
return img.thumbnail.url
@property
def summary(self):
product_counts = self.lines.values(
"product__name"
).annotate(c=Count("product__name"))
pieces = []
for pc in product_counts:
pieces.append(
"%s x %s" % (pc["c"], pc["product__name"])
)
return ", ".join(pieces)
@property
def total_price(self):
res = self.lines.aggregate(
total_price=Sum("product__price")
)
return res["total_price"]
最后,视图需要一个位于main/urls.py中的 URL:
urlpatterns = [
...
path(
"mobile-api/my-orders/",
endpoints.my_orders,
name="mobile_my_orders",
),
]
这足以让这个视图工作。我们将为main/test/test_endpoints.py添加特定的测试,以及之前的测试:
...
from rest_framework import status
from rest_framework.authtoken.models import Token
from main import factories
class TestEndpoints(APITestCase):
...
def test_mobile_flow(self):
user = factories.UserFactory(email="mobileuser@site.com")
token = Token.objects.get(user=user)
self.client.credentials(
HTTP_AUTHORIZATION="Token " + token.key
)
orders = factories.OrderFactory.create_batch(
2, user=user
)
a = factories.ProductFactory(
name="The book of A", active=True, price=12.00
)
b = factories.ProductFactory(
name="The B Book", active=True, price=14.00
)
factories.OrderLineFactory.create_batch(
2, order=orders[0], product=a
)
factories.OrderLineFactory.create_batch(
2, order=orders[1], product=b
)
response = self.client.get(reverse("mobile_my_orders"))
self.assertEqual(
response.status_code, status.HTTP_200_OK
)
expected = [
{
"id": orders[1].id,
"image": None,
"price": 28.0,
"summary": "2 x The B Book",
},
{
"id": orders[0].id,
"image": None,
"price": 24.0,
"summary": "2 x The book of A",
},
]
self.assertEqual(response.json(), expected)
订单发货跟踪
BookTime 依靠外部公司运送网上购买的商品。这家公司有自己的跟踪系统,我们希望利用它将这些额外的信息反馈给我们的移动应用用户。
我们将添加一个自定义端点来获取订单的发货状态。让我们假设快递公司已经为 BookTime 提供了对 HTTP API 的访问,我们可以使用该 API 来获取关于货物状态的实时信息,这就是这些信息的来源。
送货公司的 API 不会被移动应用直接使用,因为我们可能希望在未来添加其他公司,而不必更改移动应用代码。
这就是我们的系统要做的:
-
接收移动应用对运输信息的请求
-
向第三方公司发出 API 请求
-
将货件信息转发回手机应用
简单地说,它看起来像一个反向代理系统。
鉴于通道的异步特性,这是一个很好的用例。以异步方式这样做将使我们的解决方案可以扩展到更多的并发请求。
如果我们以标准的同步方式这样做,我们可能会阻塞所有线程等待远程系统返回装运状态。如果发生这种情况,我们的 API 将变得没有响应。我们需要避免这种情况。
通过使用非阻塞的异步代码,我们的 API 不会变得无响应,即使我们与之交互的第三方系统离线。
为了模拟这个假设的 API,我们将使用 Pastebin。进入 https://pastebin.com ,点击新建粘贴按钮,在文本框中输入在途,点击新建粘贴。一旦你这样做了(见图 8-2 ),点击“raw”标签获得其原始网址。复制网址。我们将用它来模拟我们的 API。

图 8-2
单击“raw”复制 API 模拟的原始 Pastebin URL
既然我们已经有了公认的简单的测试 API,我们可以为它编写一个客户端。这个 API 将需要与一个异步网络库一起使用。出于与我们在前面章节中使用aioredis相同的原因,我们将使用一个名为aiohttp的 HTTP 客户端库,需要安装它:
$ pipenv install aiohttp
这样做之后,我们就可以编写我们的消费者了。我们会将其添加到main/consumers.py(用您的 Pastebin URL 替换 put_url_here):
import aiohttp
...
...
class OrderTrackerConsumer(AsyncHttpConsumer):
def verify_user(self, user, order_id):
order = get_object_or_404(models.Order, pk=order_id)
return order.user == user
async def query_remote_server(self, order_id):
async with aiohttp.ClientSession() as session:
async with session.get(
"http://pastebin.com/put_url_here"
) as resp:
return await resp.read()
async def handle(self, body):
self.order_id = self.scope["url_route"]["kwargs"][
"order_id"
]
is_authorized = await database_sync_to_async(
self.verify_user
)(self.scope["user"], self.order_id)
if is_authorized:
logger.info(
"Order tracking request for user %s and order %s",
self.scope.get("user"),
self.order_id
)
payload = await self.query_remote_server(self.order_id)
logger.info(
"Order tracking response %s for user %s and order %s",
payload,
self.scope.get("user"),
self.order_id
)
await self.send_response(200, payload)
else:
raise StopConsumer("unauthorized")
这个消费者是完全异步的,除了数据库查询。它使用 URL 中指定的订单 ID 接受请求,然后将query_remote_server()的结果转发回客户端。
query_remote_server()正在使用我们刚刚安装的库向我们刚刚创建的远程 Pastebin 发出 GET 请求。这样做的结果将简单地传递回客户端。
该消费者将需要一个 URL,该 URL 需要添加到main/routing.py:
...
http_urlpatterns = [
...
path(
"mobile-api/my-orders/<int:order_id>/tracker/",
AuthMiddlewareStack(consumers.OrderTrackerConsumer),
)
]
您现在应该能够测试这一点了。请确保您在系统中至少有一个来自当前用户的订单。如果您使用正确的订单 ID 导航到前面的 URL,您应该会在浏览器中看到您的 Pastebin 的内容。
对此的测试将在main/tests/test_consumers.py中进行:
from unittest.mock import patch, MagicMock
...
class TestConsumers(TestCase):
...
def test_order_tracker_works(self):
def init_db():
user = factories.UserFactory(
email="mobiletracker@site.com"
)
order = factories.OrderFactory(user=user)
return user, order
async def test_body():
user, order = await database_sync_to_async(
init_db
)()
awaitable_requestor = asyncio.coroutine(
MagicMock(return_value=b"SHIPPED")
)
with patch.object(
consumers.OrderTrackerConsumer, "query_remote_server"
) as mock_requestor:
mock_requestor.side_effect = awaitable_requestor
communicator = HttpCommunicator(
consumers.OrderTrackerConsumer,
"GET",
"/mobile-api/my-orders/%d/tracker/" % order.id,
)
communicator.scope["user"] = user
communicator.scope["url_route"] = {
"kwargs": {"order_id": order.id}
}
response = await communicator.get_response()
data = response["body"].decode("utf8")
mock_requestor.assert_called_once()
self.assertEquals(
data,
"SHIPPED"
)
loop = asyncio.get_event_loop()
loop.run_until_complete(test_body())
在前面的测试中,我们用一个返回字符串“SHIPPED”的异步函数修补了整个方法query_remote_server()。通过这种方式,我们从测试中排除了 HTTP 客户端,这使得它运行起来更快,鉴于它的简单性,这对我们来说是一个很好的妥协。
将这一切结合在一起
为了使我们的移动集成尽可能顺利,还有一些更改需要申请。首先,我们的基于令牌的认证,目前只在 DRF 视图上工作,需要包括 WebSocket 和异步 HTTP 路由。
我们需要一个自定义的AuthMiddlewareStack,我们将把它放在一个新文件booktime/auth.py(与settings.py相同的文件夹)中,它在所有其他认证方式的基础上增加了令牌认证:
from urllib.parse import parse_qs
from channels.auth import AuthMiddlewareStack
from rest_framework.authtoken.models import Token
class TokenGetAuthMiddleware:
def __init__ (self, inner):
self.inner = inner
def __call__ (self, scope):
params = parse_qs(scope["query_string"])
if b"token" in params:
try:
token_key = params[b"token"][0].decode()
token = Token.objects.get(key=token_key)
scope["user"] = token.user
except Token.DoesNotExist:
pass
return self.inner(scope)
TokenGetAuthMiddlewareStack = lambda inner: TokenGetAuthMiddleware(
AuthMiddlewareStack(inner)
)
我们将在路由文件中使用这个新的中间件。以下是对booktime/routing.py的更改:
from .auth import TokenGetAuthMiddlewareStack
...
application = ProtocolTypeRouter(
{
"websocket": TokenGetAuthMiddlewareStack(
URLRouter(main.routing.websocket_urlpatterns)
),
...
}
)
以下是对main/routing.py的更改:
from booktime.auth import TokenGetAuthMiddlewareStack
...
http_urlpatterns = [
...
path(
"mobile-api/my-orders/<int:order_id>/tracker/",
TokenGetAuthMiddlewareStack(consumers.OrderTrackerConsumer),
)
]
在开发移动应用或任何移动应用的过程中,我们需要做的最后一件事是确保我们能够满足来自网络的请求,而不仅仅是我们的本地浏览器。
为了改变这一点,我们将为我们的booktime/settings.py添加一个额外的设置:
ALLOWED_HOSTS = ['*']
这导致 Django 允许带有任何“Host”头的请求。请确保该设置在生产环境中是而不是(就像调试一样)。
从现在开始,我们将启动 dev 服务器,并选择监听所有可用的网络接口,而不仅仅是本地接口。我们可以使用以下命令来实现这一点:
$ ./manage.py runserver 0.0.0.0:8000
Performing system checks...
System check identified no issues (0 silenced).
August 22, 2018 - 15:28:01
Django version 2.1, using settings 'booktime.settings'
Starting ASGI/Channels version 2.1.2 development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.
2018-08-22 15:28:01,644 - INFO - server - HTTP/2 support not enabled (install the http2 and tls Twisted extras)
2018-08-22 15:28:01,644 - INFO - server - Configuring endpoint tcp:port=8000:interface=0.0.0.0
2018-08-22 15:28:01,645 - INFO - server - Listening on TCP address 0.0.0.0:8000
这将允许我们的应用(运行在移动设备上)通过本地网络连接到我们的服务器。
摘要
本章的目标是使用 Channels(Django 的异步编程扩展)来构建一个聊天后端。信道引入了许多新概念,消费者、路由器、信道层和组,所有这些在本章中都有解释。
实际上,我们为公司的客服代表构建了一个聊天后端及其仪表板。我们还必须直接联系 Redis,以获得我们需要的、Channels 不提供的一些更高级的功能。
在上一节中,我们还讨论了移动应用的必要基础设施,我将在下一章介绍。
九、移动客服应用
在本章中,我们将使用 React Native 构建一个基本的移动应用。客户将使用我们开发的应用来检索他们的订单,并与客服代表讨论这些订单。
订单可视化和聊天都将使用我们在第八章中构建的 API。我们将使用这个移动应用来探索如何支持与静态 HTML 页面有不同需求的 API 消费者。
为什么反应本土
在本书中,除了 Django 之外,我不想支持任何技术。我不希望展示一个完美的原生应用,因为这需要更多的设置工作,并且会转移与 Django 集成的注意力。这一章将是关于有足够的应用来建立。
本章将介绍的许多概念可以应用于其他技术堆栈。有许多框架可以构建混合应用。鉴于在前面的章节中已经向您介绍了 React,您将不难理解 React Native。
React Native 重用了 React 的许多概念,但是它使用原生组件而不是 HTML 标签。React Native 在 mobile 上运行,这不是我们用来开发和运行 Django 应用的设备。记住这一点很重要,因为它会影响开发工作流。
我们将像使用 Bootstrap 一样使用 React Native。我们不会深入研究它,但足以消耗我们在第八章中已经完成的工作,并展示我们的用例。
设置本地反应
React Native 是用 JavaScript 写的。要运行下面的命令,您需要至少 8.x)版本的 Node,它提供了npx命令。我们将使用create-react-native-app进行初始设置:
$ npx create-react-native-app booktime_mobile
...
Inside that directory, you can run several commands:
yarn start
Starts the development server so you can open your app in the Expo app on your phone.
yarn run ios
(Mac only, requires Xcode)
Starts the development server and loads your app in an iOS simulator.
yarn run android
(Requires Android build tools)
Starts the development server and loads your app on a connected Android device or emulator.
yarn test
Starts the test runner.
yarn run eject
Removes this tool and copies build dependencies, configuration files and scripts into the app directory. If you do this, you can't go back!
We suggest that you begin by typing:
cd booktime_mobile
yarn start
Happy hacking!
$ cd booktime_mobile
在刚刚创建的booktime_mobile文件夹中,所有 React 原生命令都可以用yarn启动。在启动建议的命令yarn start之前,请注意您可能需要调整一些系统参数。如果是这种情况,您会在屏幕上看到一条警告。
组件层次结构
在开始编写任何复杂的前端代码之前,你应该知道如何将应用的不同屏幕划分成组件。组件是用户界面中可重用的部分。它们可能是整个屏幕,也可能只是屏幕的一部分。
React Native 中的每个组件就像 React 组件一样:它可以有状态和属性。属性是只读的,从父对象传递,而状态是内部管理的。
在我们的例子中,我们简单的应用将有
-
带有用户名/密码表单的登录屏幕。这将是一个独立的组件。
-
订单选择和聊天屏幕。该屏幕将分为两个主要部分。
为了帮助我们识别每个组件在显示屏上的位置,我们可以使用一些视觉提示,如设置边框颜色,如图 9-1 和 9-2 所示。一般来说,为移动设备开发不像在浏览器中开发那样立竿见影。你需要一些时间来适应它。

图 9-2
移动主页

图 9-1
移动登录页面
API 客户端库
在构建任何特定于 React Native 的东西之前,我们将创建一个 JavaScript 类来处理客户机和服务器之间的所有通信。我们这样做是因为,通过模仿它,将更容易测试我们的组件。
除了最初的App.js之外,我们将所有代码放在一个src文件夹中。这将在src/backend.js中(用相关的 URL 代替字符串'CHANGETHIS'):
export default class BackendApi {
constructor (envName) {
if (envName == 'production') {
this.hostName = 'CHANGETHIS'
} else {
this.hostName = 'CHANGETHIS'
}
this.baseHttpApi = 'http://' + this.hostName + '/mobile-api'
}
auth (username, password) {
return fetch(this.baseHttpApi + `/auth/`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: username,
password: password
})
})
.then(response => response.json())
.catch(error => {
console.error(error)
})
.then(response => {
if (response.token) {
this.loggedInToken = response.token
return true
}
return false
})
}
fetchOrders () {
return fetch(this.baseHttpApi + `/my-orders/`, {
method: 'GET',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
Authorization: 'Token ' + this.loggedInToken
}
})
.then(response => response.json())
.catch(error => {
console.error(error)
})
}
fetchShipmentStatus (id) {
let url = this.baseHttpApi + `/my-orders/${id}/tracker` +
`/?token=` + this.loggedInToken
return fetch(url, {
method: 'GET',
headers: {
Authorization: 'Token ' + this.loggedInToken
}
})
.then(response=> response.text())
.catch(error=> {
console.error(error)
})
}
openMessagesStream (id, onMessageCb) {
var ws_url =
`ws://` +
this.hostName +
`/ws/customer-service/` +
Id +
`/?token=` +
this.loggedInToken
this.ws = new WebSocket(ws_url)
this.ws.onmessage = function (event) {
var data = JSON.parse(event.data)
onMessageCb(data)
}
this.ws.onerror = function (error) {
console.error('WebSocket error: ', error)
}
this.ws.onopen = function () {
this.heartbeatTimer = setInterval(this.sendHeartbeat.bind(this), 10000)
}.bind(this)
}
closeMessagesStream () {
clearInterval(this.heartbeatTimer)
if (this.ws) {
this.ws.close()
}
}
sendMessage (message) {
this.ws.send(
JSON.stringify({
type: 'message',
message: message
})
)
}
sendHeartbeat () {
this.ws.send(
JSON.stringify({
type: 'heartbeat'
})
)
}
createAbsUrl (relative_uri) {
return 'http://' + this.hostName + relative_uri
}
}
这个基本的 JavaScript 类将处理所有的 HTTP 请求。所有请求都将被定向到实例化该类时指定的环境。
前面代码中的auth()方法使用我们在前一章中创建的认证端点,如果成功,将返回的令牌存储在BackendApi实例中。该令牌将在以后的每个请求中使用。
聊天组件有几种方法:openMessagesStream()、closeMessagesStream()和sendMessage()。在内部使用sendHeartbeat()方法向服务器发出这个客户机存在的信号。
登录查看
我们将把所有的 React 组件放在src/components/中。这是我们的第一个组件,LoginView.js:
import React, { Component } from 'react'
import {
StyleSheet,
Text,
View,
TouchableHighlight,
TextInput
} from 'react-native'
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center'
},
welcome: {
fontSize: 20,
textAlign: 'center',
margin:10
},
instructions: {
textAlign: 'center',
color: '#333333',
marginBottom: 5
},
input: {
height: 40,
fontSize: 18,
width: 150
},
button: {
padding: 10
}
})
export default class LoginView extends Component {
constructor (props) {
super(props)
this.state = {
username: '',
password: ''
}
this.handleSubmitLogin = this.handleSubmitLogin.bind(this)
}
handleSubmitLogin () {
if (this.state.username && this.state.password) {
return this .props.backendApi
.auth(this.state.username, this.state.password)
.then(loggedIn => {
if (loggedIn) {
this.props.setLoggedIn()
} else {
this.setState({
username: '',
password: ''
})
alert('Login unsuccessful')
}
})
}
}
render () {
return (
<View style={styles.container}>
<Text style={styles.welcome}>BookTime</Text>
<Text style={styles.instructions}>Please login to see your orders</Text>
<TextInput
style={styles.input}
placeholder="Username"
value={this.state.username}
onChangeText={text => {
this.setState({ username: text })
}}
/>
<TextInput
style={styles.input}
placeholder="Password"
value={this.state.password}
secureTextEntry={true}
onChangeText={text => {
this.setState({ password: text })
}}
/>
<TouchableHighlight
style={styles.button}
onPress={this.handleSubmitLogin}
>
<Text>Submit</Text>
</TouchableHighlight>
</View>
)
}
}
React Native 正在使用 flexbox 作为布局引擎,这是一个来自 CSS 的功能。这用于前面代码中的styles变量。我们的风格大多是做元素的定位和大小,而不是视觉造型。这是我们开始行动的最低要求。
在render()方法中,我们构建渲染树,这是我们使用 React 本地内置组件的地方。View是屏幕一部分的包装器,并不带来额外的功能——它纯粹是其他组件的包装器。
Text是一个标签组件,用来打印文本。TextInput,相当于 HTML 中的<input type="text">,用于输入文本。TouchableHighlight是一个带触摸反馈的按钮。
我们将包含对这个视图的测试。它非常简单,通过直接调用内部函数来避免测试事件触发。只要我们相信我们写的 JSX 1 ,对于我们的目的来说,这是一个可以接受的妥协。
我们将在src/components/LoginView.test.js中放置以下代码:
import React from 'react'
import LoginView from './LoginView'
import renderer from 'react-test-renderer'
it('renders without crashing', () => {
const rendered = renderer.create(<LoginView />)
expect(rendered).toBeTruthy()
})
it('logins successfully when backend returns true', () => {
const setLoggedIn = jest.fn()
const backendApi = {
auth: jest.fn(() => new Promise((resolve, reject) => resolve(true)))
}
const rendered = renderer.create(
<LoginView backendApi={backendApi} setLoggedIn={setLoggedIn} />
)
rendered.root.instance.state.username = 'a'
rendered.root.instance.state.password = 'b'
rendered.root.instance
.handleSubmitLogin()
.then(() => expect(setLoggedIn.mock.calls.length).toBe(1))
expect(backendApi.auth.mock.calls.length).toBe(1)
expect(backendApi.auth.mock.calls[0][0]).toBe('a')
expect(backendApi.auth.mock.calls[0][1]).toBe('b')
})
it('login fails when backend returns false', () => {
const setLoggedIn = jest.fn()
const backendApi = {
auth: jest.fn(() => new Promise((resolve, reject) => resolve(false)))
}
const rendered = renderer.create(
<LoginView backendApi={backendApi} setLoggedIn={setLoggedIn} />
)
rendered.root.instance.state.username = 'a'
rendered.root.instance.state.password = 'b'
rendered.root.instance
.handleSubmitLogin()
.then(() => expect(setLoggedIn.mock.calls.length).toBe(0))
expect(backendApi.auth.mock.calls.length).toBe(1)
expect(backendApi.auth.mock.calls[0][0]).toBe('a')
expect(backendApi.auth.mock.calls[0][1]).toBe('b')
})
it('login fails when backend fails', () => {
const setLoggedIn = jest.fn()
const backendApi = {
auth: jest.fn(() => new Promise((resolve, reject) => reject()))
}
const rendered = renderer.create(
<LoginView backendApi={backendApi} setLoggedIn={setLoggedIn} />
)
rendered.root.instance.state.username = 'a'
rendered.root.instance.state.password = 'b'
rendered.root.instance
.handleSubmitLogin()
.then(() => expect(setLoggedIn.mock.calls.length).toBe(0))
expect(backendApi.auth.mock.calls.length).toBe(1)
expect(backendApi.auth.mock.calls[0][0]).toBe('a')
expect(backendApi.auth.mock.calls[0][1]).toBe('b')
})
我将在本章的后面解释如何运行这个测试。
阅读器
我们将把这个组件专用于所有聊天交互,它将位于主屏幕(src/components/ChatView.js)的一个部分:
import React from 'react'
import {StyleSheet, Text, TextInput, ScrollView, View } from 'react-native'
const styles = StyleSheet.create({
chatContainer: {
borderColor: 'orange',
borderWidth: 1,
flex: 1
},
chatMessages: {
borderColor: 'purple',
borderWidth: 1,
flex: 1
},
chatInput: {
height: 40,
margin: 10,
borderWidth: 1
},
chatMessage: {
backgroundColor: 'lightgrey',
margin: 10,
flex: 1,
flexDirection: 'row',
alignSelf: 'stretch'
},
chatMessageText: {
flex: 1
},
chatMessageType: {
flex: 1,
textAlign: 'right'
}
})
export default class ChatView extends React.Component {
constructor (props) {
super(props)
this.state = {
messages: [],
shipmentStatus: "n/a"
}
this.handleSubmit = this.handleSubmit.bind(this)
}
componentDidUpdate (prevProps) {
if (prevProps.orderCurrentId !== this.props.orderCurrentId) {
this.props.backendApi.closeMessagesStream()
this.state = {
messages: []
}
this.props.backendApi
.fetchShipmentStatus(this.props.orderCurrentId)
.then(result => {
this.setState({shipmentStatus: result})
})
this.props.backendApi.openMessagesStream(
this.props.orderCurrentId,
message => {
this.setState({
messages: this.state.messages.concat([message])
})
}
)
}
}
handleSubmit(event) {
var text = event.nativeEvent.text
this.props.backendApi.sendMessage(text)
this.refs.textInput.setNativeProps({text: '' })
}
render() {
if (this.props.orderCurrentId) {
return (
<View style={styles.chatContainer}>
<Text>Chat for order: {this.props.orderCurrentId}
- { this.state.shipmentStatus}</Text>
<ScrollView
style={styles.chatMessages}
showsVerticalScrollIndicator={true}
ref={ref => (this.scrollView = ref)}
onContentSizeChange={(contentWidth, contentHeight) => {
this.scrollView.scrollToEnd({ animated: true })
}}
>
{this.state.messages.map((m, index) => {
return (
<View key={index} style={styles.chatMessage}>
<Text style={styles.chatMessageText}>
{m.username} {m.message}
</Text>
<Text style={styles.chatMessageType}>{m.type}</Text>
</View>
)
})}
</ScrollView>
<TextInput
style={styles.chatInput}
ref="textInput"
placeholder="Enter a message..."
returnKeyType="send"
onSubmitEditing={this.handleSubmit}
/>
</View>
)
} else {
return (
<View>
<Text>No order selected for chat</Text>
</View>
)
}
}
}
当 React 组件使用传入 2 的新prop进行渲染时,将调用前面代码中的方法componentDidUpdate()。该组件的第一次呈现总是没有选择图书订单,但是当它被选择时,该方法将接收新的订单 id 并打开相应的聊天。
当选择一个顺序时,呈现的组件树将利用ScrollView,它相当于一个固定大小的<div>,内部内容设置为自动在内部溢出。我们还通过使用事件处理程序自动滚动到其内容的底部。
对于ChatView.js中的文本输入,我们使用引用,而不是像在前面的组件中那样存储状态。引用允许 React 代码通过在代码中使用this.refs来直接引用组件。
这是测试(src/components/ChatView.test.js):
import React from 'react'
import ChatView from './ChatView'
import renderer from 'react-test-renderer'
it('renders without orders specified', () => {
const rendered = renderer.create(<ChatView />).toJSON()
expect(rendered).toBeTruthy()
})
it('renders with orders specified', () => {
const backendApi = {
openMessagesStream: jest.fn(),
closeMessagesStream: jest.fn(),
sendMessage: jest.fn(),
fetchShipmentStatus: jest.fn(
() => new Promise((resolve, reject) => resolve())
),
}
const rendered = renderer.create(
<ChatView backendApi={backendApi} orderCurrentId="1" />
)
rendered.root.instance.componentDidUpdate({})
expect(backendApi.openMessagesStream.mock.calls.length).toBe(1)
expect(backendApi.openMessagesStream.mock.calls[0][0]).toBe('1')
expect(backendApi.fetchShipmentStatus.mock.calls.length).toBe(1)
expect(backendApi.fetchShipmentStatus.mock.calls[0][0]).toBe('1')
rendered.getInstance().setState({shipmentStatus: 'shipped'})
backendApi.openMessagesStream.mock.calls[0]1
expect(rendered.toJSON()).toMatchSnapshot()
rendered.root.instance.handleSubmit({ nativeEvent:{ text: 'answer back' } })
expect(backendApi.sendMessage.mock.calls.length).toBe(1)
expect(backendApi.sendMessage.mock.calls[0][0]).toBe('answer back')
expect(rendered.toJSON()).toMatchSnapshot()
})
订单 w
该组件将管理登录屏幕后的整个屏幕。它还将加载所有子组件,包括 ChatView。
下面是组件(src/components/OrderView.js):
import React from 'react'
import{
StyleSheet,
Text,
Image,
TouchableHighlight,
TouchableOpacity,
View
} from 'react-native'
import ChatView from './ChatView'
const styles = StyleSheet.create({
container: {
paddingTop: 25,
flex: 1
},
orderContainer: {
borderColor: 'blue',
borderWidth: 1,
height: 200
},
orderRowOut: {
borderColor: 'yellow',
borderWidth: 1,
height: 52
},
orderRowIn: {
flex: 1,
flexDirection: 'row',
alignSelf: 'stretch'
},
orderSelected: {
backgroundColor: 'lightgrey'
},
orderImage: {
borderColor: 'green',
borderWidth: 1,
width: 50,
height: 50
},
orderSummary: {
borderColor: 'red',
borderWidth: 1,
flex: 1,
alignSelf: 'stretch'
},
orderPrice: {
borderColor: 'blue',
borderWidth: 1
}
})
function OrderImage (props) {
if (props.image) {
return (
<Image
style={{ width: 50, height: 50 }}
source={{ uri: props.backendApi.createAbsUrl(props.image) }}
/>
)
} else {
return <View />
}
}
function OrderTouchArea (props) {
return (
<View
style={[
styles.orderRowIn,
props.order.id == props.orderCurrentId ? styles.orderSelected: null
]}
>
<View style={styles.orderImage}>
<OrderImage backendApi={props.backendApi} image={props.order.image} />
</View>
<Text style={styles.orderSummary}>{props.order.summary}</Text>
<Text style={styles.orderPrice}>{props.order.price}</Text>
</View>
)
}
function OrderSingleView (props) {
return (
<TouchableHighlight
style={styles.orderRowOut}
onPress={() => props.setOrderId(props.order.id)}
>
<OrderTouchArea
backendApi={props.backendApi}
order={props.order}
orderCurrentId={props.orderCurrentId}
/>
</TouchableHighlight>
)
}
export default class OrderView extends React.Component {
constructor (props) {
super(props)
this.state = {
orders: [],
orderCurrentId: null
}
}
componentDidMount () {
this.props.backendApi
.fetchOrders()
.then(orders => this.setOrders(orders))
.catch(() => alert('Error fetching orders'))
}
setOrders (orders){
this.setState({
orders: orders
})
}
render(){
return (
<View style={styles.container}>
<View style={styles.orderContainer}>
<Text>Your BookTime orders</Text>
{this.state.orders.map(m=> (
<OrderSingleView
backendApi={this.props.backendApi}
key={m.id}
order={m}
orderCurrentId={this.state.orderCurrentId}
setOrderId={ordered => this.setState({ orderCurrentId: orderId })}
/>
))}
</View>
<ChatView
backendApi={this.props.backendApi}
orderCurrentId={this.state.orderCurrentId}
/>
</View>
)
}
}
在 React Native 中,像 React 的最新版本一样,可以将组件定义为函数或类。对于简单的组件,函数就足够了。这是我们在这里使用的,使代码更可读。
声明为函数的组件没有内部状态。前面代码中声明的组件是纯函数,它们的输出完全基于传入的属性。
早期组件中的所有状态都由主OrderView组件管理。
一旦安装了组件,就会获取订单,这意味着它会显示在屏幕上。可以在 UI 中按下订单条目,这将初始化聊天组件并打开到我们服务器的 WebSocket 连接。
下面是测试(src/components/OrderView.test.js):
import React from 'react'
import OrderView from './OrderView'
import renderer from 'react-test-renderer'
it('renders without crashing', () => {
const mockOrders = [{ id: 5, image: null, summary: '2xaa', price: 22 }]
const backendApi = {
fetchOrders: jest.fn(
() => new Promise((resolve,reject)=>resolve(mockOrders))
),
createAbsUrl: jest.fn(url => 'http://booktime.domain' + url)
}
const rendered = renderer.create(<OrderView backendApi={backendApi} />)
expect(rendered).toBeTruthy()
expect(backendApi.fetchOrders.mock.calls.length).toBe(1)
rendered.getInstance().setOrders(mockOrders)
expect(rendered.getInstance().state.orders).toBe(mockOrders)
expect(rendered.toJSON()).toMatchSnapshot()
})
主要成分
我们将用启动应用所需的代码覆盖主文件夹中自动生成的App.js的内容:
import React from 'react'
import LoginView from './src/components/LoginView'
import ChatView from './src/components/ChatView'
import OrderView from './src/components/OrderView'
import BackendApi from './src/backend'
export default class App extends React.Component {
constructor (props) {
super(props)
this.backendApi = new BackendApi()
this.state = {
loggedIn: false
}
}
render () {
if (this.state.loggedIn) {
return <OrderView backendApi={this.backendApi} />
} else {
return (
<LoginView
backendApi={this.backendApi}
setLoggedIn={() => this.setState({loggedIn: true })}
/>
)
}
}
}
我们在这个类中构造 API 对象,并可选地传递我们希望这个应用运行的环境,无论是生产环境还是不太重要的环境。
主容器只有一个状态标志loggedIn,用于显示第一个屏幕(登录)或主屏幕(订单视图)。
我们将把它的测试放在同一个文件夹和文件App.test.js中:
import React from 'react'
import App from './App'
import renderer from 'react-test-renderer'
it('renders without crashing', () => {
const rendered = renderer.create(<App />).toJSON()
expect(rendered).toBeTruthy()
})
运行、测试和打包
正如本章前面提到的,一切都是用yarn运行的。我们使用yarn start来启动我们的移动开发服务器(见图 9-3 )。要查看该应用,我们将在移动设备上安装一个名为 Expo ( https://expo.io )的开发客户端。请从您移动设备的应用商店下载此移动应用。
我们也可以用 Jest 运行我们在本章前面写的测试。我们正在使用 Jest 的一个称为快照测试的特性,它为我们简化了很多断言工作。
下面是yarn test的输出,它在幕后使用 Jest:
$ yarn test
yarn run v1.7.0
$ jest
PASS src/components/OrderView.test.js (6.403s)
› 1 snapshot written.
PASS src/components/LoginView.test.js (6.576s)
PASS ./App.test.js
PASS src/components/ChatView.test.js (8.404s)
- Console
console.warn node_modules/react-native/jest/setup.js:93
Calling .setNativeProps() in the test renderer environment is not supported. Instead, mock
- 2 snapshots written.
Snapshot Summary
- 3 snapshots written in 2 test suites.
Test Suites: 4 passed, 4 total
Tests: 8 passed, 8 total
Snapshots: 3 added, 3 total
Time: 9.967s
Ran all test suites.
Done in 11.87s.
在第一次运行yarn test时,它会为我们生成所有的快照。在所有后续运行中,它将使用生成的快照作为参考进行检查。

图 9-3
纱线起始样本输出
Android 包装
到目前为止,我们所做的所有工作都让我们知道,我们有一个潜在的应用,但我们仍然需要将其打包并分发给我们的客户,这就是以下步骤成为特定于平台的地方。
我将展示如何在 Android 上做到这一点,因为 apk(打包的应用)的分发不需要您登录 Google Play。您可以在使用npm install -g exp安装了exp命令行界面之后这样做
世博会客户(exp)将要求您在他们的系统上建立一个账户。打包 React 本地应用不需要这个客户端,但是如果没有它,我们将不得不做更多的设置工作。我们将不得不安装我们想要定位的所有平台的 SDK。使用 Expo 客户端允许我们使用他们的系统来编译我们的文件。
$ exp build:android
[16:29:11] Making sure project is set up correctly...[16:29:13] Your project looks good!
[16:29:13] Checking if current build exists...
[16:29:14] No currently active or previous builds for this project.
[16:29:15] Unable to find an existing exp instance for this directory, starting a new one...
[16:29:16] Starting Metro Bundler on port 19001.
[16:29:17] Metro Bundler ready.
[16:29:17] Publishing to channel 'default'...
[16:29:17] Tunnel URL not found, falled back to LAN URL.
[16:29:17] Building iOS bundle
[16:29:37] Tunnel URL not found, falled back to LAN URL.
[16:29:47] Building Android bundle
Building JavaScript bundle [======================] 100%[16:29:47]
[16:29:57] Tunnel URL not found, falled back to LAN URL.
[16:30:14] Analyzing assets
Building JavaScript bundle [======================] 100%[16:30:14]
Finished building JavaScript bundle in 26994ms.
[16:30:17] Tunnel URL not found, falled back to LAN URL.
Building JavaScript bundle [======================] 100%[16:30:23]
Finished building JavaScript bundle in 9449ms.
[16:30:33] Uploading assets
Building JavaScript bundle [=======================] 100%[16:30:33]
Finished building JavaScript bundle in 9853ms.
[16:30:34] No assets changed, skipped.
[16:30:34] Uploading JavaScript bundles
[16:30:37] Tunnel URL not found, falled back to LAN URL.
[16:30:39] Published
[16:30:39] Your URL is
https://exp.host/@flagz/booktime_mobile
[16:30:39] Building...
[16:30:40] Build started, it may take a few minutes to complete.
[16:30:40] You can check the queue length at
https://expo.io/turtle-status
[16:30:40] You can monitor the build at
https://expo.io/builds/0bcd5ecb-bd99-419d-94fa-44433820be5e
|[16:30:40] Waiting for build to complete. You can press Ctrl+C to exit.
[16:34:42] Successfully built standalone app: https://expo.io/artifacts/793ffef3-10a2-4803-a791-c659756c3a8b
成功运行之后,您可以通过输出中的链接下载生成的 APK。
摘要
本章介绍了一个简单的 React Native 应用,它使用了我们在第八章中编写的 API。您可以学习这里介绍的课程并扩展它们,或者使用 Java 或 Swift 简单地重写这个应用。
十、如何部署 Django 站点
在这一章中,我们将把我们到目前为止所构建的东西部署到生产中。生产系统是那些最终用户使用的系统。
一般来说,我们可以在互联网上有许多可用的环境,这取决于他们的受众。一些常用的环境是实时站点的“生产”环境和即将发布的版本的“准备”环境,通常只有少数人可以访问。
本章介绍了一种部署环境的方法,而不考虑其用途。
缩小 JS/CSS
当你部署一个网站时,第一个好的实践是确保你提供给客户的资产被尽可能高效地打包(缩小和捆绑)。这确保了客户端浏览器需要传输更少的数据和打开更少的连接。你的网站会感觉更有反应性,用户会为此感谢你。
这是 Webpack(我们在第五章中使用过)使之变得微不足道的操作之一;所以,没有借口不去做。我们将在顶层文件夹的webpack.prod.config.js中创建一个新的 Webpack 配置:
const common = require('./webpack.config')
module.exports = {
mode: 'production',
entry: common.entry,
plugins: common.plugins,
output: {
filename: '[name].bundle.js',
path: common.output.path
}
};
这种配置重用了开发配置中的大多数设置,只做了一些更改。它使用production模式,该模式开启 Webpack 第 4 版内置的缩小和其他优化功能。
我们将在package.json中集成新的 Webpack 配置,在scripts部分添加一个新条目:
...
"scripts": {
"test": "jest",
"build": "webpack",
"build-prod": "webpack --config webpack.prod.config.js"
},
...
我们现在可以使用以下命令来运行它:
$ npm run build-prod
> booktime@1.0.0 build-prod /code
> webpack --config webpack.prod.config.js
Hash: 58037a9e18e1cf5c3bac
Version: webpack 4.12.0
Time: 2430ms
Built at: 08/30/2018 6:40:26 PM
Asset Size ...
imageswitcher-58037a9e18e1cf5c3bac.bundle.js 100 KiB ...
[14] ./frontend/imageswitcher.js 931 bytes {0} [built]
+ 14 hidden modules
服务静态资产
到目前为止,我们只使用了开发服务器,但这不是我们在生产中要使用的。开发服务器有一种特殊的方式来服务静态资产,但是在生产中我们不能依赖于此。
生产环境需要一种更有效的方式来服务静态资产。如今大多数设置都使用 Nginx 作为反向代理, 1 连接到像 Gunicorn 或 uWSGI 这样的服务器,为应用处理流量。在这些情况下,静态资产直接由 Nginx 提供服务。
另一种可能的设置是使用外部 Django 库,它以最有效的方式处理静态资产,尽管 Nginx 的效率很难超越。
还有一种方法是,根本不要费心从你自己的 web 服务器上提供静态资产,把这个责任交给亚马逊 S3、谷歌云存储或 Minio 等开源服务。这些服务将负责存储静态资产并通过 HTTP 为它们提供服务。
对于 BookTime 网站,我们将使用第二种方法,即使用外部 Django 库。这种方法在性能和易于设置之间取得了良好的平衡。我们将使用一个名为 whiten noise(http://whitenoise.evans.io)的库,因此运行以下代码:
$ pipenv install whitenoise
我们将在所有其他INSTALLED_APPS之上插入 WhiteNoise 应用,在所有其他MIDDLEWARE指令之上插入其中间件,除了SecurityMiddleware。
我们还将添加STATIC_ROOT变量,它指定静态资产将被收集到哪里,以及STATICFILES_STORAGE,用于 WhiteNoise 可以提供的一些额外优化。
以下是对booktime/settings.py的建议配置更改:
...
INSTALLED_APPS = [
"whitenoise.runserver_nostatic",
...
]
...
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
...
]
...
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
if not DEBUG:
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
...
WhiteNoise 的CompressedManifestStaticFilesStorage存储后端为一个内置的 Django ManifestStaticFilesStorage存储后端添加了自动压缩行为,接下来描述的命令将使用该行为。
正在执行 collectstatic
我们选择的任何服务静态资产的方式,不管有没有 WhiteNoise,都需要在部署阶段执行collectstatic Django 命令,这意味着在运行应用服务器之前。
这个 Django 命令从我们所有的应用中复制所有的静态资产,无论是内置的还是我们自己构建的,都放在一个位置,由STATIC_ROOT指定:
$ ./manage.py collectstatic
197 static files copied to '/.../booktime/staticfiles', 552 post-processed.
在生产中运行该命令时,将使用ManifestStaticFilesStorage。除了复制所有静态资产之外,这个存储还会将文件哈希附加到所有收集的文件中。这是为了“破坏缓存”:确保当新版本可用时,客户端不使用旧版本的资产。
特定环境变量
对于这个项目,我们将遵循十二因素 app 方法论( https://12factor.net )。我们将外部化所有依赖于环境的变量,比如数据库 URL、调试模式是否打开等等。
为此,我们将使用另一个外部库,该库负责读取环境变量的内容并将其转换为相应的 Django 格式:
$ pipenv install django-environ
这个库将在我们的booktime/settings.py内部的几个地方使用。
变化如下:删除不涉及env()的旧行:
import environ
...
env = environ.Env(
# set casting, default value
DEBUG=(bool, False)
)
env.read_env('.env')
DEBUG = env('DEBUG')
REDIS_URL = env('REDIS_URL')
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {"hosts": [REDIS_URL]},
}
}
DATABASES = {
"default": env.db()
}
EMAIL_CONFIG = env.email_url('EMAIL_URL')
vars().update(EMAIL_CONFIG)
SECRET_KEY = env('SECRET_KEY')
...
从这段代码中可以看出,我们正在加载库,除了从当前环境中读取之外,还从.env文件中读取。这个.env文件的存在是不必要的;我们将只在本地使用它来简化我们的开发工作流程。
您必须使用您的机器所需的配置来创建这个文件。您可以使用以下带有您自己设置的模板,并将其存储在顶层文件夹内的.env中:
DATABASE_URL=postgres://user:password@localhost/booktime
REDIS_URL=redis://localhost
EMAIL_URL=consolemail://
SECRET_KEY="change-this-$%£4"
该文件包含一些 URL 形式的配置变量。将负责将这些翻译成 Django 要求的格式。我们也在这里管理SECRET_KEY。需要保护SECRET_KEY的价值。如果攻击者发现了这个密钥的值,他就可以用它来危害您的站点。将这一点以及所有其他细节保留在源代码存储库之外是一个很好的实践,现在考虑到我们正在使用环境变量,这是可能的。
然而,不要将.env提交给存储库,因为这将违背我们的目的。
确保不落下任何东西
在将项目部署到本地机器之外的环境之前,我们需要检查的是,我们是否有一些硬编码的配置需要在不同的环境之间变化。
在我们的例子中,当我们在聊天消费者中直接使用 Redis 时,我们留下了一些在部署后无法工作的代码。我们需要在我们的main/consumers.py中解决这个问题:
from django.conf import settings
...
class ChatNotifyConsumer(AsyncHttpConsumer):
...
async def stream(self):
r_conn = await aioredis.create_redis(settings.REDIS_URL)
...
class ChatConsumer(AsyncJsonWebsocketConsumer):
...
async def connect(self):
...
if authorized:
self.r_conn = await aioredis.create_redis(
settings.REDIS_URL
)
...
修复了这段代码后,我们现在确信消费者不再依赖于在本地运行的 Redis。
在杜库/赫罗库部署
我们已经准备好向世界展示我们的工作,将它部署在一个真正的服务器上。我们将为此使用 Dokku,一个与 Heroku 兼容并在任何 Linux 服务器上运行的开源平台即服务(PaaS)。使用 PaaS 代替普通虚拟机(VM)的优势在于设置工作要少得多。
常见部署步骤
首先,我们的项目需要相当于booktime/wsgi.py的东西,但是对于 Django 渠道系统。Channels 不使用 WSGI,而是使用另一种称为 ASGI 的协议。ASGI 允许通道将接收请求的系统从运行代码的系统中分离出来。它在目的上类似于 WSGI,但用于异步系统。
我们需要这样做,因为 Django 的开发服务器会自动这样做,但它只供本地使用。对于生产,这需要单独完成。
在booktime/wsgi.py的同一个文件夹中,我们将创建booktime/asgi.py:
"""
ASGI entrypoint. Configures Django and then runs the application
defined in the ASGI_APPLICATION setting.
"""
import os
import django
from channels.routing import get_default_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "booktime.settings")
django.setup()
application = get_default_application()
部署到任何系统都需要这个文件。
从现在开始,这些步骤开始是 Heroku/Dokku 特有的。我们需要在顶层文件夹中创建一个名为Procfile的文件,内容如下:
web: daphne -p $PORT --bind 0.0.0.0 -v2 booktime.asgi:application
该文件列出了 PaaS 的流程主管需要管理的所有流程类型。在我们的例子中,只有一个(web),但是在更复杂的系统中可能有更多,比如后台队列工作器。
daphne是 Django Channels 的内置网络服务器。对于标准的 Django,有很多 WSGI 服务器,比如Gunicorn、uWSGI等等。我们不能把这些用于 Django 频道。目前,ASGI 服务器很少,尽管这在将来可能会改变。
饭桶
Dokku 和 Heroku 都依赖 git 进行新的部署。如果您还没有在您的项目中使用它,您必须开始使用它。好在设置起来真的很简单。如果您不熟悉这个工具,网上有很多关于如何使用它的文档。
在顶层文件夹中,您需要初始化存储库:
$ git init
运行完这个之后,确保您的存储库中有下面的.gitignore文件:
__pycache__/
db.sqlite3
media/
staticfiles/
bundles/
node_modules/
webpack-stats.json
geckodriver.log
.env
提交这个文件,提交您的所有项目代码,您就可以开始您的第一次部署了。
特定于 Dokku 的命令
如果你想测试这个 2 ,你可以很容易地在 DigitalOcean(一家基础设施托管公司)上部署一个预装了 Dokku 的虚拟机。
一旦有了运行的 Dokku 服务器,第一步就是创建应用,如下所示。记住,从现在开始,您需要在新服务器上运行所有的dokku命令。
$ dokku apps:create booktime
-----> Creating booktime... done
您可以在服务器上运行的应用列表中看到该应用:
$ dokku apps:list booktime
=====> My Apps booktime
...
下一步是附加我们的应用将需要的所有资源,从 PostgreSQL 开始:
$ dokku plugin:install https://github.com/dokku/dokku-postgres.git postgres
...
$ dokku postgres:create booktime-database
Waiting for container to be ready
Creating container database
Securing connection to database
=====> Postgres container created: booktime-database
DSN: postgres://xxx:xxx@dokku-postgres-booktime-database:5432/booktime_database
$ dokku postgres:link booktime-database booktime
-----> Setting config vars
DATABASE_URL: postgres://xxx:xxx@dokku-postgres-booktime-database:5432/booktime_database
-----> Restarting app booktime
! App booktime has not been deployed
然后再说:
$ dokku plugin:install https://github.com/dokku/dokku-redis.git redis
$ dokku redis:create booktime-redis
Waiting for container to be ready
=====> Redis container created: booktime-redis
=====> Container Information
Config dir: /var/lib/dokku/services/redis/booktime-redis/config
Data dir: /var/lib/dokku/services/redis/booktime-redis/data
...
Status: running
Version: redis:4.0.8
$ dokku redis:link booktime-redis booktime
-----> Setting config vars
...
! App booktime has not been deployed
我们需要启动的下两个命令是关于指定我们的booktime/settings.py需要的所有环境变量。
我们不打算将.env文件用于生产,因为我们不希望将这些关键信息存储在源存储库中。通过将这些信息放在服务器上,我们可以限制只有拥有服务器访问权限的工程师才能访问这些信息。
$ dokku config:set booktime SECRET_KEY="change this"
-----> Setting config vars
SECRET_KEY: abcabcabc
-----> Restarting app booktime
! App booktime has not been deployed
$ dokku config:set booktime EMAIL_URL="consolemail://"
-----> Setting config vars
EMAIL_URL: consolemail://
-----> Restarting app booktime
! App booktime has not been deployed
SECRET_KEY的值应该是一个随机的字符串,最好超过 40 个字符。对于EMAIL_URL,如果要使用 SMTP 服务器,可以指定这个格式:smtp://user:pass@server.com:25。
使用以下命令,我们将指示npm在部署期间安装所有依赖项,因为我们需要运行 Webpack:
$ dokku config:set booktime NPM_CONFIG_PRODUCTION=false
Dokku 特定文件
我们的项目需要用两个构建包来构建,因为我们同时使用了 Node 和 Django。Node 由 Webpack 使用,它编译我们的 React 文件,而我们使用 Django 做其他事情。
构建包为部署和运行技术栈提供支持。他们负责将部署的代码转换成可以在容器内运行的工件。
Buildpacks 是一个在 Heroku 和 Dokku 中同样存在的概念,但是项目对它们的依赖声明是不同的。Heroku 使用一个名为app.json的应用清单,而 Dokku 使用一个极其简单的.buildpacks文件。
下面是我们需要提交的.buildpacks文件的内容:
https://github.com/heroku/heroku-buildpack-nodejs.git
https://github.com/heroku/heroku-buildpack-python.git
剩余的共同步骤
我们正在使用的 Python buildpack 自动执行一个collectstatic步骤,但不幸的是,它不处理 Webpack 编译。我们必须改变这种状况。
这个部署步骤是我们可以通过在我们的存储库中创建一个新文件bin/post_compile并提交它来添加的步骤。这是它的内容:
#!/bin/bash
export PATH=/app/.heroku/node/bin:$PATH
npm run build-prod
./manage.py collectstatic --noinput
./manage.py migrate --noinput
注意,我们还添加了migrate命令。在这里运行这个意味着我们相信我们的master分支包含的迁移已经被审查并且是稳定的。如果你对此没有信心,你可以通过 Dokku 服务器上的dokku run命令手动运行这个步骤:
$ dokku run booktime ./manage.py migrate
我们现在必须配置 git。请记下您的服务器地址。如果您创建了一个新的虚拟机,您可能还没有它的 DNS 条目,在这种情况下,您将必须使用它的 IP 地址。使用找到的值并运行以下命令:
$ git remote add dokku dokku@YOUR_DOKKU_FQDN:booktime
最后,我们可以实时推送我们的网站:
$ git push dokku master
Counting objects: 145, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (136/136), done.
Writing objects: 100% (145/145), 678.92 KiB | 7.89 MiB/s, done.
Total 145 (delta 12), reused 0 (delta 0)
-----> Cleaning up...
-----> Building booktime from herokuish...
-----> Injecting apt repositories and packages ...
-----> Adding BUILD_ENV to build environment...
-----> Warning: Multiple default buildpacks reported the ability to handle this app. The first buildpack in the list below will be used. Detected buildpacks: multi nodejs python
-----> Multipack app detected
=====> Downloading Buildpack: https://github.com/heroku/heroku-buildpack-nodejs.git
=====> Detected Framework: Node.js
-----> Creating runtime environment
NPM_CONFIG_LOGLEVEL=error
NPM_CONFIG_PRODUCTION=false
NODE_VERBOSE=false
NODE_ENV=production
NODE_MODULES_CACHE=true
-----> Installing binaries
engines.node (package.json): unspecified
engines.npm (package.json): unspecified (use default)
Resolving node version 8.x...
Downloading and installing node 8.11.4...
Using default npm version: 5.6.0
-----> Restoring cache
Skipping cache restore (not-found)
-----> Building dependencies
Installing node modules (package.json)
added 861 packages in 30.635s
-----> Caching build
Clearing previous node cache
Saving 2 cacheDirectories (default):
- node_modules
- bower_components (nothing to cache)
-----> Pruning devDependencies
Skipping because NPM_CONFIG_PRODUCTION is 'false'
-----> Build succeeded!
=====> Downloading Buildpack: https://github.com/heroku/heroku-buildpack-python.git
=====> Detected Framework: Python
-----> Installing python-3.6.6
-----> Installing pip
-----> Installing dependencies with Pipenv 2018.5.18...
Installing dependencies from Pipfile.lock (93c72c)...
-----> $ python manage.py collectstatic --noinput
194 static files copied to '/tmp/build/staticfiles', 574 post-processed.
Using release configuration from last framework (Python).
-----> Running post-compile hook
> booktime@1.0.0 build-prod /tmp/build
> webpack --config webpack.prod.config.js
Hash: 116cb912ed906360d169
Version: webpack 4.17.1
Time: 3997ms
Built at: 2018-09-03 09:01:56
Asset Size Chunks Chunk Names
imageswitcher-116cb912ed906360d169.bundle.js 100 KiB 0 [emitted] imageswitcher
Entrypoint imageswitcher = imageswitcher-116cb912ed906360d169.bundle.js
[5] ./frontend/imageswitcher.js 931 bytes {0} [built]
+ 14 hidden modules
1 static file copied to '/tmp/build/staticfiles', 195 unmodified, 412 post-process Operations to perform:
Apply all migrations: admin, auth, authtoken, contenttypes, main, sessions
Running migrations:
Applying contenttypes.0001_initial... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0001_initial... 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 auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying main.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices...OK
Applying authtoken.0001_initial... OK
Applying authtoken.0002_auto_20160226_1747... OK
Applying main.0002_address... OK
Applying main.0003_basket_basketline... OK
Applying main.0004_auto_20180524_2143... OK
Applying main.0005_auto_20180605_1835... OK
Applying main.0006_auto_20180606_0801... OK
Applying main.0007_order_last_spoken_to... OK
Applying sessions.0001_initial... OK
Using release configuration from last framework (Python).
-----> Discovering process types Procfile declares types -> web
-----> Releasing booktime (dokku/booktime:latest)...
-----> Deploying booktime (dokku/booktime:latest)...
-----> Attempting to run scripts.dokku.predeploy from app.json (if defined)
-----> App Procfile file found (/home/dokku/booktime/DOKKU_PROCFILE)
-----> DOKKU_SCALE file not found in app image. Generating one based on Procfile...
-----> New DOKKU_SCALE file generated
=====> web=1
-----> Attempting pre-flight checks
For more efficient zero downtime deployments, create a file CHECKS.
See http://dokku.viewdocs.io/dokku/deployment/zero-downtime-deploys/ for examples
CHECKS file not found in container: Running simple container check...
-----> Waiting for 10 seconds ...
-----> Default container check successful!
-----> Running post-deploy
-----> Creating new /home/dokku/booktime/VHOST...
-----> Setting config vars DOKKU_PROXY_PORT: 80
-----> Setting config vars DOKKU_PROXY_PORT_MAP: http:80:5000
-----> Configuring booktime....(using built-in template)
-----> Creating http nginx.conf
-----> Running nginx-pre-reload
Reloading nginx
-----> Setting config vars
DOKKU_APP_RESTORE: 1
=====> Renaming container (b6d94002989c) elastic_ardinghelli to booktime.web.1
-----> Attempting to run scripts.dokku.postdeploy from app.json (if defined)
=====> Application deployed:
http://booktime.YOUR_DOKKU_FQDN
如果你看到类似的输出,恭喜你!您已经成功部署了您的第一个 Django 应用。应用的最终 URL 是部署输出中最后打印的内容。
您可以使用以下命令查看该应用的日志输出:
$ dokku logs booktime -t
此时,如果您还没有这样做,您必须将主机 URL 添加到您的booktime/settings.py中的ALLOWED_HOSTS:
...
if DEBUG:
ALLOWED_HOSTS = ['*']
else:
ALLOWED_HOSTS = ['booktime.YOUR_DOKKU_FQDN', 'localhost']
...
提交并再次推送。下次当你浏览网址时,你会看到一个工作网站。
SSL、WebSockets 和 HTTP/2
因为我们的网站正在处理电子商务交易和管理用户数据,所以所有的连接都应该通过安全的渠道进行。现在提供 HTTPS 是一个很好的实践,但是它需要一些额外的设置。
像 Dokku 或 Heroku 这样的平台让我们很容易做到这一点。Heroku 为 SSL 证书提供了有限的内置支持,Dokku 及其 Let's Encrypt 插件为此提供了自动管理。
与 HTTP/1.x 不同,HTTP/2 内置了 SSL/TLS。但是 HTTP/2 不仅仅具有内置的安全性;这是一个比它的前身高效得多的协议,能够在一个连接中多路传输所有 HTTP 请求,并提高了压缩率。
在这些 Dokku 或 Heroku 中,Django 运行在它们的负载平衡器之后,它们管理 SSL、它的证书和 HTTP/2。Django 在这些 PaaS 环境中的作用很小。Dokku 默认提供 HTTP 到 HTTPS 的重定向,而 Heroku 不提供。
可以指示 Django 默认使用安全连接,无论是 HTTP/2 还是 HTTPS/1.x。为此,我们可以添加以下附加设置:
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
这些设置将允许 Django 检测它是否运行在安全连接上,如果不是,就触发重定向。然而,在我们的 Dokku 设置中,这是不必要的,因为 HTTP 总是被重定向。
WebSockets 是 HTTP/1.1 的扩展,也存在于 HTTP/2 中。与普通的 HTTP/1.1 连接不同,这些是长时间运行的连接。他们保持渠道畅通。Dokku 在内部使用 Nginx 作为反向代理,正确地中继这些连接。Heroku 同样能够做到这一点。
亚马逊 S3 上的媒体存储
在我们这样的项目中,可能会有成千上万的产品图像,最好将这些图像的存储和服务委托给一个为此目的而优化的独立系统。有了亚马逊 S3(或类似的)这样的系统,我们可以轻松做到这一点。
将媒体存储委托给一个专用系统还可以让我们不必管理文件系统扩展问题,也不必确保磁盘空间不会耗尽。
当部署到 PaaS 时,委派媒体存储也是唯一的方法。当使用前面提到的十二因素应用指南开发应用时,我们不能依赖于本地存储文件。对于我们部署的站点的每个新版本,都将部署一个新的容器,其中包含一个干净的文件系统。如果我们在本地存储用户上传,一旦我们网站的新版本上线,它们将变得不可用。
为此,我们需要安装两个库:
$ pipenv install boto3 django-storages
这将安装 Boto 3 和 django-storages,Boto 3 是 S3 库,django-storages 是另一个 Django 库,它将 Boto 3 链接到 Django。Django-storages 为 S3、Azure、DigitalOcean 和其他一些平台提供支持。
首先,我们需要在 S3 创建一个公众可读的存储桶。我们还需要通过 Amazon Web Services (AWS)身份和访问管理(IAM)创建必要的访问,从而实现 API 访问。我们不会详细介绍这一点,因为网上有很多关于如何做到这一点的教程。
最终,您需要一个访问密钥 ID 和一个 AWS 帐户的秘密访问密钥。不建议您使用与您的超级用户/主 AWS 帐户相关联的密钥,这就是 IAM 允许您为用户设置更合适的访问限制的原因。
以下是需要添加到我们的booktime/settings.py中的一些配置更改:
if env('AWS_ACCESS_KEY_ID', default=None):
DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
# AWS_S3_ENDPOINT_URL = "FQDN of Minio, only if you are using it"
AWS_ACCESS_KEY_ID = env('AWS_ACCESS_KEY_ID')
AWS_SECRET_ACCESS_KEY = env('AWS_SECRET_ACCESS_KEY')
AWS_STORAGE_BUCKET_NAME = env('AWS_STORAGE_BUCKET_NAME')
如果在环境中指定了 AWS 访问密钥 ID,那么这段配置将更改DEFAULT_FILE_STORAGE。创建访问后,可以通过 IAM 获得访问密钥和密码。存储桶名称是您在 S3 上创建的,作为应用上传文件的目的地。
有兼容 S3 的开源云存储平台,比如 Minio。您也可以使用这些,但是您需要指定一个额外的AWS_S3_ENDPOINT_URL来覆盖 django-storages 的默认行为。
部署此更改后,您将需要设置这些新的环境变量。你可以在 Heroku 上通过仪表盘或命令行界面,或者在你的 Dokku 服务器上使用dokku config:set命令来轻松完成。在您的本地机器上,您应该将这些行包含在您的.env文件中。
设置前端变量
Django 提供了一种称为上下文处理器的抽象,用于在模板渲染阶段注入变量。在过去的章节中,我们使用了请求上下文处理器,但是现在我们将创建一个我们自己的请求上下文处理器。
我们将使用这个上下文处理器在基本模板中注入几个简单的变量,使浏览站点的任何人都可以使用它们的值。正在讨论的变量将是
-
Git 中最后一次提交的修订
-
Google Analytics 引导其 JavaScript 客户端所需的跟踪器 ID
将在新文件main/context_processors.py中创建上下文处理器:
import os
from django.conf import settings
def globals(request):
data = {}
data.update({
'VERSION': os.environ.get("GIT_REV",""),
'GA_TRACKER_ID': settings.GA_TRACKER_ID,
})
return data
这段代码将从名为GIT_REV的环境变量中读取,该变量存在于每个 Dokku 部署中。另一个变量GA_TRACKER_ID是从我们的设置文件中读取的。我们还需要激活这个上下文处理器。
以下是需要应用到booktime/settings.py文件的更改:
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
...
"OPTIONS": {
"context_processors": [
...
"main.context_processors.globals",
]
},
}
]
...
GA_TRACKER_ID = "123"
完成以上所有工作后,现在可以在 HTML 中打印这些变量,而不需要视图显式地传递这些变量。我们将在main/templates/base.html中这样做,它是站点每个页面的基础。
{% load static %}
<!doctype html>
<html lang="en">
<head>
...
<!-- Booktime version: {{ VERSION }} -->
<script charset="utf-8">
var tracker_id={{ GA_TRACKER_ID }};
</script>
</head>
...
您现在可以提交和部署这两个更改了。要验证它是否正常工作,请打开网站上的任何页面并查看 HTML 源代码。
自定义错误页面
在网站上市之前,我们希望确保最终用户不会看到任何过于吓人和无益的错误消息。如果生产中出现异常,我们想让用户放心,它会很快得到修复,他们应该会在稍后的某个时间回到站点。
我们将定制 HTTP 状态 404 和 500 的响应,当 URL 没有映射到任何内容以及代码中出现错误时,就会触发这两个状态。
Django 让这变得非常简单。当DEBUG设置为False时,Django 会在出现 404 或 500 代码错误时自动渲染模板404.html和500.html。
将以下内容放入顶层文件夹的templates/404.html中:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width"/>
<title>404</title>
</head>
<body>
<h1>We could not find this page</h1>
<p>Please contact our support.</p>
</body>
</html>
并将以下内容放入templates/500.html:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width"/>
<title>500</title>
</head>
<body>
<h1>An error has occurred</h1>
<p>Our support team has been notified.</p>
</body>
</html>
这两个模板故意保持简单,以便在处理初始错误时最小化出现渲染错误的机会。
错误报告
虽然对最终用户隐藏错误是一种好的做法,但我们需要确保在出现问题时,BookTime 的 it 团队得到通知。因此,我们将配置 Django 在发生错误时向管理员发送电子邮件。将此代码添加到booktime/settings.py:
...
ADMINS = (
('Booktime IT', 'systems@booktime.domain'),
)
EMAIL_SUBJECT_PREFIX = "[Booktime] "
在其默认配置中,Django 会在错误/异常发生时触发给所有ADMINS的电子邮件。这仅发生在DEBUG标志设置为False的部署中。
这个报告非常简单,但对于初始设置来说已经足够了。稍后,您可能希望使用第三方系统来进行错误聚合和一般管理,例如 Sentry ( https://sentry.io )。
确保测试通过
每次我们部署站点时,我们都希望确保一切正常。为此,我们依靠测试。我们的测试一定会通过。如果它们通过了,我们将有理由确信我们即将发布的网站新版本正在按预期工作。
在项目的初始阶段,我们还没有一个持续集成(CI)系统。这将是最好的解决方案,因为它会在工程师提交失败的测试时通知他们,但是建立 CI 系统需要更多的工作。对我们来说,一个简单的权宜之计是在 Heroku/Dokku 编译阶段运行测试。
我们将把我们的bin/post_compile命令改为:
#!/bin/bash
export PATH=/app/.heroku/node/bin:$PATH
npm run build-prod
./manage.py collectstatic --noinput
./manage.py migrate --noinput
npm test -- frontend
./manage.py test --noinput
这样,如果所有这些命令都成功,我们将发布;否则,我们不会。
让我们继续尝试一下:
$ git push dokku master
Counting objects: 39, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (38/38), done.
Writing objects: 100% (39/39), 3.98 KiB | 370.00 KiB/s, done.
Total 39 (delta 25), reused 0 (delta 0)
remote: Preparing /tmp/dokku_git.PJBq (identifier dokku_git.PJBq)
remote: ~/booktime /tmp/dokku_git.PJBq ~/booktime
remote: /tmp/dokku_git.PJBq ~/booktime
-----> Cleaning up...
-----> Building booktime from herokuish...
...
-----> Running post-compile hook
> booktime@1.0.0 build-prod /tmp/build
> webpack --config webpack.prod.config.js
...
> booktime@1.0.0 test /tmp/build
> jest "frontend"
remote: PASS frontend/imageswitcher.test.js
remote: - ImageBox switches images correctly (25ms)
remote:
remote: Test Suites: 1 passed, 1 total
remote: Tests: 1 passed, 1 total
remote: Snapshots: 0 total
remote: Time: 2.833s
remote: Ran all test suites matching /frontend/i.
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...
remote: .E
remote: =======================================================
remote: ERROR: setUpClass (main.tests.test_e2e.FrontendTests)
remote: -------------------------------------------------------
remote: Traceback (most recent call last):
remote: File "/app/.heroku/python/lib/python3.6/site-packages/selenium/webdriver/common/service .py", line 76, in start
remote: stdin=PIPE)
remote: File "/app/.heroku/python/lib/python3.6/subprocess.py", line 709, in __init__
remote: restore_signals, start_new_session)
remote: File "/app/.heroku/python/lib/python3.6/subprocess.py", line 1344, in _execute_child
remote: raise child_exception_type(errno_num, err_msg, err_filename)
remote: FileNotFoundError: [Errno 2] No such file or directory: 'geckodriver': 'geckodriver'
remote:
remote: During handling of the above exception, another exception occurred:
remote:
remote: Traceback (most recent call last):
remote: File "/tmp/build/main/tests/test_e2e.py", line 15, in setUpClass
remote: cls.selenium = WebDriver()
remote: File "/app/.heroku/python/lib/python3.6/site-packages/selenium/webdriver/firefox/webdriver.py", line 157, in __init__
remote: self.service.start()
remote: File "/app/.heroku/python/lib/python3.6/site-packages/selenium/webdriver/common/service.py", line 83, in start
remote: os.path.basename(self.path), self.start_error_message)
remote: selenium.common.exceptions.WebDriverException: Message: 'geckodriver' executable needs to be in PATH.
remote:
remote:
remote: =======================================================
remote: FAIL: test_invoice_renders_exactly_as_expected (main.tests.test_admin.TestAdminViews)
remote: -------------------------------------------------------
remote: Traceback (most recent call last):
remote: File "/tmp/build/main/tests/test_admin.py", line 105, in test_invoice_renders_exactly_as_expected
remote: self.assertEqual(content, expected_content)
remote: AssertionError: '\n<![111 chars].min.450fc463b8b1.css">\n <title>Invoice</t[1573 chars]tml>' != '\n<![111 chars].min.css">\n <title>Invoice</title>\n </he[1560 chars]tml>'
remote: Diff is 1919 characters long. Set self.maxDiff to None to see it.
remote:
remote: -------------------------------------------------------
remote: Ran 25 tests in 16.839s
remote:
remote: FAILED (failures=1, errors=1)
Destroying test database for alias 'default'...
To ...:booktime
! [remote rejected] master -> master (pre-receive hook declined) error: failed to push some refs to 'dokku@...:booktime'
正如您所看到的,有几个测试不工作。第一个是硒测试。不幸的是,没有办法运行这个测试,因为它依赖于启动 Firefox。我们必须跳过它。
我们将使用 Django 的测试标记特性来标记这个测试。是main/tests/test_e2e.py的一个小变化:
from django.test import tag
...
@tag('e2e')
class FrontendTests(StaticLiveServerTestCase):
...
这样,我们现在可以从部署前运行的测试中排除它,这意味着将最后一行bin/post_compile改为
./manage.py test --noinput --exclude-tag=e2e
还有最后一个破测试,是关于发票的。这发生在本章,在项目中引入 WhiteNoise 的时候。WhiteNoise 不会为每个{% static %}标签评估生成相同的输出。
我们还将改变 PDF 上的测试,因为作为一种低级格式,它的二进制输出取决于太多因素,即使视觉上可能是相等的。
我们将以这种方式改变main/tests/test_admin.py:
import re
...
def compare_bodies(content, expected_content):
c_match = re.search(r '<body>(.*)</body>', content, re.DOTALL|re.M)
e_match = re.search(r '<body>(.*)</body>', expected_content, re.DOTALL|re.M)
if c_match and e_match:
return c_match.group(1) == e_match.group(1)
return False
class TestAdminViews(TestCase):
...
def test_invoice_renders_exactly_as_expected(self):
...
response = self.client.get(
reverse(
"admin:invoice", kwargs={"order_id": order.id}
)
)
self.assertEqual(response.status_code, 200)
content = response.content.decode("utf8")
with open(
"main/fixtures/invoice_test_order.html","r"
) as fixture:
expected_content = fixture.read()
self.assertTrue(compare_bodies(content, expected_content))
response = self.client.get(
reverse(
"admin:invoice", kwargs={"order_id": order.id}
),
{"format": "pdf"},
)
self.assertEqual(response.status_code, 200)
content = response.content
with open(
"main/fixtures/invoice_test_order.pdf", "rb"
) as fixture:
expected_content = fixture.read()
self.assertEqual(content[:5], expected_content[:5])
我们对断言进行了如下更改:
-
测试 HTML 版本的
<body>元素的内容。这将消除问题,并仍然给我们带来测试的好处。 -
仅通过测试文件签名来测试 PDF。我们将测试 PDF 是相同的二进制文件改为测试它是有效的 PDF。就目前而言,这是一个可以接受的妥协。
摘要
本章举例说明了每个项目在发布时必须经历的许多常见步骤。在成品中,可伸缩性、安全性和日志记录都是需要注意的重要事情,但在开发阶段,它们不是主要关注的问题。
我们已经看到了如何使用 JavaScript 和 Python 工具来优化静态资产的传输和执行速度,因为我试图选择最适合这项工作的工具,而不考虑语言。
尽管这本书不是关于部署和配置服务器的,但是没有提到如何做也不能给你完整的描述。PaaS 不是唯一的部署方式,但它肯定是最简单的方式之一,这也是我一开始就选择它的原因。
我们还探索了与 S3 等对象存储的集成。对于需要管理大量媒体文件的网站,选择这些工具可以省去很多麻烦。将这些与 Django 集成很容易。
我希望你喜欢这本书。现在,您已经掌握了处理大多数 Django 项目的必要知识。请尽情享受吧!




浙公网安备 33010602011771号