Django2-和-Channel2-实践教程-三-

114 阅读35分钟

Django2 和 Channel2 实践教程(三)

原文:Practical Django 2 and Channels 2

协议:CC BY-NC-SA 4.0

五、将 CSS/JavaScript 添加到 Django 项目中

如今,每个网站都使用 CSS 和 JavaScript,有些比其他网站用得更多。本章概述了这些技术,以及它们如何与 Django 集成。

我们对这些技术的调查不会很广泛——前端生态系统中的大多数工具发展得太快,以至于一本书无法保持更新。相反,我们将采取一种极简的方法,只使用几个相当稳定的库。但是,请记住,您可能仍然需要将您在这里读到的内容应用到所展示的工具的未来版本中。

管理静态文件

静态文件是作为 HTTP 请求的结果直接提供给浏览器的任何东西。以下是静态文件的一些示例:

  • CSS 文件

  • JavaScript 文件

  • 形象

  • 录像

  • 字体

  • 前端的数据文件

Django 以同样的方式管理所有这些资源。静态资源可以特定于 Django 应用,也可以存储在项目级别。当使用其开发服务器时,Django 将提供STATIC_URL下每个应用的每个static文件夹的内容,如第一章所述,不管它是什么类型。

我们在第四章中已经看到了这样一个例子,当时我们引入了一个小部件来更新购物篮数量。我们已经将支持 CSS 和 JavaScript 分别放在了main/static/cssmain/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 所示。

img/466106_1_En_5_Fig1_HTML.jpg

图 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语句,以及内嵌样式和对windowmodule.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 中看到我们项目的图表。

img/466106_1_En_6_Fig1_HTML.jpg

图 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 所示。

img/466106_1_En_6_Fig2_HTML.jpg

图 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 查询。

img/466106_1_En_6_Fig3_HTML.jpg

图 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 会将其statictemplates文件夹添加到静态文件和模板文件的搜索路径中。这就是这个库如何通过指定一个初始模板来引入它所有的依赖项。

下一个(也是最后一个)操作是使其在main/urls.py中可用:

urlpatterns = [
    ...
    path(
        "order-dashboard/",
        views.OrderView.as_view(),
        name="order_dashboard",
    ),
]

这个新创建的仪表板现在可以在上面指示的 URL 上获得。在您的项目中输入一些订单后,您将能够在仪表板中看到一些结果,并使用我们设置的过滤器,如图 6-4 所示。

img/466106_1_En_6_Fig4_HTML.jpg

图 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.htmlcontact_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 框架 8django-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命令。

img/466106_1_En_6_Fig5_HTML.png

图 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 和快速测试是有用的。

img/466106_1_En_6_Fig6_HTML.jpg

图 6-6

DRF 网络客户端

摘要

在本章中我们已经看到了这些库:

  • django-extensions:包括一些 django 中没有的非常有用的命令

  • 工厂男孩:为测试动态生成数据

  • django-debug-toolbar:在调试时给我们很多信息的工具

  • django-widget-tweaks:为定义前端属性提供了更多的灵活性

  • django-rest-framework:完全关于 rest 的框架中的框架

当然,网上还有很多其他的图书馆。本章旨在向您展示一些扩展 Django 的方法。我鼓励你在网上做你的研究,选择适合你需要的。

在下一章,我们将再次讨论 Django admin,这次会更深入。

Footnotes 1

https://github.com/django-extensions/django-extensions

  2

https://docs.djangoproject.com/en/2.0/ref/django-admin/

  3

https://factoryboy.readthedocs.io/en/latest/

  4

https://github.com/jazzband/django-debug-toolbar

  5

https://django-tables2.readthedocs.io/en/latest/

  6

https://django-filter.readthedocs.io/en/master/

  7

https://github.com/jazzband/django-widget-tweaks

  8

http://www.django-rest-framework.org/

 

七、为公司制作内部仪表板

在这一章中,我们将在 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,我们为ProductOrder专门准备了一个版本的ModelAdminDispatchersOrderAdmin覆盖了get_queryset()方法,因为调度办公室只需要看到已经被标记为已支付的订单。在这些网站上,他们只需要看到送货地址。

对于除了所有者之外的任何人,我们也限制了修改 slugs 的能力,因为它们是 URL 的一部分。如果他们被改变,谷歌或任何其他链接到我们网站的实体将会断开链接。

Django 管理接口的新实例现在需要在main/urls.pyurlpatterns中有一个条目,如下所示。不要忘记删除booktime/urls.pyadmin/的旧条目。如果你忘记删除它,你会遇到一些路径名冲突的问题。

...

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>&nbsp;</td>
            <td>&nbsp;</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 所示。

img/466106_1_En_7_Fig1_HTML.png

图 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 所示。

img/466106_1_En_7_Fig2_HTML.png

图 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 的扩展,以及我们如何使用它来构建一个聊天页面与我们的客户进行交互。

Footnotes 1

https://github.com/django/django/tree/master/django/contrib/admin/templates/admin