Django3-Web-开发秘籍第四版-全-

Django3 Web 开发秘籍第四版(全)

原文:zh.annas-archive.org/md5/49CC5D4E5506D0966D8746F9F4B56200

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Django 框架专门设计用于帮助开发人员快速高效地构建强大的 Web 应用程序。它大大减少了繁琐的工作和重复的过程,解决了项目结构、数据库对象关系映射、模板化、表单验证、会话、身份验证、安全、cookie 管理、国际化、基本管理和从脚本访问数据的接口等问题。Django 是建立在 Python 编程语言之上的,Python 本身强制执行清晰易读的代码。除了核心框架,Django 还被设计为使开发人员能够创建可与自己的应用程序一起使用的第三方模块。Django 拥有一个成熟和充满活力的社区,您可以在其中找到源代码、获得帮助并做出贡献。

Django 3 Web Development Cookbook, Fourth Edition,将指导您使用 Django 3.0 框架完成 Web 开发过程的每个阶段。我们从项目的配置和结构开始。然后,您将学习如何使用可重用组件定义数据库结构,并在项目的整个生命周期中进行管理。本书将继续介绍用于输入和列出数据的表单和视图。我们将继续使用响应式模板和 JavaScript 来增强用户体验。然后,我们将通过自定义过滤器和标签增强 Django 的模板系统,以便更灵活地进行前端开发。之后,您将调整管理界面,以简化内容编辑者的工作流程。接下来,我们将关注项目的稳定性和健壮性,帮助您保护和优化应用程序。接下来,我们将探讨如何高效地存储和操作分层结构。然后,我们将演示从不同来源收集数据并以各种格式提供您自己的数据比您想象的简单。然后,我们将向您介绍一些用于编程和调试 Django 项目代码的技巧。我们将继续介绍一些可用于测试代码的选项。在本书结束之前,我们将向您展示如何将项目部署到生产环境。最后,我们将通过设置常见的维护实践来完成开发周期。

与许多其他 Django 书籍相比,这些书籍只关注框架本身,而本书涵盖了几个重要的第三方模块,这些模块将为您提供完成网站开发所需的工具。此外,我们提供了使用 Bootstrap 前端框架和 jQuery JavaScript 库的示例,这两者都简化了高级和复杂用户界面的创建。

本书适合人群

如果您有 Django 经验并希望提高技能,这本书适合您。我们设计了中级和专业 Django 开发人员的内容,他们的目标是构建多语言、安全、响应迅速且能够随时间推移扩展的强大项目。

本书内容包括

第一章,开始使用 Django 3.0,说明了任何 Django 项目所需的基本设置和配置步骤。我们涵盖了虚拟环境、Docker 以及跨环境和数据库的项目设置。

第二章,模型和数据库结构,解释了如何编写可重用的代码,用于构建模型。新应用程序的首要事项是定义数据模型,这构成了任何项目的支柱。您将学习如何在数据库中保存多语言数据。此外,您还将学习如何使用 Django 迁移管理数据库模式更改和数据操作。

第三章,表单和视图,展示了构建用于数据显示和编辑的视图和表单的方法。您将学习如何使用微格式和其他协议,使您的页面对机器更易读,以便在搜索结果和社交网络中表示。您还将学习如何生成 PDF 文档并实现多语言搜索。

第四章,模板和 JavaScript,涵盖了一起使用模板和 JavaScript 的实际示例。我们将这些方面结合起来:渲染的模板向用户呈现信息,而 JavaScript 为现代网站提供了关键的增强功能,以实现丰富的用户体验。

第五章,自定义模板过滤器和标签,介绍了如何创建和使用自己的模板过滤器和标签。正如您将看到的,默认的 Django 模板系统可以扩展以满足模板开发人员的需求。

第六章,模型管理,探讨了默认的 Django 管理界面,并指导您如何通过自己的功能扩展它。

第七章,安全性和性能,深入探讨了 Django 内在和外部的几种方式,以确保和优化您的项目。

第八章,分层结构,探讨了在 Django 中创建和操作类似树的结构,以及将django-mptttreebeard库纳入此类工作流程的好处。本章向您展示了如何同时用于层次结构的显示和管理。

第九章,导入和导出数据,演示了数据在不同格式之间的传输,以及在各种来源之间的提供。在本章中,使用自定义管理命令进行数据导入,并利用站点地图、RSS 和 REST API 进行数据导出。

第十章,花里胡哨,展示了在日常网页开发和调试中有用的一些额外片段和技巧。

第十一章,测试,介绍了不同类型的测试,并提供了一些特征示例,说明如何测试项目代码。

第十二章,部署,涉及将第三方应用程序部署到 Python 软件包索引以及将 Django 项目部署到专用服务器。

第十三章,维护,解释了如何创建数据库备份,为常规任务设置 cron 作业,并记录事件以供进一步检查。

为了充分利用本书

要使用本书中的示例开发 Django 3.0,您需要以下内容:

  • Python 3.6 或更高版本

  • 用于图像处理的Pillow

  • 要么使用 MySQL 数据库和mysqlclient绑定库,要么使用带有psycopg2-binary绑定库的 PostgreSQL 数据库

  • Docker Desktop 或 Docker Toolbox 用于完整的系统虚拟化,或者内置虚拟环境以保持每个项目的 Python 模块分开

  • 用于版本控制的 Git

书中涵盖的软件/硬件 操作系统建议

| Python 3.6 或更高版本 Django 3.0.X

PostgreSQL 11.4 或更高版本/MySQL 5.6 或更高版本|任何最近的基于 Unix 的操作系统,如 macOS 或 Linux(尽管也可以在 Windows 上开发)

所有其他特定要求都将在每个配方中单独提到。

如果您使用的是本书的数字版本,我们建议您自己输入代码或通过 GitHub 存储库访问代码(链接在下一节中提供)。这样做将有助于避免与复制/粘贴代码或不正确缩进相关的任何潜在错误。

对于编辑项目文件,您可以使用任何代码编辑器,但我们建议使用PyCharmwww.jetbrains.com/pycharm/)或Visual Studio Codecode.visualstudio.com/)。

如果您成功发布了 Django 项目,我会非常高兴,如果您能通过电子邮件与我分享您的结果、经验和成果,我的电子邮件是 aidas@bendoraitis.lt。

所有代码示例都经过了 Django 3 的测试。但是,它们也应该适用于将来的版本发布。

下载示例代码文件

您可以从 www.packt.com 的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问 www.packtpub.com/support 并注册,以便直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. 在 www.packt.com 上登录或注册。

  2. 选择 Support 选项卡。

  3. 点击 Code Downloads。

  4. 在 Search 框中输入书名,然后按照屏幕上的说明进行操作。

一旦文件下载完成,请确保使用最新版本的解压缩或提取文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有其他代码包来自我们丰富的图书和视频目录,可在 github.com/PacktPublishing/ 上找到。请查看!

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。例如:"为了使这个配方起作用,您需要安装 contenttypes 应用程序。"

代码块设置如下:

# requirements/dev.txt
-r _base.txt
coverage
django-debug-toolbar
selenium

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

class Idea(CreationModificationDateBase, MetaTagsBase, UrlBase):
    title = models.CharField(
        _("Title"),
        max_length=200,
    )
    content = models.TextField(
        _("Content"),
    )

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

(env)$ pip install -r requirements/dev.txt

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。例如:"我们可以看到这里与上传相关的操作按钮也被替换为了一个 Remove 按钮。"

警告或重要说明会以这种方式出现。

技巧和窍门会以这种方式出现。

章节

在本书中,您会经常看到几个标题(准备工作如何做...它是如何工作的...还有更多...另请参阅)。

为了清晰地说明如何完成一个配方,使用以下各节:

准备工作

本节告诉您应该期望在配方中发生什么,并描述了为配方设置任何软件或任何必需的预设置所需的步骤。

如何做...

本节包含了遵循配方所需的步骤。

它是如何工作的...

本节通常包括对前一节中发生的事情的详细解释。

还有更多...

本节包括有关配方的其他信息,以增加您对其的了解。

另请参阅

本节提供了有关配方的其他有用信息的链接。

第一章:开始使用 Django 3.0

在本章中,我们将涵盖以下主题:

  • 使用虚拟环境

  • 创建项目文件结构

  • 使用 pip 处理项目依赖关系

  • 为开发、测试、暂存和生产环境配置设置

  • 在设置中定义相对路径

  • 处理敏感设置

  • 在项目中包含外部依赖项

  • 动态设置STATIC_URL

  • 将 UTF-8 设置为 MySQL 配置的默认编码

  • 创建 Git 的ignore文件

  • 删除 Python 编译文件

  • 遵守 Python 文件中的导入顺序

  • 创建应用程序配置

  • 定义可覆盖的应用程序设置

  • 使用 Docker 容器处理 Django、Gunicorn、Nginx 和 PostgreSQL

介绍

在本章中,我们将看到一些有价值的实践,用于使用 Python 3 在 Django 3.0 中启动新项目时遵循。我们选择了处理可扩展项目布局、设置和配置的最有用的方法,无论是使用 virtualenv 还是 Docker 来管理您的项目。

我们假设您已经熟悉 Django、Git 版本控制、MySQL 以及 PostgreSQL 数据库和命令行使用的基础知识。我们还假设您使用的是基于 Unix 的操作系统,如 macOS 或 Linux。在 Unix-based 平台上开发 Django 更有意义,因为 Django 网站很可能会发布在 Linux 服务器上,这意味着您可以建立在开发或部署时都能工作的例行程序。如果您在 Windows 上本地使用 Django,例行程序是类似的;但是它们并不总是相同的。

无论您的本地平台如何,使用 Docker 作为开发环境都可以通过部署改善应用程序的可移植性,因为 Docker 容器内的环境可以精确匹配部署服务器的环境。我们还应该提到,在本章的配方中,我们假设您已经在本地机器上安装了适当的版本控制系统和数据库服务器,无论您是否使用 Docker 进行开发。

技术要求

要使用本书的代码,您将需要最新稳定版本的 Python,可以从www.python.org/downloads/下载。在撰写本文时,最新版本为 3.8.X。您还需要 MySQL 或 PostgreSQL 数据库。您可以从dev.mysql.com/downloads/下载 MySQL 数据库服务器。PostgreSQL 数据库服务器可以从www.postgresql.org/download/下载。其他要求将在特定的配方中提出。

您可以在 GitHub 存储库的ch01目录中找到本章的所有代码,网址为github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition

使用虚拟环境

很可能您会在计算机上开发多个 Django 项目。某些模块,如 virtualenv、setuptools、wheel 或 Ansible,可以安装一次,然后为所有项目共享。其他模块,如 Django、第三方 Python 库和 Django 应用程序,需要保持彼此隔离。virtualenv 工具是一个实用程序,它将所有 Python 项目分开,并将它们保留在自己的领域中。在本配方中,我们将看到如何使用它。

准备工作

要管理 Python 包,您将需要 pip。如果您使用的是 Python 3.4+,则它将包含在您的 Python 安装中。如果您使用的是其他版本的 Python,可以通过执行http:/​/​pip.​readthedocs.​org/​en/​stable/installing/的安装说明来安装 pip。让我们升级共享的 Python 模块、pip、setuptools 和 wheel:

$ sudo pip3 install --upgrade pip setuptools wheel

虚拟环境已经内置到 Python 3.3 版本以来。

如何做...

安装完先决条件后,创建一个目录,其中将存储所有 Django 项目,例如,在您的主目录下创建projects。创建目录后,请按以下步骤进行:

  1. 转到新创建的目录并创建一个使用共享系统站点包的虚拟环境:
$ cd ~/projects
$ mkdir myproject_website
$ cd myproject_website
$ python3 -m venv env
  1. 要使用您新创建的虚拟环境,您需要在当前 shell 中执行激活脚本。可以使用以下命令完成:
$ source env/bin/activate
  1. 根据您使用的 shell,source命令可能不可用。另一种使用以下命令来源文件的方法是具有相同结果的(注意点和env之间的空格):
$ . env/bin/activate
  1. 您将看到命令行工具的提示前缀为项目名称,如下所示:
(env)$
  1. 要退出虚拟环境,请输入以下命令:
(env)$ deactivate

它是如何工作的...

创建虚拟环境时,会创建一些特定目录(binincludelib),以存储 Python 安装的副本,并定义一些共享的 Python 路径。激活虚拟环境后,您使用pipeasy_install安装的任何内容都将放在虚拟环境的站点包中,并且不会放在 Python 安装的全局站点包中。

要在虚拟环境中安装最新的 Django 3.0.x,请输入以下命令:

(env)$ pip install "Django~=3.0.0"

另请参阅

  • 创建项目文件结构食谱

  • 第十二章中的使用 Docker 容器进行 Django、Gunicorn、Nginx 和 PostgreSQL 部署食谱

  • 第十二章中的使用 mod_wsgi 在 Apache 上部署分段环境食谱,部署

  • 第十二章, 部署中的使用 Apache 和 mod_wsgi 部署生产环境食谱

  • 第十二章, 部署中的在 Nginx 和 Gunicorn 上部署分段环境食谱

  • 第十二章, 部署中的在 Nginx 和 Gunicorn 上部署生产环境食谱

创建项目文件结构

为您的项目保持一致的文件结构可以使您更有条理、更高效。当您定义了基本工作流程后,您可以更快地进入业务逻辑并创建出色的项目。

准备工作

如果还没有,请创建一个~/projects目录,您将在其中保存所有 Django 项目(您可以在使用虚拟环境食谱中了解更多信息)。

然后,为您的特定项目创建一个目录,例如myproject_website。在那里的env目录中启动虚拟环境。激活它并在其中安装 Django,如前面的食谱中所述。我们建议添加一个commands目录,用于与项目相关的本地 shell 脚本,一个用于数据库转储的db_backups目录,一个用于网站设计文件的mockups目录,最重要的是一个用于您的 Django 项目的src目录。

如何做...

按照以下步骤为您的项目创建文件结构:

  1. 激活虚拟环境后,转到src目录并启动一个新的 Django 项目,如下所示:
(env)$ django-admin.py startproject myproject

执行的命令将创建一个名为myproject的目录,其中包含项目文件。该目录将包含一个名为myproject的 Python 模块。为了清晰和方便起见,我们将顶级目录重命名为django-myproject。这是您将放入版本控制的目录,因此它将有一个.git或类似命名的子目录。

  1. django-myproject目录中,创建一个README.md文件,以向新的开发者描述您的项目。

  2. django-myproject目录还将包含以下内容:

  • 您项目的 Python 包名为myproject

  • 您的项目的 pip 要求与 Django 框架和其他外部依赖项(在使用 pip 处理项目依赖食谱中了解更多)。

  • LICENSE文件中的项目许可证。如果您的项目是开源的,可以从choosealicense.com中选择最受欢迎的许可证之一。

  1. 在您的项目的根目录django-myproject中,创建以下内容:
  • 用于项目上传的media目录

  • 用于收集静态文件的static目录

  • 用于项目翻译的locale目录

  • 用于无法使用 pip 要求的项目中包含的外部依赖的externals目录

  1. myproject目录应包含以下目录和文件:
  • apps目录,您将在其中放置项目的所有内部 Django 应用程序。建议您有一个名为coreutils的应用程序,用于项目的共享功能。

  • 用于项目设置的settings目录(在配置开发、测试、暂存和生产环境的设置食谱中了解更多)。

  • 用于特定项目的静态文件的site_static目录。

  • 项目的 HTML 模板的templates目录。

  • 项目的 URL 配置的urls.py文件。

  • 项目的 Web 服务器配置的wsgi.py文件。

  1. 在您的site_static目录中,创建site目录作为站点特定静态文件的命名空间。然后,我们将在其中的分类子目录之间划分静态文件。例如,参见以下内容:
  • Sass 文件的scss(可选)

  • 用于生成压缩的层叠样式表CSS)的css

  • 用于样式图像、网站图标和标志的img

  • 项目的 JavaScript 的js

  • vendor用于任何第三方模块,结合所有类型的文件,例如 TinyMCE 富文本编辑器

  1. 除了site目录,site_static目录还可能包含第三方应用程序的覆盖静态目录,例如,它可能包含cms,它会覆盖 Django CMS 的静态文件。要从 Sass 生成 CSS 文件并压缩 JavaScript 文件,您可以使用带有图形用户界面的 CodeKit (codekitapp.com/)或 Prepros (prepros.io/)应用程序。

  2. 将按应用程序分隔的模板放在您的templates目录中。如果模板文件表示页面(例如,change_item.htmlitem_list.html),则直接将其放在应用程序的模板目录中。如果模板包含在另一个模板中(例如,similar_items.html),则将其放在includes子目录中。此外,您的模板目录可以包含一个名为utils的目录,用于全局可重用的片段,例如分页和语言选择器。

它是如何工作的...

完整项目的整个文件结构将类似于以下内容:

myproject_website/
├── commands/
├── db_backups/
├── mockups/
├── src/
│   └── django-myproject/
│       ├── externals/
│       │   ├── apps/
│       │   │   └── README.md
│       │   └── libs/
│       │       └── README.md
│       ├── locale/
│       ├── media/
│       ├── myproject/
│       │   ├── apps/
│       │   │   ├── core/
│       │   │   │   ├── __init__.py
│       │   │   │   └── versioning.py
│       │   │   └── __init__.py
│       │   ├── settings/
│       │   │   ├── __init__.py
│       │   │   ├── _base.py
│       │   │   ├── dev.py
│       │   │   ├── production.py
│       │   │   ├── sample_secrets.json
│       │   │   ├── secrets.json
│       │   │   ├── staging.py
│       │   │   └── test.py
│       │   ├── site_static/
│       │   │   └── site/
│       │   │  django-admin.py startproject myproject     ├── css/
│       │   │       │   └── style.css
│       │   │       ├── img/
│       │   │       │   ├── favicon-16x16.png
│       │   │       │   ├── favicon-32x32.png
│       │   │       │   └── favicon.ico
│       │   │       ├── js/
│       │   │       │   └── main.js
│       │   │       └── scss/
│       │   │           └── style.scss
│       │   ├── templates/
│       │   │   ├── base.html
│       │   │   └── index.html
│       │   ├── __init__.py
│       │   ├── urls.py
│       │   └── wsgi.py
│       ├── requirements/
│       │   ├── _base.txt
│       │   ├── dev.txt
│       │   ├── production.txt
│       │   ├── staging.txt
│       │   └── test.txt
│       ├── static/
│       ├── LICENSE
│       └── manage.py
└── env/

还有更多...

为了加快按照我们刚刚描述的方式创建项目的速度,您可以使用来自github.com/archatas/django-myproject的项目样板。下载代码后,执行全局搜索并替换myproject为您的项目的有意义的名称,然后您就可以开始了。

另请参阅

  • 使用 pip 处理项目依赖的食谱

  • 在项目中包含外部依赖的食谱

  • 配置开发、测试、暂存和生产环境的设置

  • 第十二章部署中的在 Apache 上使用 mod_wsgi 部署暂存环境食谱

  • 第十二章部署中的在 Apache 上使用 mod_wsgi 部署生产环境食谱

  • 第十二章部署中的在 Nginx 和 Gunicorn 上部署暂存环境食谱

  • 在第十二章,部署中的在 Nginx 和 Gunicorn 上部署生产环境配方

使用 pip 处理项目依赖关系

安装和管理 Python 包的最方便的工具是 pip。与逐个安装包不同,可以将要安装的包的列表定义为文本文件的内容。我们可以将文本文件传递给 pip 工具,然后 pip 工具将自动处理列表中所有包的安装。采用这种方法的一个附加好处是,包列表可以存储在版本控制中。

一般来说,拥有一个与您的生产环境直接匹配的单个要求文件是理想的,通常也足够了。您可以在开发机器上更改版本或添加和删除依赖项,然后通过版本控制进行管理。这样,从一个依赖项集(和相关的代码更改)到另一个依赖项集的转换可以像切换分支一样简单。

在某些情况下,环境的差异足够大,您将需要至少两个不同的项目实例:

  • 在这里创建新功能的开发环境

  • 通常称为托管服务器中的生产环境的公共网站环境

可能有其他开发人员的开发环境,或者在开发过程中需要的特殊工具,但在生产中是不必要的。您可能还需要测试和暂存环境,以便在本地测试项目和在类似公共网站的设置中进行测试。

为了良好的可维护性,您应该能够为开发、测试、暂存和生产环境安装所需的 Python 模块。其中一些模块将是共享的,而另一些将特定于一部分环境。在本配方中,我们将学习如何为多个环境组织项目依赖项,并使用 pip 进行管理。

准备工作

在使用此配方之前,您需要准备好一个已安装 pip 并激活了虚拟环境的 Django 项目。有关如何执行此操作的更多信息,请阅读使用虚拟环境配方。

如何做...

逐步执行以下步骤,为您的虚拟环境 Django 项目准备 pip 要求:

  1. 让我们进入您正在版本控制下的 Django 项目,并创建一个包含以下文本文件的 requirements 目录:
  • _base.txt 用于共享模块

  • dev.txt 用于开发环境

  • test.txt 用于测试环境

  • staging.txt 用于暂存环境

  • production.txt 用于生产环境

  1. 编辑 _base.txt 并逐行添加在所有环境中共享的 Python 模块:
# requirements/_base.txt
Django~=3.0.4
djangorestframework
-e git://github.com/omab/python-social-auth.git@6b1e301c79#egg=python-social-auth
  1. 如果特定环境的要求与 _base.txt 中的要求相同,请在该环境的要求文件中添加包括 _base.txt 的行,如下例所示:
# requirements/production.txt
-r _base.txt
  1. 如果环境有特定要求,请在 _base.txt 包含之后添加它们,如下面的代码所示:
# requirements/dev.txt
-r _base.txt
coverage
django-debug-toolbar
selenium
  1. 您可以在虚拟环境中运行以下命令,以安装开发环境所需的所有依赖项(或其他环境的类似命令),如下所示:
(env)$ pip install -r requirements/dev.txt

它是如何工作的...

前面的 pip install 命令,无论是在虚拟环境中显式执行还是在全局级别执行,都会从 requirements/_base.txtrequirements/dev.txt 下载并安装所有项目依赖项。如您所见,您可以指定您需要的 Django 框架的模块版本,甚至可以直接从 Git 存储库的特定提交中安装,就像我们的示例中对 python-social-auth 所做的那样。

在项目中有很多依赖项时,最好坚持使用 Python 模块发布版本的狭窄范围。然后,您可以更有信心地确保项目的完整性不会因依赖项的更新而受到破坏,这可能会导致冲突或向后不兼容。当部署项目或将其移交给新开发人员时,这一点尤为重要。

如果您已经手动逐个使用 pip 安装了项目要求,您可以在虚拟环境中使用以下命令生成requirements/_base.txt文件:

(env)$ pip freeze > requirements/_base.txt

还有更多...

如果您想保持简单,并确信对于所有环境,您将使用相同的依赖项,您可以使用名为requirements.txt的一个文件来定义生成要求,如下所示:

(env)$ pip freeze > requirements.txt

要在新的虚拟环境中安装模块,只需使用以下命令:

(env)$ pip install -r requirements.txt

如果您需要从另一个版本控制系统或本地路径安装 Python 库,则可以从官方文档pip.pypa.io/en/stable/user_guide/了解有关 pip 的更多信息。

另一种越来越受欢迎的管理 Python 依赖项的方法是 Pipenv。您可以在github.com/pypa/pipenv获取并了解它。

另请参阅

  • 使用虚拟环境 教程

  • Django,Gunicorn,Nginx 和 PostgreSQL 的 Docker 容器工作 教程

  • 在项目中包含外部依赖项 教程

  • 配置开发、测试、暂存和生产环境的设置 教程

配置开发、测试、暂存和生产环境的设置

如前所述,您将在开发环境中创建新功能,在测试环境中测试它们,然后将网站放到暂存服务器上,让其他人尝试新功能。然后,网站将部署到生产服务器供公众访问。每个环境都可以有特定的设置,您将在本教程中学习如何组织它们。

准备工作

在 Django 项目中,我们将为每个环境创建设置:开发、测试、暂存和生产。

如何做到...

按照以下步骤配置项目设置:

  1. myproject目录中,创建一个settings Python 模块,并包含以下文件:
  • __init__.py 使设置目录成为 Python 模块。

  • _base.py 用于共享设置

  • dev.py 用于开发设置

  • test.py 用于测试设置

  • staging.py 用于暂存设置

  • production.py 用于生产设置

  1. 将自动在启动新的 Django 项目时创建的settings.py的内容复制到settings/_base.py。然后,删除settings.py

  2. settings/_base.py中的BASE_DIR更改为指向上一级。它应该首先如下所示:

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

更改后,它应如下所示:

BASE_DIR = os.path.dirname(
    os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
  1. 如果一个环境的设置与共享设置相同,那么只需

_base.py中导入所有内容,如下所示:

# myproject/settings/production.py
from ._base import *
  1. 在其他文件中应用您想要附加或覆盖的特定环境的设置,例如,开发环境设置应该放在dev.py中,如下面的代码片段所示:
# myproject/settings/dev.py
from ._base import *
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
  1. 修改manage.pymyproject/wsgi.py文件,以默认使用其中一个环境设置,方法是更改以下行:
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
  1. 您应该将此行更改为以下内容:
os.environ.setdefault('DJANGO_SETTINGS_MODULE',  'myproject.settings.production')

它是如何工作的...

默认情况下,Django 管理命令使用myproject/settings.py中的设置。使用此食谱中定义的方法,我们可以将所有环境中所需的非敏感设置保留在config目录中,并将settings.py文件本身忽略在版本控制中,它只包含当前开发、测试、暂存或生产环境所需的设置。

对于每个环境,建议您单独设置DJANGO_SETTINGS_MODULE环境变量,可以在 PyCharm 设置中、env/bin/activate脚本中或.bash_profile中设置。

另请参阅

  • 为 Django、Gunicorn、Nginx 和 PostgreSQL 工作的 Docker 容器食谱

  • 处理敏感设置食谱

  • 在设置中定义相对路径食谱

  • 创建 Git 忽略文件食谱

在设置中定义相对路径

Django 要求您在设置中定义不同的文件路径,例如媒体的根目录、静态文件的根目录、模板的路径和翻译文件的路径。对于项目的每个开发者,路径可能会有所不同,因为虚拟环境可以设置在任何地方,用户可能在 macOS、Linux 或 Windows 上工作。即使您的项目包装在 Docker 容器中,定义绝对路径会降低可维护性和可移植性。无论如何,有一种方法可以动态定义这些路径,使它们相对于您的 Django 项目目录。

准备工作

已经启动了一个 Django 项目并打开了settings/_base.py

如何做...

相应地修改您的与路径相关的设置,而不是将路径硬编码到本地目录中,如下所示:

# settings/_base.py
import os
BASE_DIR = os.path.dirname(
    os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
# ...
TEMPLATES = [{
    # ...
    DIRS: [
       os.path.join(BASE_DIR, 'myproject', 'templates'),
    ],
    # ...
}]
# ...
LOCALE_PATHS = [
    os.path.join(BASE_DIR, 'locale'),
]
# ...
STATICFILES_DIRS = [
    os.path.join(BASE_DIR, 'myproject', 'site_static'),
]
STATIC_ROOT = os.path.join(BASE_DIR, 'static')
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')

它是如何工作的...

默认情况下,Django 设置包括BASE_DIR值,这是一个绝对路径,指向包含manage.py的目录(通常比settings.py文件高一级,或比settings/_base.py高两级)。然后,我们使用os.path.join()函数将所有路径设置为相对于BASE_DIR

根据我们在创建项目文件结构食谱中设置的目录布局,我们将在一些先前的示例中插入'myproject'作为中间路径段,因为相关文件夹是在其中创建的。

另请参阅

  • 创建项目文件结构食谱

  • 为 Django、Gunicorn、Nginx 和 PostgreSQL 工作的 Docker 容器食谱

  • 在项目中包含外部依赖项食谱

处理敏感设置

在配置 Django 项目时,您肯定会处理一些敏感信息,例如密码和 API 密钥。不建议将这些信息放在版本控制下。存储这些信息的主要方式有两种:在环境变量中和在单独的未跟踪文件中。在这个食谱中,我们将探讨这两种情况。

准备工作

项目的大多数设置将在所有环境中共享并保存在版本控制中。这些可以直接在设置文件中定义;但是,将有一些设置是特定于项目实例的环境或敏感的,并且需要额外的安全性,例如数据库或电子邮件设置。我们将使用环境变量来公开这些设置。

如何做...

从环境变量中读取敏感设置,执行以下步骤:

  1. settings/_base.py的开头,定义get_secret()函数如下:
# settings/_base.py
import os
from django.core.exceptions import ImproperlyConfigured

def get_secret(setting):
    """Get the secret variable or return explicit exception."""
    try:
        return os.environ[setting]
    except KeyError:
        error_msg = f'Set the {setting} environment variable'
        raise ImproperlyConfigured(error_msg)
  1. 然后,每当您需要定义敏感值时,使用get_secret()函数,如下例所示:
SECRET_KEY = get_secret('DJANGO_SECRET_KEY')

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql_psycopg2',
        'NAME': get_secret('DATABASE_NAME'),
        'USER': get_secret('DATABASE_USER'),
        'PASSWORD': get_secret('DATABASE_PASSWORD'),
        'HOST': 'db',
        'PORT': '5432',
    }
}

它是如何工作的...

如果在没有设置环境变量的情况下运行 Django 管理命令,您将看到一个错误消息,例如设置DJANGO_SECRET_KEY环境变量。

您可以在 PyCharm 配置、远程服务器配置控制台、env/bin/activate脚本、.bash_profile或直接在终端中设置环境变量,如下所示:

$ export DJANGO_SECRET_KEY="change-this-to-50-characters-long-random-
  string"
$ export DATABASE_NAME="myproject"
$ export DATABASE_USER="myproject"
$ export DATABASE_PASSWORD="change-this-to-database-password"

请注意,您应该在 Django 项目配置中使用get_secret()函数来获取所有密码、API 密钥和任何其他敏感信息。

还有更多...

您还可以使用包含敏感信息的文本文件,这些文件不会被版本控制跟踪,而不是环境变量。它们可以是 YAML、INI、CSV 或 JSON 文件,放置在硬盘的某个位置。例如,对于 JSON 文件,您可以有get_secret()函数,如下所示:

# settings/_base.py
import os
import json

with open(os.path.join(os.path.dirname(__file__), 'secrets.json'), 'r') 
 as f:
    secrets = json.loads(f.read())

def get_secret(setting):
    """Get the secret variable or return explicit exception."""
    try:
        return secrets[setting]
    except KeyError:
        error_msg = f'Set the {setting} secret variable'
        raise ImproperlyConfigured(error_msg)

这将从设置目录中的secrets.json文件中读取,并期望它至少具有以下结构:

{
    "DATABASE_NAME": "myproject",
    "DATABASE_USER": "myproject",
    "DATABASE_PASSWORD": "change-this-to-database-password",
    "DJANGO_SECRET_KEY": "change-this-to-50-characters-long-random-string"
}

确保secrets.json文件被版本控制忽略,但为了方便起见,您可以创建带有空值的sample_secrets.json并将其放在版本控制下:

{
    "DATABASE_NAME": "",
    "DATABASE_USER": "",
    "DATABASE_PASSWORD": "",
    "DJANGO_SECRET_KEY": "change-this-to-50-characters-long-random-string"
}

另请参阅

  • 创建项目文件结构配方

  • Docker 容器中的 Django、Gunicorn、Nginx 和 PostgreSQL配方

在项目中包含外部依赖项

有时,您无法使用 pip 安装外部依赖项,必须直接将其包含在项目中,例如以下情况:

  • 当您有一个修补过的第三方应用程序,您自己修复了一个错误或添加了一个未被项目所有者接受的功能时

  • 当您需要使用无法在Python 软件包索引PyPI)或公共版本控制存储库中访问的私有应用程序时

  • 当您需要使用 PyPI 中不再可用的依赖项的旧版本时

在项目中包含外部依赖项可以确保每当开发人员升级依赖模块时,所有其他开发人员都将在版本控制系统的下一个更新中收到升级后的版本。

准备工作

您应该从虚拟环境下的 Django 项目开始。

如何做...

逐步执行以下步骤,针对虚拟环境项目:

  1. 如果尚未这样做,请在 Django 项目目录django-myproject下创建一个externals目录。

  2. 然后,在其中创建libsapps目录。libs目录用于项目所需的 Python 模块,例如 Boto、Requests、Twython 和 Whoosh。apps目录用于第三方 Django 应用程序,例如 Django CMS、Django Haystack 和 django-storages。

我们强烈建议您在libsapps目录中创建README.md文件,其中提到每个模块的用途、使用的版本或修订版本以及它来自哪里。

  1. 目录结构应该类似于以下内容:
externals/
 ├── apps/
 │   ├── cms/
 │   ├── haystack/
 │   ├── storages/
 │   └── README.md
 └── libs/
     ├── boto/
     ├── requests/
     ├── twython/
     └── README.md
  1. 下一步是将外部库和应用程序放在 Python 路径下,以便它们被识别为已安装。这可以通过在设置中添加以下代码来完成:
# settings/_base.py
import os
import sys
BASE_DIR = os.path.dirname(
    os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
)
EXTERNAL_BASE = os.path.join(BASE_DIR, "externals")
EXTERNAL_LIBS_PATH = os.path.join(EXTERNAL_BASE, "libs")
EXTERNAL_APPS_PATH = os.path.join(EXTERNAL_BASE, "apps")
sys.path = ["", EXTERNAL_LIBS_PATH, EXTERNAL_APPS_PATH] + sys.path

工作原理...

如果您可以运行 Python 并导入该模块,则模块应该位于 Python 路径下。将模块放在 Python 路径下的一种方法是在导入位于不寻常位置的模块之前修改sys.path变量。根据设置文件指定的sys.path的值是一个目录列表,以空字符串开头表示当前目录,然后是项目中的目录,最后是 Python 安装的全局共享目录。您可以在 Python shell 中看到sys.path的值,如下所示:

(env)$ python manage.py shell
>>> import sys
>>> sys.path

尝试导入模块时,Python 会在此列表中搜索模块,并返回找到的第一个结果。

因此,我们首先定义BASE_DIR变量,它是django-myproject的绝对路径,或者比myproject/settings/_base.py高三级。然后,我们定义EXTERNAL_LIBS_PATHEXTERNAL_APPS_PATH变量,它们是相对于BASE_DIR的。最后,我们修改sys.path属性,将新路径添加到列表的开头。请注意,我们还将空字符串添加为第一个搜索路径,这意味着始终应首先检查任何模块的当前目录,然后再检查其他 Python 路径。

这种包含外部库的方式无法跨平台使用具有 C 语言绑定的 Python 软件包,例如lxml。对于这样的依赖关系,我们建议使用在使用 pip 处理项目依赖关系配方中介绍的 pip 要求。

参见

  • 创建项目文件结构配方

  • 使用 Docker 容器处理 Django、Gunicorn、Nginx 和 PostgreSQL配方

  • 使用 pip 处理项目依赖关系配方

  • 在设置中定义相对路径配方

  • 第十章中的Django shell配方,铃声和口哨*

动态设置 STATIC_URL

如果将STATIC_URL设置为静态值,则每次更新 CSS 文件、JavaScript 文件或图像时,您和您的网站访问者都需要清除浏览器缓存才能看到更改。有一个绕过清除浏览器缓存的技巧,就是在STATIC_URL中显示最新更改的时间戳。每当代码更新时,访问者的浏览器将强制加载所有新的静态文件。

在这个配方中,我们将看到如何在STATIC_URL中放置 Git 用户的时间戳。

准备工作

确保您的项目处于 Git 版本控制下,并且在设置中定义了BASE_DIR,如在设置中定义相对路径配方中所示。

如何做...

将 Git 时间戳放入STATIC_URL设置的过程包括以下两个步骤:

  1. 如果尚未这样做,请在 Django 项目中创建myproject.apps.core应用。您还应该在那里创建一个versioning.py文件:
# versioning.py
import subprocess
from datetime import datetime

def get_git_changeset_timestamp(absolute_path):
    repo_dir = absolute_path
    git_log = subprocess.Popen(
        "git log --pretty=format:%ct --quiet -1 HEAD",
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        shell=True,
        cwd=repo_dir,
        universal_newlines=True,
    )

    timestamp = git_log.communicate()[0]
    try:
        timestamp = datetime.utcfromtimestamp(int(timestamp))
    except ValueError:
        # Fallback to current timestamp
        return datetime.now().strftime('%Y%m%d%H%M%S')
    changeset_timestamp = timestamp.strftime('%Y%m%d%H%M%S')
    return changeset_timestamp
  1. 在设置中导入新创建的get_git_changeset_timestamp()函数,并将其用于STATIC_URL路径,如下所示:
# settings/_base.py
from myproject.apps.core.versioning import get_git_changeset_timestamp
# ...
timestamp = get_git_changeset_timestamp(BASE_DIR)
STATIC_URL = f'/static/{timestamp}/'

它是如何工作的...

get_git_changeset_timestamp()函数以absolute_path目录作为参数,并调用git log shell 命令,参数是显示目录中 HEAD 修订的 Unix 时间戳。我们将BASE_DIR传递给函数,因为我们确信它处于版本控制之下。时间戳被解析,转换为由年、月、日、小时、分钟和秒组成的字符串,然后包含在STATIC_URL的定义中。

还有更多...

这种方法仅在您的每个环境中包含项目的完整 Git 存储库时才有效——在某些情况下,例如当您使用 Heroku 或 Docker 进行部署时,您无法访问远程服务器中的 Git 存储库和git log命令。为了使STATIC_URL具有动态片段,您必须从文本文件中读取时间戳,例如myproject/settings/last-modified.txt,并且应该在每次提交时更新该文件。

在这种情况下,您的设置将包含以下行:

# settings/_base.py
with open(os.path.join(BASE_DIR, 'myproject', 'settings', 'last-update.txt'), 'r') as f:
    timestamp = f.readline().strip()

STATIC_URL = f'/static/{timestamp}/'

您可以通过预提交挂钩使 Git 存储库更新last-modified.txt。这是一个可执行的 bash 脚本,应该被称为pre-commit,并放置在django-myproject/.git/hooks/下:

# django-myproject/.git/hooks/pre-commit
#!/usr/bin/env python
from subprocess import check_output, CalledProcessError
import os
from datetime import datetime

def root():
    ''' returns the absolute path of the repository root '''
    try:
        base = check_output(['git', 'rev-parse', '--show-toplevel'])
    except CalledProcessError:
        raise IOError('Current working directory is not a git repository')
    return base.decode('utf-8').strip()

def abspath(relpath):
    ''' returns the absolute path for a path given relative to the root of
        the git repository
    '''
    return os.path.join(root(), relpath)

def add_to_git(file_path):
    ''' adds a file to git '''
    try:
        base = check_output(['git', 'add', file_path])
    except CalledProcessError:
        raise IOError('Current working directory is not a git repository')
    return base.decode('utf-8').strip()

def main():
    file_path = abspath("myproject/settings/last-update.txt")

    with open(file_path, 'w') as f:
        f.write(datetime.now().strftime("%Y%m%d%H%M%S"))

    add_to_git(file_path)

if __name__ == '__main__':
    main()

每当您提交到 Git 存储库时,此脚本将更新last-modified.txt并将该文件添加到 Git 索引中。

参见

  • 创建 Git 忽略文件配方

将 UTF-8 设置为 MySQL 配置的默认编码

MySQL 自称是最流行的开源数据库。在这个食谱中,我们将告诉你如何将 UTF-8 设置为它的默认编码。请注意,如果你不在数据库配置中设置这个编码,你可能会遇到这样的情况,即默认情况下使用 LATIN1 编码你的 UTF-8 编码数据。这将导致数据库错误,每当使用€等符号时。这个食谱还将帮助你免于在将数据库数据从 LATIN1 转换为 UTF-8 时遇到困难,特别是当你有一些表以 LATIN1 编码,另一些表以 UTF-8 编码时。

准备工作

确保 MySQL 数据库管理系统和mysqlclient Python 模块已安装,并且在项目设置中使用了 MySQL 引擎。

操作步骤...

在你喜欢的编辑器中打开/etc/mysql/my.cnf MySQL 配置文件,并确保以下设置在[client][mysql][mysqld]部分中设置如下:

# /etc/mysql/my.cnf
[client]
default-character-set = utf8

[mysql]
default-character-set = utf8

[mysqld]
collation-server = utf8_unicode_ci
init-connect = 'SET NAMES utf8'
character-set-server = utf8

如果任何部分不存在,就在文件中创建它们。如果部分已经存在,就将这些设置添加到现有的配置中,然后在命令行工具中重新启动 MySQL,如下所示:

$ /etc/init.d/mysql restart

它是如何工作的...

现在,每当你创建一个新的 MySQL 数据库时,数据库和所有的表都将默认设置为 UTF-8 编码。不要忘记在开发或发布项目的所有计算机上设置这一点。

还有更多...

在 PostgreSQL 中,默认的服务器编码已经是 UTF-8,但如果你想显式地创建一个带有 UTF-8 编码的 PostgreSQL 数据库,那么你可以使用以下命令来实现:

$ createdb --encoding=UTF8 --locale=en_US.UTF-8 --template=template0 myproject

另请参阅

  • 创建项目文件结构食谱

  • 使用 Docker 容器进行 Django、Gunicorn、Nginx 和 PostgreSQL 开发食谱

创建 Git 忽略文件

Git 是最流行的分布式版本控制系统,你可能已经在你的 Django 项目中使用它。尽管你正在跟踪大部分文件的更改,但建议你将一些特定的文件和文件夹排除在版本控制之外。通常情况下,缓存、编译代码、日志文件和隐藏系统文件不应该在 Git 仓库中被跟踪。

准备工作

确保你的 Django 项目在 Git 版本控制下。

操作步骤...

使用你喜欢的文本编辑器,在你的 Django 项目的根目录创建一个.gitignore文件,并将以下文件和目录放在其中:

# .gitignore ### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class

# Installer logs
pip-log.txt
pip-delete-this-directory.txt

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/

# Translations
*.mo
*.pot

# Django stuff:
*.log
db.sqlite3

# Sphinx documentation
docs/_build/

# IPython
profile_default/
ipython_config.py

# Environments
env/

# Media and Static directories
/media/
!/media/.gitkeep

/static/
!/static/.gitkeep

# Secrets
secrets.json

它是如何工作的...

.gitignore文件指定了应该被 Git 版本控制系统有意忽略的模式。我们在这个食谱中创建的.gitignore文件将忽略 Python 编译文件、本地设置、收集的静态文件和上传文件的媒体目录。

请注意,我们对媒体和静态文件有特殊的叹号语法:

/media/
!/media/.gitkeep

这告诉 Git 忽略/media/目录,但保持/media/.gitkeep文件在版本控制下被跟踪。由于 Git 版本控制跟踪文件,而不是目录,我们使用.gitkeep来确保media目录将在每个环境中被创建,但不被跟踪。

另请参阅

  • 创建项目文件结构食谱

  • 使用 Docker 容器进行 Django、Gunicorn、Nginx 和 PostgreSQL 开发食谱

删除 Python 编译文件

当你第一次运行项目时,Python 会将所有的*.py代码编译成字节编译文件*.pyc,以便后续执行。通常情况下,当你改变*.py文件时,*.pyc会被重新编译;然而,有时当你切换分支或移动目录时,你需要手动清理编译文件。

准备工作

使用你喜欢的编辑器,在你的主目录中编辑或创建一个.bash_profile文件。

操作步骤...

  1. .bash_profile的末尾添加这个别名,如下所示:
# ~/.bash_profile alias delpyc='
find . -name "*.py[co]" -delete
find . -type d -name "__pycache__" -delete'
  1. 现在,要清理 Python 编译文件,进入你的项目目录,在命令行上输入以下命令:
(env)$ delpyc

它是如何工作的...

首先,我们创建一个 Unix 别名,用于搜索当前目录及其子目录中的*.pyc*.pyo文件和__pycache__目录,并将其删除。当您在命令行工具中启动新会话时,将执行.bash_profile文件。

还有更多...

如果您想完全避免创建 Python 编译文件,可以在.bash_profileenv/bin/activate脚本或 PyCharm 配置中设置环境变量PYTHONDONTWRITEBYTECODE=1

另请参阅

  • 创建 Git 忽略文件的方法

尊重 Python 文件中的导入顺序

在创建 Python 模块时,保持与文件结构一致是一个良好的做法。这样可以使您和其他开发人员更容易阅读代码。本方法将向您展示如何构建导入结构。

准备就绪

创建虚拟环境并在其中创建 Django 项目。

如何做...

对于您正在创建的每个 Python 文件,请使用以下结构。将导入分类为以下几个部分:

# System libraries
import os
import re
from datetime import datetime

# Third-party libraries
import boto
from PIL import Image

# Django modules
from django.db import models
from django.conf import settings

# Django apps
from cms.models import Page

# Current-app modules
from .models import NewsArticle
from . import app_settings

它是如何工作的...

我们有五个主要的导入类别,如下所示:

  • 系统库用于 Python 默认安装的软件包

  • 第三方库用于额外安装的 Python 包

  • Django 模块用于 Django 框架中的不同模块

  • Django 应用程序用于第三方和本地应用程序

  • 当前应用程序模块用于从当前应用程序进行相对导入

还有更多...

在 Python 和 Django 中编码时,请使用 Python 代码的官方样式指南 PEP 8。您可以在https:/​/​www.​python.​org/​dev/​peps/​pep-​0008/找到它。

另请参阅

  • 使用 pip 处理项目依赖的方法

  • 在项目中包含外部依赖的方法

创建应用程序配置

Django 项目由称为应用程序(或更常见的应用程序)的多个 Python 模块组成,这些模块结合了不同的模块化功能。每个应用程序都可以有模型、视图、表单、URL 配置、管理命令、迁移、信号、测试、上下文处理器、中间件等。Django 框架有一个应用程序注册表,其中收集了所有应用程序和模型,稍后用于配置和内省。自 Django 1.7 以来,有关应用程序的元信息可以保存在每个应用程序的AppConfig实例中。让我们创建一个名为magazine的示例应用程序,看看如何在那里使用应用程序配置。

准备就绪

您可以通过调用startapp管理命令或手动创建应用程序模块来创建 Django 应用程序:

(env)$ cd myproject/apps/
(env)$ django-admin.py startapp magazine

创建magazine应用程序后,在models.py中添加NewsArticle模型,在admin.py中为模型创建管理,并在设置中的INSTALLED_APPS中放入"myproject.apps.magazine"。如果您还不熟悉这些任务,请学习官方的 Django 教程docs.djangoproject.com/en/3.0/intro/tutorial01/

如何做...

按照以下步骤创建和使用应用程序配置:

  1. 修改apps.py文件并插入以下内容:
# myproject/apps/magazine/apps.py
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _

class MagazineAppConfig(AppConfig):
    name = "myproject.apps.magazine"
    verbose_name = _("Magazine")

    def ready(self):
        from . import signals
  1. 编辑magazine模块中的__init__.py文件,包含以下内容:
# myproject/apps/magazine/__init__.py
default_app_config = "myproject.apps.magazine.apps.MagazineAppConfig"
  1. 让我们创建一个signals.py文件并在其中添加一些信号处理程序:
# myproject/apps/magazine/signals.py
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.conf import settings

from .models import NewsArticle

@receiver(post_save, sender=NewsArticle)
def news_save_handler(sender, **kwargs):
    if settings.DEBUG:
        print(f"{kwargs['instance']} saved.")

@receiver(post_delete, sender=NewsArticle)
def news_delete_handler(sender, **kwargs):
    if settings.DEBUG:
        print(f"{kwargs['instance']} deleted.")

它是如何工作的...

当您运行 HTTP 服务器或调用管理命令时,会调用django.setup()。它加载设置,设置日志记录,并准备应用程序注册表。此注册表分为三个步骤初始化。Django 首先从设置中的INSTALLED_APPS导入每个项目的配置。这些项目可以直接指向应用程序名称或配置,例如"myproject.apps.magazine""myproject.apps.magazine.apps.MagazineAppConfig"

然后 Django 尝试从INSTALLED_APPS中的每个应用程序导入models.py并收集所有模型。

最后,Django 运行 ready() 方法以进行每个应用程序配置。如果有的话,此方法在开发过程中是注册信号处理程序的好时机。ready() 方法是可选的。

在我们的示例中,MagazineAppConfig 类设置了 magazine 应用程序的配置。name 参数定义了当前应用程序的模块。verbose_name 参数定义了在 Django 模型管理中使用的人类名称,其中模型按应用程序进行呈现和分组。ready() 方法导入并激活信号处理程序,当处于 DEBUG 模式时,它会在终端中打印出 NewsArticle 对象已保存或已删除的消息。

还有更多...

在调用 django.setup() 后,您可以按如下方式从注册表中加载应用程序配置和模型:

>>> from django.apps import apps as django_apps
>>> magazine_app_config = django_apps.get_app_config("magazine")
>>> magazine_app_config
<MagazineAppConfig: magazine>
>>> magazine_app_config.models_module
<module 'magazine.models' from '/path/to/myproject/apps/magazine/models.py'>
>>> NewsArticle = django_apps.get_model("magazine", "NewsArticle")
>>> NewsArticle
<class 'magazine.models.NewsArticle'>

您可以在官方 Django 文档中阅读有关应用程序配置的更多信息

docs.djangoproject.com/en/2.2/ref/applications/​。

另请参阅

  • 使用虚拟环境 配方

  • 使用 Docker 容器进行 Django、Gunicorn、Nginx 和 PostgreSQL 配方

  • 定义可覆盖的应用程序设置 配方

  • 第六章,模型管理

定义可覆盖的应用程序设置

此配方将向您展示如何定义应用程序的设置,然后可以在项目的设置文件中进行覆盖。这对于可重用的应用程序特别有用,您可以通过添加配置来自定义它们。

准备工作

按照准备工作创建应用程序配置 配方中的步骤来创建您的 Django 应用程序。

如何做...

  1. 如果只有一两个设置,可以在 models.py 中使用 getattr() 模式定义应用程序设置,或者如果设置很多并且想要更好地组织它们,可以在 app_settings.py 文件中定义:
# myproject/apps/magazine/app_settings.py
from django.conf import settings
from django.utils.translation import gettext_lazy as _

# Example:
SETTING_1 = getattr(settings, "MAGAZINE_SETTING_1", "default value")

MEANING_OF_LIFE = getattr(settings, "MAGAZINE_MEANING_OF_LIFE", 42)

ARTICLE_THEME_CHOICES = getattr(
    settings,
    "MAGAZINE_ARTICLE_THEME_CHOICES",
    [
        ('futurism', _("Futurism")),
        ('nostalgia', _("Nostalgia")),
        ('sustainability', _("Sustainability")),
        ('wonder', _("Wonder")),
    ]
)
  1. models.py 将包含以下 NewsArticle 模型:
# myproject/apps/magazine/models.py
from django.db import models
from django.utils.translation import gettext_lazy as _

class NewsArticle(models.Model):
    created_at = models.DateTimeField(_("Created at"),  
     auto_now_add=True)
    title = models.CharField(_("Title"), max_length=255)
    body = models.TextField(_("Body"))
    theme = models.CharField(_("Theme"), max_length=20)

    class Meta:
        verbose_name = _("News Article")
        verbose_name_plural = _("News Articles")

    def __str__(self):
        return self.title
  1. 接下来,在 admin.py 中,我们将从 app_settings.py 导入并使用设置,如下所示:
# myproject/apps/magazine/admin.py
from django import forms
from django.contrib import admin

from .models import NewsArticle

from .app_settings import ARTICLE_THEME_CHOICES

class NewsArticleModelForm(forms.ModelForm):
    theme = forms.ChoiceField(
        label=NewsArticle._meta.get_field("theme").verbose_name,
        choices=ARTICLE_THEME_CHOICES,
        required=not NewsArticle._meta.get_field("theme").blank,
    )
    class Meta:
        fields = "__all__"

@admin.register(NewsArticle)
class NewsArticleAdmin(admin.ModelAdmin):
 form = NewsArticleModelForm
  1. 如果要覆盖给定项目的 ARTICLE_THEME_CHOICES 设置,应在项目设置中添加 MAGAZINE_ARTICLE_THEME_CHOICES
# myproject/settings/_base.py
from django.utils.translation import gettext_lazy as _
# ...
MAGAZINE_ARTICLE_THEME_CHOICES = [
    ('futurism', _("Futurism")),
    ('nostalgia', _("Nostalgia")),
    ('sustainability', _("Sustainability")),
    ('wonder', _("Wonder")),
    ('positivity', _("Positivity")),
    ('solutions', _("Solutions")),
    ('science', _("Science")),
]

它是如何工作的...

getattr(object, attribute_name[, default_value]) Python 函数尝试从 object 获取 attribute_name 属性,并在找不到时返回 default_value。我们尝试从 Django 项目设置模块中读取不同的设置,如果在那里找不到,则使用默认值。

请注意,我们本可以在 models.py 中为 theme 字段定义 choices,但我们改为在管理中创建自定义 ModelForm 并在那里设置 choices。这样做是为了避免在更改 ARTICLE_THEME_CHOICES 时创建新的数据库迁移。

另请参阅

  • 创建应用程序配置 配方

  • 第六章,模型管理

使用 Docker 容器进行 Django、Gunicorn、Nginx 和 PostgreSQL

Django 项目不仅依赖于 Python 要求,还依赖于许多系统要求,如 Web 服务器、数据库、服务器缓存和邮件服务器。在开发 Django 项目时,您需要确保所有环境和所有开发人员都安装了相同的要求。保持这些依赖项同步的一种方法是使用 Docker。使用 Docker,您可以为每个项目单独拥有数据库、Web 或其他服务器的不同版本。

Docker 是用于创建配置、定制的虚拟机的系统,称为容器。它允许我们精确复制任何生产环境的设置。Docker 容器是从所谓的 Docker 镜像创建的。镜像由层(或指令)组成,用于构建容器。可以有一个用于 PostgreSQL 的镜像,一个用于 Redis 的镜像,一个用于 Memcached 的镜像,以及一个用于您的 Django 项目的自定义镜像,所有这些镜像都可以与 Docker Compose 结合成相应的容器。

在这个示例中,我们将使用项目模板来设置一个 Django 项目,其中包括一个由 Nginx 和 Gunicorn 提供的 PostgreSQL 数据库,并使用 Docker Compose 来管理它们。

准备工作

首先,您需要安装 Docker Engine,按照www.docker.com/get-started上的说明进行操作。这通常包括 Compose 工具,它可以管理需要多个容器的系统,非常适合完全隔离的 Django 项目。如果需要单独安装,Compose 的安装详细信息可在docs.docker.com/compose/install/上找到。

如何做...

让我们来探索 Django 和 Docker 模板:

  1. 例如,从github.com/archatas/django_docker下载代码到您的计算机的~/projects/django_docker目录。

如果您选择另一个目录,例如myproject_docker,那么您将需要全局搜索和替换django_dockermyproject_docker

  1. 打开docker-compose.yml文件。需要创建三个容器:nginxgunicorndb。如果看起来很复杂,不用担心;我们稍后会详细描述它:
# docker-compose.yml
version: "3.7"

services:
  nginx:
    image: nginx:latest
    ports:
      - "80:80"
    volumes:
      - ./config/nginx/conf.d:/etc/nginx/conf.d
      - static_volume:/home/myproject/static
      - media_volume:/home/myproject/media
    depends_on:
      - gunicorn

  gunicorn:
    build:
      context: .
      args:
        PIP_REQUIREMENTS: "${PIP_REQUIREMENTS}"
    command: bash -c "/home/myproject/env/bin/gunicorn --workers 3 
    --bind 0.0.0.0:8000 myproject.wsgi:application"
    depends_on:
      - db
    volumes:
      - static_volume:/home/myproject/static
      - media_volume:/home/myproject/media
    expose:
      - "8000"
    environment:
      DJANGO_SETTINGS_MODULE: "${DJANGO_SETTINGS_MODULE}"
      DJANGO_SECRET_KEY: "${DJANGO_SECRET_KEY}"
      DATABASE_NAME: "${DATABASE_NAME}"
      DATABASE_USER: "${DATABASE_USER}"
      DATABASE_PASSWORD: "${DATABASE_PASSWORD}"
      EMAIL_HOST: "${EMAIL_HOST}"
      EMAIL_PORT: "${EMAIL_PORT}"
      EMAIL_HOST_USER: "${EMAIL_HOST_USER}"
      EMAIL_HOST_PASSWORD: "${EMAIL_HOST_PASSWORD}"

  db:
    image: postgres:latest
    restart: always
    environment:
      POSTGRES_DB: "${DATABASE_NAME}"
      POSTGRES_USER: "${DATABASE_USER}"
      POSTGRES_PASSWORD: "${DATABASE_PASSWORD}"
    ports:
      - 5432
    volumes:
      - postgres_data:/var/lib/postgresql/data/

volumes:
  postgres_data:
  static_volume:
  media_volume:

  1. 打开并阅读Dockerfile文件。这些是创建gunicorn容器所需的层(或指令):
# Dockerfile
# pull official base image
FROM python:3.8

# accept arguments
ARG PIP_REQUIREMENTS=production.txt

# set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1

# install dependencies
RUN pip install --upgrade pip setuptools

# create user for the Django project
RUN useradd -ms /bin/bash myproject

# set current user
USER myproject

# set work directory
WORKDIR /home/myproject

# create and activate virtual environment
RUN python3 -m venv env

# copy and install pip requirements
COPY --chown=myproject ./src/myproject/requirements /home/myproject/requirements/
RUN ./env/bin/pip3 install -r /home/myproject/requirements/${PIP_REQUIREMENTS}

# copy Django project files
COPY --chown=myproject ./src/myproject /home/myproject/

  1. build_dev_example.sh脚本复制到build_dev.sh并编辑其内容。这些是要传递给docker-compose脚本的环境变量:
# build_dev.sh
#!/usr/bin/env bash
DJANGO_SETTINGS_MODULE=myproject.settings.dev \
DJANGO_SECRET_KEY="change-this-to-50-characters-long-
 random-string" \
DATABASE_NAME=myproject \
DATABASE_USER=myproject \
DATABASE_PASSWORD="change-this-too" \
PIP_REQUIREMENTS=dev.txt \
docker-compose up --detach --build
  1. 在命令行工具中,为build_dev.sh添加执行权限并运行它以构建容器:
$ chmod +x build_dev.sh
$ ./build_dev.sh
  1. 如果您现在转到http://0.0.0.0/en/,您应该会在那里看到一个 Hello, World!页面。

导航到http://0.0.0.0/en/admin/时,您应该会看到以下内容:

OperationalError at /en/admin/
 FATAL: role "myproject" does not exist

这意味着你必须在 Docker 容器中创建数据库用户和数据库。

  1. 让我们 SSH 到db容器中,在 Docker 容器中创建数据库用户、密码和数据库本身:
$ docker exec -it django_docker_db_1 bash
/# su - postgres
/$ createuser --createdb --password myproject
/$ createdb --username myproject myproject

当询问时,输入与build_dev.sh脚本中数据库相同的密码。

按下[Ctrl + D]两次以注销 PostgreSQL 用户和 Docker 容器。

如果您现在转到http://0.0.0.0/en/admin/,您应该会看到以下内容:

ProgrammingError at /en/admin/ relation "django_session" does not exist LINE 1: ...ession_data", "django_session"."expire_date" FROM "django_se...

这意味着您必须运行迁移以创建数据库架构。

  1. SSH 到gunicorn容器中并运行必要的 Django 管理命令:
$ docker exec -it django_docker_gunicorn_1 bash
$ source env/bin/activate
(env)$ python manage.py migrate
(env)$ python manage.py collectstatic
(env)$ python manage.py createsuperuser

回答管理命令提出的所有问题。

按下[Ctrl + D]两次以退出 Docker 容器。

如果您现在导航到[0.0.0.0/en/admin/](http://0.0.0.0/en/admin/),您应该会看到 Django 管理界面,您可以使用刚刚创建的超级用户凭据登录。

  1. 创建类似的脚本build_test.shbuild_staging.shbuild_production.sh,只有环境变量不同。

它是如何工作的...

模板中的代码结构类似于虚拟环境中的代码结构。项目源文件位于src目录中。我们有git-hooks目录用于预提交挂钩,用于跟踪最后修改日期和config目录用于容器中使用的服务的配置:

django_docker
├── config/
│   └── nginx/
│       └── conf.d/
│           └── myproject.conf
├── git-hooks/
│   ├── install_hooks.sh
│   └── pre-commit
├── src/
│   └── myproject/
│       ├── locale/
│       ├── media/
│       ├── myproject/
│       │   ├── apps/
│       │   │   └── __init__.py
│       │   ├── settings/
│       │   │   ├── __init__.py
│       │   │   ├── _base.py
│       │   │   ├── dev.py
│       │   │   ├── last-update.txt
│       │   │   ├── production.py
│       │   │   ├── staging.py
│       │   │   └── test.py
│       │   ├── site_static/
│       │   │   └── site/
│       │   │       ├── css/
│       │   │       ├── img/
│       │   │       ├── js/
│       │   │       └── scss/
│       │   ├── templates/
│       │   │   ├── base.html
│       │   │   └── index.html
│       │   ├── __init__.py
│       │   ├── urls.py
│       │   └── wsgi.py
│       ├── requirements/
│       │   ├── _base.txt
│       │   ├── dev.txt
│       │   ├── production.txt
│       │   ├── staging.txt
│       │   └── test.txt
│       ├── static/
│       └── manage.py
├── Dockerfile
├── LICENSE
├── README.md
├── build_dev.sh
├── build_dev_example.sh
└── docker-compose.yml

主要的与 Docker 相关的配置位于docker-compose.ymlDockerfile。Docker Compose 是 Docker 命令行 API 的包装器。build_dev.sh脚本构建并在端口8000下运行 Django 项目下的 Gunicorn WSGI HTTP 服务器,端口80下的 Nginx(提供静态和媒体文件并代理其他请求到 Gunicorn),以及端口5432下的 PostgreSQL 数据库。

docker-compose.yml文件中,请求创建三个 Docker 容器:

  • nginx用于 Nginx Web 服务器

  • gunicorn用于 Django 项目的 Gunicorn Web 服务器

  • db用于 PostgreSQL 数据库

nginxdb容器将从位于hub.docker.com的官方镜像创建。它们具有特定的配置参数,例如它们运行的端口,环境变量,对其他容器的依赖以及卷。

Docker 卷是在重新构建 Docker 容器时保持不变的特定目录。需要为数据库数据文件,媒体,静态文件等定义卷。

gunicorn容器将根据Dockerfile中的指令构建,该指令由docker-compose.yml文件中的构建上下文定义。让我们检查每个层(或指令):

  • gunicorn容器将基于python:3.7镜像

  • 它将从docker-compose.yml文件中获取PIP_REQUIREMENTS作为参数

  • 它将为容器设置环境变量

  • 它将安装并升级 pip,setuptools 和 virtualenv

  • 它将为 Django 项目创建一个名为myproject的系统用户

  • 它将把myproject设置为当前用户

  • 它将把myproject用户的主目录设置为当前工作目录

  • 它将在那里创建一个虚拟环境

  • 它将从基础计算机复制 pip 要求到 Docker 容器

  • 它将安装当前环境的 pip 要求,由PIP_REQUIREMENTS变量定义

  • 它将复制整个 Django 项目的源代码

config/nginx/conf.d/myproject.conf的内容将保存在nginx容器中的/etc/nginx/conf.d/下。这是 Nginx Web 服务器的配置,告诉它监听端口80(默认的 HTTP 端口)并将请求转发到端口8000上的 Gunicorn 服务器,除了请求静态或媒体内容:

#/etc/nginx/conf.d/myproject.conf
upstream myproject {
    server django_docker_gunicorn_1:8000;
}

server {
    listen 80;

    location / {
        proxy_pass http://myproject;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_redirect off;
    }

    rewrite "/static/\d+/(.*)" /static/$1 last;

    location /static/ {
        alias /home/myproject/static/;
    }

    location /media/ {
        alias /home/myproject/media/;
    }
}

您可以在第十二章,部署中的在 Nginx 和 Gunicorn 上部署暂存环境在 Nginx 和 Gunicorn 上部署生产环境配方中了解更多关于 Nginx 和 Gunicorn 配置的信息。

还有更多...

您可以使用docker-compose down命令销毁 Docker 容器,并使用构建脚本重新构建它们:

$ docker-compose down
$ ./build_dev.sh

如果某些内容不符合预期,您可以使用docker-compose logs命令检查日志:

$ docker-compose logs nginx
$ docker-compose logs gunicorn $ docker-compose logs db

要通过 SSH 连接到任何容器,您应该使用以下之一:

$ docker exec -it django_docker_gunicorn_1 bash
$ docker exec -it django_docker_nginx_1 bash
$ docker exec -it django_docker_db_1 bash

您可以使用docker cp命令将文件和目录复制到 Docker 容器上的卷中,并从中复制出来:

$ docker cp ~/avatar.png django_docker_gunicorn_1:/home/myproject/media/ $ docker cp django_docker_gunicorn_1:/home/myproject/media ~/Desktop/

如果您想更好地了解 Docker 和 Docker Compose,请查看官方文档docs.docker.com/,特别是docs.docker.com/compose/

另请参阅

  • 创建项目文件结构配方

  • 在 Apache 上使用 mod_wsgi 部署暂存环境配方在第十二章,部署

  • 在 Apache 上使用 mod_wsgi 部署生产环境配方在第十二章,部署

  • 在 Nginx 和 Gunicorn 上部署暂存环境配方在第十二章,部署

  • 在 Nginx 和 Gunicorn 上部署生产环境配方在第十二章,部署

第二章:模型和数据库结构

在本章中,我们将涵盖以下主题:

  • 使用模型 mixin

  • 创建一个具有 URL 相关方法的模型 mixin

  • 创建一个模型 mixin 来处理创建和修改日期

  • 创建一个处理元标签的模型 mixin

  • 创建一个处理通用关系的模型 mixin

  • 处理多语言字段

  • 使用模型翻译表

  • 避免循环依赖

  • 添加数据库约束

  • 使用迁移

  • 将外键更改为多对多字段

介绍

当您开始一个新的应用程序时,您要做的第一件事是创建代表您的数据库结构的模型。我们假设您已经创建了 Django 应用程序,或者至少已经阅读并理解了官方的 Django 教程。在本章中,您将看到一些有趣的技术,这些技术将使您的数据库结构在项目中的不同应用程序中保持一致。然后,您将学习如何处理数据库中数据的国际化。之后,您将学习如何避免模型中的循环依赖以及如何设置数据库约束。在本章的最后,您将学习如何使用迁移来在开发过程中更改数据库结构。

技术要求

要使用本书中的代码,您需要最新稳定版本的 Python、MySQL 或 PostgreSQL 数据库以及一个带有虚拟环境的 Django 项目。

您可以在 GitHub 存储库的ch02目录中找到本章的所有代码:github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition

使用模型 mixin

在面向对象的语言中,比如 Python,一个 mixin 类可以被视为一个带有实现特性的接口。当一个模型扩展一个 mixin 时,它实现了接口并包含了所有的字段、属性、属性和方法。Django 模型中的 mixin 可以在您想要多次在不同模型中重用通用功能时使用。Django 中的模型 mixin 是抽象基本模型类。我们将在接下来的几个示例中探讨它们。

准备工作

首先,您需要创建可重用的 mixin。将模型 mixin 保存在myproject.apps.core应用程序中是一个很好的地方。如果您创建了一个可重用的应用程序,将模型 mixin 保存在可重用的应用程序本身中,可能是在一个base.py文件中。

如何做...

打开任何您想要在其中使用 mixin 的 Django 应用程序的models.py文件,并键入以下代码:

# myproject/apps/ideas/models.py
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.models import (
    CreationModificationDateBase,
    MetaTagsBase,
    UrlBase,
)

class Idea(CreationModificationDateBase, MetaTagsBase, UrlBase):
    title = models.CharField(
        _("Title"),
        max_length=200,
    )
    content = models.TextField(
        _("Content"),
    )
    # other fields…

    class Meta:
        verbose_name = _("Idea")
        verbose_name_plural = _("Ideas")

    def __str__(self):
        return self.title

    def get_url_path(self):
        return reverse("idea_details", kwargs={
            "idea_id": str(self.pk),
        })

它是如何工作的...

Django 的模型继承支持三种类型的继承:抽象基类、多表继承和代理模型。模型 mixin 是抽象模型类,我们通过使用一个指定字段、属性和方法的抽象Meta类来定义它们。当您创建一个模型,比如在前面的示例中所示的Idea,它继承了CreationModificationDateMixinMetaTagsMixinUrlMixin的所有特性。这些抽象类的所有字段都保存在与扩展模型的字段相同的数据库表中。在接下来的示例中,您将学习如何定义您自己的模型 mixin。

还有更多...

在普通的 Python 类继承中,如果有多个基类,并且它们都实现了一个特定的方法,并且您在子类的实例上调用该方法,只有第一个父类的方法会被调用,就像下面的例子一样:

>>> class A(object):
... def test(self):
...     print("A.test() called")
... 

>>> class B(object):
... def test(self):
...     print("B.test() called")
... 

>>> class C(object):
... def test(self):
...     print("C.test() called")
... 

>>> class D(A, B, C):
... def test(self):
...     super().test()
...     print("D.test() called")

>>> d = D()
>>> d.test()
A.test() called
D.test() called

这与 Django 模型基类相同;然而,有一个特殊的例外。

Django 框架对元类进行了一些魔术,调用了每个基类的save()delete()方法。

这意味着您可以自信地对特定字段进行预保存、后保存、预删除和后删除操作,这些字段是通过覆盖 mixin 中的save()delete()方法来定义的。

要了解更多关于不同类型的模型继承,请参阅官方 Django 文档,网址为docs.djangoproject.com/en/2.2/topics/db/models/#model-inheritance

另请参阅

  • 创建一个具有与 URL 相关方法的模型 mixin配方

  • 创建一个模型 mixin 来处理创建和修改日期配方

  • 创建一个模型 mixin 来处理 meta 标签配方

创建一个具有与 URL 相关方法的模型 mixin

对于每个具有自己独特详细页面的模型,定义get_absolute_url()方法是一个良好的做法。这个方法可以在模板中使用,也可以在 Django 管理站点中用于预览保存的对象。但是,get_absolute_url()是模棱两可的,因为它返回 URL 路径而不是完整的 URL。

在这个配方中,我们将看看如何创建一个模型 mixin,为模型特定的 URL 提供简化的支持。这个 mixin 将使您能够做到以下几点:

  • 允许您在模型中定义 URL 路径或完整 URL

  • 根据您定义的路径自动生成其他 URL

  • 在幕后定义get_absolute_url()方法

准备工作

如果尚未这样做,请创建myproject.apps.core应用程序,您将在其中存储您的模型 mixin。然后,在 core 包中创建一个models.py文件。或者,如果您创建了一个可重用的应用程序,请将 mixin 放在该应用程序的base.py文件中。

如何做...

逐步执行以下步骤:

  1. 将以下内容添加到core应用程序的models.py文件中:
# myproject/apps/core/models.py from urllib.parse import urlparse, urlunparse
from django.conf import settings
from django.db import models

class UrlBase(models.Model):
    """
    A replacement for get_absolute_url()
    Models extending this mixin should have either get_url or 
     get_url_path implemented.
    """
    class Meta:
        abstract = True

    def get_url(self):
        if hasattr(self.get_url_path, "dont_recurse"):
            raise NotImplementedError
        try:
            path = self.get_url_path()
        except NotImplementedError:
            raise
        return settings.WEBSITE_URL + path
    get_url.dont_recurse = True

    def get_url_path(self):
        if hasattr(self.get_url, "dont_recurse"):
            raise NotImplementedError
        try:
            url = self.get_url()
        except NotImplementedError:
            raise
        bits = urlparse(url)
        return urlunparse(("", "") + bits[2:])
    get_url_path.dont_recurse = True

    def get_absolute_url(self):
        return self.get_url()
  1. WEBSITE_URL设置添加到devteststagingproduction设置中,不带斜杠。例如,对于开发环境,如下所示:
# myproject/settings/dev.py
from ._base import *

DEBUG = True
WEBSITE_URL = "http://127.0.0.1:8000"  # without trailing slash
  1. 要在您的应用程序中使用 mixin,从core应用程序导入 mixin,在您的模型类中继承 mixin,并定义get_url_path()方法,如下所示:
# myproject/apps/ideas/models.py
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.models import UrlBase

class Idea(UrlBase):
    # fields, attributes, properties and methods…

    def get_url_path(self):
        return reverse("idea_details", kwargs={
            "idea_id": str(self.pk),
        })

它是如何工作的...

UrlBase类是一个抽象模型,具有三种方法,如下所示:

  • get_url()检索对象的完整 URL。

  • get_url_path()检索对象的绝对路径。

  • get_absolute_url()模仿get_url_path()方法。

get_url()get_url_path()方法预计会在扩展模型类中被覆盖,例如Idea。您可以定义get_url()get_url_path()将会将其剥离为路径。或者,您可以定义get_url_path()get_url()将在路径的开头添加网站 URL。

一个经验法则是始终覆盖get_url_path()方法。

在模板中,当您需要链接到同一网站上的对象时,请使用get_url_path(),如下所示:

<a href="{{ idea.get_url_path }}">{{ idea.title }}</a>

在外部通信中使用get_url()进行链接,例如在电子邮件、RSS 订阅或 API 中;例如如下:

<a href="{{ 
idea.get_url }}">{{ idea.title }}</a>

默认的get_absolute_url()方法将在 Django 模型管理中用于“查看网站”功能,并且也可能被一些第三方 Django 应用程序使用。

还有更多...

一般来说,不要在 URL 中使用递增的主键,因为将它们暴露给最终用户是不安全的:项目的总数将可见,并且只需更改 URL 路径就可以轻松浏览不同的项目。

只有当它们是通用唯一标识符UUIDs)或生成的随机字符串时,您才可以在详细页面的 URL 中使用主键。否则,请创建并使用 slug 字段,如下所示:

class Idea(UrlBase):
    slug = models.SlugField(_("Slug for URLs"), max_length=50)

另请参阅

  • 使用模型 mixin配方

  • 创建一个模型 mixin 来处理创建和修改日期配方

  • 创建一个模型 mixin 来处理 meta 标签配方

  • 创建一个模型 mixin 来处理通用关系配方

  • 为开发、测试、暂存和生产环境配置设置配方,在第一章,使用 Django 3.0 入门

创建一个模型 mixin 来处理创建和修改日期

在您的模型中包含创建和修改模型实例的时间戳是很常见的。在这个示例中,您将学习如何创建一个简单的模型 mixin,为您的模型保存创建和修改的日期和时间。使用这样的 mixin 将确保所有模型使用相同的时间戳字段名称,并具有相同的行为。

准备工作

如果还没有这样做,请创建myproject.apps.core包来保存您的 mixin。然后,在核心包中创建models.py文件。

如何做...

打开myprojects.apps.core包中的models.py文件,并在其中插入以下内容:

# myproject/apps/core/models.py
from django.db import models
from django.utils.translation import gettext_lazy as _

class CreationModificationDateBase(models.Model):
    """
    Abstract base class with a creation and modification date and time
    """

    created = models.DateTimeField(
        _("Creation Date and Time"),
        auto_now_add=True,
    )

    modified = models.DateTimeField(
        _("Modification Date and Time"),
        auto_now=True,
    )

    class Meta:
        abstract = True

它是如何工作的...

CreationModificationDateMixin类是一个抽象模型,这意味着扩展模型类将在同一个数据库表中创建所有字段,也就是说,不会有使表更复杂的一对一关系。

这个 mixin 有两个日期时间字段,createdmodified。使用auto_now_addauto_now属性,时间戳将在保存模型实例时自动保存。字段将自动获得editable=False属性,因此在管理表单中将被隐藏。如果在设置中将USE_TZ设置为True(这是默认和推荐的),将使用时区感知的时间戳。否则,将使用时区无关的时间戳。时区感知的时间戳保存在数据库中的协调世界时UTC)时区,并在读取或写入时将其转换为项目的默认时区。时区无关的时间戳保存在数据库中项目的本地时区;一般来说,它们不实用,因为它们使得时区之间的时间管理更加复杂。

要使用这个 mixin,我们只需要导入它并扩展我们的模型,如下所示:

# myproject/apps/ideas/models.py
from django.db import models

from myproject.apps.core.models import CreationModificationDateBase

class Idea(CreationModificationDateBase):
    # other fields, attributes, properties, and methods…

另请参阅

  • 使用模型 mixin示例

  • 创建一个处理 meta 标签的模型 mixin示例

  • 创建一个处理通用关系的模型 mixin示例

创建一个处理 meta 标签的模型 mixin

当您为搜索引擎优化您的网站时,不仅需要为每个页面使用语义标记,还需要包含适当的 meta 标签。为了最大的灵活性,有必要定义特定于在您的网站上拥有自己详细页面的对象的常见 meta 标签的内容。在这个示例中,我们将看看如何为与关键字、描述、作者和版权 meta 标签相关的字段和方法创建模型 mixin。

准备工作

如前面的示例中所述,确保您的 mixin 中有myproject.apps.core包。另外,在该包下创建一个目录结构templates/utils/includes/,并在其中创建一个meta.html文件来存储基本的 meta 标签标记。

如何做...

让我们创建我们的模型 mixin:

  1. 确保在设置中将"myproject.apps.core"添加到INSTALLED_APPS中,因为我们希望为此模块考虑templates目录。

  2. 将以下基本的 meta 标签标记添加到meta_field.html中:

{# templates/core/includes/meta_field.html #}
<meta name="{{ name }}" content="{{ content }}" />
  1. 打开您喜欢的编辑器中的核心包中的models.py文件,并添加以下内容:
# myproject/apps/core/models.py from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils.safestring import mark_safe
from django.template.loader import render_to_string

class MetaTagsBase(models.Model):
    """
    Abstract base class for generating meta tags
    """
    meta_keywords = models.CharField(
        _("Keywords"),
        max_length=255,
        blank=True,
        help_text=_("Separate keywords with commas."),
    )
    meta_description = models.CharField(
        _("Description"),
        max_length=255,
        blank=True,
    )
    meta_author = models.CharField(
        _("Author"),
        max_length=255,
        blank=True,
    )
    meta_copyright = models.CharField(
        _("Copyright"),
        max_length=255,
        blank=True,
    )

    class Meta:
        abstract = True

    def get_meta_field(self, name, content):
        tag = ""
        if name and content:
            tag = render_to_string("core/includes/meta_field.html", 
            {
                "name": name,
                "content": content,
            })
        return mark_safe(tag)

    def get_meta_keywords(self):
        return self.get_meta_field("keywords", self.meta_keywords)

    def get_meta_description(self):
        return self.get_meta_field("description", 
         self.meta_description)

    def get_meta_author(self):
        return self.get_meta_field("author", self.meta_author)

    def get_meta_copyright(self):
        return self.get_meta_field("copyright", 
         self.meta_copyright)

    def get_meta_tags(self):
        return mark_safe("\n".join((
            self.get_meta_keywords(),
            self.get_meta_description(),
            self.get_meta_author(),
            self.get_meta_copyright(),
        )))

它是如何工作...

这个 mixin 为扩展自它的模型添加了四个字段:meta_keywordsmeta_descriptionmeta_authormeta_copyright。还添加了相应的get_*()方法,用于呈现相关的 meta 标签。其中每个方法都将名称和适当的字段内容传递给核心的get_meta_field()方法,该方法使用此输入返回基于meta_field.html模板的呈现标记。最后,提供了一个快捷的get_meta_tags()方法,用于一次生成所有可用元数据的组合标记。

如果您在模型中使用这个 mixin,比如在本章开头的使用模型 mixin配方中展示的Idea中,您可以将以下内容放在detail页面模板的HEAD部分,以一次性渲染所有的元标记:

{% block meta_tags %}
{{ block.super }}
{{ idea.get_meta_tags }}
{% endblock %}

在这里,一个meta_tags块已经在父模板中定义,这个片段展示了子模板如何重新定义块,首先将父模板的内容作为block.super,然后用idea对象的附加标签扩展它。您也可以通过类似以下的方式只渲染特定的元标记:{{ idea.get_meta_description }}

models.py代码中,您可能已经注意到,渲染的元标记被标记为安全-也就是说,它们没有被转义,我们不需要使用safe模板过滤器。只有来自数据库的值被转义,以确保最终的 HTML 格式正确。当我们为meta_field.html模板调用render_to_string()时,meta_keywords和其他字段中的数据库数据将自动转义,因为该模板在其内容中没有指定{% autoescape off %}

另请参阅

  • 使用模型 mixin配方

  • 创建一个处理创建和修改日期的模型 mixin配方

  • 创建处理通用关系的模型 mixin配方

  • 在第四章,模板和 JavaScript安排 base.html 模板配方

创建一个处理通用关系的模型 mixin

除了常规的数据库关系,比如外键关系或多对多关系,Django 还有一种将模型与任何其他模型的实例相关联的机制。这个概念被称为通用关系。对于每个通用关系,我们保存相关模型的内容类型以及该模型实例的 ID。

在这个配方中,我们将看看如何在模型 mixin 中抽象通用关系的创建。

准备工作

为了使这个配方工作,您需要安装contenttypes应用程序。它应该默认在设置中的INSTALLED_APPS列表中,如下所示:

# myproject/settings/_base.py

INSTALLED_APPS = [
    # contributed
    "django.contrib.admin",
    "django.contrib.auth",
 "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # third-party
    # ...
    # local
    "myproject.apps.core",
    "myproject.apps.categories",
    "myproject.apps.ideas",
]

再次确保您已经为模型 mixin 创建了myproject.apps.core应用程序。

如何做...

要创建和使用通用关系的 mixin,请按照以下步骤进行:

  1. 在文本编辑器中打开核心包中的models.py文件,并在那里插入以下内容:
# myproject/apps/core/models.py from django.db import models
from django.utils.translation import gettext_lazy as _
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import FieldError

def object_relation_base_factory(
        prefix=None,
        prefix_verbose=None,
        add_related_name=False,
        limit_content_type_choices_to=None,
        is_required=False):
    """
    Returns a mixin class for generic foreign keys using
    "Content type - object ID" with dynamic field names.
    This function is just a class generator.

    Parameters:
    prefix:           a prefix, which is added in front of
                      the fields
    prefix_verbose:   a verbose name of the prefix, used to
                      generate a title for the field column
                      of the content object in the Admin
    add_related_name: a boolean value indicating, that a
                      related name for the generated content
                      type foreign key should be added. This
                      value should be true, if you use more
                      than one ObjectRelationBase in your
                      model.

    The model fields are created using this naming scheme:
        <<prefix>>_content_type
        <<prefix>>_object_id
        <<prefix>>_content_object
    """
    p = ""
    if prefix:
        p = f"{prefix}_"

    prefix_verbose = prefix_verbose or _("Related object")
    limit_content_type_choices_to = limit_content_type_choices_to 
     or {}

    content_type_field = f"{p}content_type"
    object_id_field = f"{p}object_id"
    content_object_field = f"{p}content_object"

    class TheClass(models.Model):
 class Meta:
 abstract = True

    if add_related_name:
        if not prefix:
            raise FieldError("if add_related_name is set to "
                             "True, a prefix must be given")
        related_name = prefix
    else:
        related_name = None

    optional = not is_required

    ct_verbose_name = _(f"{prefix_verbose}'s type (model)")

    content_type = models.ForeignKey(
        ContentType,
        verbose_name=ct_verbose_name,
        related_name=related_name,
        blank=optional,
        null=optional,
        help_text=_("Please select the type (model) "
                    "for the relation, you want to build."),
        limit_choices_to=limit_content_type_choices_to,
        on_delete=models.CASCADE)

    fk_verbose_name = prefix_verbose

    object_id = models.CharField(
        fk_verbose_name,
        blank=optional,
        null=False,
        help_text=_("Please enter the ID of the related object."),
        max_length=255,
        default="")  # for migrations

    content_object = GenericForeignKey(
        ct_field=content_type_field,
        fk_field=object_id_field)

    TheClass.add_to_class(content_type_field, content_type)
    TheClass.add_to_class(object_id_field, object_id)
    TheClass.add_to_class(content_object_field, content_object)

    return TheClass
  1. 以下代码片段是如何在您的应用中使用两个通用关系的示例(将此代码放在ideas/models.py中):
# myproject/apps/ideas/models.py from django.db import models
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.models import (
    object_relation_base_factory as generic_relation,
)

FavoriteObjectBase = generic_relation(
    is_required=True,
)

OwnerBase = generic_relation(
    prefix="owner",
    prefix_verbose=_("Owner"),
    is_required=True,
    add_related_name=True,
    limit_content_type_choices_to={
        "model__in": (
            "user",
            "group",
        )
    }
)

class Like(FavoriteObjectBase, OwnerBase):
    class Meta:
        verbose_name = _("Like")
        verbose_name_plural = _("Likes")

    def __str__(self):
        return _("{owner} likes {object}").format(
            owner=self.owner_content_object,
            object=self.content_object
        )

它是如何工作的...

正如您所看到的,这个片段比之前的更复杂。

object_relation_base_factory函数,我们已经给它起了别名generic_relation,在我们的导入中,它本身不是一个 mixin;它是一个生成模型 mixin 的函数-也就是说,一个抽象模型类来扩展。动态创建的 mixin 添加了content_typeobject_id字段以及指向相关实例的content_object通用外键。

为什么我们不能只定义一个具有这三个属性的简单模型 mixin?动态生成的抽象类允许我们为每个字段名称添加前缀;因此,我们可以在同一个模型中拥有多个通用关系。例如,之前展示的Like模型将为喜欢的对象添加content_typeobject_idcontent_object字段,以及为喜欢对象的用户或组添加owner_content_typeowner_object_idowner_content_object

object_relation_base_factory函数,我们已经给它起了别名

对于generic_relation的简称,通过limit_content_type_choices_to参数添加了限制内容类型选择的可能性。前面的示例将owner_content_type的选择限制为UserGroup模型的内容类型。

另请参阅

  • 创建一个具有 URL 相关方法的模型 mixin配方

  • 处理创建和修改日期的模型混合的配方

  • 处理处理元标签的模型混合的配方

  • 在第四章的实现“喜欢”小部件配方中,模板和 JavaScript

处理多语言字段

Django 使用国际化机制来翻译代码和模板中的冗长字符串。但是开发人员可以决定如何在模型中实现多语言内容。我们将向您展示如何直接在项目中实现多语言模型的几种方法。第一种方法是在模型中使用特定语言字段。

这种方法具有以下特点:

  • 在模型中定义多语言字段很简单。

  • 在数据库查询中使用多语言字段很简单。

  • 您可以使用贡献的管理来编辑具有多语言字段的模型,无需额外修改。

  • 如果需要,您可以轻松地在同一模板中显示对象的所有翻译。

  • 在设置中更改语言数量后,您需要为所有多语言模型创建和运行迁移。

准备工作

您是否已经创建了本章前面配方中使用的myproject.apps.core包?现在,您需要在core应用程序中创建一个新的model_fields.py文件,用于自定义模型字段。

如何做...

执行以下步骤来定义多语言字符字段和多语言文本字段:

  1. 打开model_fields.py文件,并创建基本多语言字段,如下所示:
# myproject/apps/core/model_fields.py from django.conf import settings
from django.db import models
from django.utils.translation import get_language
from django.utils import translation

class MultilingualField(models.Field):
    SUPPORTED_FIELD_TYPES = [models.CharField, models.TextField]

    def __init__(self, verbose_name=None, **kwargs):
        self.localized_field_model = None
        for model in MultilingualField.SUPPORTED_FIELD_TYPES:
            if issubclass(self.__class__, model):
                self.localized_field_model = model
        self._blank = kwargs.get("blank", False)
        self._editable = kwargs.get("editable", True)
        super().__init__(verbose_name, **kwargs)

    @staticmethod
    def localized_field_name(name, lang_code):
        lang_code_safe = lang_code.replace("-", "_")
        return f"{name}_{lang_code_safe}"

    def get_localized_field(self, lang_code, lang_name):
        _blank = (self._blank
                  if lang_code == settings.LANGUAGE_CODE
                  else True)
        localized_field = self.localized_field_model(
            f"{self.verbose_name} ({lang_name})",
            name=self.name,
            primary_key=self.primary_key,
            max_length=self.max_length,
            unique=self.unique,
            blank=_blank,
            null=False, # we ignore the null argument!
            db_index=self.db_index,
            default=self.default or "",
            editable=self._editable,
            serialize=self.serialize,
            choices=self.choices,
            help_text=self.help_text,
            db_column=None,
            db_tablespace=self.db_tablespace)
        return localized_field

    def contribute_to_class(self, cls, name,
                            private_only=False,
                            virtual_only=False):
        def translated_value(self):
            language = get_language()
            val = self.__dict__.get(
                MultilingualField.localized_field_name(
                        name, language))
            if not val:
                val = self.__dict__.get(
                    MultilingualField.localized_field_name(
                            name, settings.LANGUAGE_CODE))
            return val

        # generate language-specific fields dynamically
        if not cls._meta.abstract:
            if self.localized_field_model:
                for lang_code, lang_name in settings.LANGUAGES:
                    localized_field = self.get_localized_field(
                        lang_code, lang_name)
                    localized_field.contribute_to_class(
                            cls,
                            MultilingualField.localized_field_name(
                                    name, lang_code))

                setattr(cls, name, property(translated_value))
            else:
                super().contribute_to_class(
                    cls, name, private_only, virtual_only)
  1. 在同一文件中,为字符和文本字段表单子类化基本字段,如下所示:
class MultilingualCharField(models.CharField, MultilingualField):
    pass

class MultilingualTextField(models.TextField, MultilingualField):
    pass
  1. 在核心应用中创建一个admin.py文件,并添加以下内容:
# myproject/apps/core/admin.py
from django.conf import settings

def get_multilingual_field_names(field_name):
    lang_code_underscored = settings.LANGUAGE_CODE.replace("-", 
     "_")
    field_names = [f"{field_name}_{lang_code_underscored}"]
    for lang_code, lang_name in settings.LANGUAGES:
        if lang_code != settings.LANGUAGE_CODE:
            lang_code_underscored = lang_code.replace("-", "_")
            field_names.append(
                f"{field_name}_{lang_code_underscored}"
            )
    return field_names

现在,我们将考虑如何在应用程序中使用多语言字段的示例,如下所示:

  1. 首先,在项目的设置中设置多种语言。假设我们的网站将支持欧盟所有官方语言,英语是默认语言:
# myproject/settings/_base.py LANGUAGE_CODE = "en"

# All official languages of European Union
LANGUAGES = [
    ("bg", "Bulgarian"),    ("hr", "Croatian"),
    ("cs", "Czech"),        ("da", "Danish"),
    ("nl", "Dutch"),        ("en", "English"),
    ("et", "Estonian"),     ("fi", "Finnish"),
    ("fr", "French"),       ("de", "German"),
    ("el", "Greek"),        ("hu", "Hungarian"),
    ("ga", "Irish"),        ("it", "Italian"),
    ("lv", "Latvian"),      ("lt", "Lithuanian"),
    ("mt", "Maltese"),      ("pl", "Polish"),
    ("pt", "Portuguese"),   ("ro", "Romanian"),
    ("sk", "Slovak"),       ("sl", "Slovene"),
    ("es", "Spanish"),      ("sv", "Swedish"),
]
  1. 然后,打开myproject.apps.ideas应用的models.py文件,并为Idea模型创建多语言字段,如下所示:
# myproject/apps/ideas/models.py
from django.db import models
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.model_fields import (
    MultilingualCharField,
    MultilingualTextField,
)

class Idea(models.Model):
    title = MultilingualCharField(
        _("Title"),
        max_length=200,
    )
    content = MultilingualTextField(
        _("Content"),
    )

    class Meta:
        verbose_name = _("Idea")
        verbose_name_plural = _("Ideas")

    def __str__(self):
        return self.title
  1. ideas应用创建一个admin.py文件:
# myproject/apps/ideas/admin.py
from django.contrib import admin
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.admin import get_multilingual_field_names

from .models import Idea

@admin.register(Idea)
class IdeaAdmin(admin.ModelAdmin):
    fieldsets = [
        (_("Title and Content"), {
            "fields": get_multilingual_field_names("title") +
                      get_multilingual_field_names("content")
        }),
    ]

它是如何工作的...

Idea的示例将生成一个类似以下的模型:

class Idea(models.Model):
    title_bg = models.CharField(
        _("Title (Bulgarian)"),
        max_length=200,
    )
    title_hr = models.CharField(
        _("Title (Croatian)"),
        max_length=200,
    )
    # titles for other languages…
    title_sv = models.CharField(
        _("Title (Swedish)"),
        max_length=200,
    )

    content_bg = MultilingualTextField(
        _("Content (Bulgarian)"),
    )
    content_hr = MultilingualTextField(
        _("Content (Croatian)"),
    )
    # content for other languages…
    content_sv = MultilingualTextField(
        _("Content (Swedish)"),
    )

    class Meta:
        verbose_name = _("Idea")
        verbose_name_plural = _("Ideas")

    def __str__(self):
        return self.title

如果有带有破折号的语言代码,比如瑞士德语的“de-ch”,那么这些语言的字段将被下划线替换,比如title_de_chcontent_de_ch

除了生成的特定语言字段之外,还将有两个属性 - titlecontent - 它们将返回当前活动语言中对应的字段。如果没有可用的本地化字段内容,它们将回退到默认语言。

MultilingualCharFieldMultilingualTextField字段将根据您的LANGUAGES设置动态地处理模型字段。它们将覆盖contribute_to_class()方法,该方法在 Django 框架创建模型类时使用。多语言字段动态地为项目的每种语言添加字符或文本字段。您需要创建数据库迁移以在数据库中添加适当的字段。此外,创建属性以返回当前活动语言的翻译值或默认情况下的主语言。

在管理中,get_multilingual_field_names() 将返回一个特定语言字段名称的列表,从LANGUAGES设置中的一个默认语言开始,然后继续使用其他语言。

以下是您可能在模板和视图中使用多语言字段的几个示例。

如果在模板中有以下代码,它将显示当前活动语言的文本,比如立陶宛语,如果翻译不存在,将回退到英语:

<h1>{{ idea.title }}</h1>
<div>{{ idea.content|urlize|linebreaks }}</div>

如果您希望将您的QuerySet按翻译后的标题排序,可以定义如下:

>>> lang_code = input("Enter language code: ")
>>> lang_code_underscored = lang_code.replace("-", "_")
>>> qs = Idea.objects.order_by(f"title_{lang_code_underscored}")

另请参阅

  • 使用模型翻译表配方

  • 使用迁移配方

  • 第六章,模型管理

使用模型翻译表

在处理数据库中的多语言内容时,第二种方法涉及为每个多语言模型使用模型翻译表。

这种方法的特点如下:

  • 您可以使用贡献的管理来编辑翻译,就像内联一样。

  • 更改设置中的语言数量后,不需要进行迁移或其他进一步的操作。

  • 您可以轻松地在模板中显示当前语言的翻译,但在同一页上显示特定语言的多个翻译会更困难。

  • 您必须了解并使用本配方中描述的特定模式来创建模型翻译。

  • 使用这种方法进行数据库查询并不那么简单,但是,正如您将看到的,这仍然是可能的。

准备工作

我们将从myprojects.apps.core应用程序开始。

如何做...

执行以下步骤来准备多语言模型:

  1. core应用程序中,创建带有以下内容的model_fields.py
# myproject/apps/core/model_fields.py
from django.conf import settings
from django.utils.translation import get_language
from django.utils import translation

class TranslatedField(object):
    def __init__(self, field_name):
        self.field_name = field_name

    def __get__(self, instance, owner):
        lang_code = translation.get_language()
        if lang_code == settings.LANGUAGE_CODE:
            # The fields of the default language are in the main
               model
            return getattr(instance, self.field_name)
        else:
            # The fields of the other languages are in the
               translation
            # model, but falls back to the main model
            translations = instance.translations.filter(
                language=lang_code,
            ).first() or instance
            return getattr(translations, self.field_name)
  1. 将以下内容添加到core应用程序的admin.py文件中:
# myproject/apps/core/admin.py
from django import forms
from django.conf import settings
from django.utils.translation import gettext_lazy as _

class LanguageChoicesForm(forms.ModelForm):
    def __init__(self, *args, **kwargs):
        LANGUAGES_EXCEPT_THE_DEFAULT = [
            (lang_code, lang_name)
            for lang_code, lang_name in settings.LANGUAGES
            if lang_code != settings.LANGUAGE_CODE
        ]
        super().__init__(*args, **kwargs)
        self.fields["language"] = forms.ChoiceField(
            label=_("Language"),
            choices=LANGUAGES_EXCEPT_THE_DEFAULT, 
            required=True,
        )

现在让我们实现多语言模型:

  1. 首先,在项目的设置中设置多种语言。假设我们的网站将支持欧盟所有官方语言,英语是默认语言:
# myproject/settings/_base.py
LANGUAGE_CODE = "en"

# All official languages of European Union
LANGUAGES = [
    ("bg", "Bulgarian"),    ("hr", "Croatian"),
    ("cs", "Czech"),        ("da", "Danish"),
    ("nl", "Dutch"),        ("en", "English"),
    ("et", "Estonian"),     ("fi", "Finnish"),
    ("fr", "French"),       ("de", "German"),
    ("el", "Greek"),        ("hu", "Hungarian"),
    ("ga", "Irish"),        ("it", "Italian"),
    ("lv", "Latvian"),      ("lt", "Lithuanian"),
    ("mt", "Maltese"),      ("pl", "Polish"),
    ("pt", "Portuguese"),   ("ro", "Romanian"),
    ("sk", "Slovak"),       ("sl", "Slovene"),
    ("es", "Spanish"),      ("sv", "Swedish"),
]
  1. 然后,让我们创建IdeaIdeaTranslations模型:
# myproject/apps/ideas/models.py
from django.db import models
from django.conf import settings
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.model_fields import TranslatedField

class Idea(models.Model):
    title = models.CharField(
        _("Title"),
        max_length=200,
    )
    content = models.TextField(
        _("Content"),
    )
    translated_title = TranslatedField("title")
    translated_content = TranslatedField("content")

    class Meta:
        verbose_name = _("Idea")
        verbose_name_plural = _("Ideas")

    def __str__(self):
        return self.title

class IdeaTranslations(models.Model):
    idea = models.ForeignKey(
        Idea,
        verbose_name=_("Idea"),
        on_delete=models.CASCADE,
        related_name="translations",
    )
    language = models.CharField(_("Language"), max_length=7)

    title = models.CharField(
        _("Title"),
        max_length=200,
    )
    content = models.TextField(
        _("Content"),
    )

    class Meta:
        verbose_name = _("Idea Translations")
        verbose_name_plural = _("Idea Translations")
        ordering = ["language"]
        unique_together = [["idea", "language"]]

    def __str__(self):
        return self.title
  1. 最后,创建ideas应用程序的admin.py如下:
# myproject/apps/ideas/admin.py
from django.contrib import admin
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.admin import LanguageChoicesForm

from .models import Idea, IdeaTranslations

class IdeaTranslationsForm(LanguageChoicesForm):
    class Meta:
        model = IdeaTranslations
        fields = "__all__"

class IdeaTranslationsInline(admin.StackedInline):
    form = IdeaTranslationsForm
    model = IdeaTranslations
    extra = 0

@admin.register(Idea)
class IdeaAdmin(admin.ModelAdmin):
    inlines = [IdeaTranslationsInline]

    fieldsets = [
        (_("Title and Content"), {
            "fields": ["title", "content"]
        }),
    ]

工作原理...

我们将默认语言的特定于语言的字段保留在Idea模型本身中。每种语言的翻译都在IdeaTranslations模型中,该模型将作为内联翻译列在管理中列出。IdeaTranslations模型没有模型的语言选择,这是有原因的——我们不希望每次添加新语言或删除某种语言时都创建迁移。相反,语言选择设置在管理表单中,还要确保默认语言被跳过或在列表中不可选择。语言选择使用LanguageChoicesForm类进行限制。

要获取当前语言中的特定字段,您将使用定义为TranslatedField的字段。在模板中,看起来像这样:

<h1>{{ idea.translated_title }}</h1>
<div>{{ idea.translated_content|urlize|linebreaks }}</div>

要按特定语言的翻译标题对项目进行排序,您将使用annotate()方法如下:

>>> from django.conf import settings
>>> from django.db import models
>>> lang_code = input("Enter language code: ")

>>> if lang_code == settings.LANGUAGE_CODE:
...     qs = Idea.objects.annotate(
...         title_translation=models.F("title"),
...         content_translation=models.F("content"),
...     )
... else:
...     qs = Idea.objects.filter(
...         translations__language=lang_code,
...     ).annotate(
...         title_translation=models.F("translations__title"),
...         content_translation=models.F("translations__content"),
...     )

>>> qs = qs.order_by("title_translation")

>>> for idea in qs:
...     print(idea.title_translation)

在这个例子中,我们在 Django shell 中提示输入语言代码。如果语言是默认语言,我们将titlecontent存储为Idea模型的title_translationcontent_translation。如果选择了其他语言,我们将从选择的语言中读取titlecontent作为IdeaTranslations模型的title_translationcontent_translation

之后,我们可以通过title_translationcontent_translation筛选或排序QuerySet

另请参阅

  • 处理多语言字段配方

  • 第六章,模型管理

避免循环依赖

在开发 Django 模型时,非常重要的是要避免循环依赖,特别是在models.py文件中。循环依赖是指不同 Python 模块之间的相互导入。您不应该从不同的models.py文件中交叉导入,因为这会导致严重的稳定性问题。相反,如果存在相互依赖,您应该使用本配方中描述的操作。

准备工作

让我们使用categoriesideas应用程序来说明如何处理交叉依赖。

如何做...

在处理使用其他应用程序模型的模型时,请遵循以下实践:

  1. 对于来自其他应用程序的模型的外键和多对多关系,请使用"<app_label>.<model>"声明,而不是导入模型。在 Django 中,这适用于ForeignKeyOneToOneFieldManyToManyField,例如:
# myproject/apps/ideas/models.py from django.db import models
from django.conf import settings
from django.utils.translation import gettext_lazy as _

class Idea(models.Model):
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name=_("Author"),
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
    )
    category = models.ForeignKey(
        "categories.Category",
        verbose_name=_("Category"),
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
    )
    # other fields, attributes, properties and methods…

这里,settings.AUTH_USER_MODEL是一个具有值如"auth.User"的设置:

  1. 如果您需要在方法中访问另一个应用程序的模型,请在方法内部导入该模型,而不是在模块级别导入,例如:
# myproject/apps/categories/models.py
from django.db import models
from django.utils.translation import gettext_lazy as _

class Category(models.Model):
    # fields, attributes, properties, and methods…

    def get_ideas_without_this_category(self):
        from myproject.apps.ideas.models import Idea
        return Idea.objects.exclude(category=self)
  1. 如果您使用模型继承,例如用于模型混合,将基类保留在单独的应用程序中,并将它们放在INSTALLED_APPS中将使用它们的其他应用程序之前,如下所示:
# myproject/settings/_base.py

INSTALLED_APPS = [
    # contributed
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # third-party
    # ...
    # local
    "myproject.apps.core",
    "myproject.apps.categories",
    "myproject.apps.ideas",
]

在这里,ideas应用程序将如下使用core应用程序的模型混合:

# myproject/apps/ideas/models.py
from django.db import models
from django.conf import settings
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.models import (
 CreationModificationDateBase,
 MetaTagsBase,
 UrlBase,
)

class Idea(CreationModificationDateBase, MetaTagsBase, UrlBase):
    # fields, attributes, properties, and methods…

另请参阅

  • 第一章中的为开发、测试、暂存和生产环境配置设置*示例,Django 3.0 入门

  • 第一章中的尊重 Python 文件的导入顺序示例,Django 3.0 入门

  • 使用模型混合示例

  • 将外键更改为多对多字段示例

添加数据库约束

为了更好地保证数据库的完整性,通常会定义数据库约束,告诉某些字段绑定到其他数据库表的字段,使某些字段唯一或非空。对于高级数据库约束,例如使字段在满足条件时唯一或为某些字段的值设置特定条件,Django 有特殊的类:UniqueConstraintCheckConstraint。在这个示例中,您将看到如何使用它们的实际示例。

准备工作

让我们从ideas应用程序和将至少具有titleauthor字段的Idea模型开始。

如何做...

Idea模型的Meta类中设置数据库约束如下:

# myproject/apps/ideas/models.py
from django.db import models
from django.utils.translation import gettext_lazy as _

class Idea(models.Model):
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name=_("Author"),
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
        related_name="authored_ideas",
    )
    title = models.CharField(
        _("Title"),
        max_length=200,
    )

    class Meta:
        verbose_name = _("Idea")
        verbose_name_plural = _("Ideas")
        constraints = [
 models.UniqueConstraint(
 fields=["title"],
 condition=~models.Q(author=None),
 name="unique_titles_for_each_author",
 ),
 models.CheckConstraint(
 check=models.Q(
 title__iregex=r"^\S.*\S$"
 # starts with non-whitespace,
 # ends with non-whitespace,
 # anything in the middle
 ),
 name="title_has_no_leading_and_trailing_whitespaces",
 )
 ]

它是如何工作的...

我们在数据库中定义了两个约束。

第一个UniqueConstraint告诉标题对于每个作者是唯一的。如果作者未设置,则标题可以重复。要检查作者是否已设置,我们使用否定查找:~models.Q(author=None)。请注意,在 Django 中,查找的~运算符等同于 QuerySet 的exclude()方法,因此这些 QuerySets 是等价的:

ideas_with_authors = Idea.objects.exclude(author=None)
ideas_with_authors2 = Idea.objects.filter(~models.Q(author=None))

第二个约束条件CheckConstraint检查标题是否不以空格开头和结尾。为此,我们使用正则表达式查找。

还有更多...

数据库约束不会影响表单验证。如果保存条目到数据库时任何数据不符合其条件,它们只会引发django.db.utils.IntegrityError

如果您希望在表单中验证数据,您必须自己实现验证,例如在模型的clean()方法中。对于Idea模型,这将如下所示:

# myproject/apps/ideas/models.py from django.db import models
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _

class Idea(models.Model):
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name=_("Author"),
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
        related_name="authored_ideas2",
    )
    title = models.CharField(
        _("Title"),
        max_length=200,
    )

    # other fields and attributes…

    class Meta:
        verbose_name = _("Idea")
        verbose_name_plural = _("Ideas")
        constraints = [
            models.UniqueConstraint(
                fields=["title"],
                condition=~models.Q(author=None),
                name="unique_titles_for_each_author2",
            ),
            models.CheckConstraint(
                check=models.Q(
                    title__iregex=r"^\S.*\S$"
                    # starts with non-whitespace,
                    # ends with non-whitespace,
                    # anything in the middle
                ),
                name="title_has_no_leading_and_trailing_whitespaces2",
            )
        ]

 def clean(self):
 import re
 if self.author and Idea.objects.exclude(pk=self.pk).filter(
 author=self.author,
 title=self.title,
 ).exists():
 raise ValidationError(
 _("Each idea of the same user should have a unique title.")
 )
 if not re.match(r"^\S.*\S$", self.title):
 raise ValidationError(
 _("The title cannot start or end with a whitespace.")
 )

    # other properties and methods…

另请参阅

  • 第三章中的表单和视图*

  • 第十章中的使用数据库查询表达式*示例,花里胡哨

使用迁移

在敏捷软件开发中,项目的要求会随着时间的推移而不断更新和更新。随着开发的进行,您将不得不沿途执行数据库架构更改。使用 Django 迁移,您不必手动更改数据库表和字段,因为大部分工作都是自动完成的,使用命令行界面。

准备工作

在命令行工具中激活您的虚拟环境,并将活动目录更改为您的项目目录。

如何做...

要创建数据库迁移,请查看以下步骤:

  1. 当您在新的categoriesideas应用程序中创建模型时,您必须创建一个初始迁移,该迁移将为您的应用程序创建数据库表。这可以通过使用以下命令来完成:
(env)$ python manage.py makemigrations ideas
  1. 第一次要为项目创建所有表时,请运行以下命令:
(env)$ python manage.py migrate

当您想要执行所有应用程序的新迁移时,请运行此命令。

  1. 如果要执行特定应用程序的迁移,请运行以下命令:
(env)$ python manage.py migrate ideas
  1. 如果对数据库模式进行了一些更改,则必须为该模式创建一个迁移。例如,如果我们向 idea 模型添加一个新的 subtitle 字段,可以使用以下命令创建迁移:
(env)$ python manage.py makemigrations --name=subtitle_added ideas

然而,--name=subtitle_added字段可以被跳过,因为在大多数情况下,Django 会生成相当自解释的默认名称。

  1. 有时,您可能需要批量添加或更改现有模式中的数据,这可以通过数据迁移而不是模式迁移来完成。要创建修改数据库表中数据的数据迁移,可以使用以下命令:
(env)$ python manage.py makemigrations --name=populate_subtitle \
> --empty ideas

--empty参数告诉 Django 创建一个骨架数据迁移,您必须在应用之前修改它以执行必要的数据操作。对于数据迁移,建议设置名称。

  1. 要列出所有可用的已应用和未应用的迁移,请运行以下命令:
(env)$ python manage.py showmigrations

已应用的迁移将以[X]前缀列出。未应用的迁移将以[ ]前缀列出。

  1. 要列出特定应用程序的所有可用迁移,请运行相同的命令,但传递应用程序名称,如下所示:
(env)$ python manage.py showmigrations ideas

它是如何工作的...

Django 迁移是数据库迁移机制的指令文件。这些指令文件告诉我们要创建或删除哪些数据库表,要添加或删除哪些字段,以及要插入、更新或删除哪些数据。它们还定义了哪些迁移依赖于其他迁移。

Django 有两种类型的迁移。一种是模式迁移,另一种是数据迁移。当您添加新模型、添加或删除字段时,应创建模式迁移。当您想要向数据库填充一些值或大量删除数据库中的值时,应使用数据迁移。数据迁移应该通过命令行工具中的命令创建,然后在迁移文件中编码。

每个应用程序的迁移都保存在它们的migrations目录中。第一个迁移通常称为0001_initial.py,在我们的示例应用程序中,其他迁移将被称为0002_subtitle_added.py0003_populate_subtitle.py。每个迁移都有一个自动递增的数字前缀。对于执行的每个迁移,都会在django_migrations数据库表中保存一个条目。

可以通过指定要迁移的迁移编号来来回迁移,如下命令所示:

(env)$ python manage.py migrate ideas 0002

要取消应用程序的所有迁移,包括初始迁移,请运行以下命令:

(env)$ python manage.py migrate ideas zero

取消迁移需要每个迁移都有前向和后向操作。理想情况下,后向操作应该恢复前向操作所做的更改。然而,在某些情况下,这样的更改是无法恢复的,例如当前向操作从模式中删除了一个列时,因为它将破坏数据。在这种情况下,后向操作可能会恢复模式,但数据将永远丢失,或者根本没有后向操作。

在测试了前向和后向迁移过程并确保它们在其他开发和公共网站环境中能够正常工作之前,不要将您的迁移提交到版本控制中。

还有更多...

在官方的How To指南中了解更多关于编写数据库迁移的信息,网址为docs.djangoproject.com/en/2.2/howto/writing-migrations/​。

另请参阅

  • 第一章中的使用虚拟环境*配方

  • 在第一章中的使用 Django、Gunicorn、Nginx 和 PostgreSQL 的 Docker 容器*食谱,使用 Django 3.0 入门

  • 在第一章中的使用 pip 处理项目依赖关系*食谱,使用 Django 3.0 入门

  • 在第一章中的在您的项目中包含外部依赖项*食谱,使用 Django 3.0 入门

  • 将外键更改为多对多字段食谱

将外键更改为多对多字段

这个食谱是如何将多对一关系更改为多对多关系的实际示例,同时保留已经存在的数据。在这种情况下,我们将同时使用模式迁移和数据迁移。

准备就绪

假设您有Idea模型,其中有一个指向Category模型的外键。

  1. 让我们在categories应用程序中定义Category模型,如下所示:
# myproject/apps/categories/models.py
from django.db import models
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.model_fields import MultilingualCharField

class Category(models.Model):
    title = MultilingualCharField(
        _("Title"),
        max_length=200,
    )

    class Meta:
        verbose_name = _("Category")
        verbose_name_plural = _("Categories")

    def __str__(self):
        return self.title
  1. 让我们在ideas应用程序中定义Idea模型,如下所示:
# myproject/apps/ideas/models.py from django.db import models
from django.conf import settings
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.model_fields import (
    MultilingualCharField,
    MultilingualTextField,
)

class Idea(models.Model):
    title = MultilingualCharField(
        _("Title"),
        max_length=200,
    )
    content = MultilingualTextField(
        _("Content"),
    )
 category = models.ForeignKey(
        "categories.Category",
        verbose_name=_("Category"),
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
        related_name="category_ideas",
    ) 
    class Meta:
        verbose_name = _("Idea")
        verbose_name_plural = _("Ideas")

    def __str__(self):
        return self.title
  1. 通过使用以下命令创建和执行初始迁移:
(env)$ python manage.py makemigrations categories
(env)$ python manage.py makemigrations ideas
(env)$ python manage.py migrate

如何做...

以下步骤将向您展示如何从外键关系切换到多对多关系,同时保留已经存在的数据:

  1. 添加一个名为categories的新多对多字段,如下所示:
# myproject/apps/ideas/models.py from django.db import models
from django.conf import settings
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.model_fields import (
    MultilingualCharField,
    MultilingualTextField,
)

class Idea(models.Model):
    title = MultilingualCharField(
        _("Title"),
        max_length=200,
    )
    content = MultilingualTextField(
        _("Content"),
    )
    category = models.ForeignKey(
        "categories.Category",
        verbose_name=_("Category"),
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
        related_name="category_ideas",
    )
    categories = models.ManyToManyField(
 "categories.Category",
 verbose_name=_("Categories"),
 blank=True,
 related_name="ideas",
 )

    class Meta:
        verbose_name = _("Idea")
        verbose_name_plural = _("Ideas")

    def __str__(self):
        return self.title
  1. 创建并运行模式迁移,以向数据库添加新的关系,如下面的代码片段所示:
(env)$ python manage.py makemigrations ideas
(env)$ python manage.py migrate ideas
  1. 创建一个数据迁移,将类别从外键复制到多对多字段,如下所示:
(env)$ python manage.py makemigrations --empty \
> --name=copy_categories ideas
  1. 打开新创建的迁移文件(0003_copy_categories.py),并定义前向迁移指令,如下面的代码片段所示:
# myproject/apps/ideas/migrations/0003_copy_categories.py from django.db import migrations

def copy_categories(apps, schema_editor):
 Idea = apps.get_model("ideas", "Idea")
 for idea in Idea.objects.all():
 if idea.category:
 idea.categories.add(idea.category)

class Migration(migrations.Migration):

    dependencies = [
        ('ideas', '0002_idea_categories'),
    ]

    operations = [
        migrations.RunPython(copy_categories),
    ]
  1. 运行新的数据迁移,如下所示:
(env)$ python manage.py migrate ideas
  1. models.py文件中删除外键category字段,只留下新的categories多对多字段,如下所示:
# myproject/apps/ideas/models.py from django.db import models
from django.conf import settings
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.model_fields import (
    MultilingualCharField,
    MultilingualTextField,
)

class Idea(models.Model):
    title = MultilingualCharField(
        _("Title"),
        max_length=200,
    )
    content = MultilingualTextField(
        _("Content"),
    )

    categories = models.ManyToManyField(
 "categories.Category",
 verbose_name=_("Categories"),
 blank=True,
 related_name="ideas",
 )

    class Meta:
        verbose_name = _("Idea")
        verbose_name_plural = _("Ideas")

    def __str__(self):
        return self.title
  1. 创建并运行模式迁移,以从数据库表中删除Categories字段,如下所示:
(env)$ python manage.py makemigrations ideas
(env)$ python manage.py migrate ideas

它是如何工作的...

首先,我们向Idea模型添加一个新的多对多字段,并生成一个迁移以相应地更新数据库。然后,我们创建一个数据迁移,将现有关系从外键category复制到新的多对多categories。最后,我们从模型中删除外键字段,并再次更新数据库。

还有更多...

我们的数据迁移目前只包括前向操作,将外键中的类别复制到多对多字段中

将类别键作为新类别关系中的第一个相关项目。虽然我们在这里没有详细说明,在实际情况下最好也包括反向操作。这可以通过将第一个相关项目复制回category外键来实现。不幸的是,任何具有多个类别的Idea对象都将丢失额外数据。

另请参阅

  • 使用迁移食谱

  • 处理多语言字段食谱

  • 使用模型翻译表食谱

  • 避免循环依赖食谱

第三章:表单和视图

在本章中,我们将涵盖以下主题:

  • 创建一个带有 CRUDL 功能的应用程序

  • 保存模型实例的作者

  • 上传图片

  • 使用自定义模板创建表单布局

  • 使用 django-crispy-forms 创建表单布局

  • 使用表单集

  • 过滤对象列表

  • 管理分页列表

  • 组合基于类的视图

  • 提供 Open Graph 和 Twitter Card 数据

  • 提供 schema.org 词汇

  • 生成 PDF 文档

  • 使用 Haystack 和 Whoosh 实现多语言搜索

  • 使用 Elasticsearch DSL 实现多语言搜索

介绍

虽然数据库结构在模型中定义,但视图提供了必要的端点,以向用户显示内容或让他们输入新的和更新的数据。在本章中,我们将重点关注用于管理表单、列表视图和生成 HTML 以外的替代输出的视图。在最简单的示例中,我们将把 URL 规则和模板的创建留给您。

技术要求

要使用本章的代码,您将需要最新稳定版本的 Python、MySQL 或 PostgreSQL 数据库,以及带有虚拟环境的 Django 项目。一些教程将需要特定的 Python 依赖项。此外,为了生成 PDF 文档,您将需要cairopangogdk-pixbuflibffi库。对于搜索,您将需要一个 Elasticsearch 服务器。您将在相应的教程中获得更多关于它们的详细信息。

本章中的大多数模板将使用 Bootstrap 4 CSS 框架,以获得更美观的外观和感觉。

您可以在 GitHub 存储库的ch03目录中找到本章的所有代码:github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition

创建一个带有 CRUDL 功能的应用程序

在计算机科学中,CRUDL首字母缩写代表创建读取更新删除列表功能。许多具有交互功能的 Django 项目将需要您实现所有这些功能来管理网站上的数据。在本教程中,我们将看到如何为这些基本功能创建 URL 和视图。

准备工作

让我们创建一个名为ideas的新应用程序,并将其放入设置中的INSTALLED_APPS中。在该应用程序中创建以下Idea模型,并在该模型内部创建IdeaTranslations模型以进行翻译:

# myproject/apps/idea/models.py import uuid

from django.db import models
from django.urls import reverse
from django.conf import settings
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.model_fields import TranslatedField
from myproject.apps.core.models import (
    CreationModificationDateBase, UrlBase
)

RATING_CHOICES = (
    (1, "★☆☆☆☆"), 
    (2, "★★☆☆☆"), 
    (3, "★★★☆☆"), 
    (4, "★★★★☆"),
    (5, "★★★★★"),
)

class Idea(CreationModificationDateBase, UrlBase):
    uuid = models.UUIDField(
        primary_key=True, default=uuid.uuid4, editable=False
    )
    author = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name=_("Author"),
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
        related_name="authored_ideas",
    )
    title = models.CharField(_("Title"), max_length=200)
    content = models.TextField(_("Content"))

    categories = models.ManyToManyField(
        "categories.Category",
        verbose_name=_("Categories"),
        related_name="category_ideas",
    )
    rating = models.PositiveIntegerField(
        _("Rating"), choices=RATING_CHOICES, blank=True, null=True
    )
    translated_title = TranslatedField("title")
    translated_content = TranslatedField("content")

    class Meta:
        verbose_name = _("Idea")
        verbose_name_plural = _("Ideas")

    def __str__(self):
        return self.title

    def get_url_path(self):
        return reverse("ideas:idea_detail", kwargs={"pk": self.pk})

class IdeaTranslations(models.Model):
    idea = models.ForeignKey(
        Idea,
        verbose_name=_("Idea"),
        on_delete=models.CASCADE,
        related_name="translations",
    )
    language = models.CharField(_("Language"), max_length=7)

    title = models.CharField(_("Title"), max_length=200)
    content = models.TextField(_("Content"))

    class Meta:
        verbose_name = _("Idea Translations")
        verbose_name_plural = _("Idea Translations")
        ordering = ["language"]
        unique_together = [["idea", "language"]]

    def __str__(self):
        return self.title

我们在这里使用了上一章的几个概念:我们从模型混合继承,并利用了模型翻译表。在使用模型混合使用模型翻译表教程中了解更多。我们将在本章的所有教程中使用ideas应用程序和这些模型。

此外,创建一个类似的categories应用程序,其中包括CategoryCategoryTranslations模型:

# myproject/apps/categories/models.py
from django.db import models
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.model_fields import TranslatedField

class Category(models.Model):
    title = models.CharField(_("Title"), max_length=200)

    translated_title = TranslatedField("title")

    class Meta:
        verbose_name = _("Category")
        verbose_name_plural = _("Categories")

    def __str__(self):
        return self.title

class CategoryTranslations(models.Model):
    category = models.ForeignKey(
        Category,
        verbose_name=_("Category"),
        on_delete=models.CASCADE,
        related_name="translations",
    )
    language = models.CharField(_("Language"), max_length=7)

    title = models.CharField(_("Title"), max_length=200)

    class Meta:
        verbose_name = _("Category Translations")
        verbose_name_plural = _("Category Translations")
        ordering = ["language"]
        unique_together = [["category", "language"]]

    def __str__(self):
        return self.title

如何做...

Django 中的 CRUDL 功能包括表单、视图和 URL 规则。让我们创建它们:

  1. ideas应用程序中添加一个新的forms.py文件,其中包含用于添加和更改Idea模型实例的模型表单:
# myprojects/apps/ideas/forms.py from django import forms
from .models import Idea

class IdeaForm(forms.ModelForm):
    class Meta:
        model = Idea
        fields = "__all__"
  1. ideas应用程序中添加一个新的views.py文件,其中包含操作Idea模型的视图:
# myproject/apps/ideas/views.py from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect, get_object_or_404
from django.views.generic import ListView, DetailView

from .forms import IdeaForm
from .models import Idea

class IdeaList(ListView):
    model = Idea

class IdeaDetail(DetailView):
    model = Idea
    context_object_name = "idea"

@login_required
def add_or_change_idea(request, pk=None):
    idea = None
    if pk:
        idea = get_object_or_404(Idea, pk=pk)

    if request.method == "POST":
        form = IdeaForm(
            data=request.POST, 
            files=request.FILES, 
            instance=idea
        )

        if form.is_valid():
            idea = form.save()
            return redirect("ideas:idea_detail", pk=idea.pk)
    else:
        form = IdeaForm(instance=idea)

    context = {"idea": idea, "form": form}
    return render(request, "ideas/idea_form.html", context)

@login_required
def delete_idea(request, pk):
    idea = get_object_or_404(Idea, pk=pk)
    if request.method == "POST":
        idea.delete()
        return redirect("ideas:idea_list")
    context = {"idea": idea}
    return render(request, "ideas/idea_deleting_confirmation.html", context)
  1. ideas应用程序中创建urls.py文件,其中包含 URL 规则:
# myproject/apps/ideas/urls.py from django.urls import path

from .views import (
    IdeaList,
    IdeaDetail,
    add_or_change_idea,
    delete_idea,
)

urlpatterns = [
    path("", IdeaList.as_view(), name="idea_list"),
    path("add/", add_or_change_idea, name="add_idea"),
    path("<uuid:pk>/", IdeaDetail.as_view(), name="idea_detail"),
    path("<uuid:pk>/change/", add_or_change_idea,  
     name="change_idea"),
    path("<uuid:pk>/delete/", delete_idea, name="delete_idea"),
]
  1. 现在,让我们将这些 URL 规则插入到项目的 URL 配置中。我们还将包括 Django 贡献的auth应用程序中的帐户 URL 规则,以便我们的@login_required装饰器正常工作:
# myproject/urls.py from django.contrib import admin
from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path
from django.conf import settings
from django.conf.urls.static import static
from django.shortcuts import redirect

urlpatterns = i18n_patterns(
    path("", lambda request: redirect("ideas:idea_list")),
    path("admin/", admin.site.urls),
    path("accounts/", include("django.contrib.auth.urls")),
 path("ideas/", include(("myproject.apps.ideas.urls", "ideas"), 
     namespace="ideas")),
)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static("/media/", document_root=settings.MEDIA_ROOT)
  1. 现在您应该能够创建以下模板:
  • registration/login.html中带有登录表单

  • ideas/idea_list.html中包含一个想法列表

  • ideas/idea_detail.html中包含有关想法的详细信息

  • ideas/idea_form.html中包含添加或更改想法的表单

  • ideas/idea_deleting_confirmation.html中包含一个空表单,用于确认删除想法

在模板中,您可以通过命名空间和路径名称来访问ideas应用程序的 URL,如下所示:

{% load i18n %}
<a href="{% url 'ideas:change_idea' pk=idea.pk %}">{% trans "Change this idea" %}</a>
<a href="{% url 'ideas:add_idea' %}">{% trans "Add idea" %}</a>

如果您遇到困难或想节省时间,请查看本书的代码文件中相应的模板,您可以在github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition/tree/master/ch03/myproject_virtualenv/src/django-myproject/myproject/templates/ideas找到。

它是如何工作的...

在这个示例中,我们使用 UUID 字段作为Idea模型的主键。有了这个 ID,每个想法都有一个不可预测的唯一 URL。或者,您也可以使用 slug 字段用于 URL,但是您必须确保每个 slug 都被填充并且在整个网站中是唯一的。

出于安全原因,不建议使用默认的增量 ID 用于 URL:用户可以找出数据库中有多少项,并尝试访问下一个或上一个项目,尽管他们可能没有权限这样做。

在我们的示例中,我们使用基于类的通用视图来列出和阅读想法,并使用基于函数的视图来创建、更新和删除它们。更改数据库中记录的视图需要经过身份验证的用户,使用@login_required装饰器。对于所有 CRUDL 功能,使用基于类的视图或基于函数的视图也是完全可以的。

成功添加或更改想法后,用户将被重定向到详细视图。删除想法后,用户将被重定向到列表视图。

还有更多...

此外,您可以使用 Django 消息框架在每次成功添加、更改或删除后在页面顶部显示成功消息。

您可以在官方文档中阅读有关它们的信息:docs.djangoproject.com/en/2.2/ref/contrib/messages/

另请参阅

  • 在第二章使用模型混合食谱中,模型和数据库结构

  • 在第二章使用模型翻译表食谱中,模型和数据库结构

  • 保存模型实例的作者食谱

  • 在第四章安排 base.html 模板食谱中,模板和 JavaScript

保存模型实例的作者

每个 Django 视图的第一个参数是HttpRequest对象,按照惯例命名为request。它包含有关从浏览器或其他客户端发送的请求的元数据,包括当前语言代码、用户数据、cookie 和会话等项目。默认情况下,视图使用的表单接受 GET 或 POST 数据、文件、初始数据和其他参数;但是,它们本身并没有访问HttpRequest对象的能力。在某些情况下,将HttpRequest附加到表单中是有用的,特别是当您想要根据其他请求数据过滤表单字段的选择或处理保存诸如当前用户或 IP 之类的内容时。

在这个示例中,我们将看到一个表单的例子,其中,对于添加或更改的想法,当前用户将被保存为作者。

准备工作

我们将在前一个示例中进行扩展。

如何做...

要完成此食谱,请执行以下两个步骤:

  1. 修改IdeaForm模型表单如下:
# myprojects/apps/ideas/forms.py from django import forms
from .models import Idea

class IdeaForm(forms.ModelForm):
    class Meta:
        model = Idea
        exclude = ["author"]

 def __init__(self, request, *args, **kwargs):
 self.request = request
 super().__init__(*args, **kwargs)

 def save(self, commit=True):
 instance = super().save(commit=False)
 instance.author = self.request.user
 if commit:
 instance.save()
            self.save_m2m()
 return instance
  1. 修改视图以添加或更改想法:
# myproject/apps/ideas/views.py from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect, get_object_or_404

from .forms import IdeaForm
from .models import Idea

@login_required
def add_or_change_idea(request, pk=None):
    idea = None
    if pk:
        idea = get_object_or_404(Idea, pk=pk)

    if request.method == "POST":
        form = IdeaForm(request, data=request.POST, 
         files=request.FILES, instance=idea)

        if form.is_valid():
            idea = form.save()
            return redirect("ideas:idea_detail", pk=idea.pk)
    else:
        form = IdeaForm(request, instance=idea)

    context = {"idea": idea, "form": form}
    return render(request, "ideas/idea_form.html", context)

它是如何工作的...

让我们来看看这个表单。首先,我们从表单中排除author字段,因为我们希望以编程方式处理它。我们重写__init__()方法,接受HttpRequest作为第一个参数,并将其存储在表单中。模型表单的save()方法处理模型的保存。commit参数告诉模型表单立即保存实例,否则创建并填充实例,但尚未保存。在我们的情况下,我们获取实例而不保存它,然后从当前用户分配作者。最后,如果commitTrue,我们保存实例。我们将调用动态添加的save_m2m()方法来保存多对多关系,例如类别。

在视图中,我们只需将request变量作为第一个参数传递给表单。

另请参阅

  • 使用 CRUDL 功能创建应用程序食谱

  • 上传图像食谱

上传图像

在这个食谱中,我们将看一下处理图像上传的最简单方法。我们将在Idea模型中添加一个picture字段,并为不同目的创建不同尺寸的图像版本。

准备工作

对于具有图像版本的图像,我们将需要Pillowdjango-imagekit库。让我们在虚拟环境中使用pip安装它们(并将它们包含在requirements/_base.txt中):

(env)$ pip install Pillow
(env)$ pip install django-imagekit==4.0.2

然后,在设置中将"imagekit"添加到INSTALLED_APPS

如何做...

执行以下步骤完成食谱:

  1. 修改Idea模型以添加picture字段和图像版本规格:
# myproject/apps/ideas/models.py
import contextlib
import os

from imagekit.models import ImageSpecField
from pilkit.processors import ResizeToFill

from django.db import models
from django.utils.translation import gettext_lazy as _
from django.utils.timezone import now as timezone_now

from myproject.apps.core.models import (CreationModificationDateBase, UrlBase)

def upload_to(instance, filename):
 now = timezone_now()
 base, extension = os.path.splitext(filename)
 extension = extension.lower()
 return f"ideas/{now:%Y/%m}/{instance.pk}{extension}"

class Idea(CreationModificationDateBase, UrlBase):
    # attributes and fields…
    picture = models.ImageField(
        _("Picture"), upload_to=upload_to
    )
    picture_social = ImageSpecField(
        source="picture",
        processors=[ResizeToFill(1024, 512)],
        format="JPEG",
        options={"quality": 100},
    )
    picture_large = ImageSpecField(
        source="picture", 
        processors=[ResizeToFill(800, 400)], 
        format="PNG"
    )
    picture_thumbnail = ImageSpecField(
        source="picture", 
        processors=[ResizeToFill(728, 250)], 
        format="PNG"
    )
    # other fields, properties, and  methods…

 def delete(self, *args, **kwargs):
 from django.core.files.storage import default_storage
 if self.picture:
 with contextlib.suppress(FileNotFoundError):
 default_storage.delete(
 self.picture_social.path
 )
 default_storage.delete(
 self.picture_large.path
 )
 default_storage.delete(
 self.picture_thumbnail.path
 )
 self.picture.delete()
 super().delete(*args, **kwargs)
  1. forms.py中为Idea模型创建一个模型表单IdeaForm,就像我们在之前的食谱中所做的那样。

  2. 在添加或更改想法的视图中,确保将request.FILESrequest.POST一起发布到表单中:

# myproject/apps/ideas/views.py from django.contrib.auth.decorators import login_required
from django.shortcuts import (render, redirect, get_object_or_404)
from django.conf import settings

from .forms import IdeaForm
from .models import Idea

@login_required
def add_or_change_idea(request, pk=None):
    idea = None
    if pk:
        idea = get_object_or_404(Idea, pk=pk)
    if request.method == "POST":
        form = IdeaForm(
            request, 
 data=request.POST, 
 files=request.FILES, 
            instance=idea,
        )

        if form.is_valid():
            idea = form.save()
            return redirect("ideas:idea_detail", pk=idea.pk)
    else:
        form = IdeaForm(request, instance=idea)

    context = {"idea": idea, "form": form}
    return render(request, "ideas/idea_form.html", context)
  1. 在模板中,确保将编码类型设置为"multipart/form-data",如下所示:
<form action="{{ request.path }}" method="post" enctype="multipart/form-data">{% csrf_token %}
{{ form.as_p }}
<button type="submit">{% trans "Save" %}</button>
</form>

如果您正在使用django-crispy-form,如使用 django-crispy-forms 创建表单布局食谱中所述,enctype属性将自动添加到表单中。

它是如何工作的...

Django 模型表单是从模型动态创建的。它们提供了模型中指定的字段,因此您不需要在表单中手动重新定义它们。在前面的示例中,我们为Idea模型创建了一个模型表单。当我们保存表单时,表单知道如何将每个字段保存在数据库中,以及如何上传文件并将其保存在媒体目录中。

在我们的示例中,upload_to()函数用于将图像保存到特定目录,并定义其名称,以便不会与其他模型实例的文件名冲突。每个文件将保存在类似ideas/2020/01/0422c6fe-b725-4576-8703-e2a9d9270986.jpg的路径下,其中包括上传的年份和月份以及Idea实例的主键。

一些文件系统(如 FAT32 和 NTFS)每个目录可用的文件数量有限;因此,将它们按上传日期、字母顺序或其他标准划分为目录是一个好习惯。

我们使用django-imagekit中的ImageSpecField创建了三个图像版本:

  • picture_social用于社交分享。

  • picture_large用于详细视图。

  • picture_thumbnail用于列表视图。

图像版本未在数据库中链接,而只是保存在默认文件存储中,路径为CACHE/images/ideas/2020/01/0422c6fe-b725-4576-8703-e2a9d9270986/

在模板中,您可以使用原始图像或特定图像版本,如下所示:

<img src="img/strong>" alt="" />
<img src="img/strong>" alt="" />

Idea模型定义的末尾,我们重写delete()方法,以便在删除Idea实例之前删除图像版本和磁盘上的图片。

另请参阅

  • 使用 django-crispy-forms 创建表单布局食谱

  • 第四章,模板和 JavaScript中的安排 base.html 模板食谱

  • 在第四章提供响应式图片食谱中

使用自定义模板创建表单布局

在早期版本的 Django 中,所有表单渲染都是在 Python 代码中处理的,但自从 Django 1.11 以来,引入了基于模板的表单小部件渲染。在这个食谱中,我们将研究如何使用自定义模板来处理表单小部件。我们将使用 Django 管理表单来说明自定义小部件模板如何提高字段的可用性。

准备工作

让我们创建Idea模型及其翻译的默认 Django 管理:

# myproject/apps/ideas/admin.py from django import forms
from django.contrib import admin
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.admin import LanguageChoicesForm

from .models import Idea, IdeaTranslations

class IdeaTranslationsForm(LanguageChoicesForm):
    class Meta:
        model = IdeaTranslations
        fields = "__all__"

class IdeaTranslationsInline(admin.StackedInline):
    form = IdeaTranslationsForm
    model = IdeaTranslations
    extra = 0

@admin.register(Idea)
class IdeaAdmin(admin.ModelAdmin):
 inlines = [IdeaTranslationsInline]

 fieldsets = [
 (_("Author and Category"), {"fields": ["author", "categories"]}),
 (_("Title and Content"), {"fields": ["title", "content", 
         "picture"]}),
 (_("Ratings"), {"fields": ["rating"]}),
 ]

如果您访问想法的管理表单,它将如下所示:

如何做到...

要完成这个食谱,请按照以下步骤进行:

  1. 通过将"django.forms"添加到INSTALLED_APPS,在模板配置中将APP_DIRS标志设置为True,并使用"TemplatesSetting"表单渲染器,确保模板系统能够找到自定义模板:
# myproject/settings/_base.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
 "django.forms",
    # other apps…
]

TEMPLATES = [
    {
        "BACKEND": 
        "django.template.backends.django.DjangoTemplates",
        "DIRS": [os.path.join(BASE_DIR, "myproject", "templates")],
 "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",

                "django.contrib.messages.context_processors
                 .messages",
                "django.template.context_processors.media",
                "django.template.context_processors.static",
                "myproject.apps.core.context_processors
                .website_url",
            ]
        },
    }
]

FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
  1. 编辑admin.py文件如下:
# myproject/apps/ideas/admin.py from django import forms
from django.contrib import admin
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.admin import LanguageChoicesForm

from myproject.apps.categories.models import Category
from .models import Idea, IdeaTranslations

class IdeaTranslationsForm(LanguageChoicesForm):
    class Meta:
        model = IdeaTranslations
        fields = "__all__"

class IdeaTranslationsInline(admin.StackedInline):
    form = IdeaTranslationsForm
    model = IdeaTranslations
    extra = 0

class IdeaForm(forms.ModelForm):
 categories = forms.ModelMultipleChoiceField(
 label=_("Categories"),
 queryset=Category.objects.all(),
 widget=forms.CheckboxSelectMultiple(),
 required=True,
 )

 class Meta:
 model = Idea
 fields = "__all__"

    def __init__(self, *args, **kwargs):
 super().__init__(*args, **kwargs)

 self.fields[
 "picture"
        ].widget.template_name = "core/widgets/image.html"

@admin.register(Idea)
class IdeaAdmin(admin.ModelAdmin):
 form = IdeaForm
    inlines = [IdeaTranslationsInline]

    fieldsets = [
        (_("Author and Category"), {"fields": ["author", 
         "categories"]}),
        (_("Title and Content"), {"fields": ["title", "content", 
         "picture"]}),
        (_("Ratings"), {"fields": ["rating"]}),
    ]
  1. 最后,为您的图片字段创建一个模板:
{# core/widgets/image.html #} {% load i18n %}

<div style="margin-left: 160px; padding-left: 10px;">
    {% if widget.is_initial %}
        <a href="{{ widget.value.url }}">
 <img src="img/{{ widget.value.url }}" width="624" 
             height="auto" alt="" />
 </a>
        {% if not widget.required %}<br />
            {{ widget.clear_checkbox_label }}:
            <input type="checkbox" name="{{ widget.checkbox_name 
             }}" id="{{ widget.checkbox_id }}">
        {% endif %}<br />
        {{ widget.input_text }}:
    {% endif %}
    <input type="{{ widget.type }}" name="{{ widget.name }}"{% 
     include "django/forms/widgets/attrs.html" %}>
</div>
<div class="help">
 {% trans "Available formats are JPG, GIF, and PNG." %}
 {% trans "Minimal size is 800 x 800 px." %}
</div>

它是如何工作的...

如果您现在查看想法的管理表单,您会看到类似这样的东西:

这里有两个变化:

  • 现在类别选择使用的是一个带有多个复选框的小部件。

  • 现在图片字段使用特定模板呈现,显示图像预览和帮助文本,显示首选文件类型和尺寸。

我们在这里做的是覆盖了 idea 的模型表单,并修改了类别的小部件和图片字段的模板。

Django 中的默认表单渲染器是"django.forms.renderers.DjangoTemplates",它只在应用程序目录中搜索模板。我们将其更改为"django.forms.renderers.TemplatesSetting",以便在DIRS路径下的模板中也进行查找。

另请参阅

  • 在第二章使用模型翻译表食谱中

  • 上传图片食谱

  • 使用 django-crispy-forms 创建表单布局食谱

使用 django-crispy-forms 创建表单布局

django-crispy-forms Django 应用程序允许您使用以下 CSS 框架之一构建、自定义和重用表单:Uni-Form、Bootstrap 3、Bootstrap 4 或 Foundation。使用django-crispy-forms有点类似于 Django 贡献的管理中的字段集;但是,它更先进和可定制。您可以在 Python 代码中定义表单布局,而不必担心每个字段在 HTML 中的呈现方式。此外,如果您需要添加特定的 HTML 属性或包装,您也可以轻松实现。django-crispy-forms使用的所有标记都位于可以根据特定需求进行覆盖的模板中。

在这个食谱中,我们将使用 Bootstrap 4 创建一个漂亮的布局,用于添加或编辑想法的前端表单,这是一个用于开发响应式、移动优先网站项目的流行前端框架。

准备工作

我们将从本章中创建的ideas应用程序开始。接下来,我们将依次执行以下任务:

  1. 确保您已经为您的站点创建了一个base.html模板。在第四章安排 base.html 模板*食谱中了解更多。

  2. 集成 Bootstrap 4 前端框架的 CSS 和 JS 文件

getbootstrap.com/docs/4.3/getting-started/introduction/中获取到base.html模板。

  1. 在您的虚拟环境中使用pip安装django-crispy-forms(并将其包含在requirements/_base.txt中):
(env)$ pip install django-crispy-forms
  1. 确保在设置中将"crispy_forms"添加到INSTALLED_APPS中,并将"bootstrap4"设置为此项目中要使用的模板包:
# myproject/settings/_base.py
INSTALLED_APPS = (
    # ...
    "crispy_forms",
    "ideas",
)
# ...
CRISPY_TEMPLATE_PACK = "bootstrap4"

如何做到...

按照以下步骤进行:

  1. 让我们修改想法的模型表单:
# myproject/apps/ideas/forms.py from django import forms
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
from django.db import models

from crispy_forms import bootstrap, helper, layout

from .models import Idea

class IdeaForm(forms.ModelForm):
    class Meta:
        model = Idea
        exclude = ["author"]

    def __init__(self, request, *args, **kwargs):
        self.request = request
        super().__init__(*args, **kwargs)

 self.fields["categories"].widget = 
         forms.CheckboxSelectMultiple()

 title_field = layout.Field(
            "title", css_class="input-block-level"
        )
 content_field = layout.Field(
            "content", css_class="input-block-level", rows="3"
        )
 main_fieldset = layout.Fieldset(
            _("Main data"), title_field, content_field
        )

 picture_field = layout.Field(
            "picture", css_class="input-block-level"
        )
 format_html = layout.HTML(
 """{% include "ideas/includes
                /picture_guidelines.html" %}"""
        )

 picture_fieldset = layout.Fieldset(
 _("Picture"),
 picture_field,
 format_html,
 title=_("Image upload"),
 css_id="picture_fieldset",
 )

 categories_field = layout.Field(
            "categories", css_class="input-block-level"
        )
 categories_fieldset = layout.Fieldset(
 _("Categories"), categories_field,
            css_id="categories_fieldset"
        )

 submit_button = layout.Submit("save", _("Save"))
 actions = bootstrap.FormActions(submit_button)

 self.helper = helper.FormHelper()
 self.helper.form_action = self.request.path
 self.helper.form_method = "POST"
        self.helper.layout = layout.Layout(
 main_fieldset,
 picture_fieldset,
 categories_fieldset,
 actions,
 )

    def save(self, commit=True):
        instance = super().save(commit=False)
        instance.author = self.request.user
        if commit:
            instance.save()
            self.save_m2m()
        return instance
  1. 然后,让我们创建picture_guidelines.html模板,内容如下:
{# ideas/includes/picture_guidelines.html #} {% load i18n %}
<p class="form-text text-muted">
    {% trans "Available formats are JPG, GIF, and PNG." %}
    {% trans "Minimal size is 800 × 800 px." %}
</p>
  1. 最后,让我们更新想法表单的模板:
{# ideas/idea_form.html #} {% extends "base.html" %}
{% load i18n crispy_forms_tags static %}

{% block content %}
    <a href="{% url "ideas:idea_list" %}">{% trans "List of 
     ideas" %}</a>
    <h1>
        {% if idea %}
            {% blocktrans trimmed with 
             title=idea.translated_title %}
                Change Idea "{{ title }}
            {% endblocktrans %}
        {% else %}
            {% trans "Add Idea" %}
        {% endif %}
    </h1>
    {% crispy form %}
{% endblock %}

它是如何工作的...

在想法的模型表单中,我们创建了一个包含主要字段集、图片字段集、类别字段集和提交按钮的表单助手布局。每个字段集都包含字段。任何字段集、字段或按钮都可以具有附加参数,这些参数成为字段的属性,例如rows="3"placeholder=_("Please enter a title")。对于 HTML 的classid属性,有特定的参数,css_classcss_id

idea 表单页面将类似于以下内容:

就像在上一个配方中一样,我们修改了类别字段的小部件,并为图片字段添加了额外的帮助文本。

还有更多...

对于基本用法,给定的示例已经足够了。但是,如果您需要项目中表单的特定标记,您仍然可以覆盖和修改django-crispy-forms应用程序的模板,因为 Python 文件中没有硬编码的标记,而是通过模板呈现所有生成的标记。只需将django-crispy-forms应用程序中的模板复制到项目的模板目录中,并根据需要进行更改。

另请参阅

  • 使用 CRUDL 功能创建应用程序配方

  • 使用自定义模板创建表单布局配方

  • 过滤对象列表配方

  • 管理分页列表配方

  • 组成基于类的视图配方

  • 第四章,模板和 JavaScript中的安排 base.html 模板配方

使用 formsets

除了普通或模型表单外,Django 还有一个表单集的概念。这些是相同类型的表单集,允许我们一次创建或更改多个实例。Django 表单集可以通过 JavaScript 进行增强,这使我们能够动态地将它们添加到页面中。这正是我们将在本配方中要做的。我们将扩展想法的表单,以允许在同一页上为不同语言添加翻译。

准备工作

让我们继续在上一个配方使用 django-crispy-forms 创建表单布局中继续工作IdeaForm

如何做...

按照以下步骤进行:

  1. 让我们修改IdeaForm的表单布局:
# myproject/apps/ideas/forms.py from django import forms
from django.utils.translation import ugettext_lazy as _
from django.conf import settings
from django.db import models

from crispy_forms import bootstrap, helper, layout

from .models import Idea, IdeaTranslations

class IdeaForm(forms.ModelForm):
    class Meta:
        model = Idea
        exclude = ["author"]

    def __init__(self, request, *args, **kwargs):
        self.request = request
        super().__init__(*args, **kwargs)

        self.fields["categories"].widget = 
         forms.CheckboxSelectMultiple()

        title_field = layout.Field(
            "title", css_class="input-block-level"
        )
        content_field = layout.Field(
            "content", css_class="input-block-level", rows="3"
        )
        main_fieldset = layout.Fieldset(
            _("Main data"), title_field, content_field
        )

        picture_field = layout.Field(
            "picture", css_class="input-block-level"
        )
        format_html = layout.HTML(
            """{% include "ideas/includes
                /picture_guidelines.html" %}"""
        )

        picture_fieldset = layout.Fieldset(
            _("Picture"),
            picture_field,
            format_html,
            title=_("Image upload"),
            css_id="picture_fieldset",
        )

        categories_field = layout.Field(
            "categories", css_class="input-block-level"
        )
        categories_fieldset = layout.Fieldset(
            _("Categories"), categories_field,
            css_id="categories_fieldset"
        )

        inline_translations = layout.HTML(
 """{% include "ideas/forms/translations.html" %}"""
        )

        submit_button = layout.Submit("save", _("Save"))
        actions = bootstrap.FormActions(submit_button)

        self.helper = helper.FormHelper()
        self.helper.form_action = self.request.path
        self.helper.form_method = "POST"
        self.helper.layout = layout.Layout(
            main_fieldset,
            inline_translations,
            picture_fieldset,
            categories_fieldset,
            actions,
        )

    def save(self, commit=True):
        instance = super().save(commit=False)
        instance.author = self.request.user
        if commit:
            instance.save()
            self.save_m2m()
        return instance
  1. 然后,在同一个文件的末尾添加IdeaTranslationsForm
class IdeaTranslationsForm(forms.ModelForm):
 language = forms.ChoiceField(
 label=_("Language"),
 choices=settings.LANGUAGES_EXCEPT_THE_DEFAULT,
 required=True,
 )

 class Meta:
 model = IdeaTranslations
 exclude = ["idea"]

 def __init__(self, request, *args, **kwargs):
 self.request = request
 super().__init__(*args, **kwargs)

 id_field = layout.Field("id")
 language_field = layout.Field(
            "language", css_class="input-block-level"
        )
 title_field = layout.Field(
            "title", css_class="input-block-level"
        )
 content_field = layout.Field(
            "content", css_class="input-block-level", rows="3"
        )
 delete_field = layout.Field("DELETE")
 main_fieldset = layout.Fieldset(
 _("Main data"),
 id_field,
 language_field,
 title_field,
 content_field,
 delete_field,
 )

 self.helper = helper.FormHelper()
 self.helper.form_tag = False
        self.helper.disable_csrf = True
        self.helper.layout = layout.Layout(main_fieldset)
  1. 修改视图以添加或更改想法,如下所示:
# myproject/apps/ideas/views.py from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect, get_object_or_404
from django.forms import modelformset_factory
from django.conf import settings

from .forms import IdeaForm, IdeaTranslationsForm
from .models import Idea, IdeaTranslations

@login_required
def add_or_change_idea(request, pk=None):
    idea = None
    if pk:
        idea = get_object_or_404(Idea, pk=pk)
    IdeaTranslationsFormSet = modelformset_factory(
 IdeaTranslations, form=IdeaTranslationsForm, 
 extra=0, can_delete=True
    )
    if request.method == "POST":
        form = IdeaForm(request, data=request.POST, 
         files=request.FILES, instance=idea)
        translations_formset = IdeaTranslationsFormSet(
 queryset=IdeaTranslations.objects.filter(idea=idea),
 data=request.POST,
 files=request.FILES,
 prefix="translations",
 form_kwargs={"request": request},
 )
        if form.is_valid() and translations_formset.is_valid():
            idea = form.save()
 translations = translations_formset.save(
 commit=False
            )
 for translation in translations:
 translation.idea = idea
 translation.save()
 translations_formset.save_m2m()
 for translation in 
             translations_formset.deleted_objects:
 translation.delete()
            return redirect("ideas:idea_detail", pk=idea.pk)
    else:
        form = IdeaForm(request, instance=idea)
 translations_formset = IdeaTranslationsFormSet(
 queryset=IdeaTranslations.objects.filter(idea=idea),
 prefix="translations",
 form_kwargs={"request": request},
 )

    context = {
        "idea": idea, 
        "form": form, 
 "translations_formset": translations_formset
    }
    return render(request, "ideas/idea_form.html", context)
  1. 然后,让我们编辑idea_form.html模板,并在末尾添加对inlines.js脚本文件的引用:
{# ideas/idea_form.html #}
{% extends "base.html" %}
{% load i18n crispy_forms_tags static %}

{% block content %}
    <a href="{% url "ideas:idea_list" %}">{% trans "List of 
     ideas" %}</a>
    <h1>
        {% if idea %}
            {% blocktrans trimmed with 
             title=idea.translated_title %}
                Change Idea "{{ title }}"
            {% endblocktrans %}
        {% else %}
            {% trans "Add Idea" %}
        {% endif %}
    </h1>
    {% crispy form %}
{% endblock %}

{% block js %}
 <script src="img/inlines.js' %}"></script>
{% endblock %}
  1. 为翻译 formsets 创建模板:
{# ideas/forms/translations.html #} {% load i18n crispy_forms_tags %}
<section id="translations_section" class="formset my-3">
    {{ translations_formset.management_form }}
    <h3>{% trans "Translations" %}</h3>
    <div class="formset-forms">
        {% for formset_form in translations_formset %}
            <div class="formset-form">
                {% crispy formset_form %}
            </div>
        {% endfor %}
    </div>
    <button type="button" class="btn btn-primary btn-sm 
     add-inline-form">{% trans "Add translations to another 
     language" %}</button>
    <div class="empty-form d-none">
        {% crispy translations_formset.empty_form %}
    </div>
</section>
  1. 最后但并非最不重要的是,添加 JavaScript 来操作 formsets:
/* site/js/inlines.js */ window.WIDGET_INIT_REGISTER = window.WIDGET_INIT_REGISTER || [];

$(function () {
    function reinit_widgets($formset_form) {
        $(window.WIDGET_INIT_REGISTER).each(function (index, func) 
        {
            func($formset_form);
        });
    }

    function set_index_for_fields($formset_form, index) {
        $formset_form.find(':input').each(function () {
            var $field = $(this);
            if ($field.attr("id")) {
                $field.attr(
                    "id",
                    $field.attr("id").replace(/-__prefix__-/, 
                     "-" + index + "-")
                );
            }
            if ($field.attr("name")) {
                $field.attr(
                    "name",
                    $field.attr("name").replace(
                        /-__prefix__-/, "-" + index + "-"
                    )
                );
            }
        });
        $formset_form.find('label').each(function () {
            var $field = $(this);
            if ($field.attr("for")) {
                $field.attr(
                    "for",
                    $field.attr("for").replace(
                        /-__prefix__-/, "-" + index + "-"
                    )
                );
            }
        });
        $formset_form.find('div').each(function () {
            var $field = $(this);
            if ($field.attr("id")) {
                $field.attr(
                    "id",
                    $field.attr("id").replace(
                        /-__prefix__-/, "-" + index + "-"
                    )
                );
            }
        });
    }

    function add_delete_button($formset_form) {
        $formset_form.find('input:checkbox[id$=DELETE]')
         .each(function () {
            var $checkbox = $(this);
            var $deleteLink = $(
                '<button class="delete btn btn-sm 
                  btn-danger mb-3">Remove</button>'
            );
            $formset_form.append($deleteLink);
            $checkbox.closest('.form-group').hide();
        });

    }

    $('.add-inline-form').click(function (e) {
        e.preventDefault();
        var $formset = $(this).closest('.formset');
        var $total_forms = $formset.find('[id$="TOTAL_FORMS"]');
        var $new_form = $formset.find('.empty-form')
        .clone(true).attr("id", null);
        $new_form.removeClass('empty-form d-none')
        .addClass('formset-form');
        set_index_for_fields($new_form, 
         parseInt($total_forms.val(), 10));
        $formset.find('.formset-forms').append($new_form);
        add_delete_button($new_form);
        $total_forms.val(parseInt($total_forms.val(), 10) + 1);
        reinit_widgets($new_form);
    });
    $('.formset-form').each(function () {
        $formset_form = $(this);
        add_delete_button($formset_form);
        reinit_widgets($formset_form);
    });
    $(document).on('click', '.delete', function (e) {
        e.preventDefault();
        var $formset = $(this).closest('.formset-form');
        var $checkbox = 
        $formset.find('input:checkbox[id$=DELETE]');
        $checkbox.attr("checked", "checked");
        $formset.hide();
    });
});

它是如何工作的...

您可能已经从 Django 模型管理中了解了 formsets。在那里,formsets 用于具有对父模型的外键的子模型的 inlines 机制。

在这个配方中,我们使用django-crispy-forms向 idea 表单添加了 formsets。结果将如下所示:

正如您所看到的,我们可以将 formsets 插入到表单的末尾,也可以在其中任何位置插入,只要有意义。在我们的示例中,将翻译列出在可翻译字段之后是有意义的。

翻译表单的表单布局与IdeaForm的布局一样,但另外还有idDELETE字段,这对于识别每个模型实例和从列表中删除它们是必要的。DELETE字段实际上是一个复选框,如果选中,将从数据库中删除相应的项目。此外,翻译的表单助手具有form_tag=False,它不生成<form>标签,以及disable_csrf=True,它不包括 CSRF 令牌,因为我们已经在父表单IdeaForm中定义了这些内容。

在视图中,如果请求是通过 POST 方法发送的,并且表单和表单集都有效,则我们保存表单并创建相应的翻译实例,但首先不保存它们。这是通过commit=False属性完成的。对于每个翻译实例,我们分配想法,然后将翻译保存到数据库中。最后,我们检查表单集中是否有任何标记为删除的表单,并将其从数据库中删除。

translations.html模板中,我们渲染表单集中的每个表单,然后添加一个额外的隐藏空表单,JavaScript 将使用它来动态生成表单集的新表单。

每个表单集表单都有所有字段的前缀。例如,第一个表单集表单的title字段将具有 HTML 字段名称"translations-0-title",同一表单集表单的DELETE字段将具有 HTML 字段名称"translations-0-DELETE"。空表单具有一个单词"__prefix__",而不是索引,例如"translations-__prefix__-title"。这在 Django 级别进行了抽象,但是在使用 JavaScript 操纵表单集表单时需要了解这一点。

inlines.js JavaScript 执行了一些操作:

  • 对于每个现有的表单集表单,它初始化其 JavaScript 驱动的小部件(您可以使用工具提示、日期或颜色选择器、地图等),并创建一个删除按钮,该按钮显示在DELETE复选框的位置。

  • 当单击删除按钮时,它会检查DELETE复选框并将表单集表单隐藏在用户视野之外。

  • 当单击添加按钮时,它会克隆空表单,并用下一个可用索引替换"__prefix__",将新表单添加到列表中,并初始化 JavaScript 驱动的小部件。

还有更多...

JavaScript 使用一个数组window.WIDGET_INIT_REGISTER,其中包含应调用以初始化具有给定表单集表单的小部件的函数。要在另一个 JavaScript 文件中注册新函数,可以执行以下操作:

/* site/js/main.js */ function apply_tooltips($formset_form) {
    $formset_form.find('[data-toggle="tooltip"]').tooltip();
}

/* register widget initialization for a formset form */
window.WIDGET_INIT_REGISTER = window.WIDGET_INIT_REGISTER || [];
window.WIDGET_INIT_REGISTER.push(apply_tooltips);

这将为标记中具有data-toggle="tooltip"title属性的表单集表单中的所有出现应用工具提示功能,就像这个例子中一样:

<button data-toggle="tooltip" title="{% trans 'Remove this translation' %}">{% trans "Remove" %}</button>

另请参阅

  • 使用 django-crispy-forms 创建表单布局的配方

  • 第四章,模板和 JavaScript中的安排 base.html 模板配方

过滤对象列表

在 Web 开发中,除了具有表单的视图之外,还典型地具有对象列表视图和详细视图。列表视图可以简单地列出按字母顺序或创建日期排序的对象;然而,对于大量数据来说,这并不是非常用户友好的。为了获得最佳的可访问性和便利性,您应该能够按所有可能的类别对内容进行筛选。在本配方中,我们将看到用于按任意数量的类别筛选列表视图的模式。

我们将要创建的是一个可以按作者、类别或评分进行筛选的想法列表视图。它将类似于以下内容,并应用了 Bootstrap 4:

准备工作

对于筛选示例,我们将使用具有与作者和类别相关的Idea模型。还可以按评分进行筛选,这是具有选择的PositiveIntegerField。让我们使用先前配方中创建的模型的 ideas 应用。

如何做...

要完成这个配方,请按照以下步骤操作:

  1. 创建IdeaFilterForm,其中包含所有可能的类别以进行过滤:
# myproject/apps/ideas/forms.py from django import forms
from django.utils.translation import ugettext_lazy as _
from django.db import models
from django.contrib.auth import get_user_model

from myproject.apps.categories.models import Category

from .models import RATING_CHOICES

User = get_user_model()

class IdeaFilterForm(forms.Form):
    author = forms.ModelChoiceField(
        label=_("Author"),
        required=False,
        queryset=User.objects.annotate(
            idea_count=models.Count("authored_ideas")
        ).filter(idea_count__gt=0),
    )
    category = forms.ModelChoiceField(
        label=_("Category"),
        required=False,
        queryset=Category.objects.annotate(
            idea_count=models.Count("category_ideas")
        ).filter(idea_count__gt=0),
    )
    rating = forms.ChoiceField(
        label=_("Rating"), required=False, choices=RATING_CHOICES
    )
  1. 创建idea_list视图以列出经过筛选的想法:
# myproject/apps/ideas/views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.conf import settings

from .forms import IdeaFilterForm
from .models import Idea, RATING_CHOICES

PAGE_SIZE = getattr(settings, "PAGE_SIZE", 24)

def idea_list(request):
    qs = Idea.objects.order_by("title")
    form = IdeaFilterForm(data=request.GET)

    facets = {
        "selected": {},
        "categories": {
            "authors": form.fields["author"].queryset,
            "categories": form.fields["category"].queryset,
            "ratings": RATING_CHOICES,
        },
    }

    if form.is_valid():
        filters = (
            # query parameter, filter parameter
            ("author", "author"),
            ("category", "categories"),
            ("rating", "rating"),
        )
        qs = filter_facets(facets, qs, form, filters)

    context = {"form": form, "facets": facets, "object_list": qs}
    return render(request, "ideas/idea_list.html", context)
  1. 在同一文件中,添加辅助函数filter_facets()
def filter_facets(facets, qs, form, filters):
    for query_param, filter_param in filters:
        value = form.cleaned_data[query_param]
        if value:
            selected_value = value
            if query_param == "rating":
                rating = int(value)
                selected_value = (rating, 
                 dict(RATING_CHOICES)[rating])
            facets["selected"][query_param] = selected_value
            filter_args = {filter_param: value}
            qs = qs.filter(**filter_args).distinct()
    return qs
  1. 如果尚未这样做,请创建base.html模板。您可以根据第四章,模板和 JavaScript中的安排 base.html 模板配方中提供的示例进行操作。

  2. 创建idea_list.html模板,内容如下:

{# ideas/idea_list.html #}
{% extends "base.html" %}
{% load i18n utility_tags %}

{% block sidebar %}
    {% include "ideas/includes/filters.html" %}
{% endblock %}

{% block main %}
    <h1>{% trans "Ideas" %}</h1>
    {% if object_list %}
        {% for idea in object_list %}
            <a href="{{ idea.get_url_path }}" class="d-block my-3">
                <div class="card">
                  <img src="img/{{ idea.picture_thumbnail.url }}" 
                   alt="" />
                  <div class="card-body">
                    <p class="card-text">{{ idea.translated_title 
                     }}</p>
                  </div>
                </div>
            </a>
        {% endfor %}
    {% else %}
        <p>{% trans "There are no ideas yet." %}</p>
    {% endif %}
    <a href="{% url 'ideas:add_idea' %}" class="btn btn-primary">
     {% trans "Add idea" %}</a>
{% endblock %}
  1. 然后,让我们创建过滤器的模板。此模板使用了在第五章,自定义模板过滤器和标记中描述的{% modify_query %}模板标记,以生成过滤器的 URL:
{# ideas/includes/filters.html #} {% load i18n utility_tags %}
<div class="filters panel-group" id="accordion">
    {% with title=_('Author') selected=facets.selected.author %}
        <div class="panel panel-default my-3">
            {% include "misc/includes/filter_heading.html" with 
             title=title %}
            <div id="collapse-{{ title|slugify }}"
                 class="panel-collapse{% if not selected %} 
                  collapse{% endif %}">
                <div class="panel-body"><div class="list-group">
                    {% include "misc/includes/filter_all.html" with 
                     param="author" %}
                    {% for cat in facets.categories.authors %}
                        <a class="list-group-item
                          {% if selected == cat %}
                          active{% endif %}"
                           href="{% modify_query "page" 
                            author=cat.pk %}">
                            {{ cat }}</a>
                    {% endfor %}
                </div></div>
            </div>
        </div>
    {% endwith %}
    {% with title=_('Category') selected=facets.selected
      .category %}
        <div class="panel panel-default my-3">
            {% include "misc/includes/filter_heading.html" with 
               title=title %}
            <div id="collapse-{{ title|slugify }}"
                 class="panel-collapse{% if not selected %} 
                  collapse{% endif %}">
                <div class="panel-body"><div class="list-group">
                    {% include "misc/includes/filter_all.html" with 
                      param="category" %}
                    {% for cat in facets.categories.categories %}
                        <a class="list-group-item
                          {% if selected == cat %}
                          active{% endif %}"
                           href="{% modify_query "page" 
                            category=cat.pk %}">
                            {{ cat }}</a>
                    {% endfor %}
                </div></div>
            </div>
        </div>
    {% endwith %}
    {% with title=_('Rating') selected=facets.selected.rating %}
        <div class="panel panel-default my-3">
            {% include "misc/includes/filter_heading.html" with 
              title=title %}
            <div id="collapse-{{ title|slugify }}"
                 class="panel-collapse{% if not selected %} 
                  collapse{% endif %}">
                <div class="panel-body"><div class="list-group">
                    {% include "misc/includes/filter_all.html" with 
                     param="rating" %}
                    {% for r_val, r_display in 
                      facets.categories.ratings %}
                        <a class="list-group-item
                          {% if selected.0 == r_val %}
                          active{% endif %}"
                           href="{% modify_query "page" 
                            rating=r_val %}">
                            {{ r_display }}</a>
                    {% endfor %}
                </div></div>
            </div>
        </div>
    {% endwith %}
</div>
  1. 每个类别将遵循过滤器侧边栏中的通用模式,因此我们可以创建和包含具有共同部分的模板。首先,我们有过滤器标题,对应于misc/includes/filter_heading.html,如下所示:
{# misc/includes/filter_heading.html #} {% load i18n %}
<div class="panel-heading">
    <h6 class="panel-title">
        <a data-toggle="collapse" data-parent="#accordion"
           href="#collapse-{{ title|slugify }}">
            {% blocktrans trimmed %}
                Filter by {{ title }}
            {% endblocktrans %}
        </a>
    </h6>
</div>
  1. 然后,每个过滤器将包含一个重置该类别过滤的链接,在这里表示为misc/includes/filter_all.html。此模板还使用了{% modify_query %}模板标记,在第五章,自定义模板过滤器和标记中描述了这个模板标记:
{# misc/includes/filter_all.html #} {% load i18n utility_tags %}
<a class="list-group-item {% if not selected %}active{% endif %}"
   href="{% modify_query "page" param %}">
    {% trans "All" %}
</a>
  1. 需要将想法列表添加到ideas应用的 URL 中:
# myproject/apps/ideas/urls.py from django.urls import path

from .views import idea_list

urlpatterns = [
    path("", idea_list, name="idea_list"),
    # other paths…
]

它是如何工作的...

我们正在使用传递给模板上下文的facets字典来了解我们有哪些过滤器以及选择了哪些过滤器。要深入了解,facets字典包括两个部分:categories字典和selected字典。categories字典包含所有可过滤类别的 QuerySets 或选择。selected字典包含每个类别的当前选定值。在IdeaFilterForm中,我们确保只列出至少有一个想法的类别和作者。

在视图中,我们检查表单中的查询参数是否有效,然后根据所选类别过滤对象的 QuerySet。此外,我们将选定的值设置为将传递给模板的facets字典。

在模板中,对于facets字典中的每个分类,我们列出所有类别,并将当前选定的类别标记为活动状态。如果没有为给定类别选择任何内容,我们将默认的“全部”链接标记为活动状态。

另请参阅

  • 管理分页列表配方

  • 基于类的视图的组合配方

  • 安排 base.html 模板配方在第四章,模板和 JavaScript

  • 在第五章,自定义模板过滤器和标记中描述的创建一个模板标记来修改请求查询参数配方

管理分页列表

如果您有动态更改的对象列表或其数量大于 24 个左右,您可能需要分页以提供良好的用户体验。分页不提供完整的 QuerySet,而是提供数据集中特定数量的项目,这对应于一页的适当大小。我们还显示链接,允许用户访问组成完整数据集的其他页面。Django 有用于管理分页数据的类,我们将看到如何在这个配方中使用它们。

准备工作

让我们从过滤对象列表配方开始ideas应用的模型、表单和视图。

如何做...

要将分页添加到想法的列表视图中,请按照以下步骤操作:

  1. 从 Django 中导入必要的分页类到views.py文件中。我们将在过滤后的idea_list视图中添加分页管理。此外,我们将通过将page分配给object_list键,稍微修改上下文字典:
# myproject/apps/ideas/views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.conf import settings
from django.core.paginator import (EmptyPage, PageNotAnInteger, Paginator)

from .forms import IdeaFilterForm
from .models import Idea, RATING_CHOICES

PAGE_SIZE = getattr(settings, "PAGE_SIZE", 24)

def idea_list(request):
    qs = Idea.objects.order_by("title")
    form = IdeaFilterForm(data=request.GET)

    facets = {
        "selected": {},
        "categories": {
            "authors": form.fields["author"].queryset,
            "categories": form.fields["category"].queryset,
            "ratings": RATING_CHOICES,
        },
    }

    if form.is_valid():
        filters = (
            # query parameter, filter parameter
            ("author", "author"),
            ("category", "categories"),
            ("rating", "rating"),
        )
        qs = filter_facets(facets, qs, form, filters)

 paginator = Paginator(qs, PAGE_SIZE)
 page_number = request.GET.get("page")
 try:
 page = paginator.page(page_number)
 except PageNotAnInteger:
 # If page is not an integer, show first page.
 page = paginator.page(1)
 except EmptyPage:
 # If page is out of range, show last existing page.
 page = paginator.page(paginator.num_pages)

    context = {
        "form": form,
        "facets": facets, 
        "object_list": page,
    }
    return render(request, "ideas/idea_list.html", context)
  1. 修改idea_list.html模板如下:
{# ideas/idea_list.html #}
{% extends "base.html" %}
{% load i18n utility_tags %}

{% block sidebar %}
    {% include "ideas/includes/filters.html" %}
{% endblock %}

{% block main %}
    <h1>{% trans "Ideas" %}</h1>
    {% if object_list %}
        {% for idea in object_list %}
            <a href="{{ idea.get_url_path }}" class="d-block my-3">
                <div class="card">
                  <img src="img/{{ idea.picture_thumbnail.url }}" 
                   alt="" />
                  <div class="card-body">
                    <p class="card-text">{{ idea.translated_title 
                     }}</p>
                  </div>
                </div>
            </a>
        {% endfor %}
        {% include "misc/includes/pagination.html" %}
    {% else %}
        <p>{% trans "There are no ideas yet." %}</p>
    {% endif %}
    <a href="{% url 'ideas:add_idea' %}" class="btn btn-primary">
     {% trans "Add idea" %}</a>
{% endblock %}
  1. 创建分页小部件模板:
{# misc/includes/pagination.html #} {% load i18n utility_tags %}
{% if object_list.has_other_pages %}
    <nav aria-label="{% trans 'Page navigation' %}">

        <ul class="pagination">
            {% if object_list.has_previous %}
                <li class="page-item"><a class="page-link" href="{% 
          modify_query page=object_list.previous_page_number %}">
                    {% trans "Previous" %}</a></li>
            {% else %}
                <li class="page-item disabled"><span class="page-
                 link">{% trans "Previous" %}</span></li>
            {% endif %}

            {% for page_number in object_list.paginator
             .page_range %}
                {% if page_number == object_list.number %}
                    <li class="page-item active">
                        <span class="page-link">{{ page_number }}
                            <span class="sr-only">{% trans 
                             "(current)" %}</span>
                        </span>
                    </li>
                {% else %}
                    <li class="page-item">
                        <a class="page-link" href="{% modify_query 
                         page=page_number %}">
                            {{ page_number }}</a>
                    </li>
                {% endif %}
            {% endfor %}

            {% if object_list.has_next %}
                <li class="page-item"><a class="page-link" href="{% 
             modify_query page=object_list.next_page_number %}">
                    {% trans "Next" %}</a></li>
            {% else %}
                <li class="page-item disabled"><span class="page-
                 link">{% trans "Next" %}</span></li>
            {% endif %}
        </ul>
    </nav>
{% endif %}

它是如何工作的...

当您在浏览器中查看结果时,您将看到分页控件,类似于以下内容:

我们如何实现这一点?当 QuerySet 被过滤掉时,我们将创建一个分页器对象,传递 QuerySet 和我们想要每页显示的最大项目数,这里是 24。然后,我们将从查询参数page中读取当前页码。下一步是从分页器中检索当前页对象。如果页码不是整数,我们获取第一页。如果页码超过可能的页数,就会检索到最后一页。页面对象具有分页小部件中所需的方法和属性,如前面截图中所示。此外,页面对象的行为类似于 QuerySet,因此我们可以遍历它并从页面的一部分获取项目。

模板中标记的片段创建了一个分页小部件,其中包含 Bootstrap 4 前端框架的标记。只有在当前页面多于一个时,我们才显示分页控件。我们有到上一页和下一页的链接,以及小部件中所有页面编号的列表。当前页码被标记为活动状态。为了生成链接的 URL,我们使用{% modify_query %}模板标签,稍后将在第五章,自定义模板过滤器和标签创建一个模板标签以修改请求查询参数方法中进行描述。

另请参阅

  • 过滤对象列表的方法

  • 组合基于类的视图的方法

  • 创建一个模板标签以修改请求查询参数的方法在第五章,自定义模板过滤器和标签

组合基于类的视图

Django 视图是可调用的,接受请求并返回响应。除了基于函数的视图之外,Django 还提供了一种将视图定义为类的替代方法。当您想要创建可重用的模块化视图或组合通用混合视图时,这种方法非常有用。在这个方法中,我们将之前显示的基于函数的idea_list视图转换为基于类的IdeaListView视图。

准备工作

创建与前面的过滤对象列表管理分页列表类似的模型、表单和模板。

如何做...

按照以下步骤执行该方法:

  1. 我们的基于类的视图IdeaListView将继承 Django 的View类并重写get()方法:
# myproject/apps/ideas/views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.conf import settings
from django.core.paginator import (EmptyPage, PageNotAnInteger, Paginator)
from django.views.generic import View

from .forms import IdeaFilterForm
from .models import Idea, RATING_CHOICES

PAGE_SIZE = getattr(settings, "PAGE_SIZE", 24)

class IdeaListView(View):
    form_class = IdeaFilterForm
    template_name = "ideas/idea_list.html"

    def get(self, request, *args, **kwargs):
        form = self.form_class(data=request.GET)
        qs, facets = self.get_queryset_and_facets(form)
        page = self.get_page(request, qs)
        context = {"form": form, "facets": facets, 
         "object_list": page}
        return render(request, self.template_name, context)

    def get_queryset_and_facets(self, form):
        qs = Idea.objects.order_by("title")
        facets = {
            "selected": {},
            "categories": {
                "authors": form.fields["author"].queryset,
                "categories": form.fields["category"].queryset,
                "ratings": RATING_CHOICES,
            },
        }
        if form.is_valid():
            filters = (
                # query parameter, filter parameter
                ("author", "author"),
                ("category", "categories"),
                ("rating", "rating"),
            )
            qs = self.filter_facets(facets, qs, form, filters)
        return qs, facets

    @staticmethod
    def filter_facets(facets, qs, form, filters):
        for query_param, filter_param in filters:
            value = form.cleaned_data[query_param]
            if value:
                selected_value = value
                if query_param == "rating":
                    rating = int(value)
                    selected_value = (rating,  
                     dict(RATING_CHOICES)[rating])
                facets["selected"][query_param] = selected_value
                filter_args = {filter_param: value}
                qs = qs.filter(**filter_args).distinct()
        return qs

    def get_page(self, request, qs):
        paginator = Paginator(qs, PAGE_SIZE)
        page_number = request.GET.get("page")
        try:
            page = paginator.page(page_number)
        except PageNotAnInteger:
            page = paginator.page(1)
        except EmptyPage:
            page = paginator.page(paginator.num_pages)
        return page
  1. 我们需要在 URL 配置中创建一个 URL 规则,使用基于类的视图。您可能之前已经为基于函数的idea_list视图添加了一个规则,这将是类似的。要在 URL 规则中包含基于类的视图,使用as_view()方法如下:
# myproject/apps/ideas/urls.py from django.urls import path

from .views import IdeaListView

urlpatterns = [
path("", IdeaListView.as_view(), name="idea_list"),
    # other paths…
]

它是如何工作的...

以下是get()方法中发生的事情,该方法用于处理 HTTP GET 请求:

  • 首先,我们创建form对象,将request.GET类似字典的对象传递给它。request.GET对象包含使用 GET 方法传递的所有查询变量。

  • 然后,将form对象传递给get_queryset_and_facets()方法,该方法通过包含两个元素的元组返回相关值:QuerySet 和facets字典。

  • 将当前请求对象和检索到的 QuerySet 传递给get_page()方法,该方法返回当前页对象。

  • 最后,我们创建一个context字典并呈现响应。

如果需要支持,我们还可以提供一个post()方法,该方法用于处理 HTTP POST 请求。

还有更多...

正如你所看到的,get()get_page()方法在很大程度上是通用的,因此我们可以在core应用程序中使用这些方法创建一个通用的FilterableListView类。然后,在任何需要可过滤列表的应用程序中,我们可以创建一个基于类的视图,该视图扩展了FilterableListView以处理这种情况。这个扩展类只需定义form_classtemplate_name属性以及get_queryset_and_facets()方法。这种模块化和可扩展性代表了基于类的视图工作的两个关键优点。

另请参阅

  • 过滤对象列表的步骤

  • 管理分页列表的步骤

提供 Open Graph 和 Twitter Card 数据

如果您希望网站的内容在社交网络上分享,您至少应该实现 Open Graph 和 Twitter Card 元标记。这些元标记定义了网页在 Facebook 或 Twitter 动态中的呈现方式:将显示什么标题和描述,将设置什么图片,以及 URL 是关于什么的。在这个步骤中,我们将为idea_detail.html模板准备社交分享。

准备工作

让我们继续使用之前步骤中的ideas应用。

如何操作...

按照以下步骤完成步骤:

  1. 确保已创建包含图片字段和图片版本规格的Idea模型。有关更多信息,请参阅使用 CRUDL 功能创建应用上传图片的步骤。

  2. 确保为 ideas 准备好详细视图。有关如何操作,请参阅使用 CRUDL 功能创建应用的步骤。

  3. 将详细视图插入 URL 配置中。如何操作在使用 CRUDL 功能创建应用的步骤中有描述。

  4. 在特定环境的设置中,定义WEBSITE_URLMEDIA_URL作为媒体文件的完整 URL,就像这个例子中一样:

# myproject/settings/dev.py from ._base import *

DEBUG = True
WEBSITE_URL = "http://127.0.0.1:8000" # without trailing slash
MEDIA_URL = f"{WEBSITE_URL}/media/"

  1. core应用中,创建一个上下文处理器,从设置中返回WEBSITE_URL变量:
# myproject/apps/core/context_processors.py from django.conf import settings

def website_url(request):
    return {
        "WEBSITE_URL": settings.WEBSITE_URL,
    }
  1. 在设置中插入上下文处理器:
# myproject/settings/_base.py
TEMPLATES = [
    {
        "BACKEND": 
        "django.template.backends.django.DjangoTemplates",
        "DIRS": [os.path.join(BASE_DIR, "myproject", "templates")],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors
                 .messages",
                "django.template.context_processors.media",
                "django.template.context_processors.static",
                "myproject.apps.core.context_processors
                .website_url",
            ]
        },
    }
]
  1. 创建包含以下内容的idea_detail.html模板:
{# ideas/idea_detail.html #} {% extends "base.html" %}
{% load i18n %}

{% block meta_tags %}
 <meta property="og:type" content="website" />
 <meta property="og:url" content="{{ WEBSITE_URL }}
     {{ request.path }}" />
 <meta property="og:title" content="{{ idea.translated_title }}" 
     />
 {% if idea.picture_social %}
 <meta property="og:image" content=
         "{{ idea.picture_social.url }}" />
 <!-- Next tags are optional but recommended -->
        <meta property="og:image:width" content=
         "{{ idea.picture_social.width }}" />
 <meta property="og:image:height" content=
         "{{ idea.picture_social.height }}" />
 {% endif %}
 <meta property="og:description" content=
     "{{ idea.translated_content }}" />
 <meta property="og:site_name" content="MyProject" />
 <meta property="og:locale" content="{{ LANGUAGE_CODE }}" />

 <meta name="twitter:card" content="summary_large_image">
 <meta name="twitter:site" content="@DjangoTricks">
 <meta name="twitter:creator" content="@archatas">
 <meta name="twitter:url" content="{{ WEBSITE_URL }}
     {{ request.path }}">
 <meta name="twitter:title" content=
     "{{ idea.translated_title }}">
 <meta name="twitter:description" content=
     "{{ idea.translated_content }}">
 {% if idea.picture_social %}
 <meta name="twitter:image" content=
         "{{ idea.picture_social.url }}">
 {% endif %}
{% endblock %}

{% block content %}
    <a href="{% url "ideas:idea_list" %}">
     {% trans "List of ideas" %}</a>
    <h1>
        {% blocktrans trimmed with title=idea.translated_title %}
            Idea "{{ title }}"
        {% endblocktrans %}
    </h1>
    <img src="img/{{ idea.picture_large.url }}" alt="" />
    {{ idea.translated_content|linebreaks|urlize }}
    <p>
        {% for category in idea.categories.all %}
            <span class="badge badge-pill badge-info">
             {{ category.translated_title }}</span>
        {% endfor %}
    </p>
    <a href="{% url 'ideas:change_idea' pk=idea.pk %}" 
     class="btn btn-primary">{% trans "Change this idea" %}</a>
    <a href="{% url 'ideas:delete_idea' pk=idea.pk %}" 
     class="btn btn-danger">{% trans "Delete this idea" %}</a>
{% endblock %} 

它是如何工作的...

Open Graph 标签是具有以og:开头的特殊名称的元标记,Twitter 卡片标签是具有以twitter:开头的特殊名称的元标记。这些元标记定义了当前页面的 URL、标题、描述和图片,站点名称、作者和区域设置。在这里提供完整的 URL 是很重要的;仅提供路径是不够的。

我们使用了picture_social图片版本,其在社交网络上具有最佳尺寸:1024×512 像素。

您可以在developers.facebook.com/tools/debug/sharing/上验证您的 Open Graph 实现。

Twitter 卡片实现可以在cards-dev.twitter.com/validator上进行验证。

另请参阅

  • 使用 CRUDL 功能创建应用的步骤

  • 上传图片的步骤

  • 提供 schema.org 词汇的步骤

提供 schema.org 词汇

对于搜索引擎优化SEO)来说,拥有语义标记是很重要的。但为了进一步提高搜索引擎排名,根据 schema.org 词汇提供结构化数据是很有益的。许多来自 Google、Microsoft、Pinterest、Yandex 等的应用程序使用 schema.org 结构,以创建丰富的可扩展体验,比如在搜索结果中为事件、电影、作者等创建特殊的一致外观卡片。

有几种编码,包括 RDFa、Microdata 和 JSON-LD,可以用来创建 schema.org 词汇。在这个步骤中,我们将以 JSON-LD 格式为Idea模型准备结构化数据,这是 Google 首选和推荐的格式。

准备工作

让我们将django-json-ld包安装到项目的虚拟环境中(并将其包含在requirements/_base.txt中):

(env)$ pip install django-json-ld==0.0.4

在设置中的INSTALLED_APPS下放置"django_json_ld"

# myproject/settings/_base.py
INSTALLED_APPS = [
    # other apps…
 "django_json_ld",
]

如何操作...

按照以下步骤完成步骤:

  1. Idea模型中添加包含以下内容的structured_data属性:
# myproject/apps/ideas/models.py
from django.db import models
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.models import ( CreationModificationDateBase, UrlBase )

class Idea(CreationModificationDateBase, UrlBase):
    # attributes, fields, properties, and methods…

 @property
    def structured_data(self):
 from django.utils.translation import get_language

 lang_code = get_language()
 data = {
 "@type": "CreativeWork",
 "name": self.translated_title,
 "description": self.translated_content,
 "inLanguage": lang_code,
 }
 if self.author:
 data["author"] = {
 "@type": "Person",
 "name": self.author.get_full_name() or 
                 self.author.username,
 }
 if self.picture:
 data["image"] = self.picture_social.url
 return data
  1. 修改idea_detail.html模板:
{# ideas/idea_detail.html #} {% extends "base.html" %}
{% load i18n json_ld %}

{% block meta_tags %}
    {# Open Graph and Twitter Card meta tags here… #}

    {% render_json_ld idea.structured_data %}
{% endblock %}

{% block content %}
    <a href="{% url "ideas:idea_list" %}">
     {% trans "List of ideas" %}</a>
    <h1>
        {% blocktrans trimmed with title=idea.translated_title %}
            Idea "{{ title }}"
        {% endblocktrans %}
    </h1>
    <img src="img/{{ idea.picture_large.url }}" alt="" />
    {{ idea.translated_content|linebreaks|urlize }}
    <p>
        {% for category in idea.categories.all %}
            <span class="badge badge-pill badge-info">
             {{ category.translated_title }}</span>
        {% endfor %}
    </p>
    <a href="{% url 'ideas:change_idea' pk=idea.pk %}" 
     class="btn btn-primary">{% trans "Change this idea" %}</a>
    <a href="{% url 'ideas:delete_idea' pk=idea.pk %}" 
     class="btn btn-danger">{% trans "Delete this idea" %}</a>
{% endblock %}

它是如何工作的...

{% render_json_ld %}模板标签将呈现类似于以下内容的脚本标签:

<script type=application/ld+json>{"@type": "CreativeWork", "author": {"@type": "Person", "name": "admin"}, "description": "Lots of African countries have not enough water. Dig a water channel throughout Africa to provide water to people who have no access to it.", "image": "http://127.0.0.1:8000/media/CACHE/images/ideas/2019/09/b919eec5-c077-41f0-afb4-35f221ab550c_bOFBDgv/9caa5e61fc832f65ff6382f3d482807a.jpg", "inLanguage": "en", "name": "Dig a water channel throughout Africa"}</script>

structured_data属性返回一个嵌套字典,根据 schema.org 词汇,这些词汇被大多数流行的搜索引擎所理解。

您可以通过查看官方文档schema.org/docs/schemas.html来决定要应用于模型的词汇。

另请参阅

  • 第二章,模型和数据库结构中的创建一个模型 mixin 来处理元标签配方

  • 使用 CRUDL 功能创建应用配方

  • 上传图片配方

  • 提供 Open Graph 和 Twitter Card 数据配方

生成 PDF 文档

Django 视图允许您创建的不仅仅是 HTML 页面。您可以创建任何类型的文件。例如,在第四章,模板和 JavaScript中的暴露设置配方中,我们的视图提供其输出作为 JavaScript 文件而不是 HTML。您还可以创建 PDF 文档,用于发票、门票、收据、预订确认等。在这个配方中,我们将向您展示如何为数据库中的每个想法生成手册以打印。我们将使用WeasyPrint库将 HTML 模板制作成 PDF 文档。

准备工作

WeasyPrint 依赖于您需要在计算机上安装的几个库。在 macOS 上,您可以使用 Homebrew 使用此命令安装它们:

$ brew install python3 cairo pango gdk-pixbuf libffi

然后,您可以在项目的虚拟环境中安装 WeasyPrint 本身。还要将其包含在requirements/_base.txt中:

(env)$ pip install WeasyPrint==48

对于其他操作系统,请查看weasyprint.readthedocs.io/en/latest/install.html上的安装说明。

此外,我们将使用django-qr-code生成链接回网站以便快速访问的QR 码。让我们也在虚拟环境中安装它(并将其包含在requirements/_base.txt中):

(env)$ pip install django-qr-code==1.0.0

在设置中将"qr_code"添加到INSTALLED_APPS

# myproject/settings/_base.py
INSTALLED_APPS = [    
    # Django apps…
    "qr_code",
]

如何做...

按照以下步骤完成配方:

  1. 创建将生成 PDF 文档的视图:
# myproject/apps/ideas/views.py
from django.shortcuts import get_object_or_404
from .models import Idea

def idea_handout_pdf(request, pk):
    from django.template.loader import render_to_string
    from django.utils.timezone import now as timezone_now
    from django.utils.text import slugify
    from django.http import HttpResponse

    from weasyprint import HTML
    from weasyprint.fonts import FontConfiguration

    idea = get_object_or_404(Idea, pk=pk)
    context = {"idea": idea}
    html = render_to_string(
        "ideas/idea_handout_pdf.html", context
    )

    response = HttpResponse(content_type="application/pdf")
    response[
        "Content-Disposition"
    ] = "inline; filename={date}-{name}-handout.pdf".format(
        date=timezone_now().strftime("%Y-%m-%d"),
        name=slugify(idea.translated_title),
    )

    font_config = FontConfiguration()
    HTML(string=html).write_pdf(
        response, font_config=font_config
    )

    return response
  1. 将此视图插入 URL 配置:
# myproject/apps/ideas/urls.py from django.urls import path

from .views import idea_handout_pdf

urlpatterns = [
    # URL configurations…
    path(
 "<uuid:pk>/handout/",
 idea_handout_pdf,
 name="idea_handout",
 ),
]
  1. 为 PDF 文档创建模板:
{# ideas/idea_handout_pdf.html #} {% extends "base_pdf.html" %}
{% load i18n qr_code %}

{% block content %}
    <h1 class="h3">{% trans "Handout" %}</h1>
    <h2 class="h1">{{ idea.translated_title }}</h2>
    <img src="img/{{ idea.picture_large.url }}" alt="" 
     class="img-responsive w-100" />
    <div class="my-3">{{ idea.translated_content|linebreaks|
     urlize }}</div>
    <p>
        {% for category in idea.categories.all %}
            <span class="badge badge-pill badge-info">
             {{ category.translated_title }}</span>
        {% endfor %}
    </p>
    <h4>{% trans "See more information online:" %}</h4>
    {% qr_from_text idea.get_url size=20 border=0 as svg_code %}
    <img alt="" src="img/>     {{ svg_code|urlencode }}" />
    <p class="mt-3 text-break">{{ idea.get_url }}</p>
{% endblock %}
  1. 还要创建base_pdf.html模板:
{# base_pdf.html #} <!doctype html>
{% load i18n static %}
<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://stackpath.bootstrapcdn.com
      /bootstrap/4.3.1/css/bootstrap.min.css"
          integrity="sha384-
           ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY
           /iJTQUOhcWr7x9JvoRxT2MZw1T" crossorigin="anonymous">

    <title>{% trans "Hello, World!" %}</title>

    <style>
    @page {
        size: "A4";
        margin: 2.5cm 1.5cm 3.5cm 1.5cm;
    }
    footer {
        position: fixed;
        bottom: -2.5cm;
        width: 100%;
        text-align: center;
        font-size: 10pt;
    }
    footer img {
        height: 1.5cm;
    }
    </style>

    {% block meta_tags %}{% endblock %}
</head>
<body>
    <main class="container">
        {% block content %}
        {% endblock %}
    </main>
    <footer>
        <img alt="" src="img/>         {# url-encoded SVG logo goes here #}" />
        <br />
        {% trans "Printed from MyProject" %}
    </footer>
</body>
</html>

它是如何工作的...

WeasyPrint 生成准备打印的像素完美的文档。我们可以向演示会的观众提供的手册示例看起来类似于这样:

文档的布局是在标记和 CSS 中定义的。WeasyPrint 有自己的渲染引擎。在官方文档中阅读更多关于支持功能的信息:weasyprint.readthedocs.io/en/latest/features.html

您可以使用 SVG 图像,这些图像将保存为矢量图形,而不是位图,因此在打印时会更清晰。内联 SVG 尚不受支持,但您可以在那里使用带有数据源或外部 URL 的<img>标签。在我们的示例中,我们使用 SVG 图像作为 QR 码和页脚中的徽标。

让我们来看一下视图的代码。我们使用所选想法作为html字符串渲染idea_handout_pdf.html模板。然后,我们创建一个 PDF 内容类型的HttpResponse对象,文件名由当前日期和 slugified 想法标题组成。然后,我们创建 WeasyPrint 的 HTML 对象与 HTML 内容,并将其写入响应,就像我们写入文件一样。此外,我们使用FontConfiguration对象,它允许我们在布局中附加和使用来自 CSS 配置的网络字体。最后,我们返回响应对象。

另请参阅

  • 使用 CRUDL 功能创建应用配方

  • 上传图片配方

  • JavaScript 中的暴露设置配方在第四章,模板和 JavaScript

使用 Haystack 和 Whoosh 实现多语言搜索

内容驱动网站的主要功能之一是全文搜索。Haystack 是一个模块化的搜索 API,支持 Solr、Elasticsearch、Whoosh 和 Xapian 搜索引擎。对于项目中每个需要在搜索中找到的模型,您需要定义一个索引,该索引将从模型中读取文本信息并将其放入后端。在本食谱中,您将学习如何为多语言网站使用 Haystack 和基于 Python 的 Whoosh 搜索引擎设置搜索。

准备工作

我们将使用先前定义的categoriesideas应用程序。

确保在您的虚拟环境中安装了django-haystackWhoosh(并将它们包含在requirements/_base.txt中):

(env)$ pip install django-haystack==2.8.1
(env)$ pip install Whoosh==2.7.4

如何操作...

让我们通过执行以下步骤来设置 Haystack 和 Whoosh 的多语言搜索:

  1. 创建一个包含MultilingualWhooshEngine和我们想法的搜索索引的search应用程序。搜索引擎将位于multilingual_whoosh_backend.py文件中:
# myproject/apps/search/multilingual_whoosh_backend.py from django.conf import settings
from django.utils import translation
from haystack.backends.whoosh_backend import (
    WhooshSearchBackend,
    WhooshSearchQuery,
    WhooshEngine,
)
from haystack import connections
from haystack.constants import DEFAULT_ALIAS

class MultilingualWhooshSearchBackend(WhooshSearchBackend):
    def update(self, index, iterable, commit=True, 
     language_specific=False):
        if not language_specific and self.connection_alias == 
         "default":
            current_language = (translation.get_language() or 
             settings.LANGUAGE_CODE)[
                :2
            ]
            for lang_code, lang_name in settings.LANGUAGES:
                lang_code_underscored = lang_code.replace("-", "_")
                using = f"default_{lang_code_underscored}"
                translation.activate(lang_code)
                backend = connections[using].get_backend()
                backend.update(index, iterable, commit, 
                 language_specific=True)
            translation.activate(current_language)
        elif language_specific:
            super().update(index, iterable, commit)

class MultilingualWhooshSearchQuery(WhooshSearchQuery):
    def __init__(self, using=DEFAULT_ALIAS):
        lang_code_underscored =   
        translation.get_language().replace("-", "_")
        using = f"default_{lang_code_underscored}"
        super().__init__(using=using)

class MultilingualWhooshEngine(WhooshEngine):
    backend = MultilingualWhooshSearchBackend
    query = MultilingualWhooshSearchQuery
  1. 让我们创建搜索索引,如下所示:
# myproject/apps/search/search_indexes.py from haystack import indexes

from myproject.apps.ideas.models import Idea

class IdeaIndex(indexes.SearchIndex, indexes.Indexable):
    text = indexes.CharField(document=True)

    def get_model(self):
        return Idea

    def index_queryset(self, using=None):
        """
        Used when the entire index for model is updated.
        """
        return self.get_model().objects.all()

    def prepare_text(self, idea):
        """
        Called for each language / backend
        """
        fields = [
            idea.translated_title, idea.translated_content
        ]
        fields += [
            category.translated_title 
            for category in idea.categories.all()
        ]
        return "\n".join(fields)
  1. 配置设置以使用MultilingualWhooshEngine
# myproject/settings/_base.py import os
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(
    os.path.abspath(__file__)
)))

#…

INSTALLED_APPS = [
    # contributed
    # …
    # third-party
    # …
    "haystack",
    # local
    "myproject.apps.core",
    "myproject.apps.categories",
    "myproject.apps.ideas",
    "myproject.apps.search",
]

LANGUAGE_CODE = "en"

# All official languages of European Union
LANGUAGES = [
    ("bg", "Bulgarian"),
    ("hr", "Croatian"),
    ("cs", "Czech"),
    ("da", "Danish"),
    ("nl", "Dutch"),
    ("en", "English"),
    ("et", "Estonian"),
    ("fi", "Finnish"),
    ("fr", "French"),
    ("de", "German"),
    ("el", "Greek"),
    ("hu", "Hungarian"),
    ("ga", "Irish"),
    ("it", "Italian"),
    ("lv", "Latvian"),
    ("lt", "Lithuanian"),
    ("mt", "Maltese"),
    ("pl", "Polish"),
    ("pt", "Portuguese"),
    ("ro", "Romanian"),
    ("sk", "Slovak"),
    ("sl", "Slovene"),
    ("es", "Spanish"),
    ("sv", "Swedish"),
]

HAYSTACK_CONNECTIONS = {}
for lang_code, lang_name in LANGUAGES:
 lang_code_underscored = lang_code.replace("-", "_")
 HAYSTACK_CONNECTIONS[f"default_{lang_code_underscored}"] = {
 "ENGINE":   
 "myproject.apps.search.multilingual_whoosh_backend
  .MultilingualWhooshEngine",
 "PATH": os.path.join(BASE_DIR, "tmp", 
  f"whoosh_index_{lang_code_underscored}"),
 }
 lang_code_underscored = LANGUAGE_CODE.replace("-", "_")
 HAYSTACK_CONNECTIONS["default"] = HAYSTACK_CONNECTIONS[
 f"default_{lang_code_underscored}"
]
  1. 添加 URL 规则的路径:
# myproject/urls.py from django.contrib import admin
from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path
from django.conf import settings
from django.conf.urls.static import static
from django.shortcuts import redirect

urlpatterns = i18n_patterns(
    path("", lambda request: redirect("ideas:idea_list")),
    path("admin/", admin.site.urls),
    path("accounts/", include("django.contrib.auth.urls")),
    path("ideas/", include(("myproject.apps.ideas.urls", "ideas"), 
    namespace="ideas")),
    path("search/", include("haystack.urls")),
)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static("/media/", document_root=settings.MEDIA_ROOT)
  1. 我们需要一个搜索表单和搜索结果的模板,如下所示:
{# search/search.html #}
{% extends "base.html" %}
{% load i18n %}

{% block sidebar %}
    <form method="get" action="{{ request.path }}">
        <div class="well clearfix">
            {{ form.as_p }}
            <p class="pull-right">
                <button type="submit" class="btn btn-primary">
                 {% trans "Search" %}</button>
            </p>
        </div>
    </form>
{% endblock %}

{% block main %}
    {% if query %}
        <h1>{% trans "Search Results" %}</h1>

        {% for result in page.object_list %}
            {% with idea=result.object %}
                <a href="{{ idea.get_url_path }}" 
                 class="d-block my-3">
                    <div class="card">
                      <img src="img/{{ idea.picture_thumbnail.url }}" 
                       alt="" />
                      <div class="card-body">
                        <p class="card-text">
                         {{ idea.translated_title }}</p>
                      </div>
                    </div>
                </a>
            {% endwith %}
        {% empty %}
            <p>{% trans "No results found." %}</p>
        {% endfor %}

        {% include "misc/includes/pagination.html" with 
         object_list=page %}
    {% endif %}
{% endblock %}
  1. misc/includes/pagination.html中添加一个分页模板,就像在管理分页列表食谱中一样。

  2. 调用rebuild_index管理命令来对数据库数据进行索引并准备全文搜索的使用:

(env)$ python manage.py rebuild_index --noinput

工作原理...

MultilingualWhooshEngine指定了两个自定义属性:

  • backend指向MultilingualWhooshSearchBackend,它确保项目将为LANGUAGES设置中给定的每种语言进行索引,并将其放在HAYSTACK_CONNECTIONS中定义的相关 Haystack 索引位置下。

  • query引用了MultilingualWhooshSearchQuery,其责任是确保在搜索关键字时,将使用特定于当前语言的 Haystack 连接。

每个索引都有一个text字段,用于存储模型特定语言的全文。索引的模型由get_model()方法确定,index_queryset()方法定义要索引的 QuerySet,prepare_text()方法中定义要在其中搜索的内容为换行分隔的字符串。

对于模板,我们已经使用了 Bootstrap 4 的一些元素,使用了表单的开箱即用的渲染功能。可以使用类似本章前面解释的使用 django-crispy-forms 创建表单布局的方法来增强这一点。

最终的搜索页面将在侧边栏中有一个表单,在主列中有搜索结果,并且看起来类似于以下内容:

定期更新搜索索引的最简单方法是调用rebuild_index管理命令,也许可以通过每晚的 cron 作业来实现。要了解更多信息,请查看第十三章维护中的设置定期任务的 cron 作业食谱。

另请参阅

  • 使用 django-crispy-forms 创建表单布局食谱

  • 管理分页列表食谱

  • 第十三章维护中的设置定期任务的 cron 作业食谱

使用 Elasticsearch DSL 实现多语言搜索

Haystack 与 Whoosh 是一个良好的稳定搜索机制,只需要一些 Python 模块,但为了获得更好的性能,我们建议使用 Elasticsearch。在本食谱中,我们将向您展示如何为多语言搜索使用它。

准备工作

首先,让我们安装 Elasticsearch 服务器。在 macOS 上,您可以使用 Homebrew 来完成:

$ brew install elasticsearch

在撰写本文时,Homebrew 上的最新稳定版本的 Elasticsearch 是 6.8.2。

在您的虚拟环境中安装django-elasticsearch-dsl(并将其包含在requirements/_base.txt中):

(env)$ pip install django-elasticsearch-dsl==6.4.1

请注意,安装匹配的django-elasticsearch-dsl版本非常重要。否则,当尝试连接到 Elasticsearch 服务器或构建索引时,将会出现错误。您可以在github.com/sabricot/django-elasticsearch-dsl上查看版本兼容性表。

如何做...

让我们通过执行以下步骤设置多语言搜索与 Elasticsearch DSL:

  1. 修改设置文件,并将"django_elasticsearch_dsl"添加到INSTALLED_APPS,并将ELASTICSEARCH_DSL设置如下:
# myproject/settings/_base.py 
INSTALLED_APPS = [
    # other apps…
    "django_elasticsearch_dsl",
]

ELASTICSEARCH_DSL={
 'default': {
 'hosts': 'localhost:9200'
    },
}
  1. ideas应用程序中,创建一个documents.py文件,其中包含IdeaDocument用于 idea 搜索索引,如下所示:
# myproject/apps/ideas/documents.py
from django.conf import settings
from django.utils.translation import get_language, activate
from django.db import models

from django_elasticsearch_dsl import fields
from django_elasticsearch_dsl.documents import (
    Document,
    model_field_class_to_field_class,
)
from django_elasticsearch_dsl.registries import registry

from myproject.apps.categories.models import Category
from .models import Idea

def _get_url_path(instance, language):
    current_language = get_language()
    activate(language)
    url_path = instance.get_url_path()
    activate(current_language)
    return url_path

@registry.register_document
class IdeaDocument(Document):
    author = fields.NestedField(
        properties={
            "first_name": fields.StringField(),
            "last_name": fields.StringField(),
            "username": fields.StringField(),
            "pk": fields.IntegerField(),
        },
        include_in_root=True,
    )
    title_bg = fields.StringField()
    title_hr = fields.StringField()
    # other title_* fields for each language in the LANGUAGES 
      setting…
    content_bg = fields.StringField()
    content_hr = fields.StringField()
    # other content_* fields for each language in the LANGUAGES 
      setting…

    picture_thumbnail_url = fields.StringField()

    categories = fields.NestedField(
        properties=dict(
            pk=fields.IntegerField(),
            title_bg=fields.StringField(),
            title_hr=fields.StringField(),
            # other title_* definitions for each language in the 
              LANGUAGES setting…
        ),
        include_in_root=True,
    )

    url_path_bg = fields.StringField()
    url_path_hr = fields.StringField()
    # other url_path_* fields for each language in the LANGUAGES 
      setting…

    class Index:
        name = "ideas"
        settings = {"number_of_shards": 1, "number_of_replicas": 0}

    class Django:
        model = Idea
        # The fields of the model you want to be indexed in 
          Elasticsearch
        fields = ["uuid", "rating"]
        related_models = [Category]

    def get_instances_from_related(self, related_instance):
        if isinstance(related_instance, Category):
            category = related_instance
            return category.category_ideas.all()
  1. IdeaDocument添加prepare_*方法以准备索引的数据:
    def prepare(self, instance):
        lang_code_underscored = settings.LANGUAGE_CODE.replace
         ("-", "_")
        setattr(instance, f"title_{lang_code_underscored}", 
         instance.title)
        setattr(instance, f"content_{lang_code_underscored}", 
         instance.content)
        setattr(
            instance,
            f"url_path_{lang_code_underscored}",
            _get_url_path(instance=instance, 
              language=settings.LANGUAGE_CODE),
        )
        for lang_code, lang_name in 
         settings.LANGUAGES_EXCEPT_THE_DEFAULT:
            lang_code_underscored = lang_code.replace("-", "_")
            setattr(instance, f"title_{lang_code_underscored}", 
             "")
            setattr(instance, f"content_{lang_code_underscored}", 
             "")
            translations = instance.translations.filter(language=
             lang_code).first()
            if translations:
                setattr(instance, f"title_{lang_code_underscored}", 
                 translations.title)
                setattr(
                    instance, f"content_{lang_code_underscored}", 
                     translations.content
                )
            setattr(
                instance,
                f"url_path_{lang_code_underscored}",
                _get_url_path(instance=instance, 
                  language=lang_code),
            )
        data = super().prepare(instance=instance)
        return data

    def prepare_picture_thumbnail_url(self, instance):
        if not instance.picture:
            return ""
        return instance.picture_thumbnail.url

    def prepare_author(self, instance):
        author = instance.author
        if not author:
            return []
        author_dict = {
            "pk": author.pk,
            "first_name": author.first_name,
            "last_name": author.last_name,
            "username": author.username,
        }
        return [author_dict]

    def prepare_categories(self, instance):
        categories = []
        for category in instance.categories.all():
            category_dict = {"pk": category.pk}
            lang_code_underscored = 
             settings.LANGUAGE_CODE.replace("-", "_")
            category_dict[f"title_{lang_code_underscored}"] = 
             category.title
            for lang_code, lang_name in 
             settings.LANGUAGES_EXCEPT_THE_DEFAULT:
                lang_code_underscored = lang_code.replace("-", "_")
                category_dict[f"title_{lang_code_underscored}"] = 
                 ""
                translations = 
                 category.translations.filter(language=
                  lang_code).first()
                if translations:
                    category_dict[f"title_{lang_code_underscored}"] 
                   = translations.title
            categories.append(category_dict)
        return categories
  1. IdeaDocument添加一些属性和方法,以从索引文档中返回翻译内容:
    @property
    def translated_title(self):
        lang_code_underscored = get_language().replace("-", "_")
        return getattr(self, f"title_{lang_code_underscored}", "")

    @property
    def translated_content(self):
        lang_code_underscored = get_language().replace("-", "_")
        return getattr(self, f"content_{lang_code_underscored}", 
         "")

    def get_url_path(self):
        lang_code_underscored = get_language().replace("-", "_")
        return getattr(self, f"url_path_{lang_code_underscored}", 
         "")

    def get_categories(self):
        lang_code_underscored = get_language().replace("-", "_")
        return [
            dict(
                translated_title=category_dict[f"title_{lang_
                 code_underscored}"],
                **category_dict,
            )
            for category_dict in self.categories
        ] 
  1. documents.py文件中还有一件事要做,那就是对UUIDField映射进行修补,因为默认情况下,Django Elasticsearch DSL 尚不支持它。为此,请在导入部分之后插入此行:
model_field_class_to_field_class[models.UUIDField] = fields.TextField
  1. ideas应用程序的forms.py中创建IdeaSearchForm
# myproject/apps/ideas/forms.py from django import forms
from django.utils.translation import ugettext_lazy as _

from crispy_forms import helper, layout

class IdeaSearchForm(forms.Form):
    q = forms.CharField(label=_("Search for"), required=False)

    def __init__(self, request, *args, **kwargs):
        self.request = request
        super().__init__(*args, **kwargs)

        self.helper = helper.FormHelper()
        self.helper.form_action = self.request.path
        self.helper.form_method = "GET"
        self.helper.layout = layout.Layout(
            layout.Field("q", css_class="input-block-level"),
            layout.Submit("search", _("Search")),
        )
  1. 添加用于使用 Elasticsearch 搜索的视图:
# myproject/apps/ideas/views.py from django.shortcuts import render
from django.conf import settings
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.utils.functional import LazyObject

from .forms import IdeaSearchForm

PAGE_SIZE = getattr(settings, "PAGE_SIZE", 24)

class SearchResults(LazyObject):
    def __init__(self, search_object):
        self._wrapped = search_object

    def __len__(self):
        return self._wrapped.count()

    def __getitem__(self, index):
        search_results = self._wrapped[index]
        if isinstance(index, slice):
            search_results = list(search_results)
        return search_results

def search_with_elasticsearch(request):
    from .documents import IdeaDocument
    from elasticsearch_dsl.query import Q

    form = IdeaSearchForm(request, data=request.GET)

    search = IdeaDocument.search()

    if form.is_valid():
        value = form.cleaned_data["q"]
        lang_code_underscored = request.LANGUAGE_CODE.replace("-", 
          "_")
        search = search.query(
            Q("match_phrase", **{f"title_{
             lang_code_underscored}": 
             value})
            | Q("match_phrase", **{f"content_{
               lang_code_underscored}": value})
            | Q(
                "nested",
                path="categories",
                query=Q(
                    "match_phrase",
                    **{f"categories__title_{
                     lang_code_underscored}": value},
                ),
            )
        )
    search_results = SearchResults(search)

    paginator = Paginator(search_results, PAGE_SIZE)
    page_number = request.GET.get("page")
    try:
        page = paginator.page(page_number)
    except PageNotAnInteger:
        # If page is not an integer, show first page.
        page = paginator.page(1)
    except EmptyPage:
        # If page is out of range, show last existing page.
        page = paginator.page(paginator.num_pages)

    context = {"form": form, "object_list": page}
    return render(request, "ideas/idea_search.html", context)
  1. 创建一个idea_search.html模板,用于搜索表单和搜索结果:
{# ideas/idea_search.html #}
{% extends "base.html" %}
{% load i18n crispy_forms_tags %}

{% block sidebar %}
    {% crispy form %}
{% endblock %}

{% block main %}
    <h1>{% trans "Search Results" %}</h1>
    {% if object_list %}
        {% for idea in object_list %}
            <a href="{{ idea.get_url_path }}" class="d-block my-3">
                <div class="card">
                  <img src="img/{{ idea.picture_thumbnail_url }}" 
                    alt="" />
                  <div class="card-body">
                    <p class="card-text">{{ idea.translated_title 
                      }}</p>
                  </div>
                </div>
            </a>
        {% endfor %}
        {% include "misc/includes/pagination.html" %}
    {% else %}
        <p>{% trans "No ideas found." %}</p>
    {% endif %}
{% endblock %}
  1. misc/includes/pagination.html中添加一个分页模板,就像管理分页列表配方中一样。

  2. 调用search_index --rebuild管理命令来索引数据库数据并准备使用全文搜索:

(env)$ python manage.py search_index --rebuild

它是如何工作的...

Django Elasticsearch DSL 文档类似于模型表单。在那里,您定义要保存到索引的模型字段,以便稍后用于搜索查询。在我们的IdeaDocument示例中,我们保存 UUID、评分、作者、类别、标题、内容和 URL 路径以及所有语言和图片缩略图 URL。Index类定义了此文档的 Elasticsearch 索引的设置。Django类定义了从哪里填充索引字段。有一个related_models设置,告诉在哪个模型更改后也更新此索引。在我们的情况下,它是一个Category模型。请注意,使用django-elasticsearch-dsl,只要保存模型,索引就会自动更新。这是使用信号完成的。

get_instances_from_related()方法告诉如何在更改Category实例时检索Idea模型实例。

IdeaDocumentprepare()prepare_*()方法告诉从哪里获取数据以及如何保存特定字段的数据。例如,我们从IdeaTranslations模型的title字段中读取title_lt的数据,其中language字段等于"lt"

IdeaDocument的最后属性和方法用于从当前活动语言的索引中检索信息。

然后,我们有一个带有搜索表单的视图。表单中有一个名为q的查询字段。当提交时,我们在当前语言的标题、内容或类别标题字段中搜索查询的单词。然后,我们用惰性评估的SearchResults类包装搜索结果,以便我们可以将其与默认的 Django 分页器一起使用。

视图的模板将在侧边栏中包含搜索表单,在主列中包含搜索结果,并且看起来会像这样:

另请参阅

  • 创建具有 CRUDL 功能的应用程序配方

  • 使用 Haystack 和 Whoosh 实现多语言搜索配方

  • 使用 django-crispy-forms 创建表单布局配方

  • 管理分页列表配方

第四章:模板和 JavaScript

在这一章中,我们将涵盖以下主题:

  • 排列base.html模板

  • 使用 Django Sekizai

  • 在 JavaScript 中公开设置

  • 使用 HTML5 数据属性

  • 提供响应式图像(env)$ python manage.py migrate ideas zero

  • 实现连续滚动

  • 在模态对话框中打开对象详细信息

  • 实现“喜欢”小部件

  • 通过 Ajax 上传图片

介绍

静态网站对于静态内容非常有用,比如传统文档、在线书籍和教程;然而,如今,大多数交互式网络应用和平台必须具有动态组件,如果它们想要脱颖而出并给访问者最佳的用户体验。在这一章中,您将学习如何使用 JavaScript 和 CSS 与 Django 模板一起使用。我们将使用 Bootstrap 4 前端框架来实现响应式布局,以及 jQuery JavaScript 框架来进行高效的脚本编写。

技术要求

与本章的代码一样,要使用本章的代码,您需要最新稳定版本的 Python、MySQL 或 PostgreSQL 数据库以及带有虚拟环境的 Django 项目。有些配方需要特定的 Python 依赖项。其中一些需要额外的 JavaScript 库。您将在本章后面看到每个配方的要求。

您可以在 GitHub 存储库的ch04目录中找到本章的所有代码,网址为github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition

排列base.html模板

当您开始处理模板时,首先要做的事情之一就是创建base.html样板,这将被项目中大多数页面模板所扩展。在本配方中,我们将演示如何为多语言 HTML5 网站创建这样的模板,并考虑到响应性。

响应式网站是指为所有设备提供相同基础内容的网站,根据视口适当地进行样式设置,无论访问者使用桌面浏览器、平板电脑还是手机。这与自适应网站不同,后者服务器会尝试根据用户代理来确定设备类型,然后根据用户代理的分类方式提供完全不同的内容、标记甚至功能。

准备工作

在您的项目中创建templates目录,并在设置中设置模板目录以包含它,如下所示:

# myproject/settings/_base.py TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [os.path.join(BASE_DIR, "myproject", "templates")],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
                "django.template.context_processors.media",
                "django.template.context_processors.static",
            ]
        },
    }
]

如何做到这一点...

按照以下步骤进行:

  1. 在模板的根目录中,创建一个base.html文件,其中包含以下内容:
{# base.html #}
<!doctype html>
{% load i18n static %}
<html lang="en">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-
     scale=1, shrink-to-fit=no" />
    <title>{% block head_title %}{% endblock %}</title>
    {% include "misc/includes/favicons.html" %}
 {% block meta_tags %}{% endblock %}

    <link rel="stylesheet"
          href="https://stackpath.bootstrapcdn.com/bootstrap
           /4.3.1/css/bootstrap.min.css"
          integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784
           /j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
          crossorigin="anonymous" />
    <link rel="stylesheet"
          href="{% static 'site/css/style.css' %}"
          crossorigin="anonymous" />

    {% block css %}{% endblock %}
    {% block extra_head %}{% endblock %}
</head>
<body>
    {% include "misc/includes/header.html" %}
    <div class="container my-5">
 {% block content %}
 <div class="row">
 <div class="col-lg-4">{% block sidebar %}
                 {% endblock %}</div>
 <div class="col-lg-8">{% block main %}
                 {% endblock %}</div>
 </div>
 {% endblock %}
 </div>
    {% include "misc/includes/footer.html" %}
    <script src="img/jquery-3.4.1.min.js"
            crossorigin="anonymous"></script>
    <script src="img/popper.min.js"
            integrity="sha384-UO2eT0CpHqdSJQ6hJty5KVphtPhzWj
             9WO1clHTMGa3JDZwrnQq4sF86dIHNDz0W1"
            crossorigin="anonymous"></script>
    <script src="img/bootstrap.min.js"
            integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6Vrj
             IEaFf/nJGzIxFDsf4x0xIM+B07jRM"
            crossorigin="anonymous"></script>
    {% block js %}{% endblock %}
    {% block extra_body %}{% endblock %}
</body>
</html>
  1. misc/includes下,创建一个包含所有版本网站图标的模板:
{# misc/includes/favicon.html #}
{% load static %}
<link rel="icon" type="image/png" href="{% static 'site/img/favicon-32x32.png' %}" sizes="32x32"/>
<link rel="icon" type="image/png" href="{% static 'site/img/favicon-16x16.png' %}" sizes="16x16"/>

网站图标是我们通常在浏览器标签、最近访问的网站的图块以及桌面快捷方式中看到的小图像。您可以使用在线生成器之一,从标志生成网站图标的不同版本,以适用于不同的用例、浏览器和平台。我们最喜欢的网站图标生成器是favicomatic.com/realfavicongenerator.net/

  1. 创建misc/includes/header.htmlmisc/includes/footer.html模板,其中包含您网站的页眉和页脚。现在,您可以在那里创建空文件。

它是如何工作的...

基础模板包含 HTML 文档的<head><body>部分,其中包含在网站的每个页面上重复使用的所有细节。根据网页设计的要求,您可以为不同的布局添加额外的基础模板。例如,我们可以添加base_simple.html文件,其中包含相同的 HTML<head>部分和非常简约的<body>部分,这可以用于登录屏幕、密码重置或其他简单页面。您还可以为其他布局添加单列、双列和三列布局的单独基础模板,每个模板都会扩展base.html并根据需要覆盖块。

让我们深入了解我们之前定义的base.html模板的<head>部分的详细信息:

  • 我们定义 UTF-8 作为默认编码以支持多语言内容。

  • 然后,我们有视口定义,将在浏览器中缩放网站以使用全宽度。这对于使用 Bootstrap 前端框架创建特定屏幕布局的小屏设备是必要的。

  • 当然,在浏览器标签和搜索引擎的搜索结果中使用的可定制网站标题。

  • 然后我们有一个用于 meta 标签的块,可用于搜索引擎优化SEO),Open Graph 和 Twitter Cards。

  • 然后我们包括不同格式和大小的网站图标。

  • 我们包括默认的 Bootstrap 和自定义网站样式。我们加载 Bootstrap CSS,因为我们希望拥有响应式布局,这也将规范化所有元素的基本样式,以保持跨浏览器的一致性。

  • 最后,我们有可扩展的块用于 meta 标签,样式表,以及<head>部分可能需要的其他内容。

以下是<body>部分的详细信息:

  • 首先,我们包括网站的页眉。在那里,您可以放置您的标志,网站标题和主导航。

  • 然后,我们有包含内容块占位符的主容器,这些内容块将通过扩展模板来填充。

  • 在容器内部,有content块,其中包含sidebarmain块。在子模板中,当我们需要带有侧边栏的布局时,我们将覆盖sidebarmain块,但是当我们需要全宽内容时,我们将覆盖content块。

  • 然后,我们包括网站的页脚。在那里,您可以放置版权信息和重要元页面的链接,例如隐私政策,使用条款,联系表格等。

  • 然后我们加载 jQuery 和 Bootstrap 脚本。可扩展的 JavaScript 块包括在<body>的末尾,遵循页面加载性能的最佳实践,就像<head>中包含的样式表一样。

  • 最后,我们有用于额外 JavaScript 和额外 HTML 的块,例如 JavaScript 的 HTML 模板或隐藏的模态对话框,我们将在本章后面探讨。

我们创建的基本模板绝不是静态不可更改的模板。您可以修改标记结构,或向其中添加您需要的元素,例如用于 body 属性的模板块,Google Analytics 代码的片段,常见 JavaScript 文件,iPhone 书签的 Apple 触摸图标,Open Graph meta 标签,Twitter Card 标签,schema.org 属性等。根据您的项目要求,您可能还想定义其他块,甚至可能包装整个 body 内容,以便您可以在子模板中覆盖它。

另请参阅

  • 使用 Django Sekizai配方

  • 在 JavaScript 中公开设置配方

使用 Django Sekizai

在 Django 模板中,通常您会使用模板继承来覆盖父模板中的块,以将样式或脚本包含到 HTML 文档中。这意味着每个视图的主模板都应该知道所有内容,然而,有时让包含的模板决定加载哪些样式和脚本会更方便得多。这可以通过 Django Sekizai 来实现,在本配方中我们将使用它。

准备工作

在我们开始配方之前,按照以下步骤做好准备:

  1. django-classy-tagsdjango-sekizai安装到您的虚拟环境中(并将它们添加到requirements/_base.txt中):
(env)$ pip install -e git+https://github.com/divio/django-classy-tags.git@4c94d0354eca1600ad2ead9c3c151ad57af398a4#egg=django-classy-tags
(env)$ pip install django-sekizai==1.0.0
  1. 然后在设置中将sekizai添加到已安装的应用程序中:
# myproject/settings/_base.py
INSTALLED_APPS = [
    # …
    "sekizai",
    # …
]
  1. 接下来,在设置中的模板配置中添加sekizai上下文处理器:
# myproject/settings/_base.py
TEMPLATES = [
    {
        "BACKEND": 
        "django.template.backends.django.DjangoTemplates",
        "DIRS": [os.path.join(BASE_DIR, "myproject", "templates")],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors
                 .messages",
                "django.template.context_processors.media",
                "django.template.context_processors.static",
 "sekizai.context_processors.sekizai",
            ]
        },
    }
]

如何做...

按照以下步骤完成配方:

  1. base.html模板的开头,加载sekizai_tags库:
{# base.html #}
<!doctype html>
{% load i18n static sekizai_tags %}
  1. 在同一文件中,在<head>部分的末尾,添加模板标签{% render_block "css" %}如下所示:
    {% block css %}{% endblock %}
    {% render_block "css" %}
    {% block extra_head %}{% endblock %}
</head>
  1. 然后,在<body>部分的末尾,添加模板标签{% render_block "js" %}如下所示:
    {% block js %}{% endblock %}
    {% render_block "js" %}
    {% block extra_body %}{% endblock %}
</body>
  1. 现在,在任何包含的模板中,当您想要添加一些样式或 JavaScript 时,请使用{% addtoblock %}模板标签,如下所示:
{% load static sekizai_tags %}

<div>Sample widget</div>
 {% addtoblock "css" %}
<link rel="stylesheet" href="{% static 'site/css/sample-widget.css' 
  %}"/>
{% endaddtoblock %}

{% addtoblock "js" %}
<script src="img/sample-widget.js' %}"></script>
{% endaddtoblock %}

它是如何工作的...

Django Sekizai 与{% include %}模板标签包含的模板、使用模板呈现的自定义模板标签或表单小部件模板一起工作。{% addtoblock %}模板标签定义了我们要向其中添加 HTML 内容的 Sekizai 块。

当您向 Sekizai 块添加内容时,django-sekizai会负责仅在那里包含它一次。这意味着您可以有多个相同类型的包含小部件,但它们的 CSS 和 JavaScript 只会加载和执行一次。

另请参阅

  • 实现 Like 小部件教程

  • 通过 Ajax 上传图像教程

在 JavaScript 中公开设置

Django 项目在设置文件中设置其配置,例如myproject/settings/dev.py用于开发环境;我们在第一章《Django 3.0 入门》中的为开发、测试、暂存和生产环境配置设置教程中描述了这一点。其中一些配置值也可能对浏览器中的功能有用,因此它们也需要在 JavaScript 中设置。我们希望有一个单一的位置来定义我们的项目设置,因此在这个教程中,我们将看到如何将一些配置值从 Django 服务器传递到浏览器。

准备工作

确保在设置中包含了TEMPLATES['OPTIONS']['context_processors']设置中的request上下文处理器,如下所示:

# myproject/settings/_base.py
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [os.path.join(BASE_DIR, "myproject", "templates")],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
"django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
                "django.template.context_processors.media",
                "django.template.context_processors.static",
                "sekizai.context_processors.sekizai",
            ]
        },
    }
]

如果您还没有创建core应用程序,请在设置中将其放在INSTALLED_APPS下:

INSTALLED_APPS = [
    # …
    "myproject.apps.core",
    # …
]

如何做...

按照以下步骤创建和包含 JavaScript 设置:

  1. core应用的views.py中,创建一个返回 JavaScript 内容类型响应的js_settings()视图,如下所示:
# myproject/apps/core/views.py import json
from django.http import HttpResponse
from django.template import Template, Context
from django.views.decorators.cache import cache_page
from django.conf import settings

JS_SETTINGS_TEMPLATE = """
window.settings = JSON.parse('{{ json_data|escapejs }}');
"""

@cache_page(60 * 15)
def js_settings(request):
    data = {
        "MEDIA_URL": settings.MEDIA_URL,
        "STATIC_URL": settings.STATIC_URL,
        "DEBUG": settings.DEBUG,
        "LANGUAGES": settings.LANGUAGES,
        "DEFAULT_LANGUAGE_CODE": settings.LANGUAGE_CODE,
        "CURRENT_LANGUAGE_CODE": request.LANGUAGE_CODE,
    }
    json_data = json.dumps(data)
    template = Template(JS_SETTINGS_TEMPLATE)
    context = Context({"json_data": json_data})
    response = HttpResponse(
        content=template.render(context),
        content_type="application/javascript; charset=UTF-8",
    )
    return response
  1. 将此视图插入 URL 配置中:
# myproject/urls.py from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path
from django.conf import settings
from django.conf.urls.static import static

from myproject.apps.core import views as core_views

urlpatterns = i18n_patterns(
    # other URL configuration rules…
 path("js-settings/", core_views.js_settings, 
     name="js_settings"),
)

urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static("/media/", document_root=settings.MEDIA_ROOT)
  1. 通过在base.html模板的末尾添加它,将基于 JavaScript 的视图加载到前端:
{# base.html #}    

    {# … #}

    <script src="img/{% url 'js_settings' %}"></script>
    {% block js %}{% endblock %}
    {% render_block "js" %}
    {% block extra_body %}{% endblock %}
</body>
</html>
  1. 现在我们可以在任何 JavaScript 文件中访问指定的设置,如下所示:
if (window.settings.DEBUG) {
    console.warn('The website is running in DEBUG mode!');
}

它是如何工作的...

js_settings视图中,我们构建了一个要传递给浏览器的设置字典,将字典转换为 JSON,并渲染了一个 JavaScript 文件的模板,解析 JSON 并将结果分配给window.settings变量。通过将字典转换为 JSON 字符串并在 JavaScript 文件中解析它,我们可以确保在最后一个元素之后没有逗号的问题-这在 Python 中是允许的,但在 JavaScript 中是无效的。

渲染的 JavaScript 文件将如下所示:

# http://127.0.0.1:8000/en/js-settings/
window.settings = JSON.parse('{\u0022MEDIA_URL\u0022: \u0022http://127.0.0.1:8000/media/\u0022, \u0022STATIC_URL\u0022: \u0022/static/20191001004640/\u0022, \u0022DEBUG\u0022: true, \u0022LANGUAGES\u0022: [[\u0022bg\u0022, \u0022Bulgarian\u0022], [\u0022hr\u0022, \u0022Croatian\u0022], [\u0022cs\u0022, \u0022Czech\u0022], [\u0022da\u0022, \u0022Danish\u0022], [\u0022nl\u0022, \u0022Dutch\u0022], [\u0022en\u0022, \u0022English\u0022], [\u0022et\u0022, \u0022Estonian\u0022], [\u0022fi\u0022, \u0022Finnish\u0022], [\u0022fr\u0022, \u0022French\u0022], [\u0022de\u0022, \u0022German\u0022], [\u0022el\u0022, \u0022Greek\u0022], [\u0022hu\u0022, \u0022Hungarian\u0022], [\u0022ga\u0022, \u0022Irish\u0022], [\u0022it\u0022, \u0022Italian\u0022], [\u0022lv\u0022, \u0022Latvian\u0022], [\u0022lt\u0022, \u0022Lithuanian\u0022], [\u0022mt\u0022, \u0022Maltese\u0022], [\u0022pl\u0022, \u0022Polish\u0022], [\u0022pt\u0022, \u0022Portuguese\u0022], [\u0022ro\u0022, \u0022Romanian\u0022], [\u0022sk\u0022, \u0022Slovak\u0022], [\u0022sl\u0022, \u0022Slovene\u0022], [\u0022es\u0022, \u0022Spanish\u0022], [\u0022sv\u0022, \u0022Swedish\u0022]], \u0022DEFAULT_LANGUAGE_CODE\u0022: \u0022en\u0022, \u0022CURRENT_LANGUAGE_CODE\u0022: \u0022en\u0022}');

另请参阅

  • 第一章《Django 3.0 入门》中的为开发、测试、暂存和生产环境配置设置教程

  • 安排 base.html 模板教程

  • 使用 HTML5 数据属性教程

使用 HTML5 数据属性

HTML5 引入了data-*属性,用于从 Web 服务器将有关特定 HTML 元素的数据传递到 JavaScript 和 CSS。在这个教程中,我们将看到一种有效地从 Django 附加数据到自定义 HTML5 数据属性的方法,然后描述如何通过一个实际的例子从 JavaScript 中读取数据:我们将在指定的地理位置渲染一个谷歌地图,并在点击标记时显示信息窗口中的地址。

准备工作

准备好,按照以下步骤进行:

  1. 为此和以下章节使用带有 PostGIS 扩展的 PostgreSQL 数据库。要了解如何安装 PostGIS 扩展,请查看官方文档docs.djangoproject.com/en/2.2/ref/contrib/gis/install/postgis/

  2. 确保为 Django 项目使用postgis数据库后端:

# myproject/settings/_base.py
DATABASES = {
    "default": {
 "ENGINE": "django.contrib.gis.db.backends.postgis",
        "NAME": get_secret("DATABASE_NAME"),
        "USER": get_secret("DATABASE_USER"),
        "PASSWORD": get_secret("DATABASE_PASSWORD"),
        "HOST": "localhost",
        "PORT": "5432",
    }
}
  1. 创建一个locations应用程序,其中包含一个Location模型。它将包含 UUID 主键,名称、街道地址、城市、国家和邮政编码的字符字段,与 PostGIS 相关的Geoposition字段以及Description文本字段:
# myproject/apps/locations/models.py import uuid
from collections import namedtuple
from django.contrib.gis.db import models
from django.urls import reverse
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from myproject.apps.core.models import (
    CreationModificationDateBase, UrlBase
)

COUNTRY_CHOICES = getattr(settings, "COUNTRY_CHOICES", [])

Geoposition = namedtuple("Geoposition", ["longitude", "latitude"])

class Location(CreationModificationDateBase, UrlBase):
    uuid = models.UUIDField(primary_key=True, default=None, 
     editable=False)
    name = models.CharField(_("Name"), max_length=200)
    description = models.TextField(_("Description"))
    street_address = models.CharField(_("Street address"), 
     max_length=255, blank=True)
    street_address2 = models.CharField(
        _("Street address (2nd line)"), max_length=255, blank=True
    )
    postal_code = models.CharField(_("Postal code"), 
     max_length=255, blank=True)
    city = models.CharField(_("City"), max_length=255, 
     blank=True)
    country = models.CharField(
        _("Country"), choices=COUNTRY_CHOICES, max_length=255, 
           blank=True
    )
    geoposition = models.PointField(blank=True, null=True)

    class Meta:
        verbose_name = _("Location")
        verbose_name_plural = _("Locations")

    def __str__(self):
        return self.name

    def get_url_path(self):
        return reverse("locations:location_detail", kwargs={"pk": 
         self.pk})
  1. 覆盖save()方法,在创建位置时生成唯一的 UUID 字段值:
def save(self, *args, **kwargs):
        if self.pk is None:
            self.pk = uuid.uuid4()
        super().save(*args, **kwargs)
  1. 创建方法来获取位置的完整地址字符串:
 def get_field_value(self, field_name):
        if isinstance(field_name, str):
            value = getattr(self, field_name)
            if callable(value):
                value = value()
            return value
        elif isinstance(field_name, (list, tuple)):
            field_names = field_name
            values = []
            for field_name in field_names:
                value = self.get_field_value(field_name)
                if value:
                    values.append(value)
            return " ".join(values)
        return ""

    def get_full_address(self):
        field_names = [
            "name",
            "street_address",
            "street_address",
            ("postal_code", "city"),
            "get_country_display",
        ]
        full_address = []
        for field_name in field_names:
            value = self.get_field_value(field_name)
            if value:
                full_address.append(value)
        return ", ".join(full_address)
  1. 创建函数来通过latitudelongitude获取或设置地理位置 - 在数据库中,geoposition保存为Point字段。我们可以在 Django shell、表单、管理命令、数据迁移和其他地方使用这些函数:
 def get_geoposition(self):
        if not self.geoposition:
            return None
        return Geoposition(
            self.geoposition.coords[0], self.geoposition.coords[1]
        )

    def set_geoposition(self, longitude, latitude): from django.contrib.gis.geos import Point
        self.geoposition = Point(longitude, latitude, srid=4326)
  1. 在更新模型后,记得为应用程序创建并运行迁移。

  2. 创建一个模型管理来添加和更改位置。我们将使用gis应用程序中的OSMGeoAdmin而不是标准的ModelAdmin。它将使用OpenStreetMap渲染地图以设置geoposition,可以在www.openstreetmap.org找到。

# myproject/apps/locations/admin.py from django.contrib.gis import admin
from .models import Location

@admin.register(Location)
class LocationAdmin(admin.OSMGeoAdmin):
    pass
  1. 在管理中添加一些位置以供进一步使用。

我们还将在后续的示例中使用和发展这个locations应用程序。

如何做...

按照以下步骤进行:

  1. 注册 Google Maps API 密钥。您可以在 Google 开发人员文档developers.google.com/maps/documentation/javascript/get-api-key中了解如何以及在哪里进行此操作。

  2. 将 Google Maps API 密钥添加到 secrets 中,然后在设置中读取它:

# myproject/settings/_base.py # …GOOGLE_MAPS_API_KEY = get_secret("GOOGLE_MAPS_API_KEY")
  1. 在核心应用程序中,创建一个上下文处理器来将GOOGLE_MAPS_API_KEY暴露给模板:
# myproject/apps/core/context_processors.py from django.conf import settings

def google_maps(request):
    return {
        "GOOGLE_MAPS_API_KEY": settings.GOOGLE_MAPS_API_KEY,
    }
  1. 在模板设置中引用此上下文处理器:
# myproject/settings/_base.py
TEMPLATES = [
    {
        "BACKEND": 
        "django.template.backends.django.DjangoTemplates",
        "DIRS": [os.path.join(BASE_DIR, "myproject", "templates")],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors
                 .messages",
                "django.template.context_processors.media",
                "django.template.context_processors.static",
                "sekizai.context_processors.sekizai",
 "myproject.apps.core.context_processors
                 .google_maps",
            ]
        },
    }
]
  1. 为位置创建列表和详细视图:
# myproject/apps/locations/views.py from django.views.generic import ListView, DetailView
from .models import Location

class LocationList(ListView):
    model = Location
    paginate_by = 10

class LocationDetail(DetailView):
    model = Location
    context_object_name = "location"
  1. locations应用创建 URL 配置:
# myproject/apps/locations/urls.py from django.urls import path
from .views import LocationList, LocationDetail

urlpatterns = [
    path("", LocationList.as_view(), name="location_list"),
    path("<uuid:pk>/", LocationDetail.as_view(), 
     name="location_detail"),
]
  1. 在项目的 URL 配置中包含位置的 URL:
# myproject/urls.py from django.contrib import admin
from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path
from django.conf import settings
from django.conf.urls.static import static
from django.shortcuts import redirect

from myproject.apps.core import views as core_views

urlpatterns = i18n_patterns(
    path("", lambda request: redirect("locations:location_list")),
    path("admin/", admin.site.urls),
    path("accounts/", include("django.contrib.auth.urls")),
    path("locations/", include(("myproject.apps.locations.urls", 
    "locations"), namespace="locations")),
    path("js-settings/", core_views.js_settings, 
     name="js_settings"),
)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static("/media/", document_root=settings.MEDIA_ROOT)
  1. 现在是时候为位置列表和位置详细视图创建模板了。位置列表现在将尽可能简单;我们只需要能够浏览位置并转到位置详细视图。
{# locations/location_list.html #} {% extends "base.html" %}
{% load i18n %}

{% block content %}
    <h1>{% trans "Interesting Locations" %}</h1>
    {% if object_list %}
        <ul>
            {% for location in object_list %}
                <li><a href="{{ location.get_url_path }}">
                    {{ location.name }}
 </a></li>
            {% endfor %}
        </ul>
    {% else %}
        <p>{% trans "There are no locations yet." %}</p>
    {% endif %}
{% endblock %}
  1. 接下来,让我们创建一个模板,通过扩展base.html并覆盖content块来显示位置详情:
{# locations/location_detail.html #} {% extends "base.html" %}
{% load i18n static %}

{% block content %}
    <a href="{% url "locations:location_list" %}">{% trans 
     "Interesting Locations" %}</a>
    <h1 class="map-title">{{ location.name }}</h1>
    <div class="my-3">
        {{ location.description|linebreaks|urlize }}
    </div>
    {% with geoposition=location.get_geoposition %}
 <div id="map" class="mb-3"
 data-latitude="{{ geoposition.latitude|stringformat:"f" }}"
 data-longitude="{{ geoposition.longitude|stringformat:"f" }}"
 data-address="{{ location.get_full_address }}"></div>
 {% endwith %}
{% endblock %}
  1. 在相同的模板中,覆盖js块:
{% block js %}
 <script src="img/location_detail.js' %}"></script>
 <script async defer src="img/js?key={{ GOOGLE_MAPS_API_KEY }}&callback=Location.init"></script>
{% endblock %}
  1. 除了模板,我们还需要 JavaScript 文件,它将读取 HTML5 数据属性并使用它们来渲染带有标记的地图:
/* site_static/site/js/location_detail.js */ (function(window) {
    "use strict";

    function Location() {
        this.case = document.getElementById("map");
        if (this.case) {
            this.getCoordinates();
            this.getAddress();
            this.getMap();
            this.getMarker();
            this.getInfoWindow();
        }
    }

    Location.prototype.getCoordinates = function() {
        this.coords = {
            lat: parseFloat(this.case.getAttribute("data-
             latitude")),
            lng: parseFloat(this.case.getAttribute("data-
             longitude"))
        };
    };

    Location.prototype.getAddress = function() {
        this.address = this.case.getAttribute("data-address");
    };

    Location.prototype.getMap = function() {
        this.map = new google.maps.Map(this.case, {
            zoom: 15,
            center: this.coords
        });
    };

    Location.prototype.getMarker = function() {
        this.marker = new google.maps.Marker({
            position: this.coords,
            map: this.map
        });
    };

    Location.prototype.getInfoWindow = function() {
        var self = this;
        var wrap = this.case.parentNode;
        var title = wrap.querySelector(".map-title").textContent;

        this.infoWindow = new google.maps.InfoWindow({
            content: "<h3>"+title+"</h3><p>"+this.address+"</p>"
        });

        this.marker.addListener("click", function() {
            self.infoWindow.open(self.map, self.marker);
        });
    };

    var instance;
    Location.init = function() {
        // called by Google Maps service automatically once loaded
        // but is designed so that Location is a singleton
        if (!instance) {
            instance = new Location();
        }
    };

    // expose in the global namespace
    window.Location = Location;
}(window));
  1. 为了使地图显示得漂亮,我们需要设置一些 CSS,如下面的代码所示:
/* site_static/site/css/style.css */ #map {
    box-sizing: padding-box;
    height: 0;
    padding-bottom: calc(9 / 16 * 100%); /* 16:9 aspect ratio */
    width: 100%;
}
@media screen and (max-width: 480px) {
    #map {
        display: none; /* hide on mobile devices (esp. portrait) */
    }
}

工作原理...

如果在本地开发服务器上运行并浏览位置的详细视图,您将导航到一个带有地图和标记的页面。当您点击标记时,将打开一个包含地址信息的弹出窗口。效果如下:

由于移动设备上地图的滚动可能会因为滚动内部滚动问题而出现问题,我们选择在小屏幕上(宽度小于或等于 480 像素)隐藏地图,这样当我们调整屏幕大小时,地图最终会变得不可见,如下所示:

让我们来看看代码。在最初的几个步骤中,我们添加了 Google Maps API 密钥并将其暴露给所有模板。然后我们创建了浏览位置的视图并将它们插入到 URL 配置中。然后我们创建了列表和详细模板。

DetailViewtemplate_name默认来自模型名称的小写版本,再加上detail;因此,我们的模板命名为location_detail.html。如果我们想使用不同的模板,可以为视图指定template_name属性。同样,ListViewtemplate_name默认来自模型名称的小写版本,再加上list,因此命名为location_list.html

在详细模板中,我们有位置标题和描述,后面是一个带有id="map"<div>元素,以及data-latitudedata-longitudedata-address自定义属性。这些组成了content块元素。在<body>末尾添加了两个<script>标签——一个是下面描述的location_detail.js,另一个是 Google Maps API 脚本,我们已经传递了我们的 Maps API 密钥和 API 加载时要调用的回调函数的名称。

在 JavaScript 文件中,我们使用原型函数创建了一个Location类。这个函数有一个静态的init()方法,它被赋予了 Google Maps API 的回调函数。当调用init()时,构造函数被调用以创建一个新的单例Location实例。在构造函数中,采取了一系列步骤来设置地图及其特性:

  1. 首先,通过其 ID 找到地图案例(容器)。只有找到该元素,我们才会继续。

  2. 接下来,使用data-latitudedata-longitude属性找到地理坐标,并将它们存储在字典中作为位置的coords。这个对象是 Google Maps API 理解的形式,稍后会用到。

  3. 接下来读取data-address,并直接将其存储为位置的地址属性。

  4. 从这里开始,我们开始构建东西,从地图开始。为了确保位置可见,我们使用之前从数据属性中提取的coords设置中心。

  5. 一个标记使位置在地图上明显可见,使用相同的coords定位。

  6. 最后,我们建立一个信息窗口,这是一种可以直接在地图上显示的弹出气泡,使用 API。除了之前检索到的地址,我们还根据模板中给出的.map-title类来查找位置标题。这被添加为窗口的<h1>标题,后跟地址作为<p>段落。为了允许窗口显示,我们在标记上添加了一个点击事件侦听器,它将打开窗口。

另请参阅

  • 在 JavaScript 中公开设置配方

  • 安排 base.html 模板配方

  • 提供响应式图片配方

  • 在模态对话框中打开对象详细信息配方

  • 在第六章将地图插入更改表单配方,模型管理

提供响应式图片

随着响应式网站成为常态,提供相同内容给移动设备和台式电脑时出现了许多性能问题。在小设备上减少响应式网站的加载时间的一个非常简单的方法是提供更小的图像。这就是响应式图片的关键组件srcsetsizes属性发挥作用的地方。

准备工作

让我们从之前的配方中使用的locations应用开始。

如何做到...

按照以下步骤添加响应式图片:

  1. 首先,让我们将django-imagekit安装到您的虚拟环境中,并将其添加到requirements/_base.txt中。我们将使用它来调整原始图像的大小:
(env)$ pip install django-imagekit==4.0.2

  1. 在设置中将"imagekit"添加到INSTALLED_APPS中:
# myproject/settings/_base.py INSTALLED_APPS = [
    # …
    "imagekit",
    # …
]
  1. models.py文件的开头,让我们导入一些用于图像版本的库,并定义一个负责图片文件的目录和文件名的函数:
# myproject/apps/locations/models.py import contextlib
import os
# …
from imagekit.models import ImageSpecField
from pilkit.processors import ResizeToFill
# …

def upload_to(instance, filename):
    now = timezone_now()
    base, extension = os.path.splitext(filename)
    extension = extension.lower()
    return f"locations/{now:%Y/%m}/{instance.pk}{extension}"
  1. 现在让我们在同一个文件中的Location模型中添加一个picture字段,以及图像版本的定义:
class Location(CreationModificationDateBase, UrlBase):
    # …
    picture = models.ImageField(_("Picture"), upload_to=upload_to)
    picture_desktop = ImageSpecField(
        source="picture",
        processors=[ResizeToFill(1200, 600)],
        format="JPEG",
        options={"quality": 100},
    )
    picture_tablet = ImageSpecField(
        source="picture", processors=[ResizeToFill(768, 384)], 
         format="PNG"
    )
    picture_mobile = ImageSpecField(
        source="picture", processors=[ResizeToFill(640, 320)], 
         format="PNG"
    )
  1. 然后,覆盖Location模型的delete()方法,以在模型实例被删除时删除生成的版本:
def delete(self, *args, **kwargs):
    from django.core.files.storage import default_storage

    if self.picture:
        with contextlib.suppress(FileNotFoundError):
            default_storage.delete(self.picture_desktop.path)
            default_storage.delete(self.picture_tablet.path)
            default_storage.delete(self.picture_mobile.path)
        self.picture.delete()

    super().delete(*args, **kwargs)
  1. 创建并运行迁移以将新的picture字段添加到数据库架构中。

  2. 更新位置详细模板以包括图像:

{# locations/location_detail.html #}
{% extends "base.html" %}
{% load i18n static %}

{% block content %}
    <a href="{% url "locations:location_list" %}">{% trans 
     "Interesting Locations" %}</a>
    <h1 class="map-title">{{ location.name }}</h1>
 {% if location.picture %}
 <picture class="img-fluid">
 <source
                media="(max-width: 480px)"
                srcset="{{ location.picture_mobile.url }}" />
 <source
                media="(max-width: 768px)"
                srcset="{{ location.picture_tablet.url }}" />
 <img
                src="img/{{ location.picture_desktop.url }}"
                alt="{{ location.name }}"
                class="img-fluid"
            />
 </picture>
 {% endif %}    {# … #}
{% endblock %}

{% block js %}
    {# … #}
{% endblock %}
  1. 最后,在管理中为位置添加一些图像。

工作原理...

响应式图像非常强大,基本上是为了根据指示每个图像将显示在哪些显示器上的媒体规则提供不同的图像。我们在这里做的第一件事是添加django-imagekit应用程序,这使得可以动态生成所需的不同图像。

显然,我们还需要原始图像源,因此在我们的Location模型中,我们添加了一个名为picture的图像字段。在upload_to()函数中,我们根据当前年份和月份、位置的 UUID 以及与上传文件相同的文件扩展名构建了上传路径和文件名。我们还在那里定义了图像版本规格,如下所示:

  • picture_desktop将具有 1,200 x 600 的尺寸,并将用于桌面布局

  • picture_tablet将具有 768 x 384 的尺寸,并将用于平板电脑

  • picture_mobile将具有 640 x 320 的尺寸,并将用于智能手机

在位置的delete()方法中,我们检查picture字段是否有任何值,然后尝试在删除位置本身之前删除它及其图像版本。我们使用contextlib.suppress(FileNotFoundError)来静默忽略如果在磁盘上找不到文件的任何错误。

最有趣的工作发生在模板中。当位置图片存在时,我们构建我们的<picture>元素。表面上,这基本上是一个容器。实际上,除了在我们的模板中最后出现的默认<img>标签之外,它可能什么都没有,尽管那将没有什么用。除了默认图像,我们还为其他宽度生成缩略图—480 像素和 768 像素—然后用它们来构建额外的<source>元素。每个<source>元素都有media规则,规定了在哪些条件下从srcset属性值中选择图像。在我们的情况下,我们为每个<source>只提供一个图像。位置详细页面现在将包括地图上方的图像,并且应该看起来像这样:

当浏览器加载此标记时,它会按照一系列步骤确定要加载哪个图像:

  • 依次检查每个<source>media规则,以查看是否有任何一个与当前视口匹配

  • 当规则匹配时,将读取srcset并加载和显示适当的图像 URL

  • 如果没有规则匹配,则加载最终默认图像的src

因此,在较小的视口上将加载较小的图像。例如,我们可以看到仅 375 像素宽的视口上加载了最小的图像:

对于根本无法理解<picture><source>标签的浏览器,仍然可以加载默认图像,因为它只是一个普通的<img>标签。

还有更多...

您不仅可以使用响应式图像来提供针对性的图像尺寸,还可以区分像素密度,并为在任何给定的视口大小上专门为设计精心策划的图像。这被称为艺术指导。如果您有兴趣了解更多信息,Mozilla 开发者网络MDN)在该主题上有一篇详尽的文章,可在developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images上找到。

另请参阅

  • 排列 base.html 模板配方

  • 使用 HTML5 数据属性配方

  • 在模态对话框中打开对象详细信息配方

  • 在第六章模型管理中的将地图插入更改表单*教程

实现连续滚动

社交网站通常具有称为连续滚动的功能,也称为无限滚动,作为分页的替代。与其单独具有查看额外项目集的链接不同,这里有大量项目的长列表,当您向下滚动页面时,新项目将自动加载并附加到底部。在本教程中,我们将看到如何使用 Django 和 jScroll jQuery 插件实现这样的效果。

您可以从jscroll.com/下载 jScroll 脚本,并在该网站找到有关该插件的详细文档。

准备工作

我们将重用我们在之前的教程中创建的locations应用。

为了在列表视图中显示一些更有趣的数据,让我们将ratings字段添加到Location模型中,如下所示:

# myproject/apps/locations/models.py # …
RATING_CHOICES = ((1, "★☆☆☆☆"), (2, "★★☆☆☆"), (3, "★★★☆☆"), (4, "★★★★☆"), (5, "★★★★★"))

class Location(CreationModificationDateBase, UrlBase):
    # …

    rating = models.PositiveIntegerField(
        _("Rating"), choices=RATING_CHOICES, blank=True, null=True
    )

    # …

    def get_rating_percentage(self):
 return self.rating * 20 if self.rating is not None else None

get_rating_percentage()方法将需要返回评分作为百分比的表示。

不要忘记进行迁移并添加一些位置的评分到管理中。

如何做...

按照以下步骤创建一个连续滚动页面:

  1. 首先,在管理中添加足够的位置。正如您从使用 HTML5 数据属性教程中所看到的,我们将通过每页 10 个项目对LocationList视图进行分页,因此我们至少需要 11 个位置来查看连续滚动是否按预期工作。

  2. 修改位置列表视图的模板如下:

{# locations/location_list.html #} {% extends "base.html" %}
{% load i18n static utility_tags %}

{% block content %}
    <div class="row">
        <div class="col-lg-8">
            <h1>{% trans "Interesting Locations" %}</h1>
            {% if object_list %}
                <div class="item-list">
 {% for location in object_list %}
                        <a href="{{ location.get_url_path }}"
                           class="item d-block my-3">
                            <div class="card">
                                <div class="card-body">
                                    <div class="float-right">
                                        <div class="rating" aria-
                                          label="{% blocktrans with 
                                           stars=location.rating %}
                                           {{ stars }} of 5 stars
                                            {% endblocktrans %}">
                                            <span style="width:{{ 
                                           location.get_rating
                                        _percentage }}%"></span>
                                        </div>
                                    </div>
                                    <p class="card-text">{{ 
                                        location.name }}<br/>
                                        <small>{{ location.city }},

                                         {{location.get_country
                                          _display }}</small>
                                    </p>
                                </div>
                            </div>
                        </a>
 {% endfor %}
 {% if page_obj.has_next %}
 <div class="text-center">
 <div class="loading-indicator"></div>
 </div>
 <p class="pagination">
 <a class="next-page"
 href="{% modify_query    
                               page=page_obj.next_page_number %}">
 {% trans "More..." %}</a>
 </p>
 {% endif %}
                </div>
            {% else %}
                <p>{% trans "There are no locations yet." %}</p>
            {% endif %}
        </div>
        <div class="col-lg-4">
            {% include "locations/includes/navigation.html" %}
        </div>
    </div>
{% endblock %}
  1. 在相同的模板中,使用以下标记覆盖cssjs块:
{% block css %}
    <link rel="stylesheet" type="text/css"
          href="{% static 'site/css/rating.css' %}">
{% endblock %}

{% block js %}
    <script src="img/jquery.jscroll.min.js"></script>
    <script src="img/list.js' %}"></script>
{% endblock %}
  1. 作为最后一步,使用加载指示器的 JavaScript 模板覆盖extra_body块:
{% block extra_body %}
    <script type="text/template" class="loader">
        <div class="text-center">
            <div class="loading-indicator"></div>
        </div>
    </script>
{% endblock %}
  1. locations/includes/navigation.html中创建页面导航。现在,您只需在那里创建一个空文件。

  2. 下一步是添加 JavaScript 并初始化连续滚动小部件:

/* site_static/site/js/list.js */ jQuery(function ($) {
    var $list = $('.item-list');
    var $loader = $('script[type="text/template"].loader');
    $list.jscroll({
        loadingHtml: $loader.html(),
        padding: 100,
        pagingSelector: '.pagination',
        nextSelector: 'a.next-page:last',
        contentSelector: '.item,.pagination'
    });
});
  1. 最后,我们将添加一些 CSS,以便评分可以使用用户友好的星星来显示,而不仅仅是数字。
/* site_static/site/css/rating.css */ .rating {
  color: #c90;
  display: block;
  position: relative;
  margin: 0;
  padding: 0;
  white-space: nowrap;
}

.rating span {
  color: #fc0;
  display: block;
  position: absolute;
  overflow: hidden;
  top: 0;
  left: 0;
  bottom: 0;
  white-space: nowrap;
}

.rating span:before,
.rating span:after {
  display: block;
  position: absolute;
  overflow: hidden;
  left: 0;
  top: 0;
  bottom: 0;
}

.rating:before {
  content: "☆☆☆☆☆";
}

.rating span:after {
  content: "★★★★★";
}
  1. 在主网站样式的主文件中,添加一个用于加载指示器的样式:
/* site_static/site/css/style.css */ /* … */
.loading-indicator {
  display: inline-block;
  width: 45px;
  height: 45px;
}
.loading-indicator:after {
  content: "";
  display: block;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 5px solid rgba(0,0,0,.25);
  border-color: rgba(0,0,0,.25) transparent rgba(0,0,0,.25) 
   transparent;
  animation: dual-ring 1.2s linear infinite;
}
@keyframes dual-ring {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

它是如何工作的...

当您在浏览器中打开位置列表视图时,页面上会显示视图中设置的预定义项目数量(即 10)。当您向下滚动时,将自动加载并附加另一页的项目和下一个分页链接到项目容器中。分页链接使用了来自在第五章创建一个模板标签来修改请求查询参数教程中的{% modify_query %}自定义模板标签,以根据当前的 URL 生成一个调整后的 URL,但指向正确的下一页编号。如果您的连接速度较慢,那么当您滚动到页面底部时,您将看到一个类似以下的页面,直到加载并附加到列表中的下一页的项目:

向下滚动,项目的第二、第三和以后的页面将在底部加载并附加。这将持续进行,直到没有更多页面需要加载,这是由最终组中没有进一步加载的分页链接来表示的。

我们在这里使用 Cloudflare CDN URL 来加载 jScroll 插件,但是,如果您选择将其作为静态文件下载到本地副本,则可以使用{% static %}查找将脚本添加到模板中。

在初始页面加载时,包含项目和分页链接的具有item-list CSS 类的元素将通过list.js中的代码成为一个 jScroll 对象。事实上,这个实现是足够通用的,可以用来为任何遵循类似标记结构的列表显示启用连续滚动。

以下选项用于定义其功能:

  • loadingHtml:这设置了 jScroll 在加载新项目页面时将注入到列表末尾的标记。在我们的情况下,它是一个动画加载指示器,并且它是直接从标记中的<script type="text/template" />标签中提取的 HTML。通过给出这个type属性,浏览器不会像执行普通 JavaScript 那样尝试执行它,而且内部内容对用户来说仍然是不可见的。

  • padding:当页面的滚动位置在滚动区域的末尾距离内时,应加载新页面。在这里,我们将其设置为 100 像素。

  • pagingSelector:一个 CSS 选择器,指示object_list中的哪些 HTML 元素是分页链接。在 jScroll 插件激活的浏览器中,这些链接将被隐藏,以便连续滚动可以接管加载额外页面的工作,但在其他浏览器中,用户仍然可以通过正常点击分页来导航。

  • nextSelector:此 CSS 选择器查找要从中读取下一页 URL 的 HTML 元素。

  • contentSelector:另一个 CSS 选择器。这指定应从 Ajax 加载的内容中提取哪些 HTML 元素,并添加到容器中。

rating.css插入 Unicode 星字符,并将轮廓与填充版本重叠,以创建评级效果。使用与评级值百分比的最大值(在本例中为 5)相当的宽度,填充的星星覆盖了空心星星的适当空间,允许小数评级。在标记中,有一个aria-label属性,其中包含供使用屏幕阅读器的人使用的评级信息。

最后,style.css文件中的 CSS 使用 CSS 动画来创建旋转加载指示器。

还有更多...

我们在侧边栏中有一个导航的占位符。请注意,使用连续滚动时,所有项目列表后面的所有次要导航应该放在侧边栏中,而不是页脚中,因为访问者可能永远不会到达页面的末尾。

另请参阅

  • 在第三章,表单和视图中的过滤对象列表食谱

  • 在第三章,表单和视图中的管理分页列表食谱

  • 在第三章,表单和视图中的组合基于类的视图食谱

  • 在 JavaScript 中公开设置食谱

  • 第五章,自定义模板过滤器和标签中的创建模板标签以修改请求查询参数食谱

在模态对话框中打开对象详细信息

在这个食谱中,我们将创建一个到位置的链接列表,当点击时,会打开一个 Bootstrap 模态对话框,显示有关位置的一些信息和了解更多...链接,指向位置详细页面:

对话框的内容将通过 Ajax 加载。对于没有 JavaScript 的访问者,详细页面将立即打开,而不需要这个中间步骤。

准备工作

让我们从之前创建的locations应用程序开始。

确保您有视图、URL 配置和位置列表和位置详细信息的模板,就像我们之前定义的那样。

如何做...

逐步执行这些步骤,将模态对话框作为列表视图和详细视图之间的中间步骤添加:

  1. 首先,在locations应用程序的 URL 配置中,为模态对话框的响应添加一个规则:
# myproject/apps/locations/urls.py from django.urls import path
from .views import LocationList, LocationDetail

urlpatterns = [
    path("", LocationList.as_view(), name="location_list"),
    path("add/", add_or_change_location, name="add_location"),
    path("<uuid:pk>/", LocationDetail.as_view(), 
     name="location_detail"),
    path(
 "<uuid:pk>/modal/",
 LocationDetail.as_view(template_name=
         "locations/location_detail_modal.html"),
 name="location_detail_modal",
 ),
]
  1. 为模态对话框创建一个模板:
{# locations/location_detail_modal.html #}
{% load i18n %}
<p class="text-center">
    {% if location.picture %}
        <picture class="img-fluid">
            <source media="(max-width: 480px)"
                    srcset="{{ location.picture_mobile.url }}"/>
            <source media="(max-width: 768px)"
                    srcset="{{ location.picture_tablet.url }}"/>
            <img src="img/{{ location.picture_desktop.url }}"
                 alt="{{ location.name }}"
                 class="img-fluid"
            />
        </picture>
    {% endif %}
</p>
<div class="modal-footer text-right">
    <a href="{% url "locations:location_detail" pk=location.pk %}" 
     class="btn btn-primary pull-right">
        {% trans "Learn more…" %}
    </a>
</div>
  1. 在位置列表的模板中,通过添加自定义数据属性来更新到位置详细信息的链接:
{# locations/location_list.html #} {# … #}
<a href="{{ location.get_url_path }}"
   data-modal-title="{{ location.get_full_address }}"
   data-modal-url="{% url 'locations:location_detail_modal' 
    pk=location.pk %}"
   class="item d-block my-3">
    {# … #}
</a>
{# … #}
  1. 在同一个文件中,使用模态对话框的标记覆盖extra_body内容:
{% block extra_body %}
    {# … #}
 <div id="modal" class="modal fade" tabindex="-1" role="dialog"
         aria-hidden="true" aria-labelledby="modal_title">
 <div class="modal-dialog modal-dialog-centered"
             role="document">
 <div class="modal-content">
 <div class="modal-header">
 <h4 id="modal_title"
                        class="modal-title"></h4>
 <button type="button" class="close"
                            data-dismiss="modal"
                            aria-label="{% trans 'Close' %}">
 <span aria-hidden="true">&times;</span>
 </button>
 </div>
 <div class="modal-body"></div>
 </div>
 </div>
 </div>
{% endblock %}
  1. 最后,通过添加脚本来修改list.js文件,以处理模态对话框的打开和关闭:
/* site_static/js/list.js */ /* … */
jQuery(function ($) {
    var $list = $('.item-list');
    var $modal = $('#modal');
    $modal.on('click', '.close', function (event) {
        $modal.modal('hide');
        // do something when dialog is closed…
    });
    $list.on('click', 'a.item', function (event) {
        var $link = $(this);
        var url = $link.data('modal-url');
        var title = $link.data('modal-title');
        if (url && title) {
            event.preventDefault();
            $('.modal-title', $modal).text(title);
            $('.modal-body', $modal).load(url, function () {
                $modal.on('shown.bs.modal', function () {
                    // do something when dialog is shown…
                }).modal('show');
            });
        }
    });
});

它是如何工作的...

如果我们在浏览器中转到位置列表视图并点击其中一个位置,我们将看到类似以下的模态对话框:

让我们来看看这是如何一起实现的。名为location_detail_modal的 URL 路径指向相同的位置详细视图,但使用不同的模板。提到的模板只有一个响应式图像和一个带有链接“了解更多…”的模态对话框页脚,该链接指向位置的正常详细页面。在列表视图中,我们更改了列表项的链接,以包括稍后 JavaScript 将引用的data-modal-titledata-modal-url属性。第一个属性规定应将完整地址用作标题。第二个属性规定应从模态对话框的主体中获取的位置。在列表视图的末尾,我们有 Bootstrap 4 模态对话框的标记。对话框包含一个带有关闭按钮和标题的页眉,以及用于主要详细信息的内容区域。JavaScript 应该通过js块添加。

在 JavaScript 文件中,我们使用了 jQuery 框架来利用更短的语法和统一的跨浏览器功能。当页面加载时,我们为.item-list元素分配了一个事件处理程序on('click')。当点击任何a.item时,该事件被委派给这个处理程序,该处理程序读取并存储自定义数据属性作为urltitle。当这些成功提取时,我们阻止原始点击操作(导航到完整的详细页面),然后设置模态进行显示。我们为隐藏的对话框设置新标题,并通过 Ajax 将模态对话框的内容加载到.modal-body元素上。最后,使用 Bootstrap 4 的modal() jQuery 插件向访问者显示模态。

如果 JavaScript 文件无法处理模态对话框的 URL 自定义属性,或者更糟糕的是,如果list.js中的 JavaScript 加载或执行失败,点击位置链接将像往常一样将用户带到详细页面。我们已经将我们的模态实现为渐进增强,以便用户体验正确,即使面临失败。

另请参阅

  • 使用 HTML5 数据属性配方

  • 提供响应式图像配方

  • 实现连续滚动配方

  • 实现“喜欢”小部件配方

实现“喜欢”小部件

一般来说,网站,尤其是那些具有社交组件的网站,通常会集成 Facebook、Twitter 和 Google+小部件,以喜欢和分享内容。在这个配方中,我们将指导您通过构建类似的 Django 功能,每当用户喜欢某物时,都会将信息保存在您的数据库中。您将能够根据用户在您的网站上喜欢的内容创建特定的视图。我们将类似地创建一个带有两状态按钮和显示总喜欢数量的徽章的“喜欢”小部件。

以下屏幕截图显示了非活动状态,您可以单击按钮将其激活:

以下屏幕截图显示了活动状态,您可以单击按钮将其停用:

小部件状态的更改将通过 Ajax 调用处理。

准备工作

首先,创建一个likes应用程序并将其添加到INSTALLED_APPS中。然后,设置一个Like模型,该模型与喜欢某物的用户具有外键关系,并且与数据库中的任何对象具有通用关系。我们将使用我们在第二章,模型和数据库结构中定义的object_relation_base_factory,该工厂用于处理通用关系的模型混合。如果您不想使用混合,您也可以自己在以下模型中定义通用关系:

# myproject/apps/likes/models.py from django.db import models
from django.utils.translation import ugettext_lazy as _
from django.conf import settings

from myproject.apps.core.models import (
    CreationModificationDateBase,
    object_relation_base_factory,
)

LikeableObject = object_relation_base_factory(is_required=True)

class Like(CreationModificationDateBase, LikeableObject):
    class Meta:
        verbose_name = _("Like")
        verbose_name_plural = _("Likes")
        ordering = ("-created",)

    user = models.ForeignKey(settings.AUTH_USER_MODEL, 
     on_delete=models.CASCADE)

    def __str__(self):
        return _("{user} likes {obj}").format(user=self.user, 
         obj=self.content_object)

还要确保在设置中设置了request上下文处理器。我们还需要在设置中添加身份验证中间件,以便将当前登录的用户附加到请求:

# myproject/settings/_base.py # …
MIDDLEWARE = [
    # …
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    # …
]

TEMPLATES = [
    {
        # …
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.request",
                # …
            ]
        },
    }
]

记得创建并运行迁移,以便为新的Like模型设置数据库。

如何做...

逐步执行以下步骤:

  1. likes应用程序中,创建一个带有空__init__.py文件的templatetags目录,使其成为 Python 模块。然后,添加likes_tags.py文件,在其中定义{% like_widget %}模板标记如下:
# myproject/apps/likes/templatetags/likes_tags.py from django import template
from django.contrib.contenttypes.models import ContentType
from django.template.loader import render_to_string

from ..models import Like

register = template.Library()

# TAGS

class ObjectLikeWidget(template.Node):
    def __init__(self, var):
        self.var = var

    def render(self, context):
        liked_object = self.var.resolve(context)
        ct = ContentType.objects.get_for_model(liked_object)
        user = context["request"].user

        if not user.is_authenticated:
            return ""

        context.push(object=liked_object, content_type_id=ct.pk)
        output = render_to_string("likes/includes/widget.html", 
         context.flatten())
        context.pop()
        return output

@register.tag
def like_widget(parser, token):
    try:
        tag_name, for_str, var_name = token.split_contents()
    except ValueError:
        tag_name = "%r" % token.contents.split()[0]
        raise template.TemplateSyntaxError(
            f"{tag_name} tag requires a following syntax: "
            f"{{% {tag_name} for <object> %}}"
        )
    var = template.Variable(var_name)
    return ObjectLikeWidget(var)

  1. 我们还将在同一文件中添加过滤器,以获取用户的 Like 状态以及指定对象的总 Like 数:
# myproject/apps/likes/templatetags/likes_tags.py # …
# FILTERS

@register.filter
def liked_by(obj, user):
    ct = ContentType.objects.get_for_model(obj)
    liked = Like.objects.filter(user=user, content_type=ct, object_id=obj.pk)
    return liked.count() > 0

@register.filter
def liked_count(obj):
    ct = ContentType.objects.get_for_model(obj)
    likes = Like.objects.filter(content_type=ct, object_id=obj.pk)
    return likes.count()
  1. 在 URL 规则中,我们需要一个处理使用 Ajax 进行点赞和取消点赞的视图规则:
# myproject/apps/likes/urls.py from django.urls import path
from .views import json_set_like

urlpatterns = [
    path("<int:content_type_id>/<str:object_id>/",
         json_set_like,
         name="json_set_like")
]
  1. 确保将 URL 映射到项目:
# myproject/urls.py from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path

urlpatterns = i18n_patterns(
    # …
 path("likes/", include(("myproject.apps.likes.urls", "likes"), 
     namespace="likes")),
)
  1. 然后,我们需要定义视图,如下所示:
# myproject/apps/likes/views.py from django.contrib.contenttypes.models import ContentType
from django.http import JsonResponse
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt

from .models import Like
from .templatetags.likes_tags import liked_count

@never_cache
@csrf_exempt
def json_set_like(request, content_type_id, object_id):
    """
    Sets the object as a favorite for the current user
    """
    result = {
        "success": False,
    }
    if request.user.is_authenticated and request.method == "POST":
        content_type = ContentType.objects.get(id=content_type_id)
        obj = content_type.get_object_for_this_type(pk=object_id)

        like, is_created = Like.objects.get_or_create(
            content_type=ContentType.objects.get_for_model(obj),
            object_id=obj.pk,
            user=request.user)
        if not is_created:
            like.delete()

        result = {
            "success": True,
            "action": "add" if is_created else "remove",
            "count": liked_count(obj),
        }

    return JsonResponse(result)
  1. 在任何对象的列表或详细视图模板中,我们可以添加小部件的模板标记。让我们将小部件添加到先前食谱中创建的位置详细信息中,如下所示:
{# locations/location_detail.html #} {% extends "base.html" %}
{% load i18n static likes_tags %}

{% block content %}
    <a href="{% url "locations:location_list" %}">{% trans 
     "Interesting Locations" %}</a>
    <div class="float-right">
 {% if request.user.is_authenticated %}
 {% like_widget for location %}
 {% endif %}
    </div>
    <h1 class="map-title">{{ location.name }}</h1>
    {# … #}
{% endblock %}
  1. 然后,我们需要一个小部件的模板,如下所示:
{# likes/includes/widget.html #}
{% load i18n static likes_tags sekizai_tags %}
<p class="like-widget">
    <button type="button"
            class="like-button btn btn-primary{% if object|
 liked_by:request.user %} active{% endif %}"
            data-href="{% url "likes:json_set_like" 
             content_type_id=content_type_id 
             object_id=object.pk %}"
            data-remove-label="{% trans "Like" %}"
            data-add-label="{% trans "Unlike" %}">
        {% if object|liked_by:request.user %}
            {% trans "Unlike" %}
        {% else %}
            {% trans "Like" %}
        {% endif %}
    </button>
    <span class="like-badge badge badge-secondary">
        {{ object|liked_count }}</span>
</p>
{% addtoblock "js" %}
<script src="img/widget.js' %}"></script>
{% endaddtoblock %}
  1. 最后,我们创建 JavaScript 来处理浏览器中的点赞和取消点赞操作,如下所示:
/* myproject/apps/likes/static/likes/js/widget.js */
(function($) {
    $(document).on("click", ".like-button", function() {
        var $button = $(this);
        var $widget = $button.closest(".like-widget");
        var $badge = $widget.find(".like-badge");

        $.post($button.data("href"), function(data) {
            if (data.success) {
                var action = data.action; // "add" or "remove"
                var label = $button.data(action + "-label");

                $buttonaction + "Class";
                $button.html(label);

                $badge.html(data.count);
            }
        }, "json");
    });
}(jQuery));

它是如何工作的...

现在,您可以为网站中的任何对象使用{% like_widget for object %}模板标记。它生成一个小部件,根据当前登录用户对对象的响应方式显示 Like 状态。

Like 按钮有三个自定义的 HTML5 数据属性:

  • data-href提供了一个唯一的、特定于对象的 URL,用于更改小部件的当前状态

  • data-add-text是在添加Like关联时要显示的翻译文本(取消关联)

  • data-remove-text类似地是在取消Like关联时要显示的翻译文本(Like)

使用django-sekizai,我们将<script src="img/widget.js' %}"></script>添加到页面。请注意,如果页面上有多个Like小部件,我们只需包含 JavaScript 一次。如果页面上没有Like小部件,则根本不会在页面上包含 JavaScript。

在 JavaScript 文件中,Like按钮由like-button CSS 类识别。附加到文档的事件侦听器会监视页面中找到的任何此类按钮的单击事件,然后将 Ajax 调用发布到data-href属性指定的 URL。

指定的视图json_set_like接受两个参数:内容类型 ID 和喜欢的对象的主键。视图检查指定对象是否存在Like,如果存在,则删除它;否则,添加Like对象。因此,视图返回带有success状态的 JSON 响应,对于对象的Like对象采取的操作(添加或删除)以及所有用户对对象的喜欢总数。根据返回的操作,JavaScript 将显示按钮的适当状态。

您可以在浏览器的开发者工具中调试 Ajax 响应,通常在网络选项卡中。如果在开发过程中发生任何服务器错误,并且在设置中打开了DEBUG,您将在响应的预览中看到错误的回溯;否则,您将看到返回的 JSON,如下面的屏幕截图所示:

另请参阅

  • 使用 Django Sekizai 食谱

  • 在模态对话框中打开对象详细信息食谱

  • 实现连续滚动食谱

  • 通过 Ajax 上传图片的食谱

  • 在第二章,  模型和数据库结构中创建一个处理通用关系的模型 mixin 的食谱

  • 第五章,自定义模板过滤器和标记

通过 Ajax 上传图片

使用默认文件输入字段,很快就会发现我们可以做很多事情来改善用户体验:

  • 首先,只有所选文件的路径显示在字段内,而人们希望在选择文件后立即看到他们选择的内容。

  • 其次,文件输入本身通常太窄,无法显示所选路径的大部分内容,并且从左端读取。因此,文件名很少在字段内可见。

  • 最后,如果表单有验证错误,没有人想再次选择文件;文件应该仍然在具有验证错误的表单中被选中。

在这个示例中,我们将看到如何改进文件上传。

准备工作

让我们从我们在之前的示例中创建的locations应用程序开始。

我们自己的 JavaScript 文件将依赖于外部库 - jQuery 文件上传。您可以从github.com/blueimp/jQuery-File-Upload/tree/v10.2.0下载并提取文件,并将它们放在site_static/site/vendor/jQuery-File-Upload-10.2.0中。该实用程序还需要jquery.ui.widget.js,该文件可在vendor/子目录中与其他文件一起使用。有了这些,我们就可以开始了。

如何做...

让我们定义位置表单,以便它可以支持使用以下步骤进行 Ajax 上传:

  1. 让我们为具有非必需的picture字段、隐藏的picture_path字段和geopositionlatitudelongitude字段创建一个模型表单:
# myproject/apps/locations/forms.py import os
from django import forms
from django.urls import reverse
from django.utils.translation import ugettext_lazy as _
from django.core.files.storage import default_storage
from crispy_forms import bootstrap, helper, layout
from .models import Location

class LocationForm(forms.ModelForm):
    picture = forms.ImageField(
        label=_("Picture"), max_length=255, 
         widget=forms.FileInput(), required=False
    )
    picture_path = forms.CharField(
        max_length=255, widget=forms.HiddenInput(), required=False
    )
    latitude = forms.FloatField(
        label=_("Latitude"),
        help_text=_("Latitude (Lat.) is the angle between any point 
        and the equator (north pole is at 90; south pole is at 
        -90)."),
        required=False,
    )
    longitude = forms.FloatField(
        label=_("Longitude"),
        help_text=_("Longitude (Long.) is the angle east or west 
        of an arbitrary point on Earth from Greenwich (UK), 
        which is the international zero-longitude point 
        (longitude=0 degrees). The anti-meridian of Greenwich is 
        both 180 (direction to east) and -180 (direction to 
        west)."),
        required=False,
    )
    class Meta:
        model = Location
        exclude = ["geoposition", "rating"]
  1. 在此表单的__init__()方法中,我们将从模型实例中读取地理位置,然后为表单定义django-crispy-forms布局:
def __init__(self, request, *args, **kwargs):
    self.request = request
    super().__init__(*args, **kwargs)
    geoposition = self.instance.get_geoposition()
 if geoposition:
 self.fields["latitude"].initial = geoposition.latitude
 self.fields["longitude"].initial = geoposition.longitude

    name_field = layout.Field("name", css_class="input-block-
     level")
    description_field = layout.Field(
        "description", css_class="input-block-level", rows="3"
    )
    main_fieldset = layout.Fieldset(_("Main data"), name_field, 
     description_field)

 picture_field = layout.Field(
 "picture",
 data_url=reverse("upload_file"),
 template="core/includes/file_upload_field.html",
 )
 picture_path_field = layout.Field("picture_path")

 picture_fieldset = layout.Fieldset(
 _("Picture"),
 picture_field,
 picture_path_field,
 title=_("Picture upload"),
 css_id="picture_fieldset",
 )

    street_address_field = layout.Field(
        "street_address", css_class="input-block-level"
    )
    street_address2_field = layout.Field(
        "street_address2", css_class="input-block-level"
    )
    postal_code_field = layout.Field("postal_code", 
     css_class="input-block-level")
    city_field = layout.Field("city", css_class="input-block-
     level")
    country_field = layout.Field("country", css_class="input-
     block-level")
    latitude_field = layout.Field("latitude", css_class="input-
     block-level")
    longitude_field = layout.Field("longitude", css_class="input-
     block-level")
    address_fieldset = layout.Fieldset(
        _("Address"),
        street_address_field,
        street_address2_field,
        postal_code_field,
        city_field,
        country_field,
        latitude_field,
        longitude_field,
    )

    submit_button = layout.Submit("save", _("Save"))
    actions = bootstrap.FormActions(layout.Div(submit_button, 
      css_class="col"))

    self.helper = helper.FormHelper()
    self.helper.form_action = self.request.path
    self.helper.form_method = "POST"
    self.helper.attrs = {"noValidate": "noValidate"}
    self.helper.layout = layout.Layout(main_fieldset, 
     picture_fieldset, address_fieldset, actions) 
  1. 然后我们需要为同一表单的picturepicture_path字段添加验证:
def clean(self):
    cleaned_data = super().clean()
    picture_path = cleaned_data["picture_path"]
    if not self.instance.pk and not self.files.get("picture") 
     and not picture_path:
        raise forms.ValidationError(_("Please choose an image."))
  1. 最后,我们将为此表单添加保存方法,该方法将负责保存图像和地理位置:
def save(self, commit=True):
    instance = super().save(commit=False)
    picture_path = self.cleaned_data["picture_path"]
    if picture_path:
        temporary_image_path = os.path.join("temporary-uploads", 
         picture_path)
        file_obj = default_storage.open(temporary_image_path)
        instance.picture.save(picture_path, file_obj, save=False)
        default_storage.delete(temporary_image_path)
    latitude = self.cleaned_data["latitude"]
    longitude = self.cleaned_data["longitude"]
    if latitude is not None and longitude is not None:
        instance.set_geoposition(longitude=longitude, 
         latitude=latitude)
    if commit:
        instance.save()
        self.save_m2m()
    return instance
  1. 除了locations应用程序中先前定义的视图之外,我们将添加一个add_or_change_location视图,如下面的代码所示:
# myproject/apps/locations/views.py from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect, get_object_or_404

from .forms import LocationForm
from .models import Location

# …

@login_required
def add_or_change_location(request, pk=None):
    location = None
    if pk:
        location = get_object_or_404(Location, pk=pk)
    if request.method == "POST":
        form = LocationForm(request, data=request.POST, 
         files=request.FILES, instance=location)
        if form.is_valid():
            location = form.save()
            return redirect("locations:location_detail", 
             pk=location.pk)
    else:
        form = LocationForm(request, instance=location)

    context = {"location": location, "form": form}
    return render(request, "locations/location_form.html", context)
  1. 让我们将此视图添加到 URL 配置中:
# myproject/apps/locations/urls.py
from django.urls import path
from .views import add_or_change_location

urlpatterns = [
    # …
    path("<uuid:pk>/change/", add_or_change_location, 
     name="add_or_change_location"),
]
  1. core应用程序的视图中,我们将添加一个通用的upload_file函数,用于上传可以被具有picture字段的其他应用程序重用的图片:
# myproject/apps/core/views.py import os
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.http import JsonResponse
from django.core.exceptions import SuspiciousOperation
from django.urls import reverse
from django.views.decorators.csrf import csrf_protect
from django.utils.translation import gettext_lazy as _
from django.conf import settings
# …

@csrf_protect
def upload_file(request):
    status_code = 400
    data = {"files": [], "error": _("Bad request")}
    if request.method == "POST" and request.is_ajax() and "picture" 
     in request.FILES:
        file_types = [f"image/{x}" for x in ["gif", "jpg", "jpeg", 
         "png"]]
        file = request.FILES.get("picture")
        if file.content_type not in file_types:
            status_code = 405
            data["error"] = _("Invalid file format")
        else:
            upload_to = os.path.join("temporary-uploads", 
             file.name)
            name = default_storage.save(upload_to, 
             ContentFile(file.read()))
            file = default_storage.open(name)
            status_code = 200
            del data["error"]
            absolute_uploads_dir = os.path.join(
                settings.MEDIA_ROOT, "temporary-uploads"
            )
            file.filename = os.path.basename(file.name)
            data["files"].append(
                {
                    "name": file.filename,
                    "size": file.size,
                    "deleteType": "DELETE",
                    "deleteUrl": (
                        reverse("delete_file") + 
                         f"?filename={file.filename}"
                    ),
                    "path": file.name[len(absolute_uploads_dir) 
                      + 1 :],
                }
            )

    return JsonResponse(data, status=status_code)
  1. 我们将为新的上传视图设置 URL 规则如下:
# myproject/urls.py from django.urls import path
from myproject.apps.core import views as core_views

# …

urlpatterns += [
    path(
        "upload-file/",
        core_views.upload_file,
        name="upload_file",
    ),
]
  1. 现在让我们创建一个如下所示的位置表单模板:
{# locations/location_form.html #}
{% extends "base.html" %}
{% load i18n crispy_forms_tags %}

{% block content %}
    <div class="row">
        <div class="col-lg-8">
            <a href="{% url "locations:location_list" %}">{% trans 
             "Interesting Locations" %}</a>
            <h1>
                {% if location %}
                    {% blocktrans trimmed with name=
                      location.name %}
                        Change Location "{{ name }}"
                    {% endblocktrans %}
                {% else %}
                    {% trans "Add Location" %}
                {% endif %}
            </h1>
            {% crispy form %}
        </div>
    </div>
{% endblock %}
  1. 我们需要几个更多的模板。为文件上传字段创建一个自定义模板,其中将包括必要的 CSS 和 JavaScript:
{# core/includes/file_upload_field.html #}
{% load i18n crispy_forms_field static sekizai_tags %}

{% include "core/includes/picture_preview.html" %}
<{% if tag %}{{ tag }}{% else %}div{% endif %} id="div_{{ field.auto_id }}"
class="form-group{% if 'form-horizontal' in form_class %} row{% endif %}{% if wrapper_class %} {{ wrapper_class }}{% endif %}{% if field.css_classes %} {{ field.css_classes }}{% endif %}">
  {% if field.label and form_show_labels %}
    <label for="{{ field.id_for_label }}"
           class="col-form-label {{ label_class }}{% if field
            .field.required %} requiredField{% endif %}">
      {{ field.label|safe }}{% if field.field.required %}<span 
       class="asteriskField">*</span>{% endif %}
    </label>
  {% endif %}

 <div class="{{ field_class }}">
 <span class="btn btn-success fileinput-button">
 <span>{% trans "Upload File..." %}</span>
 {% crispy_field field %}
 </span>
 {% include 'bootstrap4/layout/help_text_and_errors.html' %}
 <p class="form-text text-muted">
 {% trans "Available formats are JPG, GIF, and PNG." %}
 {% trans "Minimal size is 800 × 800 px." %}
 </p>
 </div>
</{% if tag %}{{ tag }}{% else %}div{% endif %}>

{% addtoblock "css" %}
<link rel="stylesheet" href="{% static 'site/vendor/jQuery-File-Upload-10.2.0/css/jquery.fileupload-ui.css' %}"/>
<link rel="stylesheet" href="{% static 'site/vendor/jQuery-File-Upload-10.2.0/css/jquery.fileupload.css' %}"/>
{% endaddtoblock %}

{% addtoblock "js" %}
<script src="img/jquery.ui.widget.js' %}"></script>
<script src="img/jquery.iframe-transport.js' %}"></script>
<script src="img/jquery.fileupload.js' %}"></script>
<script src="img/picture_upload.js' %}"></script>
{% endaddtoblock %}
  1. 接下来,让我们为图片预览创建一个模板:
{# core/includes/picture_preview.html #} <div id="picture_preview">
  {% if form.instance.picture %}
    <img src="img/{{ form.instance.picture.url }}" alt="" 
     class="img-fluid"/>
  {% endif %}
</div>
<div id="progress" class="progress" style="visibility: hidden">
  <div class="progress-bar progress-bar-striped 
   progress-bar-animated"
       role="progressbar"
       aria-valuenow="0"
       aria-valuemin="0"
       aria-valuemax="100"
       style="width: 0%"></div>
</div>
  1. 最后,让我们添加处理图片上传和预览的 JavaScript:
/* site_static/site/js/picture_upload.js */ $(function() {
  $("#id_picture_path").each(function() {
    $picture_path = $(this);
    if ($picture_path.val()) {
      $("#picture_preview").html(
        '<img src="img/>          "temporary-uploads/" +
          $picture_path.val() +
          '" alt="" class="img-fluid" />'
      );
    }
  });
  $("#id_picture").fileupload({
    dataType: "json",
    add: function(e, data) {
      $("#progress").css("visibility", "visible");
      data.submit();
    },
    progressall: function(e, data) {
      var progress = parseInt((data.loaded / data.total) * 100, 
       10);
      $("#progress .progress-bar")
        .attr("aria-valuenow", progress)
        .css("width", progress + "%");
    },
    done: function(e, data) {
      $.each(data.result.files, function(index, file) {
        $("#picture_preview").html(
          '<img src="img/>            "temporary-uploads/" +
            file.name +
            '" alt="" class="img-fluid" />'
        );
        $("#id_picture_path").val(file.name);
      });
      $("#progress").css("visibility", "hidden");
    }
  });
});

工作原理...

如果 JavaScript 执行失败,则表单仍然完全可用,但是当 JavaScript 正常运行时,我们将获得一个增强的表单,其中文件字段被一个简单的按钮替换,如下所示:

当通过单击“上传文件…”按钮选择图像时,浏览器中的结果将类似于以下屏幕截图:

单击“上传文件…”按钮会触发一个文件对话框,要求您选择文件,并在选择后立即开始 Ajax 上传过程。然后我们会看到已附加的图像的预览。预览图片上传到临时目录,并且其文件名保存在picture_path隐藏字段中。当您提交表单时,表单会从此临时位置或picture字段保存图片。如果表单是在没有 JavaScript 或 JavaScript 加载失败的情况下提交的,则picture字段将具有一个值。如果在页面重新加载后其他字段有任何验证错误,则加载的预览图像基于picture_path

让我们通过以下步骤深入了解该过程并看看它是如何工作的。

在我们的“位置”模型的模型表单中,我们将“图片”字段设置为非必填,尽管在模型级别上是必需的。此外,我们在那里添加了picture_path字段,然后我们期望其中任何一个字段被提交到表单中。在crispy-forms布局中,我们为picture字段定义了一个自定义模板file_upload_field.html。在那里,我们设置了预览图像、上传进度条和自定义帮助文本,其中包括允许的文件格式和最小尺寸。在同一个模板中,我们还附加了来自 jQuery 文件上传库的 CSS 和 JavaScript 文件以及一个自定义脚本picture_upload.js。CSS 文件将文件上传字段呈现为一个漂亮的按钮。JavaScript 文件负责基于 Ajax 的文件上传。

picture_upload.js将所选文件发送到upload_file视图。该视图检查文件是否为图像类型,然后尝试将其保存在项目的MEDIA_ROOT下的temporary-uploads/目录中。该视图返回一个 JSON,其中包含有关成功或失败的文件上传的详细信息。

在选择并上传图片并提交表单后,LocationForm的“save()”方法将被调用。如果picture_path字段的值存在,则将从临时目录中取出文件并复制到Location模型的picture字段中。然后删除临时目录中的图片,并保存Location实例。

还有更多...

我们从模型表单中排除了geoposition字段,而是为地理位置数据呈现了“纬度”和“经度”字段。默认的地理位置PointField被呈现为一个没有自定义可能性的Leaflet.js地图。通过这两个“纬度”和“经度”字段,我们可以灵活地利用 Google Maps API、Bing Maps API 或Leaflet.js来在地图中显示它们,手动输入,或者从填写的位置地址中进行地理编码。

为了方便起见,我们使用了两个辅助方法“get_geoposition()”和“set_geoposition()”,这些方法我们在“使用 HTML5 数据属性”配方中定义过。

另请参阅

  • 在“使用 HTML5 数据属性”配方中

  • 在“第三章”(ac26c6a6-3fd1-4b28-8b01-5b3cda40f4f9.xhtml)的“表单和视图”中的“上传图片”配方

  • 在“以模态对话框形式打开对象详细信息”配方中

  • 在“实现连续滚动”配方中

  • 在“实现点赞小部件”配方中

  • 在“第七章”(0d629161-25ac-4edc-a361-aff632f37b33.xhtml)的“安全性和性能”中的“使表单免受跨站请求伪造(CSRF)”配方

第五章:自定义模板过滤器和标签

在本章中,我们将涵盖以下配方:

  • 遵循自己的模板过滤器和标签的约定

  • 创建一个模板过滤器以显示自发布以来经过了多少天

  • 创建一个模板过滤器来提取第一个媒体对象

  • 创建一个模板过滤器以使 URL 更加人性化

  • 创建一个模板标签以包含模板(如果存在)

  • 创建一个模板标签以在模板中加载 QuerySet

  • 创建一个模板标签以将内容解析为模板

  • 创建模板标签以修改请求查询参数

介绍

Django 具有功能丰富的模板系统,包括模板继承、更改值表示的过滤器和用于表现逻辑的标签等功能。此外,Django 允许您向应用程序添加自定义模板过滤器和标签。自定义过滤器或标签应位于您的应用程序中的templatetags Python 包下的模板标签库文件中。然后可以使用{% load %}模板标签在任何模板中加载您的模板标签库。在本章中,我们将创建几个有用的过滤器和标签,以便更多地控制模板编辑者。

技术要求

要使用本章的代码,您将需要最新稳定版本的 Python 3,MySQL 或 PostgreSQL 数据库,以及带有虚拟环境的 Django 项目。

您可以在 GitHub 存储库的ch05目录中找到本章的所有代码:github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition

遵循自己的模板过滤器和标签的约定

如果没有遵循指南,自定义模板过滤器和标签可能会令人困惑和不一致。拥有方便灵活的模板过滤器和标签对于模板编辑者来说非常重要。在本篇中,我们将看一些增强 Django 模板系统功能时应该使用的约定:

  1. 当页面的逻辑更适合于视图、上下文处理器或模型方法时,不要创建或使用自定义模板过滤器或标签。当您的内容是特定于上下文的,例如对象列表或对象详细视图时,在视图中加载对象。如果您需要在几乎每个页面上显示一些内容,请创建上下文处理器。当您需要获取与模板上下文无关的对象的一些属性时,请使用模型的自定义方法而不是模板过滤器。

  2. 使用_tags后缀命名模板标签库。当您的模板标签库与您的应用程序命名不同时,您可以避免模糊的包导入问题。

  3. 在新创建的库中,将过滤器与标签分开,例如使用注释,如下面的代码所示:

# myproject/apps/core/templatetags/utility_tags.py from django import template


register = template.Library()

""" TAGS """

# Your tags go here…

""" FILTERS """

# Your filters go here…
  1. 在创建高级自定义模板标签时,确保其语法易于记忆,包括以下可以跟随标签名称的构造:
  • for [app_name.model_name]:包括此构造以使用特定模型。

  • using [template_name]:包括此构造以使用模板作为模板标签的输出。

  • limit [count]:包括此构造以将结果限制为特定数量。

  • as [context_variable]:包括此构造以将结果存储在可以多次重用的上下文变量中。

  1. 尽量避免在模板标签中定义多个按位置定义的值,除非它们是不言自明的。否则,这可能会使模板开发人员感到困惑。

  2. 尽可能使可解析的参数多。没有引号的字符串应被视为需要解析的上下文变量,或者作为提醒模板标签组件结构的简短单词。

创建一个模板过滤器以显示自发布以来经过了多少天

在谈论创建或修改日期时,方便阅读更加人性化的时间差异,例如,博客条目是 3 天前发布的,新闻文章是今天发布的,用户上次登录是昨天。在这个示例中,我们将创建一个名为date_since的模板过滤器,它将根据天、周、月或年将日期转换为人性化的时间差异。

准备工作

如果尚未完成,请创建core应用程序,并将其放置在设置中的INSTALLED_APPS中。然后,在此应用程序中创建一个templatetags Python 包(Python 包是带有空的__init__.py文件的目录)。

如何做...

创建一个utility_tags.py文件,其中包含以下内容:

# myproject/apps/core/templatetags/utility_tags.py from datetime import datetime
from django import template
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _

register = template.Library()

""" FILTERS """

DAYS_PER_YEAR = 365
DAYS_PER_MONTH = 30
DAYS_PER_WEEK = 7

@register.filter(is_safe=True)
def date_since(specific_date):
    """
    Returns a human-friendly difference between today and past_date
    (adapted from https://www.djangosnippets.org/snippets/116/)
    """
    today = timezone.now().date()
    if isinstance(specific_date, datetime):
        specific_date = specific_date.date()
    diff = today - specific_date
    diff_years = int(diff.days / DAYS_PER_YEAR)
    diff_months = int(diff.days / DAYS_PER_MONTH)
    diff_weeks = int(diff.days / DAYS_PER_WEEK)
    diff_map = [
        ("year", "years", diff_years,),
        ("month", "months", diff_months,),
        ("week", "weeks", diff_weeks,),
        ("day", "days", diff.days,),
    ]
    for parts in diff_map:
        (interval, intervals, count,) = parts
        if count > 1:
            return _(f"{count} {intervals} ago")
        elif count == 1:
            return _("yesterday") \
                if interval == "day" \
                else _(f"last {interval}")
    if diff.days == 0:
        return _("today")
    else:
        # Date is in the future; return formatted date.
        return f"{specific_date:%B %d, %Y}"

它是如何工作的...

在模板中使用此过滤器,如下所示的代码将呈现类似于昨天、上周或 5 个月前的内容:

{% load utility_tags %}
{{ object.published|date_since }}

您可以将此过滤器应用于datedatetime类型的值。

每个模板标签库都有一个template.Library类型的注册表,其中收集了过滤器和标签。 Django 过滤器是由@register.filter装饰器注册的函数。在这种情况下,我们传递了is_safe=True参数,以指示我们的过滤器不会引入任何不安全的 HTML 标记。

默认情况下,模板系统中的过滤器将与函数或其他可调用对象的名称相同。如果需要,可以通过将名称传递给装饰器来为过滤器设置不同的名称,如下所示:

@register.filter(name="humanized_date_since", is_safe=True)
def date_since(value):
    # …

过滤器本身相当不言自明。首先读取当前日期。如果过滤器的给定值是datetime类型,则提取其date。然后,根据DAYS_PER_YEARDAYS_PER_MONTHDAYS_PER_WEEK或天数间隔计算今天和提取值之间的差异。根据计数,返回不同的字符串结果,如果值在未来,则返回格式化日期。

还有更多...

如果需要,我们也可以覆盖其他时间段,例如 20 分钟前、5 小时前,甚至是 10 年前。为此,我们将在现有的diff_map集合中添加更多的间隔,并且为了显示时间差异,我们需要对datetime值进行操作,而不是date值。

另请参阅

  • 提取第一个媒体对象的模板过滤器的方法

  • 创建一个模板过滤器以使 URL 更加人性化的方法

创建一个模板过滤器来提取第一个媒体对象

想象一下,您正在开发一个博客概述页面,对于每篇文章,您希望从内容中显示图像、音乐或视频,这些内容来自内容。在这种情况下,您需要从帖子模型的字段中存储的 HTML 内容中提取<figure><img><object><embed><video><audio><iframe>标签。在这个示例中,我们将看到如何使用first_media过滤器来执行此操作。

准备工作

我们将从core应用程序开始,在设置中应设置为INSTALLED_APPS,并且应该包含此应用程序中的templatetags包。

如何做...

utility_tags.py文件中,添加以下内容:

# myproject/apps/core/templatetags/utility_tags.py import re
from django import template
from django.utils.safestring import mark_safe

register = template.Library()

""" FILTERS """

MEDIA_CLOSED_TAGS = "|".join([
    "figure", "object", "video", "audio", "iframe"])
MEDIA_SINGLE_TAGS = "|".join(["img", "embed"])
MEDIA_TAGS_REGEX = re.compile(
    r"<(?P<tag>" + MEDIA_CLOSED_TAGS + ")[\S\s]+?</(?P=tag)>|" +
    r"<(" + MEDIA_SINGLE_TAGS + ")[^>]+>",
    re.MULTILINE)

@register.filter
def first_media(content):
    """
    Returns the chunk of media-related markup from the html content
    """
    tag_match = MEDIA_TAGS_REGEX.search(content)
    media_tag = ""
    if tag_match:
        media_tag = tag_match.group()
    return mark_safe(media_tag)

它是如何工作的...

如果数据库中的 HTML 内容有效,并且将以下代码放入模板中,则将从对象的内容字段中检索媒体标签;否则,如果未找到媒体,则将返回空字符串:

{% load utility_tags %}
{{ object.content|first_media }} 

正则表达式是搜索或替换文本模式的强大功能。首先,我们定义了所有支持的媒体标签名称的列表,将它们分成具有开放和关闭标签(MEDIA_CLOSED_TAGS)和自关闭标签(MEDIA_SINGLE_TAGS)的组。从这些列表中,我们生成了编译后的正则表达式MEDIA_TAGS_REGEX。在这种情况下,我们搜索所有可能的媒体标签,允许它们跨越多行出现。

让我们看看这个正则表达式是如何工作的,如下所示:

  • 交替模式由管道(|)符号分隔。

  • 模式中有两组——首先是那些具有开放和关闭普通标签(<figure><object><video><audio><iframe><picture>)的标签,然后是最后一个模式,用于所谓的自关闭

或空标签(<img><embed>)。

  • 对于可能是多行的普通标签,我们将使用[\S\s]+?模式,该模式至少匹配任何符号一次;但是,我们尽可能少地执行这个操作,直到找到它后面的字符串。

  • 因此,<figure[\S\s]+?</figure>搜索<figure>标签的开始以及它后面的所有内容,直到找到</figure>标签的闭合。

  • 类似地,对于自关闭标签的[^>]+模式,我们搜索除右尖括号(可能更为人所知的是大于号符号,即>)之外的任何符号,至少一次,尽可能多次,直到遇到指示标签关闭的尖括号。

re.MULTILINE标志确保可以找到匹配项,即使它们跨越内容中的多行。然后,在过滤器中,我们使用这个正则表达式模式进行搜索。默认情况下,在 Django 中,任何过滤器的结果都会显示为<>&符号转义为&lt;&gt;&amp;实体。然而,在这种情况下,我们使用mark_safe()函数来指示结果是安全的并且已准备好用于 HTML,以便任何内容都将被呈现而不进行转义。因为原始内容是用户输入,所以我们这样做,而不是在注册过滤器时传递is_safe=True,因为我们需要明确证明标记是安全的。

还有更多...

如果您对正则表达式感兴趣,可以在官方 Python 文档中了解更多信息docs.python.org/3/library/re.html

另请参阅

  • 创建一个模板过滤器以显示发布后经过多少天食谱

  • 创建一个模板过滤器以使 URL 更加人性化食谱

创建一个模板过滤器以使 URL 更加人性化

Web 用户通常在地址字段中以不带协议(http://)或斜杠(/)的方式识别 URL,并且以类似的方式输入 URL。在这个食谱中,我们将创建一个humanize_url过滤器,用于以更短的格式向用户呈现 URL,截断非常长的地址,类似于 Twitter 在推文中对链接所做的操作。

准备工作

与之前的食谱类似,我们将从core应用程序开始,在设置中应该设置INSTALLED_APPS,其中包含应用程序中的templatetags包。

如何做...

core应用程序的utility_tags.py模板库的FILTERS部分中,让我们添加humanize_url过滤器并注册它,如下所示:

# myproject/apps/core/templatetags/utility_tags.py import re
from django import template

register = template.Library()

""" FILTERS """

@register.filter
def humanize_url(url, letter_count=40):
    """
    Returns a shortened human-readable URL
    """
    letter_count = int(letter_count)
    re_start = re.compile(r"^https?://")
    re_end = re.compile(r"/$")
    url = re_end.sub("", re_start.sub("", url))
    if len(url) > letter_count:
        url = f"{url[:letter_count - 1]}…"
    return url

工作原理...

我们可以在任何模板中使用humanize_url过滤器,如下所示:

{% load utility_tags %}
<a href="{{ object.website }}" target="_blank">
    {{ object.website|humanize_url }}
</a>
<a href="{{ object.website }}" target="_blank">
    {{ object.website|humanize_url:30 }}
</a>

该过滤器使用正则表达式来删除前导协议和尾部斜杠,将 URL 缩短到给定的字母数量(默认为 40),并在截断后添加省略号,如果完整的 URL 不符合指定的字母数量。例如,对于https://docs.djangoproject.com/en/3.0/howto/custom-template-tags/的 URL,40 个字符的人性化版本将是docs.djangoproject.com/en/3.0/howto/cus…

另请参阅

  • 创建一个模板过滤器以显示发布后经过多少天食谱

  • 创建一个模板过滤器以提取第一个媒体对象食谱

  • 创建一个模板标签以包含模板(如果存在)食谱

创建一个模板标签以包含模板(如果存在)

Django 提供了{% include %}模板标签,允许一个模板呈现和包含另一个模板。但是,如果您尝试包含文件系统中不存在的模板,则此模板标签会引发错误。在此食谱中,我们将创建一个{% try_to_include %}模板标签,如果存在,则包含另一个模板,并通过渲染为空字符串来静默失败。

准备工作

我们将从已安装并准备好自定义模板标签的core应用程序开始。

如何做...

执行以下步骤创建{% try_to_include %}模板标签:

  1. 首先,让我们创建解析模板标签参数的函数,如下所示:
# myproject/apps/core/templatetags/utility_tags.py from django import template
from django.template.loader import get_template

register = template.Library()

""" TAGS """

@register.tag
def try_to_include(parser, token):
    """
    Usage: {% try_to_include "some_template.html" %}

    This will fail silently if the template doesn't exist.
    If it does exist, it will be rendered with the current context.
    """
    try:
        tag_name, template_name = token.split_contents()
    except ValueError:
        tag_name = token.contents.split()[0]
        raise template.TemplateSyntaxError(
            f"{tag_name} tag requires a single argument")
    return IncludeNode(template_name)
  1. 然后,我们需要在同一文件中创建一个自定义的IncludeNode类,该类从基本的template.Node扩展。让我们在try_to_include()函数之前插入它,如下所示:
class IncludeNode(template.Node):
    def __init__(self, template_name):
        self.template_name = template.Variable(template_name)

    def render(self, context):
        try:
            # Loading the template and rendering it
            included_template = self.template_name.resolve(context)
            if isinstance(included_template, str):
                included_template = get_template(included_template)
            rendered_template = included_template.render(
                context.flatten()
            )
        except (template.TemplateDoesNotExist,
                template.VariableDoesNotExist,
                AttributeError):
            rendered_template = ""
        return rendered_template

@register.tag
def try_to_include(parser, token):
    # …

它是如何工作的...

高级自定义模板标签由两部分组成:

  • 解析模板标签参数的函数

  • 负责模板标签逻辑和输出的Node

{% try_to_include %}模板标签期望一个参数——即template_name。因此,在try_to_include()函数中,我们尝试将令牌的拆分内容仅分配给tag_name变量(即try_to_include)和template_name变量。如果这不起作用,将引发TemplateSyntaxError。该函数返回IncludeNode对象,该对象获取template_name字段并将其存储在模板Variable对象中以供以后使用。

IncludeNoderender()方法中,我们解析template_name变量。如果上下文变量被传递给模板标签,则其值将在此处用于template_name。如果引用的字符串被传递给模板标签,那么引号内的内容将用于included_template,而与上下文变量对应的字符串将被解析为其相应的字符串等效。

最后,我们将尝试加载模板,使用解析的included_template字符串,并在当前模板上下文中呈现它。如果这不起作用,则返回空字符串。

至少有两种情况可以使用此模板标签:

  • 在包含路径在模型中定义的模板时,如下所示:
{% load utility_tags %}
{% try_to_include object.template_path %}
  • 在模板上下文变量的范围中的某个地方使用{% with %}模板标签定义路径的模板。当您需要为 Django CMS 中模板的占位符创建自定义布局时,这是非常有用的:
{# templates/cms/start_page.html #} {% load cms_tags %}
{% with editorial_content_template_path=
"cms/plugins/editorial_content/start_page.html" %}
    {% placeholder "main_content" %}
{% endwith %}

稍后,占位符可以使用editorial_content插件填充,然后读取editorial_content_template_path上下文变量,如果可用,则可以安全地包含模板:

{# templates/cms/plugins/editorial_content.html #}
{% load utility_tags %}
{% if editorial_content_template_path %}
    {% try_to_include editorial_content_template_path %}
{% else %}
    <div>
        <!-- Some default presentation of
        editorial content plugin -->
    </div>
{% endif %}

还有更多...

您可以在任何组合中使用{% try_to_include %}标签和默认的{% include %}标签来包含扩展其他模板的模板。这对于大型网络平台非常有益,其中您有不同类型的列表,其中复杂的项目与小部件具有相同的结构,但具有不同的数据来源。

例如,在艺术家列表模板中,您可以包含artist_item模板,如下所示:

{% load utility_tags %}
{% for object in object_list %}
    {% try_to_include "artists/includes/artist_item.html" %}
{% endfor %}

此模板将从项目基础扩展,如下所示:

{# templates/artists/includes/artist_item.html #} {% extends "utils/includes/item_base.html" %}
{% block item_title %}
    {{ object.first_name }} {{ object.last_name }}
{% endblock %}

项目基础定义了任何项目的标记,并包括Like小部件,如下所示:

{# templates/utils/includes/item_base.html #} {% load likes_tags %}
<h3>{% block item_title %}{% endblock %}</h3>
{% if request.user.is_authenticated %}
    {% like_widget for object %}
{% endif %}

另请参阅

  • 在第四章中实现Like小部件的食谱,模板和 JavaScript

  • 创建一个模板标签以在模板中加载 QuerySet食谱

  • 创建一个将内容解析为模板的模板标签食谱

  • 创建模板标签以修改请求查询参数食谱

创建一个模板标签以在模板中加载 QuerySet

通常,应在视图中定义应显示在网页上的内容。如果要在每个页面上显示内容,逻辑上应创建上下文处理器以使其全局可用。另一种情况是当您需要在某些页面上显示其他内容,例如最新新闻或随机引用,例如起始页面或对象的详细页面。在这种情况下,您可以使用自定义 {% load_objects %} 模板标签加载必要的内容,我们将在本教程中实现。

准备工作

我们将再次从 core 应用程序开始,该应用程序应已安装并准备好用于自定义模板标签。

此外,为了说明这个概念,让我们创建一个带有 Article 模型的 news 应用程序,如下所示:

# myproject/apps/news/models.py from django.db import models
from django.urls import reverse
from django.utils.translation import ugettext_lazy as _

from myproject.apps.core.models import CreationModificationDateBase, UrlBase

class ArticleManager(models.Manager):
 def random_published(self):
 return self.filter(
 publishing_status=self.model.PUBLISHING_STATUS_PUBLISHED,
 ).order_by("?")

class Article(CreationModificationDateBase, UrlBase):
    PUBLISHING_STATUS_DRAFT, PUBLISHING_STATUS_PUBLISHED = "d", "p"
    PUBLISHING_STATUS_CHOICES = (
        (PUBLISHING_STATUS_DRAFT, _("Draft")),
        (PUBLISHING_STATUS_PUBLISHED, _("Published")),
    )
    title = models.CharField(_("Title"), max_length=200)
    slug = models.SlugField(_("Slug"), max_length=200)
    content = models.TextField(_("Content"))
    publishing_status = models.CharField(
        _("Publishing status"),
        max_length=1,
        choices=PUBLISHING_STATUS_CHOICES,
        default=PUBLISHING_STATUS_DRAFT,
    )

 custom_manager = ArticleManager()

    class Meta:
        verbose_name = _("Article")
        verbose_name_plural = _("Articles")

    def __str__(self):
        return self.title

    def get_url_path(self):
        return reverse("news:article_detail", kwargs={"slug": self.slug})

在这里,有趣的部分是 Article 模型的 custom_manager。该管理器可用于列出随机发布的文章。

使用上一章的示例,您可以完成具有 URL 配置、视图、模板和管理设置的应用程序。然后,使用管理表单向数据库添加一些文章。

如何做...

高级自定义模板标签由解析传递给标签的参数的函数和呈现标签输出或修改模板上下文的 Node 类组成。执行以下步骤创建 {% load_objects %} 模板标签:

  1. 首先,让我们创建处理模板标签参数解析的函数,如下所示:
# myproject/apps/core/templatetags/utility_tags.py from django import template
from django.apps import apps

register = template.Library()

""" TAGS """

@register.tag
def load_objects(parser, token):
    """
    Gets a queryset of objects of the model specified by app and
    model names

    Usage:
        {% load_objects [<manager>.]<method>
                        from <app_name>.<model_name>
                        [limit <amount>]
                        as <var_name> %}

    Examples:
        {% load_objects latest_published from people.Person
                        limit 3 as people %}
        {% load_objects site_objects.all from news.Article
                        as articles %}
        {% load_objects site_objects.all from news.Article
                        limit 3 as articles %}
    """
    limit_count = None
    try:
        (tag_name, manager_method,
         str_from, app_model,
         str_limit, limit_count,
         str_as, var_name) = token.split_contents()
    except ValueError:
        try:
            (tag_name, manager_method,
             str_from, app_model,
             str_as, var_name) = token.split_contents()
        except ValueError:
            tag_name = token.contents.split()[0]
            raise template.TemplateSyntaxError(
                f"{tag_name} tag requires the following syntax: "
                f"{{% {tag_name} [<manager>.]<method> from "
                "<app_name>.<model_name> [limit <amount>] "
                "as <var_name> %}")
    try:
        app_name, model_name = app_model.split(".")
    except ValueError:
        raise template.TemplateSyntaxError(
            "load_objects tag requires application name "
            "and model name, separated by a dot")
    model = apps.get_model(app_name, model_name)
    return ObjectsNode(
        model, manager_method, limit_count, var_name
    )
  1. 然后,我们将在同一文件中创建自定义 ObjectsNode 类,扩展自 template.Node 基类。让我们在 load_objects() 函数之前插入它,如下面的代码所示:
class ObjectsNode(template.Node):
    def __init__(self, model, manager_method, limit, var_name):
        self.model = model
        self.manager_method = manager_method
        self.limit = template.Variable(limit) if limit else None
        self.var_name = var_name

    def render(self, context):
        if "." in self.manager_method:
            manager, method = self.manager_method.split(".")
        else:
            manager = "_default_manager"
            method = self.manager_method

        model_manager = getattr(self.model, manager)
        fallback_method = self.model._default_manager.none
        qs = getattr(model_manager, method, fallback_method)()
        limit = None
        if self.limit:
            try:
                limit = self.limit.resolve(context)
            except template.VariableDoesNotExist:
                limit = None
        context[self.var_name] = qs[:limit] if limit else qs
        return ""

@register.tag
def load_objects(parser, token):
    # …

它是如何工作的...

{% load_objects %} 模板标签加载由管理器方法定义的指定应用程序和模型的 QuerySet,将结果限制为指定的计数,并将结果保存到给定的上下文变量中。

以下代码是如何使用我们刚刚创建的模板标签的简单示例。它将在任何模板中加载所有新闻文章,使用以下代码片段:

{% load utility_tags %}
{% load_objects all from news.Article as all_articles %}
<ul>
    {% for article in all_articles %}
        <li><a href="{{ article.get_url_path }}">
         {{ article.title }}</a></li>
    {% endfor %}
</ul>

这是使用 Article 模型的默认 objects 管理器的 all() 方法,并且它将按照模型的 Meta 类中定义的 ordering 属性对文章进行排序。

接下来是一个示例,使用自定义管理器和自定义方法从数据库中查询对象。管理器是为模型提供数据库查询操作的接口。

每个模型至少有一个默认的名为 objects 的管理器。对于我们的 Article 模型,我们添加了一个名为 custom_manager 的额外管理器,其中包含一个名为 random_published() 的方法。以下是我们如何在 {% load_objects %} 模板标签中使用它来加载一个随机发布的文章:

{% load utility_tags %}
{% load_objects custom_manager.random_published from news.Article limit 1 as random_published_articles %}
<ul>
    {% for article in random_published_articles %}
        <li><a href="{{ article.get_url_path }}">
         {{ article.title }}</a></li>
    {% endfor %}
</ul>

让我们来看一下 {% load_objects %} 模板标签的代码。在解析函数中,标签有两种允许的形式——带有或不带有 limit。字符串被解析,如果识别格式,则模板标签的组件将传递给 ObjectsNode 类。

Node 类的 render() 方法中,我们检查管理器的名称及其方法的名称。如果未指定管理器,则将使用 _default_manager。这是 Django 注入的任何模型的自动属性,并指向第一个可用的 models.Manager() 实例。在大多数情况下,_default_manager 将是 objects 管理器。之后,我们将调用管理器的方法,并在方法不存在时回退到空的 QuerySet。如果定义了 limit,我们将解析其值并相应地限制 QuerySet。最后,我们将将结果的 QuerySet 存储在上下文变量中,如 var_name 所给出的那样。

另请参阅

  • 在 Chapter 2,模型和数据库结构中创建一个带有 URL 相关方法的模型混合的食谱

  • 在 Chapter 2,Models and Database Structure中的创建模型混合以处理创建和修改日期配方

  • 在 Chapter 2,Models and Database Structure中的创建一个模板标签以包含模板(如果存在)配方

  • 在 Chapter 2,Models and Database Structure中的创建一个模板标签以将内容解析为模板配方

  • 创建模板标签以修改请求查询参数的配方

创建一个模板标签以将内容解析为模板

在这个配方中,我们将创建{% parse %}模板标签,它将允许您将模板片段放入数据库。当您想要为经过身份验证和未经身份验证的用户提供不同的内容,当您想要包含个性化的称谓,或者当您不想在数据库中硬编码媒体路径时,这将非常有价值。

准备工作

像往常一样,我们将从core应用程序开始,该应用程序应该已经安装并准备好用于自定义模板标签。

如何做...

高级自定义模板标签由一个解析传递给标签的参数的函数和一个Node类组成,该类渲染标签的输出或修改模板上下文。执行以下步骤来创建{% parse %}模板标签:

  1. 首先,让我们创建解析模板标签参数的函数,如下所示:
# myproject/apps/core/templatetags/utility_tags.py
from django import template

register = template.Library()

""" TAGS """

@register.tag
def parse(parser, token):
    """
    Parses a value as a template and prints or saves to a variable

    Usage:
        {% parse <template_value> [as <variable>] %}

    Examples:
        {% parse object.description %}
        {% parse header as header %}
        {% parse "{{ MEDIA_URL }}js/" as js_url %}
    """
    bits = token.split_contents()
    tag_name = bits.pop(0)
    try:
        template_value = bits.pop(0)
        var_name = None
        if len(bits) >= 2:
            str_as, var_name = bits[:2]
    except ValueError:
        raise template.TemplateSyntaxError(
            f"{tag_name} tag requires the following syntax: "
            f"{{% {tag_name} <template_value> [as <variable>] %}}")
    return ParseNode(template_value, var_name)
  1. 然后,我们将在同一文件中创建自定义的ParseNode类,该类从基本的template.Node扩展,如下面的代码所示(将其放在parse()函数之前):
class ParseNode(template.Node):
    def __init__(self, template_value, var_name):
        self.template_value = template.Variable(template_value)
        self.var_name = var_name

    def render(self, context):
        template_value = self.template_value.resolve(context)
        t = template.Template(template_value)
        context_vars = {}
        for d in list(context):
            for var, val in d.items():
                context_vars[var] = val
        req_context = template.RequestContext(
            context["request"], context_vars
        )
        result = t.render(req_context)
        if self.var_name:
            context[self.var_name] = result
            result = ""
        return result

@register.tag
def parse(parser, token):
    # …

它是如何工作的...

{% parse %}模板标签允许您将值解析为模板并立即渲染它,或将其存储在上下文变量中。

如果我们有一个带有描述字段的对象,该字段可以包含模板变量或逻辑,我们可以使用以下代码解析和渲染它:

{% load utility_tags %}
{% parse object.description %}

还可以使用引号字符串定义要解析的值,如下面的代码所示:

{% load static utility_tags %}
{% get_static_prefix as STATIC_URL %}
{% parse "{{ STATIC_URL }}site/img/" as image_directory %}
<img src="img/{{ image_directory }}logo.svg" alt="Logo" />

让我们来看一下{% parse %}模板标签的代码。解析函数逐位检查模板标签的参数。首先,我们期望解析名称和模板值。如果仍然有更多的位于令牌中,我们期望可选的as单词后跟上上下文变量名的组合。模板值和可选变量名被传递给ParseNode类。

该类的render()方法首先解析模板变量的值,并将其创建为模板对象。然后复制context_vars并生成请求上下文,模板进行渲染。如果定义了变量名,则将结果存储在其中并渲染一个空字符串;否则,立即显示渲染的模板。

另请参阅

  • 在 Chapter 2,Models and Database Structure中的创建一个模板标签以包含模板(如果存在)配方

  • 在模板中加载查询集的创建模板标签配方

  • 创建模板标签以修改请求查询参数配方中

创建模板标签以修改请求查询参数

Django 有一个方便灵活的系统,可以通过向 URL 配置文件添加正则表达式规则来创建规范和干净的 URL。然而,缺乏内置技术来管理查询参数。诸如搜索或可过滤对象列表的视图需要接受查询参数,以通过另一个参数深入筛选结果或转到另一页。在这个配方中,我们将创建{% modify_query %}{% add_to_query %}{% remove_from_query %}模板标签,让您可以添加、更改或删除当前查询的参数。

准备工作

再次,我们从core应用程序开始,该应用程序应该在INSTALLED_APPS中设置,其中包含templatetags包。

还要确保在OPTIONS下的TEMPLATES设置中将request上下文处理器添加到context_processors列表中。

# myproject/settings/_base.py
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [os.path.join(BASE_DIR, "myproject", "templates")],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
 "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
                "django.template.context_processors.media",
                "django.template.context_processors.static",
                "myproject.apps.core.context_processors.website_url",
            ]
        },
    }
]

如何做...

对于这些模板标签,我们将使用@simple_tag装饰器来解析组件,并要求您只需定义呈现函数,如下所示:

  1. 首先,让我们添加一个辅助方法来组合每个标签输出的查询字符串:
# myproject/apps/core/templatetags/utility_tags.py from urllib.parse import urlencode

from django import template
from django.utils.encoding import force_str
from django.utils.safestring import mark_safe

register = template.Library()

""" TAGS """

def construct_query_string(context, query_params):
    # empty values will be removed
    query_string = context["request"].path
    if len(query_params):
        encoded_params = urlencode([
            (key, force_str(value))
            for (key, value) in query_params if value
        ]).replace("&", "&amp;")
        query_string += f"?{encoded_params}"
    return mark_safe(query_string)
  1. 然后,我们将创建{% modify_query %}模板标签:
@register.simple_tag(takes_context=True)
def modify_query(context, *params_to_remove, **params_to_change):
    """Renders a link with modified current query parameters"""
    query_params = []
    for key, value_list in context["request"].GET.lists():
        if not key in params_to_remove:
            # don't add key-value pairs for params_to_remove
            if key in params_to_change:
                # update values for keys in params_to_change
                query_params.append((key, params_to_change[key]))
                params_to_change.pop(key)
            else:
                # leave existing parameters as they were
                # if not mentioned in the params_to_change
                for value in value_list:
                    query_params.append((key, value))
                    # attach new params
    for key, value in params_to_change.items():
        query_params.append((key, value))
    return construct_query_string(context, query_params)
  1. 接下来,让我们创建{% add_to_query %}模板标签:
@register.simple_tag(takes_context=True)
def add_to_query(context, *params_to_remove, **params_to_add):
    """Renders a link with modified current query parameters"""
    query_params = []
    # go through current query params..
    for key, value_list in context["request"].GET.lists():
        if key not in params_to_remove:
            # don't add key-value pairs which already
            # exist in the query
            if (key in params_to_add
                    and params_to_add[key] in value_list):
                params_to_add.pop(key)
            for value in value_list:
                query_params.append((key, value))
    # add the rest key-value pairs
    for key, value in params_to_add.items():
        query_params.append((key, value))
    return construct_query_string(context, query_params)
  1. 最后,让我们创建{% remove_from_query %}模板标签:
@register.simple_tag(takes_context=True)
def remove_from_query(context, *args, **kwargs):
    """Renders a link with modified current query parameters"""
    query_params = []
    # go through current query params..
    for key, value_list in context["request"].GET.lists():
        # skip keys mentioned in the args
        if key not in args:
            for value in value_list:
                # skip key-value pairs mentioned in kwargs
                if not (key in kwargs and
                        str(value) == str(kwargs[key])):
                    query_params.append((key, value))
    return construct_query_string(context, query_params)

工作原理...

所有三个创建的模板标签的行为都类似。首先,它们从request.GET字典样的QueryDict对象中读取当前查询参数,然后将其转换为新的(键,值)query_params元组列表。然后,根据位置参数和关键字参数更新值。最后,通过首先定义的辅助方法形成新的查询字符串。在此过程中,所有空格和特殊字符都被 URL 编码,并且连接查询参数的和号被转义。将此新的查询字符串返回到模板。

要了解有关QueryDict对象的更多信息,请参阅官方 Django 文档

docs.djangoproject.com/en/3.0/ref/request-response/#querydict-objects

让我们看一个示例,演示了{% modify_query %}模板标签的用法。模板标签中的位置参数定义要删除哪些查询参数,关键字参数定义要在当前查询中更新哪些查询参数。如果当前 URL 是http://127.0.0.1:8000/artists/?category=fine-art&page=5,我们可以使用以下模板标签呈现一个转到下一页的链接:

{% load utility_tags %}
<a href="{% modify_query page=6 %}">6</a>

使用前述模板标签呈现的输出如下代码段所示:

<a href="/artists/?category=fine-art&amp;page=6">6</a>

我们还可以使用以下示例来呈现一个重置分页并转到另一个类别sculpture的链接,如下所示:

{% load utility_tags %}
<a href="{% modify_query "page" category="sculpture" %}">
    Sculpture
</a>

因此,使用前述模板标签呈现的输出将如下代码段所示:

<a href="/artists/?category=sculpture">
    Sculpture
</a>

使用{% add_to_query %}模板标签,您可以逐步添加具有相同名称的参数。例如,如果当前 URL 是http://127.0.0.1:8000/artists/?category=fine-art,您可以使用以下代码段添加另一个类别Sculpture

{% load utility_tags %}
<a href="{% add_to_query category="sculpture" %}">
    + Sculpture
</a> 

这将在模板中呈现,如下代码段所示:

<a href="/artists/?category=fine-art&amp;category=sculpture">
    + Sculpture
</a>

最后,借助{% remove_from_query %}模板标签的帮助,您可以逐步删除具有相同名称的参数。例如,如果当前 URL 是http://127.0.0.1:8000/artists/?category=fine-art&category=sculpture,您可以使用以下代码段删除Sculpture类别:

{% load utility_tags %}
<a href="{% remove_from_query category="sculpture" %}">
    - Sculpture
</a>

这将在模板中呈现如下:

<a href="/artists/?category=fine-art">
    - Sculpture
</a>

另请参阅

  • 第三章中的对象列表过滤器*配方,表单和视图

  • 创建一个模板标签来包含模板(如果存在)配方

  • 创建一个模板标签来在模板中加载 QuerySet配方

  • 创建一个模板标签来解析内容作为模板配方

第六章:模型管理

在本章中,我们将涵盖以下主题:

  • 在更改列表页面上自定义列

  • 创建可排序的内联

  • 创建管理操作

  • 开发更改列表过滤器

  • 更改第三方应用程序的应用程序标签

  • 创建自定义帐户应用

  • 获取用户 Gravatars

  • 将地图插入更改表单

介绍

Django 框架提供了一个内置的管理系统,用于数据模型。通过很少的努力,您可以设置可过滤、可搜索和可排序的列表,以浏览您的模型,并且可以配置表单以添加和管理数据。在本章中,我们将通过开发一些实际案例来介绍我们可以使用的高级技术来自定义管理。

技术要求

要使用本章中的代码,您需要最新稳定版本的 Python,一个 MySQL 或 PostgreSQL 数据库,以及一个带有虚拟环境的 Django 项目。

您可以在本书的 GitHub 存储库的chapter 06目录中找到本章的所有代码:github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition

在更改列表页面上自定义列

默认的 Django 管理系统中的更改列表视图提供了特定模型的所有实例的概述。默认情况下,list_display模型管理属性控制在不同列中显示的字段。此外,您还可以实现自定义管理方法,该方法将返回关系的数据或显示自定义 HTML。在本示例中,我们将创建一个特殊函数,用于list_display属性,该函数将在列表视图的一列中显示图像。作为奖励,我们将通过添加list_editable设置使一个字段直接在列表视图中可编辑。

准备工作

对于本示例,我们将需要Pillowdjango-imagekit库。让我们使用以下命令在虚拟环境中安装它们:

(env)$ pip install Pillow
(env)$ pip install django-imagekit

确保在设置中INSTALLED_APPS中包含django.contrib.adminimagekit

# myproject/settings/_base.py
INSTALLED_APPS = [
   # …
   "django.contrib.admin",
   "imagekit",
]

然后,在 URL 配置中连接管理站点,如下所示:

# myproject/urls.py
from django.contrib import admin
from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path

urlpatterns = i18n_patterns(
    # …
    path("admin/", admin.site.urls),
)

接下来,创建一个新的products应用程序,并将其放在INSTALLED_APPS下。此应用程序将包含ProductProductPhoto模型。在这里,一个产品可能有多张照片。例如,我们还将使用在第二章的创建具有 URL 相关方法的模型 mixin食谱中定义的UrlMixin

让我们在models.py文件中创建ProductProductPhoto模型,如下所示:

# myproject/apps/products/models.py import os

from django.urls import reverse, NoReverseMatch
from django.db import models
from django.utils.timezone import now as timezone_now
from django.utils.translation import ugettext_lazy as _

from ordered_model.models import OrderedModel

from myproject.apps.core.models import UrlBase

def product_photo_upload_to(instance, filename):
    now = timezone_now()
    slug = instance.product.slug
    base, ext = os.path.splitext(filename)
    return f"products/{slug}/{now:%Y%m%d%H%M%S}{ext.lower()}"

class Product(UrlBase):
    title = models.CharField(_("title"), max_length=200)
    slug = models.SlugField(_("slug"), max_length=200)
    description = models.TextField(_("description"), blank=True)
    price = models.DecimalField(
        _("price (EUR)"), max_digits=8, decimal_places=2, 
         blank=True, null=True
    )

    class Meta:
        verbose_name = _("Product")
        verbose_name_plural = _("Products")

    def get_url_path(self):
        try:
            return reverse("product_detail", kwargs={"slug": self.slug})
        except NoReverseMatch:
            return ""

    def __str__(self):
        return self.title

class ProductPhoto(models.Model):
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    photo = models.ImageField(_("photo"), 
     upload_to=product_photo_upload_to)

    class Meta:
        verbose_name = _("Photo")
        verbose_name_plural = _("Photos")

    def __str__(self):
        return self.photo.name

如何做...

在本示例中,我们将为Product模型创建一个简单的管理,该管理将具有附加到产品的ProductPhoto模型的实例。

list_display属性中,我们将包括模型管理的first_photo()方法,该方法将用于显示一对多关系中的第一张照片。所以,让我们开始:

  1. 让我们创建一个包含以下内容的admin.py文件:
# myproject/apps/products/admin.py from django.contrib import admin
from django.template.loader import render_to_string
from django.utils.html import mark_safe
from django.utils.translation import ugettext_lazy as _

from .models import Product, ProductPhoto

class ProductPhotoInline(admin.StackedInline):
    model = ProductPhoto
    extra = 0
    fields = ["photo"]
  1. 然后,在同一个文件中,让我们为产品添加管理:
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
    list_display = ["first_photo", "title", "has_description", 
     "price"]
    list_display_links = ["first_photo", "title"]
    list_editable = ["price"]

    fieldsets = ((_("Product"), {"fields": ("title", "slug", 
     "description", "price")}),)
    prepopulated_fields = {"slug": ("title",)}
    inlines = [ProductPhotoInline]

def first_photo(self, obj):
        project_photos = obj.productphoto_set.all()[:1]
         if project_photos.count() > 0:
 photo_preview = render_to_string(
           "admin/products/includes/photo-preview.html",
             {"photo": project_photos[0], "product": obj},
            )
           return mark_safe(photo_preview)
         return ""

    first_photo.short_description = _("Preview")

def has_description(self, obj):
return bool(obj.description)

    has_description.short_description = _("Has description?")
    has_description.admin_order_field = "description"
    has_description.boolean = True
  1. 现在,让我们创建将用于生成photo-preview的模板,如下所示:
{# admin/products/includes/photo-preview.html #} {% load imagekit %}
{% thumbnail "120x120" photo.photo -- alt=
 "{{ product.title }} preview" %}

它是如何工作的...

如果您添加了一些带有照片的产品,然后在浏览器中查看产品管理列表,它将类似于以下截图:

list_display属性通常用于定义字段,以便它们在管理列表视图中显示;例如,TITLEPRICEProduct模型的字段。除了正常的字段名称之外,list_display属性还接受以下内容:

  • 一个函数,或者另一个可调用的

  • 模型管理类的属性名称

  • 模型的属性名称

list_display中使用可调用函数时,每个函数都将模型实例作为第一个参数传递。因此,在我们的示例中,我们在模型管理类中定义了get_photo()方法,该方法将Product实例作为obj接收。该方法尝试从一对多关系中获取第一个ProductPhoto对象,如果存在,则返回从包含<img>标签的包含模板生成的 HTML。通过设置list_display_links,我们使照片和标题都链接到Product模型的管理更改表单。

您可以为在list_display中使用的可调用函数设置多个属性:

  • 可调用的short_description属性定义了列顶部显示的标题。

  • 默认情况下,可调用返回的值在管理中是不可排序的,但可以设置admin_order_field属性来定义应该按哪个数据库字段对生成的列进行排序。可选地,您可以使用连字符前缀来指示反向排序顺序。

  • 通过设置boolean = True,您可以显示TrueFalse值的图标。

最后,如果我们将 PRICE 字段包含在list_editable设置中,它可以被编辑。由于现在有可编辑字段,底部将出现一个保存按钮,以便我们可以保存整个产品列表。

另请参阅

  • 使用 URL 相关方法创建模型 mixin配方在第二章,模型和数据库结构

  • 创建管理操作配方

  • 开发更改列表过滤器配方

创建可排序的内联

您将希望对数据库中的大多数模型按创建日期、发生日期或按字母顺序进行排序。但有时,用户必须能够以自定义排序顺序显示项目。这适用于类别、图库、策划列表和类似情况。在这个配方中,我们将向您展示如何使用django-ordered-model在管理中允许自定义排序。

准备工作

在这个配方中,我们将在之前的配方中定义的products应用程序的基础上构建。按照以下步骤开始:

  1. 让我们在虚拟环境中安装django-ordered-model
(env)$ pip install django-ordered-model 
  1. 在设置中将ordered_model添加到INSTALLED_APPS中。

  2. 然后,修改之前定义的products应用程序中的ProductPhoto模型,如下所示:

# myproject/apps/products/models.py from django.db import models
from django.utils.translation import ugettext_lazy as _

from ordered_model.models import OrderedModel

# …

class ProductPhoto(OrderedModel):
    product = models.ForeignKey(Product, on_delete=models.CASCADE)
    photo = models.ImageField(_("photo"), 
     upload_to=product_photo_upload_to)

order_with_respect_to = "product" 
    class Meta(OrderedModel.Meta):
        verbose_name = _("Photo")
        verbose_name_plural = _("Photos")

def __str__(self):
return self.photo.name

OrderedModel类引入了一个order字段。创建并运行迁移,将新的order字段添加到数据库中的ProductPhoto

如何做...

要设置可排序的产品照片,我们需要修改products应用程序的模型管理。让我们开始吧:

  1. 在管理文件中修改ProductPhotoInline,如下所示:
# myproject/apps/products/admin.py from django.contrib import admin
from django.template.loader import render_to_string
from django.utils.html import mark_safe
from django.utils.translation import ugettext_lazy as _
from ordered_model.admin import OrderedTabularInline, OrderedInlineModelAdminMixin

from .models import Product, ProductPhoto

class ProductPhotoInline(OrderedTabularInline):
    model = ProductPhoto
    extra = 0
    fields = ("photo_preview", "photo", "order", 
    "move_up_down_links")
    readonly_fields = ("photo_preview", "order", 
    "move_up_down_links")
    ordering = ("order",)

    def get_photo_preview(self, obj):
 photo_preview = render_to_string(
 "admin/products/includes/photo-preview.html",
 {"photo": obj, "product": obj.product},
 )
 return mark_safe(photo_preview)

 get_photo_preview.short_description = _("Preview")
  1. 然后,修改ProductAdmin如下:
@admin.register(Product)
class ProductAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin):
    # …

它是如何工作的...

如果您打开更改产品表单,您将看到类似于这样的内容:

在模型中,我们设置了order_with_respect_to属性,以确保对每个产品进行排序,而不仅仅是对产品照片的整个列表进行排序。

在 Django 管理中,产品照片可以通过将产品详细信息本身作为表格内联来进行编辑。在第一列中,我们有一个照片预览。我们使用与之前配方中使用的相同的photo-preview.html模板来生成它。在第二列中,有一个用于更改照片的字段。然后,有一个用于 ORDER 字段的列,旁边是一个带有箭头按钮的列,以便我们可以手动重新排序照片。箭头按钮来自move_up_down_links方法。最后,有一个带有复选框的列,以便我们可以删除内联。

readonly_fields属性告诉 Django,某些字段或方法仅用于阅读。如果要使用另一种方法在更改表单中显示某些内容,必须将这些方法放在readonly_fields列表中。在我们的情况下,get_photo_previewmove_up_down_links就是这样的方法。

move_up_down_linksOrderedTabularInline中定义,我们正在扩展它而不是admin.StackedInlineadmin.TabularInline。这样可以渲染箭头按钮,使它们在产品照片中交换位置。

另请参阅

  • 自定义更改列表页面上的列食谱

  • 创建管理操作食谱

  • 开发更改列表过滤器食谱

创建管理操作

Django 管理系统提供了可以为列表中的选定项目执行的操作。默认情况下提供了一个操作,用于删除选定的实例。在这个食谱中,我们将为Product模型的列表创建一个额外的操作,允许管理员将选定的产品导出到 Excel 电子表格中。

准备工作

我们将从前面的食谱中创建的products应用程序开始。确保您的虚拟环境中安装了openpyxl模块,以便创建 Excel 电子表格,如下所示:

(env)$ pip install openpyxl

如何做...

管理操作是带有三个参数的函数,如下所示:

  • 当前的ModelAdmin

  • 当前的HttpRequest

  • 包含所选项目的QuerySet

执行以下步骤创建自定义管理操作以导出电子表格:

  1. products应用程序的admin.py文件中为电子表格列配置创建ColumnConfig类,如下所示:
# myproject/apps/products/admin.py from openpyxl import Workbook
from openpyxl.styles import Alignment, NamedStyle, builtins
from openpyxl.styles.numbers import FORMAT_NUMBER
from openpyxl.writer.excel import save_virtual_workbook

from django.http.response import HttpResponse
from django.utils.translation import ugettext_lazy as _
from ordered_model.admin import OrderedTabularInline, OrderedInlineModelAdminMixin

# other imports…

class ColumnConfig:
    def __init__(
            self,
            heading,
            width=None,
            heading_style="Headline 1",
            style="Normal Wrapped",
            number_format=None,
         ):
        self.heading = heading
        self.width = width
        self.heading_style = heading_style
        self.style = style
        self.number_format = number_format
  1. 然后,在同一个文件中,创建export_xlsx()函数:
def export_xlsx(modeladmin, request, queryset):
    wb = Workbook()
    ws = wb.active
    ws.title = "Products"

    number_alignment = Alignment(horizontal="right")
    wb.add_named_style(
        NamedStyle(
            "Identifier", alignment=number_alignment, 
             number_format=FORMAT_NUMBER
        )
    )
    wb.add_named_style(
        NamedStyle("Normal Wrapped", 
         alignment=Alignment(wrap_text=True))
    )

    column_config = {
        "A": ColumnConfig("ID", width=10, style="Identifier"),
        "B": ColumnConfig("Title", width=30),
        "C": ColumnConfig("Description", width=60),
        "D": ColumnConfig("Price", width=15, style="Currency", 
             number_format="#,##0.00 €"),
        "E": ColumnConfig("Preview", width=100, style="Hyperlink"),
    }

    # Set up column widths, header values and styles
    for col, conf in column_config.items():
        ws.column_dimensions[col].width = conf.width

        column = ws[f"{col}1"]
        column.value = conf.heading
        column.style = conf.heading_style

    # Add products
    for obj in queryset.order_by("pk"):
        project_photos = obj.productphoto_set.all()[:1]
        url = ""
        if project_photos:
            url = project_photos[0].photo.url

        data = [obj.pk, obj.title, obj.description, obj.price, url]
        ws.append(data)

        row = ws.max_row
        for row_cells in ws.iter_cols(min_row=row, max_row=row):
            for cell in row_cells:
                conf = column_config[cell.column_letter]
                cell.style = conf.style
                if conf.number_format:
                    cell.number_format = conf.number_format

    mimetype = "application/vnd.openxmlformats-
     officedocument.spreadsheetml.sheet"
    charset = "utf-8"
    response = HttpResponse(
        content=save_virtual_workbook(wb),
        content_type=f"{mimetype}; charset={charset}",
        charset=charset,
    )
    response["Content-Disposition"] = "attachment; 
     filename=products.xlsx"
    return response

export_xlsx.short_description = _("Export XLSX")
  1. 然后,将actions设置添加到ProductAdmin中,如下所示:
@admin.register(Product)
class ProductAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin):
    # …
 actions = [export_xlsx]
    # …

它是如何工作的...

如果您在浏览器中查看产品管理列表页面,您将看到一个名为 Export XLSX 的新操作,以及默认的 Delete selected Products 操作,如下截图所示:

我们使用openpyxl Python 模块创建与 Excel 和其他电子表格软件兼容的 OpenOffice XML 文件。

首先创建一个工作簿,并选择活动工作表,为其设置标题为Products。因为有一些通用样式,我们希望在整个工作表中使用,所以这些样式被设置为命名样式,这样它们可以按名称应用到每个单元格中。这些样式、列标题和列宽度被存储为Config对象,并且column_config字典将列字母键映射到对象。然后迭代设置标题和列宽度。

我们使用工作表的append()方法为QuerySet中的每个选定产品添加内容,按 ID 排序,包括产品的第一张照片的 URL(如果有照片)。然后通过迭代刚添加的行中的每个单元格来单独设置产品数据的样式,再次参考column_config以保持样式一致。

默认情况下,管理操作对QuerySet执行某些操作,并将管理员重定向回更改列表页面。但是,对于更复杂的操作,可以返回HttpResponseexport_xlsx()函数将工作簿的虚拟副本保存到HttpResponse中,内容类型和字符集适合Office Open XMLOOXML)电子表格。使用Content-Disposition标头,我们设置响应以便可以将其下载为products.xlsx文件。生成的工作表可以在 Open Office 中打开,并且看起来类似于以下内容:

另请参阅

  • 自定义更改列表页面上的列食谱

  • 开发更改列表过滤器食谱

  • 第九章,导入和导出数据

开发更改列表过滤器

如果您希望管理员能够按日期、关系或字段选择过滤更改列表,您必须使用 admin 模型的list_filter属性。此外,还有可能有定制的过滤器。在本教程中,我们将添加一个过滤器,允许我们按附加到产品的照片数量进行选择。

准备工作

让我们从我们在之前的教程中创建的products应用程序开始。

如何做...

执行以下步骤:

  1. admin.py文件中,创建一个PhotoFilter类,该类扩展自SimpleListFilter,如下所示:
# myproject/apps/products/admin.py
from django.contrib import admin
from django.db import models
from django.utils.translation import ugettext_lazy as _

# other imports…

ZERO = "zero"
ONE = "one"
MANY = "many"

class PhotoFilter(admin.SimpleListFilter):
    # Human-readable title which will be displayed in the
    # right admin sidebar just above the filter options.
    title = _("photos")

    # Parameter for the filter that will be used in the
    # URL query.
    parameter_name = "photos"

    def lookups(self, request, model_admin):
        """
        Returns a list of tuples, akin to the values given for
        model field choices. The first element in each tuple is the
        coded value for the option that will appear in the URL
        query. The second element is the human-readable name for
        the option that will appear in the right sidebar.
        """
        return (
            (ZERO, _("Has no photos")),
            (ONE, _("Has one photo")),
            (MANY, _("Has more than one photo")),
        )

    def queryset(self, request, queryset):
        """
        Returns the filtered queryset based on the value
        provided in the query string and retrievable via
        `self.value()`.
        """
        qs = queryset.annotate(num_photos=
         models.Count("productphoto"))

        if self.value() == ZERO:
            qs = qs.filter(num_photos=0)
        elif self.value() == ONE:
            qs = qs.filter(num_photos=1)
        elif self.value() == MANY:
            qs = qs.filter(num_photos__gte=2)
        return qs
  1. 然后,在ProductAdmin中添加一个列表过滤器,如下所示:
@admin.register(Product)
class ProductAdmin(OrderedInlineModelAdminMixin, admin.ModelAdmin):
    # …
    list_filter = [PhotoFilter]
    # …

工作原理...

基于我们刚刚创建的自定义字段的列表过滤器将显示在产品列表的侧边栏中,如下所示:

PhotoFilter类具有可翻译的标题和查询参数名称作为属性。它还有两种方法,如下所示:

  • lookups()方法,定义了过滤器的选择

  • queryset()方法,定义了如何在选择特定值时过滤QuerySet对象

lookups()方法中,我们定义了三个选择,如下所示:

  • 没有照片

  • 有一张照片

  • 有多张照片附加

queryset()方法中,我们使用QuerySetannotate()方法来选择每个产品的照片数量。然后根据所选的选择进行过滤。

要了解有关聚合函数(如annotate())的更多信息,请参阅官方 Django 文档docs.djangoproject.com/en/3.0/topics/db/aggregation/

另请参阅

  • 自定义更改列表页面上的列教程

  • 创建管理员操作教程

  • 创建自定义帐户应用程序教程

更改第三方应用程序的应用程序标签

Django 框架有很多第三方应用程序可以在项目中使用。您可以在djangopackages.org/上浏览和比较大多数应用程序。在本教程中,我们将向您展示如何在管理中重命名python-social-auth应用程序的标签。类似地,您可以更改任何 Django 第三方应用程序的标签。

准备工作

按照python-social-auth.readthedocs.io/en/latest/configuration/django.html上的说明将 Python Social Auth 安装到您的项目中。Python Social Auth 允许用户使用社交网络帐户或其 Open ID 登录。完成后,管理页面的索引页面将如下所示:

如何做...

首先,将 PYTHON SOCIAL AUTH 标签更改为更用户友好的内容,例如 SOCIAL AUTHENTICATION。现在,请按照以下步骤进行操作:

  1. 创建一个名为accounts的应用程序。在那里的apps.py文件中,添加以下内容:
# myproject/apps/accounts/apps.py
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _

class AccountsConfig(AppConfig):
    name = "myproject.apps.accounts"
    verbose_name = _("Accounts")

    def ready(self):
        pass

class SocialDjangoConfig(AppConfig):
 name = "social_django"
    verbose_name = _("Social Authentication")
  1. 设置 Python Social Auth 的一个步骤涉及将"social_django"应用添加到INSTALLED_APPS中。现在,请将该应用替换为"myproject.apps.accounts.apps.SocialDjangoConfig"
# myproject/settings/_base.py # …
INSTALLED_APPS = [
    # …
    #"social_django",
    "myproject.apps.accounts.apps.SocialDjangoConfig",
    # …
]

工作原理...

如果您检查管理的索引页面,您将看到类似于以下内容:

INSTALLED_APPS设置接受应用程序的路径或应用程序配置的路径。我们可以传递应用程序配置而不是默认的应用程序路径。在那里,我们更改应用程序的显示名称,甚至可以应用一些信号处理程序或对应用程序进行一些其他初始设置。

另请参阅

  • 创建自定义帐户应用程序教程

  • 获取用户 Gravatars教程

创建自定义帐户应用程序

Django 自带了一个用于身份验证的django.contrib.auth应用程序。它允许用户使用他们的用户名和密码登录以使用管理功能,例如。这个应用程序被设计成可以通过您自己的功能进行扩展。在这个示例中,我们将创建自定义用户和角色模型,并为它们设置管理。您将能够通过电子邮件和密码而不是用户名和密码进行登录。

准备工作

创建一个accounts应用程序,并将该应用程序放在设置的INSTALLED_APPS下:

# myproject/apps/_base.py
INSTALLED_APPS = [
   # …
   "myproject.apps.accounts",
]

如何做...

按照以下步骤覆盖用户和组模型:

  1. accounts应用程序中创建models.py,内容如下:
# myproject/apps/accounts/models.py import uuid

from django.contrib.auth.base_user import BaseUserManager
from django.db import models
from django.contrib.auth.models import AbstractUser, Group
from django.utils.translation import ugettext_lazy as _

class Role(Group):
    class Meta:
        proxy = True
        verbose_name = _("Role")
        verbose_name_plural = _("Roles")

    def __str__(self):
        return self.name

class UserManager(BaseUserManager):
    def create_user(self, username="", email="", password="", 
     **extra_fields):
        if not email:
            raise ValueError("Enter an email address")
        email = self.normalize_email(email)
        user = self.model(username=username, email=email, 
         **extra_fields)
        user.set_password(password)
        user.save(using=self._db)
        return user

    def create_superuser(self, username="", email="", password=""):
        user = self.create_user(email=email, password=password, 
         username=username)
        user.is_superuser = True
        user.is_staff = True
        user.save(using=self._db)
        return user

class User(AbstractUser):
    uuid = models.UUIDField(primary_key=True, default=None, 
     editable=False)
    # change username to non-editable non-required field
    username = models.CharField(
        _("username"), max_length=150, editable=False, blank=True
    )
    # change email to unique and required field
    email = models.EmailField(_("email address"), unique=True)

    USERNAME_FIELD = "email"
    REQUIRED_FIELDS = []

    objects = UserManager()

    def save(self, *args, **kwargs):
        if self.pk is None:
            self.pk = uuid.uuid4()
        super().save(*args, **kwargs)
  1. accounts应用程序中创建admin.py文件,其中包含User模型的管理配置:
# myproject/apps/accounts/admin.py
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin, Group, GroupAdmin
from django.urls import reverse
from django.contrib.contenttypes.models import ContentType
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect
from django.utils.encoding import force_bytes
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.forms import UserCreationForm

from .helpers import download_avatar
from .models import User, Role

class MyUserCreationForm(UserCreationForm):
    def save(self, commit=True):
        user = super().save(commit=False)
        user.username = user.email
        user.set_password(self.cleaned_data["password1"])
        if commit:
            user.save()
        return user

@admin.register(User)
class MyUserAdmin(UserAdmin):
    save_on_top = True
    list_display = [
        "get_full_name",
        "is_active",
        "is_staff",
        "is_superuser",
    ]
    list_display_links = [
        "get_full_name",
    ]
    search_fields = ["email", "first_name", "last_name", "id", 
     "username"]
    ordering = ["-is_superuser", "-is_staff", "last_name", 
     "first_name"]

    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")}),
    )
    add_form = MyUserCreationForm

    def get_full_name(self, obj):
        return obj.get_full_name()

    get_full_name.short_description = _("Full name")

  1. 在同一文件中,为Role模型添加配置:
admin.site.unregister(Group)

@admin.register(Role)
class MyRoleAdmin(GroupAdmin):
    list_display = ("__str__", "display_users")
    save_on_top = True

    def display_users(self, obj):
        links = []
        for user in obj.user_set.all():
            ct = ContentType.objects.get_for_model(user)
            url = reverse(
                "admin:{}_{}_change".format(ct.app_label, 
                  ct.model), args=(user.pk,)
            )
            links.append(
                """<a href="{}" target="_blank">{}</a>""".format(
                    url,
                    user.get_full_name() or user.username,
                )
            )
        return mark_safe(u"<br />".join(links))

    display_users.short_description = _("Users")

工作原理...

默认的用户管理列表看起来类似于以下屏幕截图:

默认的组管理列表看起来类似于以下屏幕截图:

在这个示例中,我们创建了两个模型:

  • Role模型是django.contrib.auth应用程序中Group模型的代理。Role模型被创建来将Group的显示名称重命名为Role

  • User模型,它扩展了与django.contrib.auth中的User模型相同的抽象AbstractUser类。User模型被创建来用UUIDField替换主键,并允许我们通过电子邮件和密码而不是用户名和密码进行登录。

管理类MyUserAdminMyRoleAdmin扩展了贡献的UserAdminGroupAdmin类,并覆盖了一些属性。然后,我们取消注册了现有的UserGroup模型的管理类,并注册了新的修改后的管理类。

以下屏幕截图显示了用户管理的外观:

修改后的用户管理设置在列表视图中显示了更多字段,还有额外的过滤和排序选项,并在编辑表单顶部有提交按钮。

在新的组管理设置的更改列表中,我们将显示那些已被分配到特定组的用户。在浏览器中,这将类似于以下屏幕截图:

另请参阅

  • 自定义更改列表页面上的列示例

  • 在更改表单中插入地图示例

获取用户 Gravatars

现在我们已经开始使用自定义的User模型进行身份验证,我们可以通过添加更多有用的字段来进一步增强它。在这个示例中,我们将添加一个avatar字段,并且可以从 Gravatar 服务(en.gravatar.com/)下载用户的头像。该服务的用户可以上传头像并将其分配给他们的电子邮件。通过这样做,不同的评论系统和社交平台将能够根据用户电子邮件的哈希值从 Gravatar 显示这些头像。

准备工作

让我们继续使用之前创建的accounts应用程序。

如何做...

按照以下步骤增强accounts应用程序中的User模型:

  1. User模型添加avatar字段和django-imagekit缩略图规范:
# myproject/apps/accounts/models.py import os

from imagekit.models import ImageSpecField
from pilkit.processors import ResizeToFill
from django.utils import timezone

# …

def upload_to(instance, filename):
 now = timezone.now()
 filename_base, filename_ext = os.path.splitext(filename)
 return "users/{user_id}/{filename}{ext}".format(
 user_id=instance.pk,
 filename=now.strftime("%Y%m%d%H%M%S"),
 ext=filename_ext.lower(),
 )

class User(AbstractUser):
    # …

 avatar = models.ImageField(_("Avatar"), upload_to=upload_to, 
     blank=True)
 avatar_thumbnail = ImageSpecField(
 source="avatar",
 processors=[ResizeToFill(60, 60)],
 format="JPEG",
 options={"quality": 100},
 )

    # …
  1. 添加一些方法以便在MyUserAdmin类中下载和显示 Gravatar:
# myprojects/apps/accounts/admin.py from django.contrib import admin
from django.contrib.auth.admin import UserAdmin, Group, GroupAdmin
from django.urls import reverse
from django.contrib.contenttypes.models import ContentType
from django.http import HttpResponse
from django.shortcuts import get_object_or_404
from django.utils.encoding import force_bytes
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth.forms import UserCreationForm

from .helpers import download_avatar
from .models import User, Role

class MyUserCreationForm(UserCreationForm):
    def save(self, commit=True):
        user = super().save(commit=False)
        user.username = user.email
        user.set_password(self.cleaned_data["password1"])
        if commit:
            user.save()
        return user

@admin.register(User)
class MyUserAdmin(UserAdmin):
    save_on_top = True
    list_display = [
        "get_avatar",
        "get_full_name",
        "download_gravatar",
        "is_active",
        "is_staff",
        "is_superuser",
    ]
    list_display_links = [
        "get_avatar",
        "get_full_name",
    ]
    search_fields = ["email", "first_name", "last_name", "id", 
     "username"]
    ordering = ["-is_superuser", "-is_staff", "last_name", 
     "first_name"]

    fieldsets = [
        (None, {"fields": ("email", "password")}),
        (_("Personal info"), {"fields": ("first_name", 
         "last_name")}),
        (
            _("Permissions"),
            {
                "fields": (
                    "is_active",
                    "is_staff",
                    "is_superuser",
                    "groups",
                    "user_permissions",
                )
            },
        ),
 (_("Avatar"), {"fields": ("avatar",)}),
        (_("Important dates"), {"fields": ("last_login", 
         "date_joined")}),
    ]
    add_fieldsets = (
        (None, {"classes": ("wide",), "fields": ("email", 
         "password1", "password2")}),
    )
    add_form = MyUserCreationForm

    def get_full_name(self, obj):
        return obj.get_full_name()

    get_full_name.short_description = _("Full name")

    def get_avatar(self, obj):
        from django.template.loader import render_to_string
        html = render_to_string("admin/accounts
         /includes/avatar.html", context={
            "obj": obj
        })
        return mark_safe(html)

    get_avatar.short_description = _("Avatar")

    def download_gravatar(self, obj):
        from django.template.loader import render_to_string
        info = self.model._meta.app_label, 
         self.model._meta.model_name
        gravatar_url = reverse("admin:%s_%s_download_gravatar" % 
         info, args=[obj.pk])
        html = render_to_string("admin/accounts
         /includes/download_gravatar.html", context={
            "url": gravatar_url
        })
        return mark_safe(html)

    download_gravatar.short_description = _("Gravatar")

    def get_urls(self):
        from functools import update_wrapper
        from django.conf.urls import url

        def wrap(view):
            def wrapper(*args, **kwargs):
                return self.admin_site.admin_view(view)(*args, 
                 **kwargs)

            wrapper.model_admin = self
            return update_wrapper(wrapper, view)

        info = self.model._meta.app_label, 
         self.model._meta.model_name

        urlpatterns = [
            url(
                r"^(.+)/download-gravatar/$",
                wrap(self.download_gravatar_view),
                name="%s_%s_download_gravatar" % info,
            )
        ] + super().get_urls()

        return urlpatterns

    def download_gravatar_view(self, request, object_id):
        if request.method != "POST":
            return HttpResponse(
                "{} method not allowed.".format(request.method), 
                 status=405
            )
        from .models import User

        user = get_object_or_404(User, pk=object_id)
        import hashlib

        m = hashlib.md5()
        m.update(force_bytes(user.email))
        md5_hash = m.hexdigest()
        # d=404 ensures that 404 error is raised if gravatar is not 
        # found instead of returning default placeholder
        url = "https://www.gravatar.com/avatar
         /{md5_hash}?s=800&d=404".format(
            md5_hash=md5_hash
        )
        download_avatar(object_id, url)
        return HttpResponse("Gravatar downloaded.", status=200)
  1. accounts应用程序中添加一个helpers.py文件,内容如下:
# myproject/apps/accounts/helpers.py 
def download_avatar(user_id, image_url):
    import tempfile
    import requests
    from django.contrib.auth import get_user_model
    from django.core.files import File

    response = requests.get(image_url, allow_redirects=True, 
     stream=True)
    user = get_user_model().objects.get(pk=user_id)

    if user.avatar:  # delete the old avatar
        user.avatar.delete()

    if response.status_code != requests.codes.ok:
        user.save()
        return

    file_name = image_url.split("/")[-1]

    image_file = tempfile.NamedTemporaryFile()

    # Read the streamed image in sections
    for block in response.iter_content(1024 * 8):
        # If no more file then stop
        if not block:
            break
        # Write image block to temporary file
        image_file.write(block)

    user.avatar.save(file_name, File(image_file))
    user.save()
  1. 为管理文件中的头像创建一个模板:
{# admin/accounts/includes/avatar.html #}
{% if obj.avatar %}
    <img src="img/{{ obj.avatar_thumbnail.url }}" alt="" 
     width="30" height="30" />
{% endif %}
  1. 为下载Gravatarbutton创建一个模板:
{# admin/accounts/includes/download_gravatar.html #}
{% load i18n %}
<button type="button" data-url="{{ url }}" class="button js_download_gravatar download-gravatar">
    {% trans "Get Gravatar" %}
</button>
  1. 最后,为用户更改列表管理创建一个模板,其中包含处理鼠标点击Get Gravatar按钮的 JavaScript:
{# admin/accounts/user/change_list.html #}
{% extends "admin/change_list.html" %}
{% load static %}

{% block footer %}
{{ block.super }}
<style nonce="{{ request.csp_nonce }}">
.button.download-gravatar {
    padding: 2px 10px;
}
</style>
<script nonce="{{ request.csp_nonce }}">
django.jQuery(function($) {
    $('.js_download_gravatar').on('click', function(e) {
        e.preventDefault();
        $.ajax({
            url: $(this).data('url'),
            cache: 'false',
            dataType: 'json',
            type: 'POST',
            data: {},
            beforeSend: function(xhr) {
                xhr.setRequestHeader('X-CSRFToken', 
                 '{{ csrf_token }}');
            }
        }).then(function(data) {
            console.log('Gravatar downloaded.');
            document.location.reload(true);
        }, function(data) {
            console.log('There were problems downloading the 
             Gravatar.');
            document.location.reload(true);
        });
    })
})

</script>
{% endblock %}

工作原理...

如果您现在查看用户更改列表管理,您将看到类似于以下内容:

列从用户的 AVATAR 开始,然后是 FULL NAME,然后是一个获取 Gravatar 的按钮。当用户点击获取 Gravatar 按钮时,JavaScript 的onclick事件处理程序会向download_gravatar_view发出POST请求。此视图将为用户的 Gravatar 创建一个 URL,该 URL 依赖于用户电子邮件的 MD5 哈希,然后调用一个帮助函数为用户下载图像,并将其链接到avatar字段。

还有更多...

Gravatar 图像相当小,下载速度相对较快。如果您从其他服务下载更大的图像,可以使用 Celery 或 Huey 任务队列在后台检索图像。您可以在docs.celeryproject.org/en/latest/django/first-steps-with-django.html了解有关 Celery 的信息,并在huey.readthedocs.io/en/0.4.9/django.html了解有关 Huey 的信息。

另请参阅

  • 更改第三方应用程序的应用标签示例

  • 创建自定义帐户应用程序示例

在更改表单中插入地图

Google Maps 提供了一个 JavaScript API,我们可以使用它将地图插入到我们的网站中。在这个示例中,我们将创建一个带有Location模型的locations应用程序,并扩展更改表单的模板,以便管理员可以找到并标记位置的地理坐标。

准备工作

注册一个 Google Maps API 密钥,并将其暴露给模板,就像我们在第四章模板和 JavaScript中的使用 HTML5 数据属性示例中所做的那样。请注意,对于此示例,在 Google Cloud Platform 控制台中,您需要激活地图 JavaScript API 和地理编码 API。为了使这些 API 正常工作,您还需要设置计费数据。

我们将继续创建一个locations应用程序:

  1. 将应用程序放在设置中的INSTALLED_APPS下:
# myproject/settings/_base.py
INSTALLED_APPS = [
    # …
    "myproject.apps.locations",
]
  1. 在那里创建一个Location模型,包括名称、描述、地址、地理坐标和图片,如下所示:
# myproject/apps/locations/models.py
import os
import uuid
from collections import namedtuple

from django.contrib.gis.db import models
from django.urls import reverse
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from django.utils.timezone import now as timezone_now

from myproject.apps.core.models import CreationModificationDateBase, UrlBase

COUNTRY_CHOICES = getattr(settings, "COUNTRY_CHOICES", [])

Geoposition = namedtuple("Geoposition", ["longitude", "latitude"])

def upload_to(instance, filename):
    now = timezone_now()
    base, extension = os.path.splitext(filename)
    extension = extension.lower()
    return f"locations/{now:%Y/%m}/{instance.pk}{extension}"

class Location(CreationModificationDateBase, UrlBase):
    uuid = models.UUIDField(primary_key=True, default=None, 
     editable=False)
    name = models.CharField(_("Name"), max_length=200)
    description = models.TextField(_("Description"))
    street_address = models.CharField(_("Street address"), 
     max_length=255, blank=True)
    street_address2 = models.CharField(
        _("Street address (2nd line)"), max_length=255, blank=True
    )
    postal_code = models.CharField(_("Postal code"), 
     max_length=255, blank=True)
    city = models.CharField(_("City"), max_length=255, blank=True)
    country = models.CharField(
        _("Country"), choices=COUNTRY_CHOICES, max_length=255, 
         blank=True
    )
    geoposition = models.PointField(blank=True, null=True)
    picture = models.ImageField(_("Picture"), upload_to=upload_to)

    class Meta:
        verbose_name = _("Location")
        verbose_name_plural = _("Locations")

    def __str__(self):
        return self.name

    def get_url_path(self):
        return reverse("locations:location_detail", 
         kwargs={"pk": self.pk})

    def save(self, *args, **kwargs):
        if self.pk is None:
            self.pk = uuid.uuid4()
        super().save(*args, **kwargs)

    def delete(self, *args, **kwargs):
        if self.picture:
            self.picture.delete()
        super().delete(*args, **kwargs)

    def get_geoposition(self):
        if not self.geoposition:
            return None
        return Geoposition(self.geoposition.coords[0], 
         self.geoposition.coords[1])

    def set_geoposition(self, longitude, latitude):
        from django.contrib.gis.geos import Point
        self.geoposition = Point(longitude, latitude, srid=4326)
  1. 接下来,我们需要为我们的 PostgreSQL 数据库安装 PostGIS 扩展。最简单的方法是运行dbshell管理命令,并执行以下命令:
> CREATE EXTENSION postgis;
  1. 现在,使用地理位置模型创建默认管理(我们将在如何做...部分中更改这一点):
# myproject/apps/locations/admin.py
from django.contrib.gis import admin
from .models import Location

@admin.register(Location)
class LocationAdmin(admin.OSMGeoAdmin):
    pass

来自gis模块的地理Point字段的默认 Django 管理使用Leaflet.js JavaScript 映射库。瓷砖来自 Open Street Maps,管理将如下所示:

请注意,在默认设置中,您无法手动输入经度和纬度,也无法从地址信息中获取地理位置的可能性。我们将在此示例中实现这一点。

如何做...

Location模型的管理将从多个文件中组合而成。执行以下步骤来创建它:

  1. 让我们为Location模型创建管理配置。请注意,我们还创建了一个自定义模型表单,以创建单独的latitudelongitude字段:
# myproject/apps/locations/admin.py from django.contrib import admin
from django import forms
from django.conf import settings
from django.template.loader import render_to_string
from django.utils.translation import ugettext_lazy as _

from .models import Location

LATITUDE_DEFINITION = _(
    "Latitude (Lat.) is the angle between any point and the "
    "equator (north pole is at 90°; south pole is at -90°)."
)

LONGITUDE_DEFINITION = _(
    "Longitude (Long.) is the angle east or west of a point "
    "on Earth at Greenwich (UK), which is the international "
    "zero-longitude point (longitude = 0°). The anti-meridian "
    "of Greenwich (the opposite side of the planet) is both "
    "180° (to the east) and -180° (to the west)."
)

class LocationModelForm(forms.ModelForm):
    latitude = forms.FloatField(
        label=_("Latitude"), required=False, help_text=LATITUDE_DEFINITION
    )
    longitude = forms.FloatField(
        label=_("Longitude"), required=False, help_text=LONGITUDE_DEFINITION
    )

    class Meta:
        model = Location
        exclude = ["geoposition"]

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if self.instance:
            geoposition = self.instance.get_geoposition()
            if geoposition:
                self.fields["latitude"].initial = 
               geoposition.latitude
                self.fields["longitude"].initial = 
               geoposition.longitude

    def save(self, commit=True):
        cleaned_data = self.cleaned_data
        instance = super().save(commit=False)
        instance.set_geoposition(
            longitude=cleaned_data["longitude"],
            latitude=cleaned_data["latitude"],
        )
        if commit:
            instance.save()
            self.save_m2m()
        return instance

@admin.register(Location)
class LocationAdmin(admin.ModelAdmin):
    form = LocationModelForm
    save_on_top = True
    list_display = ("name", "street_address", "description")
    search_fields = ("name", "street_address", "description")

    def get_fieldsets(self, request, obj=None):
        map_html = render_to_string(
            "admin/locations/includes/map.html",
            {"MAPS_API_KEY": settings.GOOGLE_MAPS_API_KEY},
        )
        fieldsets = [
            (_("Main Data"), {"fields": ("name", "description")}),
            (
                _("Address"),
                {
                    "fields": (
                        "street_address",
                        "street_address2",
                        "postal_code",
                        "city",
                        "country",
                        "latitude",
 "longitude",
                    )
                },
            ),
            (_("Map"), {"description": map_html, "fields": []}),
            (_("Image"), {"fields": ("picture",)}),
        ]
        return fieldsets
  1. 要创建自定义更改表单模板,请在admin/locations/location/下的模板目录中添加一个新的change_form.html文件。此模板将扩展默认的admin/change_form.html模板,并将覆盖extrastylefield_sets块,如下所示:
{# admin/locations/location/change_form.html #} {% extends "admin/change_form.html" %}
{% load i18n static admin_modify admin_urls %}

{% block extrastyle %}
    {{ block.super }}
    <link rel="stylesheet" type="text/css"
          href="{% static 'site/css/location_map.css' %}" />
{% endblock %}

{% block field_sets %}
    {% for fieldset in adminform %}
        {% include "admin/includes/fieldset.html" %}
    {% endfor %}
    <script src="img/>     %}"></script>
{% endblock %}
  1. 然后,我们必须为将插入到Map字段集中的地图创建模板,如下所示:
{# admin/locations/includes/map.html #} {% load i18n %}
<div class="form-row map js_map">
    <div class="canvas">
        <!-- THE GMAPS WILL BE INSERTED HERE DYNAMICALLY -->
    </div>
    <ul class="locations js_locations"></ul>
    <div class="btn-group">
        <button type="button"
                class="btn btn-default locate-address  
                 js_locate_address">
            {% trans "Locate address" %}
        </button>
        <button type="button"
                class="btn btn-default remove-geo js_remove_geo">
            {% trans "Remove from map" %}
        </button>
    </div>
</div>
<script src="img/js?key={{ MAPS_API_KEY }}"></script>
  1. 当然,默认情况下地图不会被自动设置样式。因此,我们需要添加一些 CSS,如下所示:
/* site_static/site/css/location_map.css */ .map {
    box-sizing: border-box;
    width: 98%;
}
.map .canvas,
.map ul.locations,
.map .btn-group {
    margin: 1rem 0;
}
.map .canvas {
    border: 1px solid #000;
    box-sizing: padding-box;
    height: 0;
    padding-bottom: calc(9 / 16 * 100%); /* 16:9 aspect ratio */
    width: 100%;
}
.map .canvas:before {
    color: #eee;
    color: rgba(0, 0, 0, 0.1);
    content: "map";
    display: block;
    font-size: 5rem;
    line-height: 5rem;
    margin-top: -25%;
    padding-top: calc(50% - 2.5rem);
    text-align: center;
}
.map ul.locations {
    padding: 0;
}
.map ul.locations li {
    border-bottom: 1px solid #ccc;
    list-style: none;
}
.map ul.locations li:first-child {
    border-top: 1px solid #ccc;
}
.map .btn-group .btn.remove-geo {
    float: right;
}
  1. 接下来,让我们创建一个location_change_form.js的 JavaScript 文件。我们不想用全局变量来污染环境。因此,我们将从闭包开始,以便为变量和函数创建一个私有作用域。

在这个文件中,我们将使用 jQuery(因为 jQuery 随着贡献的管理系统而来,使得这变得简单且跨浏览器),如下所示:

/* site_static/site/js/location_change_form.js */
(function ($, undefined) {
    var gettext = window.gettext || function (val) {
        return val;
    };
    var $map, $foundLocations, $lat, $lng, $street, $street2,
        $city, $country, $postalCode, gMap, gMarker;
    // …this is where all the further JavaScript functions go…
}(django.jQuery));
  1. 我们将逐一创建 JavaScript 函数并将它们添加到location_change_form.js中。getAddress4search()函数将从地址字段中收集地址字符串,以便稍后用于地理编码,如下所示:
function getAddress4search() {
    var sStreetAddress2 = $street2.val();
    if (sStreetAddress2) {
        sStreetAddress2 = " " + sStreetAddress2;
    }

    return [
        $street.val() + sStreetAddress2,
        $city.val(),
        $country.val(),
        $postalCode.val()
    ].join(", ");
}
  1. updateMarker()函数将接受latitudelongitude参数,并在地图上绘制或移动标记。它还会使标记可拖动,如下所示:
function updateMarker(lat, lng) {
    var point = new google.maps.LatLng(lat, lng);

    if (!gMarker) {
        gMarker = new google.maps.Marker({
            position: point,
            map: gMap
        });
    }

    gMarker.setPosition(point);
    gMap.panTo(point, 15);
    gMarker.setDraggable(true);

    google.maps.event.addListener(gMarker, "dragend",
        function() {
            var point = gMarker.getPosition();
            updateLatitudeAndLongitude(point.lat(), point.lng());
        }
    );
}
  1. updateLatitudeAndLongitude()函数,如前面的 dragend 事件监听器中所引用的,接受latitudelongitude参数,并更新具有id_latitudeid_longitude ID 的字段的值,如下所示:
function updateLatitudeAndLongitude(lat, lng) {
    var precision = 1000000;
    $lat.val(Math.round(lat * precision) / precision);
    $lng.val(Math.round(lng * precision) / precision);
}
  1. autocompleteAddress()函数从 Google Maps 地理编码中获取结果,并在地图下方列出这些结果,以便选择正确的结果。如果只有一个结果,它将更新地理位置和地址字段,如下所示:
function autocompleteAddress(results) {
    var $item = $('<li/>');
    var $link = $('<a href="#"/>');

    $foundLocations.html("");
    results = results || [];

    if (results.length) {
        results.forEach(function (result, i) {
            $link.clone()
                 .html(result.formatted_address)
                 .click(function (event) {
                     event.preventDefault();
                     updateAddressFields(result
                      .address_components);

                     var point = result.geometry.location;
                     updateLatitudeAndLongitude(
                         point.lat(), point.lng());
                     updateMarker(point.lat(), point.lng());
                     $foundLocations.hide();
                 })
                 .appendTo($item.clone()
                  .appendTo($foundLocations));
        });
        $link.clone()
             .html(gettext("None of the above"))
             .click(function(event) {
                 event.preventDefault();
                 $foundLocations.hide();
             })
             .appendTo($item.clone().appendTo($foundLocations));
        $foundLocations.show();
    } else {
        $foundLocations.hide();
    }
}
  1. updateAddressFields()函数接受一个嵌套字典,其中包含地址组件作为参数,并填写所有地址字段,如下所示:
function updateAddressFields(addressComponents) {
    var streetName, streetNumber;
    var typeActions = {
        "locality": function(obj) {
            $city.val(obj.long_name);
        },
        "street_number": function(obj) {
            streetNumber = obj.long_name;
        },
        "route": function(obj) {
            streetName = obj.long_name;
        },
        "postal_code": function(obj) {
            $postalCode.val(obj.long_name);
        },
        "country": function(obj) {
            $country.val(obj.short_name);
        }
    };

    addressComponents.forEach(function(component) {
        var action = typeActions[component.types[0]];
        if (typeof action === "function") {
            action(component);
        }
    });

    if (streetName) {
        var streetAddress = streetName;
        if (streetNumber) {
            streetAddress += " " + streetNumber;
        }
        $street.val(streetAddress);
    }
}
  1. 最后,我们有初始化函数,在页面加载时调用。它将为按钮附加onclick事件处理程序,创建一个 Google 地图,并最初标记在latitudelongitude字段中定义的地理位置,如下所示:
$(function(){
    $map = $(".map");

    $foundLocations = $map.find("ul.js_locations").hide();
    $lat = $("#id_latitude");
    $lng = $("#id_longitude");
    $street = $("#id_street_address");
    $street2 = $("#id_street_address2");
    $city = $("#id_city");
    $country = $("#id_country");
    $postalCode = $("#id_postal_code");

    $map.find("button.js_locate_address")
        .click(function(event) {
            var geocoder = new google.maps.Geocoder();
            geocoder.geocode(
                {address: getAddress4search()},
                function (results, status) {
                    if (status === google.maps.GeocoderStatus.OK) {
                        autocompleteAddress(results);
                    } else {
                        autocompleteAddress(false);
                    }
                }
            );
        });

    $map.find("button.js_remove_geo")
        .click(function() {
            $lat.val("");
            $lng.val("");
            gMarker.setMap(null);
            gMarker = null;
        });

    gMap = new google.maps.Map($map.find(".canvas").get(0), {
        scrollwheel: false,
        zoom: 16,
        center: new google.maps.LatLng(51.511214, -0.119824),
        disableDoubleClickZoom: true
    });

    google.maps.event.addListener(gMap, "dblclick", function(event) 
    {
        var lat = event.latLng.lat();
        var lng = event.latLng.lng();
        updateLatitudeAndLongitude(lat, lng);
        updateMarker(lat, lng);
    });

    if ($lat.val() && $lng.val()) {
        updateMarker($lat.val(), $lng.val());
    }
});

工作原理...

如果您在浏览器中查看更改位置表单,您将看到一个地图显示在一个字段集中,后面是包含地址字段的字段集,如下截图所示:

在地图下方有两个按钮:定位地址和从地图中删除。

当您单击“定位地址”按钮时,将调用地理编码以搜索输入地址的地理坐标。执行地理编码的结果是以嵌套字典格式列出的一个或多个地址。我们将把地址表示为可点击链接的列表,如下所示:

要在开发者工具的控制台中查看嵌套字典的结构,请在autocompleteAddress()函数的开头放置以下行:

console.log(JSON.stringify(results, null, 4));

当您点击其中一个选择时,地图上会出现标记,显示位置的确切地理位置。纬度和经度字段将填写如下:

然后,管理员可以通过拖放在地图上移动标记。此外,双击地图上的任何位置将更新地理坐标和标记位置。

最后,如果单击“从地图中删除”按钮,则地理坐标将被清除,并且标记将被移除。

管理使用自定义的LocationModelForm,其中排除了geoposition字段,添加了LatitudeLongitude字段,并处理它们的值的保存和加载。

另请参阅

  • 第四章,模板和 JavaScript

第七章:安全和性能

在本章中,我们将涵盖以下配方:

  • 使表单免受跨站点请求伪造(CSRF)的攻击

  • 使用内容安全策略(CSP)使请求安全

  • 使用 django-admin-honeypot

  • 实施密码验证

  • 下载经授权的文件

  • 向图像添加动态水印

  • 使用 Auth0 进行身份验证

  • 缓存方法返回值

  • 使用 Memcached 缓存 Django 视图

  • 使用 Redis 缓存 Django 视图

介绍

如果软件不适当地暴露敏感信息,使用户遭受无休止的等待时间,或需要大量的硬件,那么它将永远无法持久。作为开发人员,我们有责任确保应用程序是安全和高性能的。在本章中,我们将仅仅讨论保持用户(和自己)在 Django 应用程序中安全运行的许多方法之一。然后,我们将介绍一些可以减少处理并以更低的成本(金钱和时间)将数据传递给用户的缓存选项。

技术要求

要使用本章中的代码,您需要最新稳定版本的 Python,一个 MySQL 或 PostgreSQL 数据库,以及一个带有虚拟环境的 Django 项目。

您可以在本书的 GitHub 存储库的ch07目录中找到本章的所有代码:github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition

使表单免受跨站点请求伪造(CSRF)的攻击

如果没有适当的预防措施,恶意网站可能会针对您的网站发起请求,这将导致对服务器进行不希望的更改。例如,他们可能会影响用户的身份验证或未经用户同意地更改内容。Django 捆绑了一个系统来防止此类 CSRF 攻击,我们将在本章中进行审查。

准备工作

从我们在第三章中创建的使用 CRUDL 功能创建应用中的ideas应用开始。

如何做…

要在 Django 中启用 CSRF 预防,请按照以下步骤操作:

  1. 确保在项目设置中包含CsrfViewMiddleware,如下所示:
# myproject/settings/_base.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
    "django.middleware.locale.LocaleMiddleware",
]
  1. 确保使用请求上下文呈现表单视图。例如,在现有的ideas应用中,我们有这样的:
# myproject/apps/ideas/views.py from django.contrib.auth.decorators import login_required
from django.shortcuts import render

@login_required
def add_or_change_idea(request, pk=None):
    # …
    return render(request, "ideas/idea_form.html", context)
  1. 在表单模板中,确保使用POST方法并包括{% csrf_token %}标记:
{# ideas/idea_form.html #}
{% extends "base.html" %}
{% load i18n crispy_forms_tags static %}

{% block content %}
    <h1>
        {% if idea %}
            {% blocktrans trimmed with title=idea
             .translated_title %}
                Change Idea "{{ title }}"
            {% endblocktrans %}
        {% else %}
            {% trans "Add Idea" %}
        {% endif %}
    </h1>
    <form action="{{ request.path }}" method="post">
 {% csrf_token %}
        {{ form.as_p }}
        <p>
            <button type="submit">{% trans "Save" %}</button>
        </p>
    </form>
{% endblock %}
  1. 如果您使用django-crispy-forms进行表单布局,则 CSRF 令牌将默认包含在其中:
{# ideas/idea_form.html #}
{% extends "base.html" %}
{% load i18n crispy_forms_tags static %}

{% block content %}
    <h1>
        {% if idea %}
            {% blocktrans trimmed with title=idea
             .translated_title %}
                Change Idea "{{ title }}"
            {% endblocktrans %}
        {% else %}
            {% trans "Add Idea" %}
        {% endif %}
    </h1>
    {% crispy form %}
{% endblock %}

它是如何工作的…

Django 使用隐藏字段方法来防止 CSRF 攻击。服务器上生成一个令牌,基于请求特定和随机化的信息组合。通过CsrfViewMiddleware,此令牌会自动通过请求上下文提供。

虽然不建议禁用此中间件,但可以通过应用@csrf_protect装饰器来标记单个视图以获得相同的行为:

from django.views.decorators.csrf import csrf_protect

@csrf_protect
def my_protected_form_view():
    # …

同样,我们可以使用@csrf_exempt装饰器从 CSRF 检查中排除单个视图,即使中间件已启用:

from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def my_unsecured_form_view():
    # …

内置的{% csrf_token %}标记生成提供令牌的隐藏输入字段,如下例所示:

<input type="hidden" name="csrfmiddlewaretoken" value="29sQH3UhogpseHH60eEaTq0xKen9TvbKe5lpT9xs30cR01dy5QVAtATWmAHvUZFk">

在使用GETHEADOPTIONSTRACE方法提交请求的表单中包含令牌被认为是无效的,因为任何使用这些方法的请求首先不应该引起副作用。在大多数情况下,需要 CSRF 保护的 Web 表单将是POST表单。

当使用不安全的方法提交受保护的表单而没有所需的令牌时,Django 的内置表单验证将识别此情况并拒绝请求。只有包含有效值令牌的提交才允许继续进行。因此,外部站点将无法更改您的服务器,因为它们将无法知道并包含当前有效的令牌值。

还有更多...

在许多情况下,希望增强一个表单,以便可以通过 Ajax 提交。这些也需要使用 CSRF 令牌进行保护,虽然可能在每个请求中作为额外数据注入令牌,但使用这种方法需要开发人员记住为每个POST请求这样做。使用 CSRF 令牌标头的替代方法存在,并且使事情更有效。

首先,需要检索令牌值,我们如何做取决于CSRF_USE_SESSIONS设置的值。当它为True时,令牌存储在会话中而不是 cookie 中,因此我们必须使用{% csrf_token %}标签将其包含在 DOM 中。然后,我们可以读取该元素以在 JavaScript 中检索数据:

var input = document.querySelector('[name="csrfmiddlewaretoken"]');
var csrfToken = input && input.value; 

CSRF_USE_SESSIONS设置处于默认的False状态时,令牌值的首选来源是csrftoken cookie。虽然可以自己编写 cookie 操作方法,但有许多可简化此过程的实用程序可用。例如,我们可以使用js-cookie API 轻松按名称提取令牌,该 API 可在github.com/js-cookie/js-cookie上找到,如下所示:

var csrfToken = Cookies.get('crsftoken');

一旦令牌被提取,它需要被设置为XmlHttpRequest的 CSRF 令牌标头值。虽然可以为每个请求单独执行此操作,但这样做与为每个请求添加数据到请求参数具有相同的缺点。相反,我们可以使用 jQuery 及其在发送请求之前自动附加数据的能力,如下所示:

var CSRF_SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS', 'TRACE'];
$.ajaxSetup({
    beforeSend: function(xhr, settings) {
        if (CSRF_SAFE_METHODS.indexOf(settings.type) < 0
            && !this.crossDomain) {
            xhr.setRequestHeader("X-CSRFToken", csrfToken);
        } 
    }
});

参见

  • 使用 CRUDL 功能创建应用程序配方在第三章,表单和视图

  • 实施密码验证配方

  • 下载授权文件配方

  • 使用 Auth0 进行身份验证配方

使用内容安全策略(CSP)使请求安全

动态多用户网站通常允许用户从各种媒体类型中添加各种数据:图像、视频、音频、HTML、JavaScript 片段等。这打开了用户向网站添加恶意代码的潜力,这些代码可能窃取 cookie 或其他个人信息,在后台调用不需要的 Ajax 请求,或者造成其他伤害。现代浏览器支持额外的安全层,它列入白名单您媒体资源的来源。它被称为 CSP,在这个配方中,我们将向您展示如何在 Django 网站中使用它。

准备工作

让我们从一个现有的 Django 项目开始;例如,包含来自第三章,表单和视图ideas应用程序。

如何做...

要使用 CSP 保护您的项目,请按照以下步骤:

  1. django-csp安装到您的虚拟环境中:
(env)$ pip install django-csp==3.6
  1. 在设置中,添加CSPMiddleware
# myproject/settings/_base.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
    "django.middleware.locale.LocaleMiddleware",
    "csp.middleware.CSPMiddleware",
]
  1. 在相同的设置文件中,添加django-csp设置以列入您信任的包含媒体的来源,例如,jQuery 和 Bootstrap 的 CDN(您将在它是如何工作的...部分找到对此的详细解释):
# myproject/settings/_base.py
CSP_DEFAULT_SRC = [
    "'self'",
    "https://stackpath.bootstrapcdn.com/",
]
CSP_SCRIPT_SRC = [
    "'self'",
    "https://stackpath.bootstrapcdn.com/",
    "https://code.jquery.com/",
    "https://cdnjs.cloudflare.com/",
]
CSP_IMG_SRC = ["*", "data:"]
CSP_FRAME_SRC = ["*"]
  1. 如果在模板中的任何地方有内联脚本或样式,请使用加密的nonce将它们列入白名单,如下所示:
<script nonce="{{ request.csp_nonce }}">
    window.settings = {
        STATIC_URL: '{{ STATIC_URL }}',
        MEDIA_URL: '{{ MEDIA_URL }}',
    }
</script>

它是如何工作的...

CSP 指令可以添加到头部的 meta 标签或响应头中:

  • meta标签的语法如下:
<meta http-equiv="Content-Security-Policy" content="img-src * data:; default-src 'self' https://stackpath.bootstrapcdn.com/ 'nonce-WWNu7EYqfTcVVZDs'; frame-src *; script-src 'self' https://stackpath.bootstrapcdn.com/ https://code.jquery.com/ https://cdnjs.cloudflare.com/">
  • 我们选择的django-csp模块使用响应头来创建您希望加载到网站中的源列表。您可以在浏览器检查器的网络部分中检查头,如下所示:
Content-Security-Policy: img-src * data:; default-src 'self' https://stackpath.bootstrapcdn.com/ 'nonce-WWNu7EYqfTcVVZDs'; frame-src *; script-src 'self' https://stackpath.bootstrapcdn.com/ https://code.jquery.com/ https://cdnjs.cloudflare.com/

CSP 允许您将资源类型和允许的来源定义在一起。您可以使用的主要指令如下:

  • default-src用作所有未设置来源的回退,并在 Django 设置中由CSP_DEFAULT_SRC控制。

  • script-src用于<script>标签,并在 Django 设置中由CSP_DEFAULT_SRC控制。

  • style-src用于<style><link rel="stylesheet">标签以及 CSS @import语句,并由CSP_STYLE_SRC设置控制。

  • img-src用于<img>标签,并由CSP_IMG_SRC设置控制。

  • frame-src用于<frame><iframe>标签,并由CSP_FRAME_SRC设置控制。

  • media-src用于<audio><video><track>标签,并由CSP_MEDIA_SRC设置控制。

  • font-src用于 Web 字体,并由CSP_FONT_SRC设置控制。

  • connect-src用于 JavaScript 加载的资源,并由CSP_CONNECT_SRC设置控制。

可以在developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy django-csp.readthedocs.io/en/latest/configuration.html找到每个指令的值的完整列表。

每个指令的值可以是以下列表中的一个或多个(单引号很重要):

  • *:允许所有来源

  • 'none':禁止所有来源

  • 'self':允许来自相同域的来源

  • 协议;例如,https:data:

  • 域名;例如,example.com*.example.com

  • 网站 URL,例如,https://example.com

  • 'unsafe-inline':允许内联<script><style>标签

  • 'unsafe-eval':允许使用eval()函数执行脚本

  • 'nonce-<b64-value>':通过加密 nonce 允许特定标签

  • 'sha256-...':通过源哈希允许资源

没有通用的配置django-csp的绝对方法。这总是一个反复试验的过程。不过,以下是我们的指导原则:

  1. 首先为现有的工作项目添加 CSP。过早的限制只会使开发网站变得更加困难。

  2. 检查所有已硬编码到模板中的脚本、样式、字体和其他静态文件,并将它们列入白名单。

  3. 如果允许媒体嵌入到博客文章或其他动态内容中,请允许所有来源的图像、媒体和框架,如下所示:

# myproject/settings/_base.py CSP_IMG_SRC = ["*"]
CSP_MEDIA_SRC = ["*"]
CSP_FRAME_SRC = ["*"]
  1. 如果您使用内联脚本或样式,请在其中添加nonce="{{ request.csp_nonce }}"

  2. 除非通过在模板中硬编码 HTML 是唯一的进入网站的方式,否则避免使用'unsafe-inline''unsafe-eval'CSP 值。

  3. 浏览网站,搜索任何未正确加载的内容。如果在开发者控制台中看到以下消息,意味着内容受到 CSP 的限制:

拒绝执行内联脚本,因为它违反了以下内容安全策略指令:“script-src 'self' https://stackpath.bootstrapcdn.com/ https://code.jquery.com/ https://cdnjs.cloudflare.com/”。要启用内联执行,需要使用'unsafe-inline'关键字、哈希('sha256-P1v4zceJ/oPr/yp20lBqDnqynDQhHf76lljlXUxt7NI=')或 nonce('nonce-...')。

这类错误通常是因为一些第三方工具,如 django-cms、Django Debug Toolbar 和 Google Analytics,试图通过 JavaScript 包含资源而发生的。您可以使用资源哈希来将这些资源列入白名单,就像我们在错误消息中看到的那样:

'sha256-P1v4zceJ/oPr/yp20lBqDnqynDQhHf76lljlXUxt7NI='

  1. 如果您开发现代的渐进式 Web 应用PWA),请考虑检查由CSP_MANIFEST_SRCCSP_WORKER_SRC设置控制的清单和 Web Workers 的指令。

另请参阅

  • 使表单免受跨站请求伪造(CSRF)的安全配方

使用 django-admin-honeypot

如果您保留 Django 网站的默认管理路径,您将使黑客能够执行暴力攻击,并尝试使用其列表中的不同密码登录。有一个名为 django-admin-honeypot 的应用程序,允许您伪造登录屏幕并检测这些暴力攻击。在本教程中,我们将学习如何使用它。

准备就绪

我们可以从任何要保护的 Django 项目开始。例如,您可以扩展上一个教程中的项目。

如何做...

按照以下步骤设置 django-admin-honeypot:

  1. 在您的虚拟环境中安装模块:
(env)$ pip install django-admin-honeypot==1.1.0
  1. 在设置中的INSTALLED_APPS中添加"admin_honeypot"
# myproject/settings/_base.py INSTALLED_APPS = (
    # …
    "admin_honeypot",
)
  1. 修改 URL 规则:
# myproject/urls.py from django.contrib import admin
from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path

urlpatterns = i18n_patterns(
    # …
    path("admin/", include("admin_honeypot.urls", 
    namespace="admin_honeypot")),
 path("management/", admin.site.urls),
)

它是如何工作的...

如果您转到默认的管理 URL,http://127.0.0.1:8000/en/admin/,您将看到登录屏幕,但无论您输入什么都将被描述为无效密码:

真实网站的管理现在位于http://127.0.0.1:8000/en/management/,您可以在那里看到来自蜜罐的跟踪登录。

还有更多...

在撰写本文时,django-admin-honeypot 与 Django 3.0 的功能不完善-管理界面会转义 HTML,而应该安全地呈现它。在 django-admin-honeypot 更新并提供新版本之前,我们可以通过进行一些更改来修复它,如下所示:

  1. 创建一个名为admin_honeypot_fix的应用程序,其中包含以下代码的admin.py文件:
# myproject/apps/admin_honeypot_fix/admin.py from django.contrib import admin

from admin_honeypot.admin import LoginAttemptAdmin
from admin_honeypot.models import LoginAttempt
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _

admin.site.unregister(LoginAttempt)

@admin.register(LoginAttempt)
class FixedLoginAttemptAdmin(LoginAttemptAdmin):
    def get_session_key(self, instance):
        return mark_safe('<a href="?session_key=
        %(key)s">%(key)s</a>' % {'key': instance.session_key})
    get_session_key.short_description = _('Session')

    def get_ip_address(self, instance):
        return mark_safe('<a href="?ip_address=%(ip)s">%(ip)s</a>' 
         % {'ip': instance.ip_address})
    get_ip_address.short_description = _('IP Address')

    def get_path(self, instance):
        return mark_safe('<a href="?path=%(path)s">%(path)s</a>' 
         % {'path': instance.path})
    get_path.short_description = _('URL')
  1. 在同一个应用程序中,创建一个带有新应用程序配置的apps.py文件:
# myproject/apps/admin_honeypot_fix/apps.py from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _

class AdminHoneypotConfig(AppConfig):
    name = "admin_honeypot"
    verbose_name = _("Admin Honeypot")

    def ready(self):
 from .admin import FixedLoginAttemptAdmin
  1. 在设置中的INSTALLED_APPS中用新的应用程序配置替换"admin_honeypot"
# myproject/settings/_base.py INSTALLED_APPS = [
    # …
    #"admin_honeypot",
    "myproject.apps.admin_honeypot_fix.apps.AdminHoneypotConfig",
]

蜜罐中的登录尝试现在看起来是这样的:

另请参阅

  • 实施密码验证教程

  • 使用 Auth0 进行身份验证教程

实施密码验证

在软件安全失败列表的前面,有一项是用户选择不安全密码。在本教程中,我们将学习如何通过内置和自定义密码验证器来强制执行最低密码要求,以便用户被引导设置更安全的身份验证。

准备就绪

打开项目的设置文件并找到AUTH_PASSWORD_VALIDATORS设置。此外,创建一个新的auth_extra应用程序,其中包含一个password_validation.py文件。

如何做...

按照以下步骤为您的项目设置更强大的密码验证:

  1. 通过添加一些选项来自定义 Django 中包含的验证器的设置:
# myproject/settings/_base.py
AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": "django.contrib.auth.password_validation."
        "UserAttributeSimilarityValidator",
        "OPTIONS": {"max_similarity": 0.5},
    },
    {
        "NAME": "django.contrib.auth.password_validation." 
        "MinimumLengthValidator",
        "OPTIONS": {"min_length": 12},
    },
    {"NAME": "django.contrib.auth.password_validation." 
    "CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation." 
    "NumericPasswordValidator"},
]
  1. 在新的auth_extra应用程序的password_validation.py文件中添加MaximumLengthValidator类,如下所示:
# myproject/apps/auth_extra/password_validation.py from django.core.exceptions import ValidationError
from django.utils.translation import gettext as _

class MaximumLengthValidator:
    def __init__(self, max_length=24):
        self.max_length = max_length

    def validate(self, password, user=None):
        if len(password) > self.max_length:
            raise ValidationError(
                self.get_help_text(pronoun="this"),
                code="password_too_long",
                params={'max_length': self.max_length},
            )

    def get_help_text(self, pronoun="your"):
        return _(f"{pronoun.capitalize()} password must contain "
                 f"no more than {self.max_length} characters")
  1. 在同一文件中,创建SpecialCharacterInclusionValidator类:
class SpecialCharacterInclusionValidator:
    DEFAULT_SPECIAL_CHARACTERS = ('$', '%', ':', '#', '!')

    def __init__(self, special_chars=DEFAULT_SPECIAL_CHARACTERS):
        self.special_chars = special_chars

    def validate(self, password, user=None):
        has_specials_chars = False
        for char in self.special_chars:
            if char in password:
                has_specials_chars = True
                break
        if not has_specials_chars:
            raise ValidationError(
                self.get_help_text(pronoun="this"),
                code="password_missing_special_chars"
            )

    def get_help_text(self, pronoun="your"):
        return _(f"{pronoun.capitalize()} password must contain at"
                 " least one of the following special characters: "
                 f"{', '.join(self.special_chars)}")
  1. 然后,将新的验证器添加到设置中:
# myproject/settings/_base.py
from myproject.apps.auth_extra.password_validation import (
 SpecialCharacterInclusionValidator,
)

AUTH_PASSWORD_VALIDATORS = [
    {
        "NAME": "django.contrib.auth.password_validation."
        "UserAttributeSimilarityValidator",
        "OPTIONS": {"max_similarity": 0.5},
    },
    {
        "NAME": "django.contrib.auth.password_validation." 
        "MinimumLengthValidator",
        "OPTIONS": {"min_length": 12},
    },
    {"NAME": "django.contrib.auth.password_validation." 
    "CommonPasswordValidator"},
    {"NAME": "django.contrib.auth.password_validation." 
    "NumericPasswordValidator"},
    {
 "NAME": "myproject.apps.auth_extra.password_validation."
        "MaximumLengthValidator",
 "OPTIONS": {"max_length": 32},
 },
 {
 "NAME": "myproject.apps.auth_extra.password_validation."
        "SpecialCharacterInclusionValidator",
 "OPTIONS": {
 "special_chars": ("{", "}", "^", "&")
 + SpecialCharacterInclusionValidator
              .DEFAULT_SPECIAL_CHARACTERS
 },
 },
]

它是如何工作的...

Django 包含一组默认密码验证器:

  • UserAttributeSimilarityValidator确保所选择的任何密码不会与用户的某些属性太相似。默认情况下,相似性比率设置为0.7,并且检查的属性是用户名,名字和姓氏以及电子邮件地址。如果这些属性中包含多个单词,则每个单词都会被独立检查。

  • MinimumLengthValidator检查输入的密码至少是多少个字符长。默认情况下,密码必须至少为八个字符长。

  • CommonPasswordValidator指的是一个包含经常使用的密码列表的文件,因此是不安全的。 Django 默认使用的列表包含 1,000 个这样的密码。

  • NumericPasswordValidator验证输入的密码是否完全由数字组成。

当您使用startproject管理命令创建新项目时,这些选项将作为初始验证器集合的默认选项添加。在这个配方中,我们已经展示了如何调整这些选项以满足我们项目的需求,将密码的最小长度增加到 12 个字符。

对于UserAttributeSimilarityValidator,我们还将max_similarity减少到0.5,这意味着密码必须与用户属性有更大的差异。

查看password_validation.py,我们定义了两个新的验证器:

  • MaximumLengthValidator与内置的最小长度验证器非常相似,确保密码不超过默认的 24 个字符

  • SpecialCharacterInclusionValidator检查密码中是否包含一个或多个特殊字符,默认情况下定义为$%:#!符号

每个验证器类都有两个必需的方法:

  • validate()方法执行对password参数的实际检查。可选地,当用户已经通过身份验证时,将传递第二个user参数。

  • 我们还必须提供一个get_help_text()方法,该方法返回描述用户验证要求的字符串。

最后,我们将新的验证器添加到设置中,以覆盖默认设置,允许密码的最大长度为 32 个字符,并且能够将符号{}^&添加到默认的特殊字符列表中。

还有更多...

AUTH_PASSWORD_VALIDATORS中提供的验证器会自动执行createsuperuserchangepassword管理命令,以及用于更改或重置密码的内置表单。但是,有时您可能希望对自定义密码管理代码使用相同的验证。Django 提供了该级别集成的函数,您可以在django.contrib.auth.password_validation模块中的贡献的 Django auth应用程序中检查详细信息。

另请参阅

  • 下载授权文件配方

  • 使用 Auth0 进行身份验证配方

下载授权文件

有时,您可能只需要允许特定的人从您的网站下载知识产权。例如,音乐、视频、文学或其他艺术作品只应该对付费会员开放。在这个配方中,您将学习如何使用贡献的 Django auth 应用程序,将图像下载限制仅对经过身份验证的用户。

准备工作

让我们从我们在第三章中创建的ideas应用开始。

如何做...

逐步执行这些步骤:

  1. 创建需要身份验证才能下载文件的视图,如下所示:
# myproject/apps/ideas/views.py import os

from django.contrib.auth.decorators import login_required
from django.http import FileResponse, HttpResponseNotFound
from django.shortcuts import get_object_or_404
from django.utils.text import slugify

from .models import Idea

@login_required
def download_idea_picture(request, pk):
    idea = get_object_or_404(Idea, pk=pk)
    if idea.picture:
        filename, extension = 
        os.path.splitext(idea.picture.file.name)
        extension = extension[1:] # remove the dot
        response = FileResponse(
            idea.picture.file, content_type=f"image/{extension}"
        )
        slug = slugify(idea.title)[:100]
        response["Content-Disposition"] = (
            "attachment; filename="
            f"{slug}.{extension}"
        )
    else:
        response = HttpResponseNotFound(
            content="Picture unavailable"
        )
    return response
  1. 将下载视图添加到 URL 配置中:
# myproject/apps/ideas/urls.py from django.urls import path

from .views import download_idea_picture

urlpatterns = [
    # …
    path(
 "<uuid:pk>/download-picture/",
 download_idea_picture,
 name="download_idea_picture",
 ),
]
  1. 在我们项目的 URL 配置中设置登录视图:
# myproject/urls.py from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path

urlpatterns = i18n_patterns(
    # …
    path("accounts/", include("django.contrib.auth.urls")),
    path("ideas/", include(("myproject.apps.ideas.urls", "ideas"), 
     namespace="ideas")),
)
  1. 创建登录表单的模板,如下所示:
{# registration/login.html #} {% extends "base.html" %}
{% load i18n %}

{% block content %}
    <h1>{% trans "Login" %}</h1>
    <form action="{{ request.path }}" method="POST">
        {% csrf_token %}
        {{ form.as_p }}
        <button type="submit" class="btn btn-primary">{% trans 
         "Log in" %}</button>
    </form>
{% endblock %}
  1. 在想法详情的模板中,添加一个下载链接:
{# ideas/idea_detail.html #}
{% extends "base.html" %}
{% load i18n %}

{% block content %}
…
 <a href="{% url 'ideas:download_idea_picture' pk=idea.pk %}" 
     class="btn btn-primary">{% trans "Download picture" %}</a>
{% endblock %}

您应该限制用户绕过 Django 直接下载受限文件。要做到这一点,在 Apache web 服务器上,如果您正在运行 Apache 2.4,可以在media/ideas目录中放置一个.htaccess文件,内容如下:

# media/ideas/.htaccess Require all denied

当使用django-imagekit时,如本书中的示例所示,生成的图像版本将存储在media/CACHE目录中,并从那里提供服务,因此我们的.htaccess配置不会影响它。

工作原理...

download_idea_picture视图从特定想法中流式传输原始上传的图片。设置为attachmentContent-Disposition标头使文件可下载,而不是立即在浏览器中显示。该文件的文件名也在此标头中设置,类似于gamified-donation-platform.jpg。如果某个想法的图片不可用,将显示一个带有非常简单消息的 404 页面:图片不可用。

@login_required装饰器将在访问可下载文件时重定向访问者到登录页面,如果他们未登录。默认情况下,登录屏幕如下所示:

另请参阅

  • 来自第三章的上传图像食谱,表单和视图

  • 来自第三章的使用自定义模板创建表单布局食谱,表单和视图

  • 来自第三章的使用 django-crispy-forms 创建表单布局食谱,表单和视图

  • 来自第四章的安排 base.html 模板食谱,模板和 JavaScript

  • 实施密码验证食谱

  • 向图像添加动态水印食谱

向图像添加动态水印

有时,允许用户查看图像,但防止由于知识产权和艺术权利而重新分发是可取的。在这个食谱中,我们将学习如何向在您的网站上显示的图像应用水印。

做好准备

让我们从我们在第三章中创建的coreideas应用程序开始,创建具有 CRUDL 功能的应用程序食谱,表单和视图

如何做...

按照以下步骤将水印应用于显示的 idea 图像:

  1. 如果尚未这样做,请将django-imagekit安装到您的虚拟环境中:
(env)$ pip install django-imagekit==4.0.2
  1. 在设置中将"imagekit"放入INSTALLED_APPS
# myproject/settings/_base.py
INSTALLED_APPS = [
    # …
    "imagekit",
]
  1. core应用程序中,创建一个名为processors.py的文件,其中包含WatermarkOverlay类,如下所示:
# myproject/apps/core/processors.py
from pilkit.lib import Image

class WatermarkOverlay(object):
    def __init__(self, watermark_image):
        self.watermark_image = watermark_image

    def process(self, img):
        original = img.convert('RGBA')
        overlay = Image.open(self.watermark_image)
        img = Image.alpha_composite(original, 
        overlay).convert('RGB')
        return img
  1. Idea模型中,将watermarked_picture_large规格添加到picture字段旁边,如下所示:
# myproject/apps/ideas/models.py import os

from imagekit.models import ImageSpecField
from pilkit.processors import ResizeToFill

from django.db import models
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from django.utils.timezone import now as timezone_now

from myproject.apps.core.models import CreationModificationDateBase, UrlBase
from myproject.apps.core.processors import WatermarkOverlay

def upload_to(instance, filename):
    now = timezone_now()
    base, extension = os.path.splitext(filename)
    extension = extension.lower()
    return f"ideas/{now:%Y/%m}/{instance.pk}{extension}"

class Idea(CreationModificationDateBase, UrlBase):
    # …
    picture = models.ImageField(
        _("Picture"), upload_to=upload_to
    )
    watermarked_picture_large = ImageSpecField(
 source="picture",
 processors=[
 ResizeToFill(800, 400),
 WatermarkOverlay(
 watermark_image=os.path.join(settings.STATIC_ROOT, 
                'site', 'img', 'watermark.png'),
 )
 ],
 format="PNG"
    )
  1. 使用您选择的图形程序,在透明背景上创建一个带有白色文本或标志的半透明 PNG 图像。将其大小设置为 800 x 400 像素。将图像保存为site_static/site/img/watermark.png。它可能看起来像这样:

  1. 之后运行collectstatic管理命令:
(env)$ export DJANGO_SETTINGS_MODULE=myproject.settings.dev
(env)$ python manage.py collectstatic
  1. 编辑 idea 详细模板,并添加水印图像,如下所示:
{# ideas/idea_detail.html #} {% extends "base.html" %}
{% load i18n %}

{% block content %}
    <a href="{% url "ideas:idea_list" %}">{% trans "List of ideas" 
     %}</a>
    <h1>
        {% blocktrans trimmed with title=idea.translated_title %}
            Idea "{{ title }}"
        {% endblocktrans %}
    </h1>
    <img src="img/{{ idea.watermarked_picture_large.url }}" alt="" />
    {{ idea.translated_content|linebreaks|urlize }}
    <p>
        {% for category in idea.categories.all %}
            <span class="badge badge-pill badge-info">
             {{ category.translated_title }}</span>
        {% endfor %}
    </p>
    <a href="{% url 'ideas:download_idea_picture' pk=idea.pk %}" 
     class="btn btn-primary">{% trans "Download picture" %}</a>
{% endblock %}

它是如何工作的...

如果我们导航到 idea 详细页面,我们应该看到大图像被我们的水印遮盖,类似于这样:

让我们来看看是如何做到的。在详细模板中,<img>标签的src属性使用了 idea 的图像规格,即watermarked_picture_large,以创建一个修改后的图像,然后将其保存在media/CACHE/目录下并从那里提供服务。

django-imagekit规格使用处理器修改图像。那里使用了两个处理器:

  • ResizeToFill将图像调整为 800×400 像素

  • 我们的自定义处理器WatermarkOverlay将半透明叠加层应用于它

django-imagekit处理器必须具有一个process()方法,该方法获取来自先前处理器的图像并返回一个新的修改后的图像。在我们的情况下,我们将结果从原始图像和半透明叠加层组合而成。

另请参阅

  • 下载授权文件食谱

使用 Auth0 进行身份验证

随着人们每天互动的服务数量的增加,他们需要记住的用户名和密码的数量也在增加。除此之外,用户信息存储的每个额外位置都是在安全漏洞发生时可能被盗窃的另一个位置。为了帮助缓解这一问题,诸如Auth0之类的服务允许您在单一安全平台上集中身份验证服务。

除了支持用户名和密码凭据外,Auth0 还可以通过 Google、Facebook 或 Twitter 等社交平台验证用户。您可以使用通过短信或电子邮件发送的一次性代码进行无密码登录,甚至支持不同服务的企业级支持。在本教程中,您将学习如何将 Auth0 应用连接到 Django,并如何集成它以处理用户身份验证。

准备就绪

如果尚未这样做,请在 auth0.com/ 创建一个 Auth0 应用,并按照那里的说明进行配置。免费计划提供了两个社交连接,因此我们将激活 Google 和 Twitter 以使用它们登录。您还可以尝试其他服务。请注意,其中一些服务需要您注册应用并获取 API 密钥和密钥。

接下来,我们需要在项目中安装 python-social-auth 和其他一些依赖项。将这些依赖项包含在您的 pip 要求中:

# requirements/_base.txt
social-auth-app-django~=3.1
python-jose~=3.0
python-dotenv~=0.9

social-auth-app-djangopython-social-auth 项目的 Django 特定包,允许您使用许多社交连接之一进行网站身份验证。

使用 pip 将这些依赖项安装到您的虚拟环境中。

如何做...

要将 Auth0 连接到您的 Django 项目,请按照以下步骤进行:

  1. 在设置文件中的 INSTALLED_APPS 中添加社交身份验证应用,如下所示:
# myproject/settings/_base.py
INSTALLED_APPS = [
    # …
    "social_django",
]
  1. 现在,添加 social_django 应用所需的 Auth0 设置,如下所示:
# myproject/settings/_base.py
SOCIAL_AUTH_AUTH0_DOMAIN = get_secret("AUTH0_DOMAIN")
SOCIAL_AUTH_AUTH0_KEY = get_secret("AUTH0_KEY")
SOCIAL_AUTH_AUTH0_SECRET = get_secret("AUTH0_SECRET")
SOCIAL_AUTH_AUTH0_SCOPE = ["openid", "profile", "email"]
SOCIAL_AUTH_TRAILING_SLASH = False

确保您在您的秘密或环境变量中定义 AUTH0_DOMAINAUTH0_KEYAUTH0_SECRET。这些变量的值可以在您在本教程的 准备就绪 部分的 第 1 步 中创建的 Auth0 应用的设置中找到。

  1. 我们需要为 Auth0 连接创建一个后端,如下例所示:
# myproject/apps/external_auth/backends.py from urllib import request
from jose import jwt
from social_core.backends.oauth import BaseOAuth2

class Auth0(BaseOAuth2):
    """Auth0 OAuth authentication backend"""

    name = "auth0"
    SCOPE_SEPARATOR = " "
    ACCESS_TOKEN_METHOD = "POST"
    REDIRECT_STATE = False
    EXTRA_DATA = [("picture", "picture"), ("email", "email")]

    def authorization_url(self):
        return "https://" + self.setting("DOMAIN") + "/authorize"

    def access_token_url(self):
        return "https://" + self.setting("DOMAIN") + "/oauth/token"

    def get_user_id(self, details, response):
        """Return current user id."""
        return details["user_id"]

    def get_user_details(self, response):
        # Obtain JWT and the keys to validate the signature
        id_token = response.get("id_token")
        jwks = request.urlopen(
            "https://" + self.setting("DOMAIN") + "/.well-
              known/jwks.json"
        )
        issuer = "https://" + self.setting("DOMAIN") + "/"
        audience = self.setting("KEY")  # CLIENT_ID
        payload = jwt.decode(
            id_token,
            jwks.read(),
            algorithms=["RS256"],
            audience=audience,
            issuer=issuer,
        )
        first_name, last_name = (payload.get("name") or 
         " ").split(" ", 1)
        return {
            "username": payload.get("nickname") or "",
            "first_name": first_name,
            "last_name": last_name,
            "picture": payload.get("picture") or "",
            "user_id": payload.get("sub") or "",
            "email": payload.get("email") or "",
        }
  1. 将新后端添加到您的 AUTHENTICATION_BACKENDS 设置中,如下所示:
# myproject/settings/_base.py
AUTHENTICATION_BACKENDS = {
    "myproject.apps.external_auth.backends.Auth0",
    "django.contrib.auth.backends.ModelBackend",
}
  1. 我们希望社交身份验证用户可以从任何模板中访问。因此,我们将为其创建一个上下文处理器:
# myproject/apps/external_auth/context_processors.py
def auth0(request):
    data = {}
    if request.user.is_authenticated:
        auth0_user = request.user.social_auth.filter(
            provider="auth0",
        ).first()
        data = {
            "auth0_user": auth0_user,
        }
    return data
  1. 接下来,我们需要在设置中注册它:
# myproject/settings/_base.py
TEMPLATES = [
    {
        "BACKEND": 
        "django.template.backends.django.DjangoTemplates",
        "DIRS": [os.path.join(BASE_DIR, "myproject", "templates")],
        "APP_DIRS": True,
        "OPTIONS": {
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors
                 .messages",
                "django.template.context_processors.media",
                "django.template.context_processors.static",
                "myproject.apps.core.context_processors
                 .website_url",
                "myproject.apps.external_auth
               .context_processors.auth0",
            ]
        },
    }
]
  1. 现在,让我们为索引页面、仪表板和注销创建视图:
# myproject/apps/external_auth/views.py
from urllib.parse import urlencode

from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from django.contrib.auth import logout as log_out
from django.conf import settings

def index(request):
    user = request.user
    if user.is_authenticated:
        return redirect(dashboard)
    else:
        return render(request, "index.html")

@login_required
def dashboard(request):
    return render(request, "dashboard.html")

def logout(request):
    log_out(request)
    return_to = urlencode({"returnTo": 
     request.build_absolute_uri("/")})
    logout_url = "https://%s/v2/logout?client_id=%s&%s" % (
        settings.SOCIAL_AUTH_AUTH0_DOMAIN,
        settings.SOCIAL_AUTH_AUTH0_KEY,
        return_to,
    )
    return redirect(logout_url)
  1. 创建索引模板,如下所示:
{# index.html #}
{% extends "base.html" %}
{% load i18n utility_tags %}

{% block content %}
<div class="login-box auth0-box before">
    <h3>{% trans "Please log in for the best user experience" %}</h3>
    <a class="btn btn-primary btn-lg" href="{% url "social:begin" 
     backend="auth0" %}">{% trans "Log in" %}</a>
</div>
{% endblock %}
  1. 相应地创建仪表板模板:
{# dashboard.html #}
{% extends "base.html" %}
{% load i18n %}

{% block content %}
    <div class="logged-in-box auth0-box logged-in">
        <img alt="{% trans 'Avatar' %}" src="img/>         auth0_user.extra_data.picture }}" 
         width="50" height="50" />
        <h2>{% blocktrans with name=request.user
         .first_name %}Welcome, {{ name }}
         {% endblocktrans %}!</h2>

        <a class="btn btn-primary btn-logout" href="{% url 
         "auth0_logout" %}">{% trans "Log out" %}</a>
    </div>
{% endblock %}
  1. 更新 URL 规则:
# myproject/urls.py
from django.conf.urls.i18n import i18n_patterns
from django.urls import path, include

from myproject.apps.external_auth import views as external_auth_views

urlpatterns = i18n_patterns(
    path("", external_auth_views.index, name="index"),
    path("dashboard/", external_auth_views.dashboard, 
     name="dashboard"),
    path("logout/", external_auth_views.logout, 
     name="auth0_logout"),
    path("", include("social_django.urls")),
    # …
)
  1. 最后,添加登录 URL 设置:
LOGIN_URL = "/login/auth0"
LOGIN_REDIRECT_URL = "dashboard"

工作原理...

如果您将浏览器指向项目的索引页面,您将看到一个链接邀请您登录。当您点击它时,您将被重定向到 Auth0 身份验证系统,其屏幕将类似于以下内容:

这些都是由 python-social-authAuth0 后端的 SOCIAL_AUTH_* 设置配置的开箱即用功能。

一旦成功完成登录,Auth0 后端将接收来自响应的数据并处理它。相关数据附加到与请求关联的用户对象。在达到 LOGIN_REDIRECT_URL 的身份验证结果的仪表板视图中,提取用户详细信息并添加到模板上下文中。然后呈现 dashboard.html。结果可能如下所示:

仪表板上呈现的注销按钮在按下时将注销用户。

另请参阅

  • 实施密码验证 教程

  • 下载授权文件 教程

缓存方法返回值

如果在请求-响应周期中多次调用具有繁重计算或数据库查询的模型方法,则视图的性能可能会变得非常慢。在本教程中,您将了解一种模式,可以使用它来缓存方法的返回值以供以后重复使用。请注意,我们在这里不使用 Django 缓存框架,只使用 Python 默认提供的内容。

准备就绪

选择一个具有耗时方法的模型的应用程序,该方法将在同一请求-响应周期中重复使用。

如何做...

执行以下步骤:

  1. 这是一个模式,您可以用它来缓存模型的方法返回值,以便在视图、表单或模板中重复使用,如下所示:
class SomeModel(models.Model):
    def some_expensive_function(self):
        if not hasattr(self, "_expensive_value_cached"):
            # do some heavy calculations...
            # ... and save the result to result variable
            self._expensive_value_cached = result
        return self._expensive_value_cached
  1. 例如,让我们为ViralVideo模型创建一个get_thumbnail_url()方法。您将在第十章数据库查询表达式食谱中更详细地探讨这个问题,标题是《花里胡哨》:
# myproject/apps/viral_videos/models.py
import re
from django.db import models
from django.utils.translation import ugettext_lazy as _

from myproject.apps.core.models import CreationModificationDateBase, UrlBase

class ViralVideo(CreationModificationDateBase, UrlBase):
    embed_code = models.TextField(
        _("YouTube embed code"),
        blank=True)

    # …

    def get_thumbnail_url(self):
        if not hasattr(self, "_thumbnail_url_cached"):
            self._thumbnail_url_cached = ""
            url_pattern = re.compile(
                r'src="img/([^"]+)"'
            )
            match = url_pattern.search(self.embed_code)
            if match:
                video_id = match.groups()[0]
                self._thumbnail_url_cached = (
                    f"https://img.youtube.com/vi/{video_id}/0.jpg"
                )
        return self._thumbnail_url_cached

它是如何工作的...

在这个通用的例子中,该方法检查模型实例是否存在_expensive_value_cached属性。如果不存在,将执行耗时的计算,并将结果赋给这个新属性。在方法结束时,返回缓存的值。当然,如果您有几个繁重的方法,您将需要使用不同的属性名称来保存每个计算出的值。

现在,您可以在模板的页眉和页脚中使用{{ object.some_expensive_function }}之类的东西,耗时的计算将只进行一次。

在模板中,您还可以在{% if %}条件和值的输出中使用该函数,如下所示:

{% if object.some_expensive_function %}
    <span class="special">
        {{ object.some_expensive_function }}
    </span>
{% endif %}

在另一个例子中,我们通过解析视频嵌入代码的 URL,获取其 ID,然后组成缩略图图像的 URL 来检查 YouTube 视频的缩略图。通过这样做,您可以在模板中使用它,如下所示:

{% if video.get_thumbnail_url %}
    <figure>
        <img src="img/{{ video.get_thumbnail_url }}"
             alt="{{ video.title }}" 
        />
        <figcaption>{{ video.title }}</figcaption>
    </figure>
{% endif %}

还有更多...

我们刚刚描述的方法只有在方法被调用时没有参数时才有效,这样结果将始终相同。但是如果输入有所不同怎么办?自 Python 3.2 以来,有一个装饰器可以使用,基于参数的哈希(至少是可哈希的参数)提供基本的最近最少使用LRU)缓存。

例如,让我们看一个人为而琐碎的例子,有一个函数接受两个值,并返回一些昂贵逻辑的结果:

def busy_bee(a, b):
    # expensive logic
    return result

如果我们有这样一个函数,并且希望提供一个缓存来存储一些常用输入变化的结果,我们可以很容易地使用functools包中的@lru_cache装饰器来实现,如下所示:

from functools import lru_cache

@lru_cache(maxsize=100, typed=True)
def busy_bee(a, b):
    # expensive logic
    return result

现在,我们提供了一个缓存机制,它将在从输入中计算出的哈希键下存储最多 100 个结果。typed选项是在 Python 3.3 中添加的,通过指定True,我们使得具有a=1b=2的调用将与具有a=1.0b=2.0的调用分开存储。根据逻辑操作的方式和返回值的内容,这种变化可能合适也可能不合适。

您可以在docs.python.org/3/library/functools.html#functools.lru_cachefunctools文档中了解更多关于@lru_cache装饰器的信息。

我们还可以在本食谱中的前面的例子中使用这个装饰器来简化代码,如下所示:

# myproject/apps/viral_videos/models.py
from functools import lru_cache # …

class ViralVideo(CreationModificationDateMixin, UrlMixin):
    # …
    @lru_cache
    def get_thumbnail_url(self):
        # …

另请参阅

  • 第四章模板和 JavaScript

  • 使用 Memcached 缓存 Django 视图食谱

  • 使用 Redis 缓存 Django 视图食谱

使用 Memcached 缓存 Django 视图

Django 允许我们通过缓存最昂贵的部分,如数据库查询或模板渲染,来加快请求-响应周期。Django 本身支持的最快、最可靠的缓存是基于内存的缓存服务器Memcached。在这个食谱中,您将学习如何使用 Memcached 来为viral_videos应用程序缓存视图。我们将在第十章数据库查询表达式食谱中进一步探讨这个问题,标题是《花里胡哨》。

准备工作

为了为我们的 Django 项目准备缓存,我们需要做几件事:

  1. 让我们安装memcached服务。例如,在 macOS 上最简单的方法是使用 Homebrew:
$ brew install memcached
  1. 然后,您可以使用以下命令启动、停止或重新启动 Memcached 服务:
$ brew services start memcached
$ brew services stop memcached
$ brew services restart memcached

在其他操作系统上,您可以使用 apt-get、yum 或其他默认的软件包管理工具安装 Memcached。另一个选项是从源代码编译,如memcached.org/downloads中所述。

  1. 在您的虚拟环境中安装 Memcached Python 绑定,如下:
(env)$ pip install python-memcached==1.59

如何做...

要为特定视图集成缓存,请执行以下步骤:

  1. 在项目设置中设置CACHES如下:
# myproject/settings/_base.py
CACHES = {
    "memcached": {
        "BACKEND": 
        "django.core.cache.backends.memcached.MemcachedCache",
        "LOCATION": get_secret("CACHE_LOCATION"),
        "TIMEOUT": 60,  # 1 minute
        "KEY_PREFIX": "myproject",
    },
}
CACHES["default"] = CACHES["memcached"]
  1. 确保您的秘密或环境变量中的CACHE_LOCATION设置为"localhost:11211"

  2. 修改viral_videos应用的视图,如下:

# myproject/apps/viral_videos/views.py from django.shortcuts import render
from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_cookie

@vary_on_cookie
@cache_page(60)
def viral_video_detail(request, pk):
    # …
    return render(
        request,
        "viral_videos/viral_video_detail.html",
        {'video': video}
    )

如果您按照下一个配方中的 Redis 设置,您会发现views.py文件没有任何变化。这表明我们可以随意更改底层的缓存机制,而无需修改使用它的代码。

工作原理...

正如您将在第十章的使用数据库查询表达式配方中看到的那样,病毒视频的详细视图显示了经过认证和匿名用户的印象数量。如果您访问一个病毒视频(例如在http://127.0.0.1:8000/en/videos/1/)并启用缓存后刷新页面几次,您会注意到印象数量只在一分钟内改变一次。这是因为每个响应对于每个用户都被缓存 60 秒。我们使用@cache_page装饰器为视图设置了缓存。

Memcached 是一个键值存储,它默认使用完整的 URL 来为每个缓存页面生成键。当两个访问者同时访问同一页面时,第一个访问者的请求会收到由 Python 代码生成的页面,而第二个访问者会从 Memcached 服务器获取相同的 HTML 代码。

在我们的示例中,为了确保每个访问者即使访问相同的 URL 也会被单独处理,我们使用了@vary_on_cookie装饰器。这个装饰器检查了 HTTP 请求中Cookie头的唯一性。

您可以从官方文档docs.djangoproject.com/en/3.0/topics/cache/了解更多关于 Django 缓存框架的信息。同样,您也可以在memcached.org/了解更多关于 Memcached 的信息。

另请参阅

  • 缓存方法返回值配方

  • 使用 Redis 缓存 Django 视图配方

  • 第十章,花里胡哨

使用 Redis 缓存 Django 视图

尽管 Memcached 在市场上作为缓存机制已经很成熟,并且得到了 Django 的很好支持,但 Redis 是一个提供了 Memcached 所有功能以及更多功能的备用系统。在这里,我们将重新审视使用 Memcached 缓存 Django 视图的过程,并学习如何使用 Redis 来实现相同的功能。

准备工作

为了为我们的 Django 项目准备缓存,我们需要做几件事:

  1. 让我们安装 Redis 服务。例如,在 macOS 上最简单的方法是使用 Homebrew:
$ brew install redis
  1. 然后,您可以使用以下命令启动、停止或重新启动 Redis 服务:
$ brew services start redis
$ brew services stop redis
$ brew services restart redis

在其他操作系统上,您可以使用 apt-get、yum 或其他默认的软件包管理工具安装 Redis。另一个选项是从源代码编译,如redis.io/download中所述。

  1. 在您的虚拟环境中安装 Django 和其依赖的 Redis 缓存后端,如下:
(env)$ pip install redis==3.3.11
(env)$ pip install hiredis==1.0.1
(env)$ pip install django-redis-cache==2.1.0

如何做...

要为特定视图集成缓存,请执行以下步骤:

  1. 在项目设置中设置CACHES如下:
# myproject/settings/_base.py
CACHES = {
    "redis": {
        "BACKEND": "redis_cache.RedisCache",
        "LOCATION": [get_secret("CACHE_LOCATION")],
        "TIMEOUT": 60, # 1 minute
        "KEY_PREFIX": "myproject",
    },
}
CACHES["default"] = CACHES["redis"]
  1. 确保您的秘密或环境变量中的CACHE_LOCATION设置为"localhost:6379"

  2. 修改viral_videos应用的视图,如下:

# myproject/apps/viral_videos/views.py from django.shortcuts import render
from django.views.decorators.cache import cache_page
from django.views.decorators.vary import vary_on_cookie

@vary_on_cookie
@cache_page(60)
def viral_video_detail(request, pk):
    # …
    return render(
        request,
        "viral_videos/viral_video_detail.html",
        {'video': video}
    )

如果您按照上一个教程中的 Memcached 设置进行操作,您会发现在这里的views.py中没有任何变化。这表明我们可以随意更改底层缓存机制,而无需修改使用它的代码。

它是如何工作的...

就像使用 Memcached 一样,我们使用@cache_page装饰器为视图设置缓存。因此,每个用户的每个响应都会被缓存 60 秒。视频详细信息视图(例如http://127.0.0.1:8000/en/videos/1/)显示了经过认证和匿名用户的印象数量。启用缓存后,如果您多次刷新页面,您会注意到印象数量每分钟只变化一次。

就像 Memcached 一样,Redis 是一个键值存储,当用于缓存时,它会根据完整的 URL 为每个缓存页面生成密钥。当两个访问者同时访问同一页面时,第一个访问者的请求将接收到由 Python 代码生成的页面,而第二个访问者将从 Redis 服务器获取相同的 HTML 代码。

在我们的示例中,为了确保每个访问者即使访问相同的 URL 也会被单独对待,我们使用了@vary_on_cookie装饰器。该装饰器检查 HTTP 请求中Cookie头的唯一性。

您可以从官方文档了解有关 Django 缓存框架的更多信息docs.djangoproject.com/en/3.0/topics/cache/。同样,您也可以在redis.io/上了解有关 Memcached 的更多信息。

还有更多...

Redis 能够像 Memcached 一样处理缓存,系统内置了大量额外的缓存算法选项。除了缓存,Redis 还可以用作数据库或消息存储。它支持各种数据结构、事务、发布/订阅和自动故障转移等功能。

通过 django-redis-cache 后端,Redis 也可以轻松配置为会话后端,就像这样:

# myproject/settings/_base.py
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"

另请参阅

  • 缓存方法返回值教程

  • 使用 Memcached 缓存 Django 视图教程

  • 第十章,花里胡哨

第八章:分层结构

在本章中,我们将涵盖以下主题:

  • 使用 django-mptt 创建分层类别

  • 使用 django-mptt-admin 创建一个类别管理界面

  • 在模板中呈现类别与 django-mptt

  • 在表单中使用单选字段选择类别与 django-mptt

  • 在表单中使用复选框列表选择多个类别与 django-mptt

  • 使用 django-treebeard 创建分层类别

  • 使用 django-treebeard 创建基本的类别管理界面

介绍

无论你是构建自己的论坛、分级评论还是分类系统,总会有一个时刻,你需要在数据库中保存分层结构。尽管关系数据库(如 MySQL 和 PostgreSQL)的表是平面的,但有一种快速有效的方法可以存储分层结构。它被称为修改的先序树遍历MPTT)。MPTT 允许你在不需要递归调用数据库的情况下读取树结构。

首先,让我们熟悉树结构的术语。树数据结构是从节点开始,具有对子节点的引用的嵌套集合。有一些限制:例如,没有节点应该引用回来创建一个循环,也不应该重复引用。以下是一些其他要记住的术语:

  • 父节点是具有对子节点的引用的任何节点。

  • 后代是通过从父节点递归遍历到其子节点可以到达的节点。因此,一个节点的后代将是它的子节点、子节点的子节点等等。

  • 祖先是通过从子节点递归遍历到其父节点可以到达的节点。因此,一个节点的祖先将是其父节点、父节点的父节点等等,一直到根节点。

  • 兄弟节点是具有相同父节点的节点。

  • 叶子是没有子节点的节点。

现在,我将解释 MPTT 的工作原理。想象一下,将树水平布置,根节点在顶部。树中的每个节点都有左右值。想象它们是节点左右两侧的小手柄。然后,你从根节点开始,逆时针绕树行走(遍历),并用数字标记每个左右值:1、2、3 等等。它看起来类似于以下图表:

在这个分层结构的数据库表中,每个节点都有标题、左值和右值。

现在,如果你想获取B节点的子树,左值为2,右值为11,你需要选择所有左值在211之间的节点。它们是CDEF

要获取D节点的所有祖先,左值为5,右值为10,你必须选择所有左值小于5且右值大于10的节点。这些将是BA

要获取节点的后代数量,可以使用以下公式:

后代 = (右值 - 左值 - 1) / 2

因此,B节点的后代数量可以根据以下公式计算:

(11 - 2 - 1) / 2 = 4

如果我们想把E节点附加到C节点,我们只需要更新它们的第一个共同祖先B节点的左右值。然后,C节点的左值仍然是3E节点的左值将变为4,右值为5C节点的右值将变为6D节点的左值将变为7F节点的左值将保持为8;其他节点也将保持不变。

类似地,MPTT 中还有其他与节点相关的树操作。对于项目中的每个分层结构自己管理所有这些可能太复杂了。幸运的是,有一个名为django-mptt的 Django 应用程序,它有很长的历史来处理这些算法,并提供了一个简单的 API 来处理树结构。另一个应用程序django-treebeard也经过了尝试和测试,并在取代 MPTT 成为 django CMS 3.1 的强大替代品时获得了额外的关注。在本章中,您将学习如何使用这些辅助应用程序。

技术要求

您将需要 Python 3 的最新稳定版本、MySQL 或 PostgreSQL 以及一个带有虚拟环境的 Django 项目。

您可以在 GitHub 存储库的ch08目录中找到本章的所有代码,网址为:github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition

使用 django-mptt 创建分层类别

为了说明如何处理 MPTT,我们将在第三章,表单和视图中的ideas应用程序的基础上构建。在我们的更改中,我们将使用分层的Category模型替换类别,并更新Idea模型以与类别具有多对多的关系。或者,您可以从头开始创建应用程序,仅使用此处显示的内容,以实现Idea模型的非常基本的版本。

准备工作

要开始,请执行以下步骤:

  1. 使用以下命令在虚拟环境中安装django-mptt
(env)$ pip install django-mptt==0.10.0
  1. 如果尚未创建categoriesideas应用程序,请创建它们。将这些应用程序以及mptt添加到设置中的INSTALLED_APPS中,如下所示:
# myproject/settings/_base.py
INSTALLED_APPS = [
    # …
    "mptt",
    # …
    "myproject.apps.categories",
 "myproject.apps.ideas",
]

操作步骤

我们将创建一个分层的Category模型,并将其与Idea模型关联,后者将与类别具有多对多的关系,如下所示:

  1. categories应用程序的models.py文件中添加一个扩展mptt.models.MPTTModelCategory模型

CreationModificationDateBase,在第二章,模型和数据库结构中定义。除了来自混合类的字段之外,Category模型还需要具有TreeForeignKey类型的parent字段和title字段:

# myproject/apps/ideas/models.py
from django.db import models
from django.utils.translation import ugettext_lazy as _
from mptt.models import MPTTModel
from mptt.fields import TreeForeignKey

from myproject.apps.core.models import CreationModificationDateBase

class Category(MPTTModel, CreationModificationDateBase):
    parent = TreeForeignKey(
 "self", on_delete=models.CASCADE, 
 blank=True, null=True, related_name="children"
    )
    title = models.CharField(_("Title"), max_length=200)

    class Meta:
 ordering = ["tree_id", "lft"]
        verbose_name = _("Category")
        verbose_name_plural = _("Categories")

 class MPTTMeta:
 order_insertion_by = ["title"]

    def __str__(self):
        return self.title
  1. 更新Idea模型以包括TreeManyToManyField类型的categories字段:
# myproject/apps/ideas/models.py from django.utils.translation import gettext_lazy as _

from mptt.fields import TreeManyToManyField

from myproject.apps.core.models import CreationModificationDateBase, UrlBase

class Idea(CreationModificationDateBase, UrlBase):
    # …
    categories = TreeManyToManyField(
 "categories.Category",
 verbose_name=_("Categories"),
 related_name="category_ideas",
 )
  1. 通过进行迁移并运行它们来更新您的数据库:
(env)$ python manage.py makemigrations
(env)$ python manage.py migrate

工作原理

MPTTModel混合类将向Category模型添加tree_idlftrghtlevel字段:

  • tree_id字段用作数据库表中可以有多个树的标识。实际上,每个根类别都保存在单独的树中。

  • lftrght字段存储 MPTT 算法中使用的左值和右值。

  • level字段存储树中节点的深度。根节点的级别将为 0。

通过 MPTT 特有的order_insertion_by元选项,我们确保添加新类别时,它们按标题的字母顺序排列。

除了新字段之外,MPTTModel混合类还添加了用于浏览树结构的方法,类似于使用 JavaScript 浏览 DOM 元素。这些方法如下:

  • 如果要访问类别的祖先,请使用以下代码。在这里,

ascending参数定义从哪个方向读取节点(默认为False),include_self参数定义是否在QuerySet中包含类别本身(默认为False):

ancestor_categories = category.get_ancestors(
    ascending=False,
    include_self=False,
)
  • 要仅获取根类别,请使用以下代码:
root = category.get_root()
  • 如果要获取类别的直接子类,请使用以下代码:
children = category.get_children()
  • 要获取类别的所有后代,请使用以下代码。在这里,include_self参数再次定义是否在QuerySet中包含类别本身:
descendants = category.get_descendants(include_self=False)
  • 如果要获取后代计数而不查询数据库,请使用以下代码:
descendants_count = category.get_descendant_count()
  • 要获取所有兄弟节点,请调用以下方法:
siblings = category.get_siblings(include_self=False)

根类别被视为其他根类别的兄弟节点。

  • 要只获取前一个和后一个兄弟节点,请调用以下方法:
previous_sibling = category.get_previous_sibling()
next_sibling = category.get_next_sibling()
  • 此外,还有一些方法可以检查类别是根、子还是叶子,如下所示:
category.is_root_node()
category.is_child_node()
category.is_leaf_node()

所有这些方法都可以在视图、模板或管理命令中使用。如果要操作树结构,还可以使用insert_at()move_to()方法。在这种情况下,您可以在django-mptt.readthedocs.io/en/stable/models.html上阅读有关它们和树管理器方法的信息。

在前面的模型中,我们使用了TreeForeignKeyTreeManyToManyField。这些类似于ForeignKeyManyToManyField,只是它们在管理界面中以层次结构缩进显示选择项。

还要注意,在Category模型的Meta类中,我们按tree_idlft值对类别进行排序,以在树结构中自然显示类别。

另请参阅

  • 使用 django-mptt-admin 创建类别管理界面的说明

第二章,模型和数据库结构

  • 使用 django-mptt 创建模型混合以处理创建和修改日期的说明

使用 django-mptt-admin 创建类别管理界面

django-mptt应用程序配备了一个简单的模型管理混合功能,允许您创建树结构并使用缩进列出它。要重新排序树,您需要自己创建此功能,或者使用第三方解决方案。一个可以帮助您为分层模型创建可拖动的管理界面的应用程序是django-mptt-admin。让我们在这个教程中看一下它。

准备工作

首先,按照前面使用 django-mptt 创建分层类别的说明设置categories应用程序。然后,我们需要通过执行以下步骤安装django-mptt-admin应用程序:

  1. 使用以下命令在虚拟环境中安装应用程序:
(env)$ pip install django-mptt-admin==0.7.2
  1. 将其放在设置中的INSTALLED_APPS中,如下所示:
# myproject/settings/_base.py
INSTALLED_APPS = [
    # …
    "mptt",
 "django_mptt_admin",
]
  1. 确保django-mptt-admin的静态文件对您的项目可用:
(env)$ python manage.py collectstatic

如何做...

创建一个admin.py文件,在其中我们将定义Category模型的管理界面。它将扩展DjangoMpttAdmin而不是admin.ModelAdmin,如下所示:

# myproject/apps/categories/admin.py from django.contrib import admin
from django_mptt_admin.admin import DjangoMpttAdmin

from .models import Category

@admin.register(Category)
class CategoryAdmin(DjangoMpttAdmin):
    list_display = ["title", "created", "modified"]
    list_filter = ["created"]

它是如何工作的...

类别的管理界面将有两种模式:树视图和网格视图。您的树视图将类似于以下屏幕截图:

树视图使用jqTree jQuery 库进行节点操作。您可以展开和折叠类别,以便更好地查看。要重新排序或更改依赖关系,您可以在此列表视图中拖放标题。在重新排序期间,用户界面UI)类似于以下屏幕截图:

请注意,树视图中将忽略任何常规与列表相关的设置,例如list_displaylist_filter。此外,order_insertion_by元属性驱动的任何排序都将被手动排序覆盖。

如果要筛选类别、按特定字段对其进行排序或应用管理操作,可以切换到网格视图,它显示默认的类别更改列表,如以下屏幕截图所示:

另请参阅

  • 使用 django-mptt 创建分层类别的说明

  • 使用 django-treebeard 创建类别管理界面的说明

使用 django-mptt 在模板中呈现类别

一旦您在应用程序中创建了类别,您需要在模板中以分层方式显示它们。使用 MPTT 树的最简单方法是使用django-mptt应用程序中的{% recursetree %}模板标记,如使用 django-mptt 创建分层类别食谱中所述。我们将在这个食谱中向您展示如何做到这一点。

准备就绪

确保您拥有categoriesideas应用程序。在那里,您的Idea模型应该与Category模型有多对多的关系,就像使用 django-mptt 创建分层类别食谱中所述。在数据库中输入一些类别。

如何做...

将您的分层类别的QuerySet传递到模板,然后使用{% recursetree %}模板标记,如下所示:

  1. 创建一个视图,加载所有类别并将它们传递到模板:
# myproject/apps/categories/views.py from django.views.generic import ListView

from .models import Category

class IdeaCategoryList(ListView):
    model = Category
    template_name = "categories/category_list.html"
    context_object_name = "categories"
  1. 创建一个模板,其中包含以下内容以输出类别的层次结构:
{# categories/category_list.html #}
{% extends "base.html" %}
{% load mptt_tags %}

{% block content %}
    <ul class="root">
        {% recursetree categories %}
            <li>
                {{ node.title }}
                {% if not node.is_leaf_node %}
                    <ul class="children">
                        {{ children }}
                    </ul>
                {% endif %}
            </li>
        {% endrecursetree %}
    </ul>
{% endblock %}
  1. 创建一个 URL 规则来显示视图:
# myproject/urls.py from django.conf.urls.i18n import i18n_patterns
from django.urls import path

from myproject.apps.categories import views as categories_views

urlpatterns = i18n_patterns(
    # …
    path(
 "idea-categories/",
 categories_views.IdeaCategoryList.as_view(),
 name="idea_categories",
 ),
)

它是如何工作的...

模板将呈现为嵌套列表,如下截图所示:

{% recursetree %}块模板标记接受类别的QuerySet并使用标记内嵌的模板内容呈现列表。这里使用了两个特殊变量:

  • node变量是Category模型的一个实例,其字段或方法可用于添加特定的 CSS 类或 HTML5data-*属性,例如{{ node.get_descendent_count }}{{ node.level }}{{ node.is_root }}

  • 其次,我们有一个children变量,用于定义当前类别的渲染子节点将放置在何处。

还有更多...

如果您的分层结构非常复杂,超过 20 个级别,建议使用非递归的tree_info模板过滤器或{% full_tree_for_model %}{% drilldown_tree_for_node %}迭代标记。

有关如何执行此操作的更多信息,请参阅官方文档django-mptt.readthedocs.io/en/latest/templates.html#iterative-tags.

另请参阅

  • 在第四章使用 HTML5 数据属性食谱,模板和 JavaScript*

  • 使用 django-mptt 创建分层类别食谱

  • 使用 django-treebeard 创建分层类别食谱

  • 在使用 django-mptt 在表单中选择类别的单选字段食谱

在表单中使用单选字段来选择类别与 django-mptt

如果您想在表单中显示类别选择,会发生什么?层次结构将如何呈现?在django-mptt中,有一个特殊的TreeNodeChoiceField表单字段,您可以使用它来在选定字段中显示分层结构。让我们看看如何做到这一点。

准备就绪

我们将从前面的食谱中定义的categoriesideas应用程序开始。对于这个食谱,我们还需要django-crispy-forms。查看如何在第三章使用 django-crispy-forms 创建表单布局*食谱中安装它。

如何做...

让我们通过在第三章表单和视图中创建的ideas的过滤对象列表的过滤对象列表*食谱,添加一个按类别进行过滤的字段。

  1. ideas应用程序的forms.py文件中,创建一个带有类别字段的表单,如下所示:
# myproject/apps/ideas/forms.py from django import forms
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth import get_user_model

from crispy_forms import bootstrap, helper, layout
from mptt.forms import TreeNodeChoiceField

from myproject.apps.categories.models import Category

from .models import Idea, RATING_CHOICES

User = get_user_model()

class IdeaFilterForm(forms.Form):
    author = forms.ModelChoiceField(
        label=_("Author"),
        required=False,
        queryset=User.objects.all(),
    )
 category = TreeNodeChoiceField(
 label=_("Category"),
 required=False,
 queryset=Category.objects.all(),
 level_indicator=mark_safe("&nbsp;&nbsp;&nbsp;&nbsp;")
 )
    rating = forms.ChoiceField(
        label=_("Rating"), required=False, choices=RATING_CHOICES
    )
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

        author_field = layout.Field("author")
        category_field = layout.Field("category")
        rating_field = layout.Field("rating")
        submit_button = layout.Submit("filter", _("Filter"))
        actions = bootstrap.FormActions(submit_button)

        main_fieldset = layout.Fieldset(
            _("Filter"),
            author_field,
            category_field,
            rating_field,
            actions,
        )

        self.helper = helper.FormHelper()
        self.helper.form_method = "GET"
        self.helper.layout = layout.Layout(main_fieldset)
  1. 我们应该已经创建了IdeaListView,一个相关的 URL 规则和idea_list.html模板来显示此表单。确保在模板中使用{% crispy %}模板标记呈现过滤表单,如下所示:
{# ideas/idea_list.html #}
{% extends "base.html" %}
{% load i18n utility_tags crispy_forms_tags %}

{% block sidebar %}
 {% crispy form %}
{% endblock %}

{% block main %}
    {# … #}
{% endblock %}

它是如何工作的...

类别选择下拉菜单将类似于以下内容:

TreeNodeChoiceField的作用类似于ModelChoiceField;但是,它显示缩进的分层选择。默认情况下,TreeNodeChoiceField表示每个更深层级都以三个破折号---为前缀。在我们的示例中,我们通过将level_indicator参数传递给字段,将级别指示器更改为四个不间断空格(&nbsp; HTML 实体)。为了确保不间断空格不被转义,我们使用mark_safe()函数。

另请参阅

  • 在模板中使用 django-mptt 呈现类别食谱

  • 在表单中使用 checkbox 列表来选择多个类别,使用 django-mptt食谱

在表单中使用 checkbox 列表来选择多个类别,使用 django-mptt

当需要一次选择一个或多个类别时,可以使用django-mptt提供的TreeNodeMultipleChoiceField多选字段。然而,多选字段(例如,<select multiple>)在界面上并不是非常用户友好,因为用户需要滚动并按住控制键或命令键来进行多次选择。特别是当需要从中选择相当多的项目,并且用户希望一次选择多个项目,或者用户有辅助功能障碍,如运动控制能力差,这可能会导致非常糟糕的用户体验。一个更好的方法是提供一个复选框列表,用户可以从中选择类别。在这个食谱中,我们将创建一个允许你在表单中显示分层树结构的缩进复选框的字段。

准备工作

我们将从我们之前定义的categoriesideas应用程序以及你的项目中应该有的core应用程序开始。

操作步骤...

为了呈现带复选框的缩进类别列表,我们将创建并使用一个新的MultipleChoiceTreeField表单字段,并为该字段创建一个 HTML 模板。

特定模板将传递给表单中的crispy_forms布局。为此,请执行以下步骤:

  1. core应用程序中,添加一个form_fields.py文件,并创建一个扩展ModelMultipleChoiceFieldMultipleChoiceTreeField表单字段,如下所示:
# myproject/apps/core/form_fields.py
from django import forms

class MultipleChoiceTreeField(forms.ModelMultipleChoiceField):
    widget = forms.CheckboxSelectMultiple

    def label_from_instance(self, obj):
        return obj
  1. 在新的想法创建表单中使用带有类别选择的新字段。此外,在表单布局中,将自定义模板传递给categories字段,如下所示:
# myproject/apps/ideas/forms.py from django import forms
from django.utils.translation import ugettext_lazy as _
from django.contrib.auth import get_user_model

from crispy_forms import bootstrap, helper, layout

from myproject.apps.categories.models import Category
from myproject.apps.core.form_fields import MultipleChoiceTreeField

from .models import Idea, RATING_CHOICES

User = get_user_model()

class IdeaForm(forms.ModelForm):
 categories = MultipleChoiceTreeField(
 label=_("Categories"),
 required=False,
 queryset=Category.objects.all(),
 )

    class Meta:
        model = Idea
        exclude = ["author"]

    def __init__(self, request, *args, **kwargs):
        self.request = request
        super().__init__(*args, **kwargs)

        title_field = layout.Field("title")
        content_field = layout.Field("content", rows="3")
        main_fieldset = layout.Fieldset(_("Main data"), 
         title_field, content_field)

        picture_field = layout.Field("picture")
        format_html = layout.HTML(
            """{% include "ideas/includes/picture_guidelines.html" 
                %}"""
        )

        picture_fieldset = layout.Fieldset(
            _("Picture"),
            picture_field,
            format_html,
            title=_("Image upload"),
            css_id="picture_fieldset",
        )

 categories_field = layout.Field(
 "categories",
 template="core/includes
            /checkboxselectmultiple_tree.html"
        )
 categories_fieldset = layout.Fieldset(
 _("Categories"), categories_field, 
             css_id="categories_fieldset"
        )

        submit_button = layout.Submit("save", _("Save"))
        actions = bootstrap.FormActions(submit_button, 
         css_class="my-4")

        self.helper = helper.FormHelper()
        self.helper.form_action = self.request.path
        self.helper.form_method = "POST"
        self.helper.layout = layout.Layout(
            main_fieldset,
            picture_fieldset,
 categories_fieldset,
            actions,
        )

    def save(self, commit=True):
        instance = super().save(commit=False)
        instance.author = self.request.user
        if commit:
            instance.save()
            self.save_m2m()
        return instance
  1. 创建一个基于crispy表单模板bootstrap4/layout/checkboxselectmultiple.html的 Bootstrap 风格复选框列表的模板,如下所示:
{# core/include/checkboxselectmultiple_tree.html #} {% load crispy_forms_filters l10n %}

<div class="{% if field_class %} {{ field_class }}{% endif %}"{% if flat_attrs %} {{ flat_attrs|safe }}{% endif %}>

    {% for choice_value, choice_instance in field.field.choices %}
    <div class="{%if use_custom_control%}custom-control custom-
     checkbox{% if inline_class %} custom-control-inline{% endif 
     %}{% else %}form-check{% if inline_class %} form-check-
     inline{% endif %}{% endif %}">
        <input type="checkbox" class="{%if use_custom_control%}
         custom-control-input{% else %}form-check-input
         {% endif %}{% if field.errors %} is-invalid{% endif %}"
 {% if choice_value in field.value or choice_
         value|stringformat:"s" in field.value or 
         choice_value|stringformat:"s" == field.value
         |default_if_none:""|stringformat:"s" %} checked=
         "checked"{% endif %} name="{{ field.html_name }}" 
          id="id_{{ field.html_name }}_{{ forloop.counter }}" 
          value="{{ choice_value|unlocalize }}" {{ field.field
          .widget.attrs|flatatt }}>
        <label class="{%if use_custom_control%}custom-control-
         label{% else %}form-check-label{% endif %} level-{{ 
        choice_instance.level }}" for="id_{{ field.html_name 
          }}_{{ forloop.counter }}">
            {{ choice_instance|unlocalize }}
        </label>
        {% if field.errors and forloop.last and not inline_class %}
            {% include 'bootstrap4/layout/field_errors_block.html' 
              %}
        {% endif %}
    </div>
    {% endfor %}
    {% if field.errors and inline_class %}
    <div class="w-100 {%if use_custom_control%}custom-control 
     custom-checkbox{% if inline_class %} custom-control-inline
     {% endif %}{% else %}form-check{% if inline_class %} form-
      check-inline{% endif %}{% endif %}">
        <input type="checkbox" class="custom-control-input {% if 
         field.errors %}is-invalid{%endif%}">
        {% include 'bootstrap4/layout/field_errors_block.html' %}
    </div>
    {% endif %}

    {% include 'bootstrap4/layout/help_text.html' %}
</div>
  1. 创建一个新的视图来添加一个想法,使用我们刚刚创建的表单:
# myproject/apps/ideas/views.py from django.contrib.auth.decorators import login_required
from django.shortcuts import render, redirect, get_object_or_404

from .forms import IdeaForm
from .models import Idea

@login_required
def add_or_change_idea(request, pk=None):
    idea = None
    if pk:
        idea = get_object_or_404(Idea, pk=pk)
    if request.method == "POST":
        form = IdeaForm(request, data=request.POST, 
         files=request.FILES, instance=idea)
        if form.is_valid():
            idea = form.save()
            return redirect("ideas:idea_detail", pk=idea.pk)
    else:
        form = IdeaForm(request, instance=idea)

    context = {"idea": idea, "form": form}
    return render(request, "ideas/idea_form.html", context)
  1. 将相关模板添加到显示带有{% crispy %}模板标记的表单中,你可以在第三章,表单和视图中了解更多关于其用法的内容:
{# ideas/idea_form.html #}
{% extends "base.html" %}
{% load i18n crispy_forms_tags static %}

{% block content %}
    <a href="{% url "ideas:idea_list" %}">{% trans "List of ideas" %}</a>
    <h1>
        {% if idea %}
            {% blocktrans trimmed with title=idea.translated_title 
              %}
                Change Idea "{{ title }}"
            {% endblocktrans %}
        {% else %}
            {% trans "Add Idea" %}
        {% endif %}
    </h1>
 {% crispy form %}
{% endblock %}
  1. 我们还需要一个指向新视图的 URL 规则,如下所示:
# myproject/apps/ideas/urls.py from django.urls import path

from .views import add_or_change_idea

urlpatterns = [
    # …
    path("add/", add_or_change_idea, name="add_idea"),
    path("<uuid:pk>/change/", add_or_change_idea, 
     name="change_idea"),
]
  1. 在 CSS 文件中添加规则,使用复选框树字段模板中生成的类(如.level-0.level-1.level-2),通过设置margin-left参数来缩进标签。确保你的 CSS 类有合理数量,以适应上下文中树的预期最大深度,如下所示:
/* myproject/site_static/site/css/style.css */
.level-0 {margin-left: 0;}
.level-1 {margin-left: 20px;}
.level-2 {margin-left: 40px;}

工作原理...

结果如下,我们得到以下表单:

与 Django 的默认行为相反,后者在 Python 代码中硬编码字段生成,django-crispy-forms应用程序使用模板来呈现字段。你可以在crispy_forms/templates/bootstrap4下浏览它们,并在必要时将其中一些复制到项目模板目录的类似路径下以覆盖它们。

在我们的创意创建和编辑表单中,我们传递了一个自定义模板,用于categories字段,该模板将为<label>标签添加.level-*CSS 类,包装复选框。正常的CheckboxSelectMultiple小部件的一个问题是,当呈现时,它只使用选择值和选择文本,而我们需要类别的其他属性,例如深度级别。为了解决这个问题,我们还创建了一个自定义的MultipleChoiceTreeField表单字段,它扩展了ModelMultipleChoiceField并覆盖了label_from_instance()方法,以返回类别实例本身,而不是其 Unicode 表示。字段的模板看起来很复杂;但实际上,它主要是一个重构后的多复选框字段模板(crispy_forms/templates/bootstrap4/layout/checkboxselectmultiple.html),其中包含所有必要的 Bootstrap 标记。我们主要只是稍微修改了一下,添加了.level-*CSS 类。

另请参阅

  • 第三章中的使用 django-crispy-forms 创建表单布局方法

  • 使用 django-mptt 在模板中呈现类别的方法

  • 在表单中使用单个选择字段选择类别的方法

使用 django-treebeard 创建分层类别

树结构有几种算法,每种算法都有其自己的优点。一个名为django-treebeard的应用程序,它是 django CMS 使用的django-mptt的替代方案,提供了对三种树形表单的支持:

  • 邻接列表树是简单的结构,其中每个节点都有一个父属性。尽管读取操作很快,但这是以写入速度慢为代价的。

  • 嵌套集树和 MPTT 树是相同的;它们将节点结构化为嵌套在父节点下的集合。这种结构还提供了非常快速的读取访问,但写入和删除的成本更高,特别是当写入需要某种特定的排序时。

  • Materialized Path树是由树中的每个节点构建的,每个节点都有一个关联的路径属性,该属性是一个字符串,指示从根到节点的完整路径,就像 URL 路径指示在网站上找到特定页面的位置一样。这是支持的最有效方法。

作为对其支持所有这些算法的演示,我们将使用django-treebeard及其一致的 API。我们将扩展第三章中的categories应用程序,表单和视图。在我们的更改中,我们将通过支持的树算法之一增强Category模型的层次结构。

准备工作

要开始,请执行以下步骤:

  1. 使用以下命令在虚拟环境中安装django-treebeard
(env)$ pip install django-treebeard==4.3
  1. 如果尚未创建categoriesideas应用程序,请创建。将categories应用程序以及treebeard添加到设置中的INSTALLED_APPS中,如下所示:
# myproject/settings/_base.py
INSTALLED_APPS = [
    # …
    "treebeard",
    # …
    "myproject.apps.categories",
 "myproject.apps.ideas",
]

如何做...

我们将使用Materialized Path算法增强Category模型,如下所示:

  1. 打开models.py文件,并更新Category模型,以扩展treebeard.mp_tree.MP_Node而不是标准的 Django 模型。它还应该继承自我们在第二章中定义的CreationModificationDateMixin。除了从混合中继承的字段外,Category模型还需要有一个title字段:
# myproject/apps/categories/models.py
from django.db import models
from django.utils.translation import ugettext_lazy as _
from treebeard.mp_tree import MP_Node

from myproject.apps.core.models import CreationModificationDateBase

class Category(MP_Node, CreationModificationDateBase):
    title = models.CharField(_("Title"), max_length=200)

    class Meta:
        verbose_name = _("Category")
        verbose_name_plural = _("Categories")

    def __str__(self):
        return self.title
  1. 这将需要对数据库进行更新,因此接下来,我们需要迁移categories应用程序:
(env)$ python manage.py makemigrations
(env)$ python manage.py migrate
  1. 通过使用抽象模型继承,treebeard 树节点可以使用标准关系与其他模型相关联。因此,Idea模型可以继续与Category具有简单的ManyToManyField关系:
# myproject/apps/ideas/models.py from django.db import models
from django.utils.translation import gettext_lazy as _

from myproject.apps.core.models import CreationModificationDateBase, UrlBase

class Idea(CreationModificationDateBase, UrlBase):
    # …
 categories = models.ManyToManyField(
 "categories.Category",
 verbose_name=_("Categories"),
 related_name="category_ideas",
 )

它是如何工作的...

MP_Node抽象模型为Category模型提供了pathdepthnumchild字段,以及steplenalphabetnode_order_by属性,以便根据需要构建树:

  • depthnumchild字段提供了关于节点位置和后代的元数据。

  • path字段被索引,使得可以使用LIKE进行数据库查询非常快。

  • path字段由固定长度的编码段组成,每个段的大小由steplen属性值确定(默认为 4),编码使用alphabet属性值中的字符(默认为拉丁字母数字字符)。

pathdepthnumchild字段应被视为只读。此外,steplenalphabetnode_order_by值在保存第一个对象到树后不应更改;否则,数据将被损坏。

除了新字段和属性之外,MP_Node抽象类还添加了用于浏览树结构的方法。这些方法的一些重要示例在这里列出:

  • 如果要获取类别的ancestors,返回从根到当前节点的父代的QuerySet,请使用以下代码:
ancestor_categories = category.get_ancestors()
  • 要只获取root类别,其深度为 1,请使用以下代码:
root = category.get_root()
  • 如果要获取类别的直接children,请使用以下代码:
children = category.get_children()
  • 要获取类别的所有后代,返回为所有子代及其子代的QuerySet,依此类推,但不包括当前节点本身,请使用以下代码:
descendants = category.get_descendants()
  • 如果要只获取descendant计数,请使用以下代码:
descendants_count = category.get_descendant_count()
  • 要获取所有siblings,包括参考节点,请调用以下方法:
siblings = category.get_siblings()

根类别被认为是其他根类别的兄弟。

  • 要只获取前一个和后一个siblings,请调用以下方法,其中get_prev_sibling()将对最左边的兄弟返回Noneget_next_sibling()对最右边的兄弟也是如此:
previous_sibling = category.get_prev_sibling()
next_sibling = category.get_next_sibling()
  • 此外,还有方法可以检查类别是否为rootleaf或与另一个节点相关:
category.is_root()
category.is_leaf()
category.is_child_of(another_category)
category.is_descendant_of(another_category)
category.is_sibling_of(another_category)

还有更多...

这个食谱只是揭示了django-treebeard及其 Materialized Path 树的强大功能的一部分。还有许多其他可用于导航和树构建的方法。此外,Materialized Path 树的 API 与嵌套集树和邻接列表树的 API 基本相同,只需使用NS_NodeAL_Node抽象类之一来实现您的模型,而不是使用MP_Node

阅读django-treebeard API 文档,了解每个树实现的可用属性和方法的完整列表django-treebeard.readthedocs.io/en/latest/api.html

另请参阅

  • 第三章,表单和视图

  • 使用 django-mptt 创建分层类别的食谱

  • 使用 django-treebeard 创建类别管理界面的食谱

使用 django-treebeard 创建基本类别管理界面

django-treebeard应用程序提供了自己的TreeAdmin,扩展自标准的ModelAdmin。这允许您在管理界面中按层次查看树节点,并且界面功能取决于所使用的树算法。让我们在这个食谱中看看这个。

准备就绪

首先,按照本章前面的使用 django-treebeard 创建分层类别食谱中的说明设置categories应用程序和django-treebeard。此外,确保django-treebeard的静态文件对您的项目可用:

(env)$ python manage.py collectstatic

如何做...

categories应用程序中的Category模型创建管理界面,该界面扩展了treebeard.admin.TreeAdmin而不是admin.ModelAdmin,并使用自定义表单工厂,如下所示:

# myproject/apps/categories/admin.py
from django.contrib import admin
from treebeard.admin import TreeAdmin
from treebeard.forms import movenodeform_factory

from .models import Category

@admin.register(Category)
class CategoryAdmin(TreeAdmin):
    form = movenodeform_factory(Category)
    list_display = ["title", "created", "modified"]
    list_filter = ["created"]

工作原理...

类别的管理界面将具有两种模式,取决于所使用的树实现。对于 Materialized Path 和 Nested Sets 树,提供了高级 UI,如下所示:

此高级视图允许您展开和折叠类别,以便更好地进行概述。要重新排序或更改依赖关系,您可以拖放标题。在重新排序期间,用户界面看起来类似于以下截图:

如果您对类别按特定字段进行过滤或排序,则高级功能将被禁用,但高级界面的更具吸引力的外观和感觉仍然保留。我们可以在这里看到这种中间视图,只显示过去 7 天创建的类别:

但是,如果您的树使用邻接列表算法,则提供了基本 UI,呈现较少的美学呈现,并且没有在高级 UI 中提供的切换或重新排序功能。

有关django-treebeard管理的更多细节,包括基本界面的截图,可以在文档中找到:django-treebeard.readthedocs.io/en/latest/admin.html

另请参阅

  • 使用 django-mptt 创建分层类别配方

  • 使用 django-treebeard 创建分层类别配方

  • 使用 django-mptt-admin 创建类别管理界面配方

第九章:导入和导出数据

在这一章中,我们将涵盖以下主题:

  • 从本地 CSV 文件导入数据

  • 从本地 Excel 文件导入数据

  • 从外部 JSON 文件导入数据

  • 从外部 XML 文件导入数据

  • 为搜索引擎准备分页站点地图

  • 创建可过滤的 RSS 订阅

  • 使用 Django REST 框架创建 API

介绍

偶尔,您的数据需要从本地格式传输到数据库,从外部资源导入,或者提供给第三方。在这一章中,我们将看一些实际的例子,演示如何编写管理命令和 API 来实现这一点。

技术要求

要使用本章的代码,您需要最新稳定版本的 Python、MySQL 或 PostgreSQL 数据库,以及一个带有虚拟环境的 Django 项目。还要确保在虚拟环境中安装 Django、Pillow 和数据库绑定。

您可以在 GitHub 存储库的ch09目录中找到本章的所有代码:github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition

从本地 CSV 文件导入数据

逗号分隔值CSV)格式可能是在文本文件中存储表格数据的最简单方式。在这个示例中,我们将创建一个管理命令,将数据从 CSV 文件导入到 Django 数据库中。我们需要一个歌曲的 CSV 列表。您可以使用 Excel、Calc 或其他电子表格应用程序轻松创建这样的文件。

准备工作

让我们创建一个music应用程序,我们将在本章中使用它:

  1. 创建music应用程序本身,并将其放在设置中的INSTALLED_APPS下:
# myproject/settings/_base.py
INSTALLED_APPS = [
    # …
    "myproject.apps.core",
    "myproject.apps.music",
]
  1. Song模型应该包含uuidartisttitleurlimage字段。我们还将扩展CreationModificationDateBase以添加创建和修改时间戳,以及UrlBase以添加用于处理模型详细 URL 的方法:
# myproject/apps/music/models.py
import os
import uuid
from django.urls import reverse
from django.utils.translation import ugettext_lazy as _
from django.db import models
from django.utils.text import slugify
from myproject.apps.core.models import CreationModificationDateBase, UrlBase

def upload_to(instance, filename):
    filename_base, filename_ext = os.path.splitext(filename)
    artist = slugify(instance.artist)
    title = slugify(instance.title)
    return f"music/{artist}--{title}{filename_ext.lower()}"

class Song(CreationModificationDateBase, UrlBase):
    uuid = models.UUIDField(primary_key=True, default=None, 
     editable=False)
    artist = models.CharField(_("Artist"), max_length=250)
    title = models.CharField(_("Title"), max_length=250)
    url = models.URLField(_("URL"), blank=True)
    image = models.ImageField(_("Image"), upload_to=upload_to, 
     blank=True, null=True)

    class Meta:
        verbose_name = _("Song")
        verbose_name_plural = _("Songs")
        unique_together = ["artist", "title"]

    def __str__(self):
        return f"{self.artist} - {self.title}"

    def get_url_path(self):
        return reverse("music:song_detail", kwargs={"pk": self.pk})

    def save(self, *args, **kwargs):
        if self.pk is None:
            self.pk = uuid.uuid4()
        super().save(*args, **kwargs)
  1. 使用以下命令创建和运行迁移:
(env)$ python manage.py makemigrations
(env)$ python manage.py migrate
  1. 然后,让我们为Song模型添加一个简单的管理:
# myproject/apps/music/admin.py
from django.contrib import admin
from .models import Song

@admin.register(Song)
class SongAdmin(admin.ModelAdmin):
    list_display = ["title", "artist", "url"]
    list_filter = ["artist"]
    search_fields = ["title", "artist"]
  1. 此外,我们需要一个用于验证和创建导入脚本中的Song模型的表单。它是最简单的模型表单,如下所示:
# myproject/apps/music/forms.py
from django import forms
from django.utils.translation import ugettext_lazy as _
from .models import Song

class SongForm(forms.ModelForm):
    class Meta:
        model = Song
        fields = "__all__" 

如何做...

按照以下步骤创建和使用一个管理命令,从本地 CSV 文件导入歌曲:

  1. 创建一个 CSV 文件,第一行包含列名artisttitleurl。在接下来的行中添加一些歌曲数据,与列匹配。例如,可以是一个内容如下的data/music.csv文件:
artist,title,url
Capital Cities,Safe And Sound,https://open.spotify.com/track/40Fs0YrUGuwLNQSaHGVfqT?si=2OUawusIT-evyZKonT5GgQ
Milky Chance,Stolen Dance,https://open.spotify.com/track/3miMZ2IlJiaeSWo1DohXlN?si=g-xMM4m9S_yScOm02C2MLQ
Lana Del Rey,Video Games - Remastered,https://open.spotify.com/track/5UOo694cVvjcPFqLFiNWGU?si=maZ7JCJ7Rb6WzESLXg1Gdw
Men I Trust,Tailwhip,https://open.spotify.com/track/2DoO0sn4SbUrz7Uay9ACTM?si=SC_MixNKSnuxNvQMf3yBBg
  1. music应用程序中,创建一个management目录,然后在新的management目录中创建一个commands目录。在这两个新目录中都放入空的__init__.py文件,使它们成为 Python 包。

  2. 在那里添加一个名为import_music_from_csv.py的文件,内容如下:

# myproject/apps/music/management/commands/import_music_from_csv.py
from django.core.management.base import BaseCommand

class Command(BaseCommand):
    help = (
        "Imports music from a local CSV file. "
        "Expects columns: artist, title, url"
    )
    SILENT, NORMAL, VERBOSE, VERY_VERBOSE = 0, 1, 2, 3

    def add_arguments(self, parser):
        # Positional arguments
        parser.add_argument("file_path", nargs=1, type=str)

    def handle(self, *args, **options):
        self.verbosity = options.get("verbosity", self.NORMAL)
        self.file_path = options["file_path"][0]
        self.prepare()
        self.main()
        self.finalize()
  1. 然后,在Command类的同一文件中,创建一个prepare()方法:
    def prepare(self):
        self.imported_counter = 0
        self.skipped_counter = 0
  1. 然后,我们应该创建main()方法:
    def main(self):
        import csv
        from ...forms import SongForm

        if self.verbosity >= self.NORMAL:
            self.stdout.write("=== Importing music ===")

        with open(self.file_path, mode="r") as f:
            reader = csv.DictReader(f)
            for index, row_dict in enumerate(reader):
                form = SongForm(data=row_dict)
                if form.is_valid():
                    song = form.save()
                    if self.verbosity >= self.NORMAL:
                        self.stdout.write(f" - {song}\n")
                    self.imported_counter += 1
                else:
                    if self.verbosity >= self.NORMAL:
                        self.stderr.write(
                            f"Errors importing song "
                            f"{row_dict['artist']} - 
                             {row_dict['title']}:\n"
                        )
                        self.stderr.write(f"{form.errors.as_json()}\n")
                    self.skipped_counter += 1
  1. 我们将使用finalize()方法完成这个类:
    def finalize(self)
        if self.verbosity >= self.NORMAL:
            self.stdout.write(f"-------------------------\n")
            self.stdout.write(f"Songs imported:         
             {self.imported_counter}\n")
            self.stdout.write(f"Songs skipped: 
             {self.skipped_counter}\n\n")
  1. 要运行导入,请在命令行中调用以下命令:
(env)$ python manage.py import_music_from_csv data/music.csv

它是如何工作的...

Django 管理命令是从BaseCommand派生的Command类的脚本,并覆盖add_arguments()handle()方法。help属性定义了管理命令的帮助文本。当您在命令行中输入以下内容时,可以看到它:

(env)$ python manage.py help import_music_from_csv

Django 管理命令使用内置的argparse模块来解析传递的参数。add_arguments()方法定义了应该传递给管理命令的位置或命名参数。在我们的情况下,我们将添加一个 Unicode 类型的位置参数file_path。通过将nargs变量设置为1属性,我们只允许一个值。

要了解您可以定义的其他参数以及如何做到这一点,请参阅官方的argparse文档docs.python.org/3/library/argparse.html#adding-arguments

handle()方法的开始,检查verbosity参数。Verbosity 定义了命令应该提供多少终端输出,从 0,不提供任何日志,到 3,提供详尽的日志。您可以将这个命名参数传递给命令,如下所示:

(env)$ python manage.py import_music_from_csv data/music.csv --verbosity=0

我们还期望文件名作为第一个位置参数。options["file_path"]返回一个值的列表,其长度由nargs定义。在我们的情况下,nargs等于一;因此,options["file_path"]将等于一个元素的列表。

将您的管理命令的逻辑分割成多个较小的方法是一个很好的做法,例如,就像我们在这个脚本中使用的prepare()main()finalize()一样:

  • prepare()方法将导入计数器设置为零。它也可以用于脚本所需的任何其他设置。

  • main()方法中,我们执行管理命令的主要逻辑。首先,我们打开给定的文件进行读取,并将其指针传递给csv.DictReader。文件中的第一行被假定为每列的标题。DictReader将它们用作每行的字典的键。当我们遍历行时,我们将字典传递给模型表单,并尝试验证它。如果验证通过,歌曲将被保存,并且imported_counter将被递增。如果验证失败,因为值过长,缺少必需值,错误类型或其他验证错误,skipped_counter将被递增。如果 verbosity 等于或大于NORMAL(即数字 1),每个导入或跳过的歌曲也将与可能的验证错误一起打印出来。

  • finalize()方法打印出导入了多少首歌曲,以及因验证错误而被跳过了多少首。

如果您想在开发时调试管理命令的错误,请将--traceback参数传递给它。当发生错误时,您将看到问题的完整堆栈跟踪。

假设我们使用--verbosity=1或更高的参数两次调用命令,我们可以期望的输出可能如下:

正如您所看到的,当一首歌被导入第二次时,它不会通过unique_together约束,因此会被跳过。

另请参阅

  • 从本地 Excel 文件导入数据食谱

  • 从外部 JSON 文件导入数据食谱

  • 从外部 XML 文件导入数据食谱

从本地 Excel 文件导入数据

存储表格数据的另一种流行格式是 Excel 电子表格。在这个食谱中,我们将从这种格式的文件中导入歌曲。

准备工作

让我们从之前的食谱中创建的music应用程序开始。要读取 Excel 文件,您需要安装openpyxl包,如下所示:

(env)$ pip install openpyxl==3.0.2

如何做...

按照以下步骤创建并使用一个管理命令,从本地 XLSX 文件导入歌曲:

  1. 创建一个 XLSX 文件,其中包含列名 Artist、Title 和 URL 在第一行。在接下来的行中添加一些与列匹配的歌曲数据。您可以在电子表格应用程序中执行此操作,将前一个食谱中的 CSV 文件保存为 XLSX 文件,data/music.xlsx。以下是一个示例:

  1. 如果还没有这样做,在music应用程序中,创建一个management目录,然后在其下创建一个commands子目录。在这两个新目录中添加空的__init__.py文件,使它们成为 Python 包。

  2. 添加一个名为import_music_from_xlsx.py的文件,内容如下:

# myproject/apps/music/management/commands
# /import_music_from_xlsx.py
from django.core.management.base import BaseCommand

class Command(BaseCommand):
    help = (
        "Imports music from a local XLSX file. "
        "Expects columns: Artist, Title, URL"
    )
    SILENT, NORMAL, VERBOSE, VERY_VERBOSE = 0, 1, 2, 3

    def add_arguments(self, parser):
        # Positional arguments
        parser.add_argument("file_path",
                            nargs=1,
                            type=str)

    def handle(self, *args, **options):
        self.verbosity = options.get("verbosity", self.NORMAL)
        self.file_path = options["file_path"][0]
        self.prepare()
        self.main()
        self.finalize()
  1. 然后,在相同的文件中为Command类创建一个prepare()方法:
    def prepare(self):
        self.imported_counter = 0
        self.skipped_counter = 0

  1. 然后,在那里创建main()方法:
    def main(self):
        from openpyxl import load_workbook
        from ...forms import SongForm

        wb = load_workbook(filename=self.file_path)
        ws = wb.worksheets[0]

        if self.verbosity >= self.NORMAL:
            self.stdout.write("=== Importing music ===")

        columns = ["artist", "title", "url"]
        rows = ws.iter_rows(min_row=2)  # skip the column captions
        for index, row in enumerate(rows, start=1):
            row_values = [cell.value for cell in row]
            row_dict = dict(zip(columns, row_values))
            form = SongForm(data=row_dict)
            if form.is_valid():
                song = form.save()
                if self.verbosity >= self.NORMAL:
                    self.stdout.write(f" - {song}\n")
                self.imported_counter += 1
            else:
                if self.verbosity >= self.NORMAL:
                    self.stderr.write(
                        f"Errors importing song "
                        f"{row_dict['artist']} - 
                         {row_dict['title']}:\n"
                    )
                    self.stderr.write(f"{form.errors.as_json()}\n")
                self.skipped_counter += 1
  1. 最后,我们将使用finalize()方法完成类:
    def finalize(self):
        if self.verbosity >= self.NORMAL:
            self.stdout.write(f"-------------------------\n")
            self.stdout.write(f"Songs imported: 
             {self.imported_counter}\n")
            self.stdout.write(f"Songs skipped: 
             {self.skipped_counter}\n\n")
  1. 要运行导入,请在命令行中调用以下命令:
(env)$ python manage.py import_music_from_xlsx data/music.xlsx

它是如何工作的...

从 XLSX 文件导入的原则与 CSV 相同。我们打开文件,逐行读取,形成数据字典,通过模型表单验证它们,并从提供的数据创建Song对象。

同样,我们使用prepare()main()finalize()方法将逻辑分割成更多的原子部分。

以下是main()方法的详细说明,因为它可能是管理命令的唯一不同部分:

  • Excel 文件是包含不同选项卡的工作簿。

  • 我们使用openpyxl库打开作为命令的位置参数传递的文件。然后,我们从工作簿中读取第一个工作表。

  • 第一行包含列标题。我们跳过它。

  • 之后,我们将逐行读取行作为值列表,使用zip()函数创建字典,将它们传递给模型表单,验证,并从中创建Song对象。

  • 如果存在任何验证错误并且 verbosity 大于或等于NORMAL,那么我们将输出验证错误。

  • 再次,管理命令将把导入的歌曲打印到控制台上,除非您设置--verbosity=0

如果我们使用--verbosity=1或更高的参数运行命令两次,输出将如下所示:

您可以在www.python-excel.org/了解有关如何处理 Excel 文件的更多信息。

另请参阅

  • 从本地 CSV 文件导入数据的方法

  • 从外部 JSON 文件导入数据的方法

  • 从外部 XML 文件导入数据的方法

从外部 JSON 文件导入数据

Last.fm音乐网站在ws.audioscrobbler.com/域下有一个 API,您可以使用它来读取专辑、艺术家、曲目、事件等等。该 API 允许您使用 JSON 或 XML 格式。在这个方法中,我们将使用 JSON 格式导入标记为indie的热门曲目。

准备就绪

按照以下步骤从Last.fm导入 JSON 格式的数据:

  1. 让我们从我们在从本地 CSV 文件导入数据方法中创建的music应用程序开始。

  2. 要使用Last.fm,您需要注册并获取 API 密钥。API 密钥可以是

www.last.fm/api/account/create创建。

  1. API 密钥必须在设置中设置为LAST_FM_API_KEY。我们建议

从秘密文件提供它或从环境变量中提取它并将其绘制到您的设置中,如下所示:

# myproject/settings/_base.py
LAST_FM_API_KEY = get_secret("LAST_FM_API_KEY")
  1. 还要使用以下命令在虚拟环境中安装requests库:
(env)$ pip install requests==2.22.0
  1. 让我们来看看用于热门 indie 曲目的 JSON 端点的结构(https://ws.audioscrobbler.com/2.0/?method=tag.gettoptracks&tag=indie&api_key=YOUR_API_KEY&format=json),它应该看起来像这样:
{
  "tracks": {
    "track": [
      {
        "name": "Mr. Brightside",
        "duration": "224",
        "mbid": "37d516ab-d61f-4bcb-9316-7a0b3eb845a8",
        "url": "https://www.last.fm/music
         /The+Killers/_/Mr.+Brightside",
        "streamable": {
          "#text": "0",
          "fulltrack": "0"
        },
        "artist": {
          "name": "The Killers",
          "mbid": "95e1ead9-4d31-4808-a7ac-32c3614c116b",
          "url": "https://www.last.fm/music/The+Killers"
        },
        "image": [
          {
            "#text": 
            "https://lastfm.freetls.fastly.net/i/u/34s
             /2a96cbd8b46e442fc41c2b86b821562f.png",
            "size": "small"
          },
          {
            "#text":  
           "https://lastfm.freetls.fastly.net/i/u/64s
            /2a96cbd8b46e442fc41c2b86b821562f.png",
            "size": "medium"
          },
          {
            "#text": 
            "https://lastfm.freetls.fastly.net/i/u/174s
             /2a96cbd8b46e442fc41c2b86b821562f.png",
            "size": "large"
          },
          {
            "#text": 
            "https://lastfm.freetls.fastly.net/i/u/300x300
             /2a96cbd8b46e442fc41c2b86b821562f.png",
            "size": "extralarge"
          }
        ],
        "@attr": {
          "rank": "1"
        }
      },
      ...
    ],
    "@attr": {
      "tag": "indie",
      "page": "1",
      "perPage": "50",
      "totalPages": "4475",
      "total": "223728"
    }
  }
}

我们想要读取曲目的名称艺术家URL和中等大小的图像。此外,我们对总共有多少页感兴趣,这是在 JSON 文件的末尾作为元信息提供的。

如何做...

按照以下步骤创建一个Song模型和一个管理命令,该命令以 JSON 格式将Last.fm的热门曲目导入到数据库中:

  1. 如果尚未这样做,在music应用程序中,创建一个management目录,然后在其中创建一个commands子目录。在这两个新目录中添加空的__init__.py文件,使它们成为 Python 包。

  2. 添加一个import_music_from_lastfm_json.py文件,内容如下:

# myproject/apps/music/management/commands
# /import_music_from_lastfm_json.py
from django.core.management.base import BaseCommand

class Command(BaseCommand):
    help = "Imports top songs from last.fm as JSON."
    SILENT, NORMAL, VERBOSE, VERY_VERBOSE = 0, 1, 2, 3
    API_URL = "https://ws.audioscrobbler.com/2.0/"

    def add_arguments(self, parser):
        # Named (optional) arguments
        parser.add_argument("--max_pages", type=int, default=0)

    def handle(self, *args, **options):
        self.verbosity = options.get("verbosity", self.NORMAL)
        self.max_pages = options["max_pages"]
        self.prepare()
        self.main()
        self.finalize()
  1. 然后,在Command类的同一文件中,创建一个prepare()方法:
    def prepare(self):
        from django.conf import settings

        self.imported_counter = 0
        self.skipped_counter = 0
        self.params = {
            "method": "tag.gettoptracks",
            "tag": "indie",
            "api_key": settings.LAST_FM_API_KEY,
            "format": "json",
            "page": 1,
        }
  1. 然后,在那里创建main()方法:
    def main(self):
        import requests

        response = requests.get(self.API_URL, params=self.params)
        if response.status_code != requests.codes.ok:
            self.stderr.write(f"Error connecting to 
             {response.url}")
            return
        response_dict = response.json()
        pages = int(
            response_dict.get("tracks", {})
            .get("@attr", {}).get("totalPages", 1)
        )

        if self.max_pages > 0:
            pages = min(pages, self.max_pages)

        if self.verbosity >= self.NORMAL:
            self.stdout.write(f"=== Importing {pages} page(s) 
             of tracks ===")

        self.save_page(response_dict)

        for page_number in range(2, pages + 1):
            self.params["page"] = page_number
            response = requests.get(self.API_URL, 
            params=self.params)
            if response.status_code != requests.codes.ok:
                self.stderr.write(f"Error connecting to 
                 {response.url}")
                return
            response_dict = response.json()
            self.save_page(response_dict)
  1. 分页源的每一页将由我们应该创建的save_page()方法保存,如下所示:
    def save_page(self, data):
        import os
        import requests
        from io import BytesIO
        from django.core.files import File
        from ...forms import SongForm

        for track_dict in data.get("tracks", {}).get("track"):
            if not track_dict:
                continue

            song_dict = {
                "artist": track_dict.get("artist", {}).get("name", ""),
                "title": track_dict.get("name", ""),
                "url": track_dict.get("url", ""),
            }
            form = SongForm(data=song_dict)
            if form.is_valid():
                song = form.save()

                image_dict = track_dict.get("image", None)
                if image_dict:
                    image_url = image_dict[1]["#text"]
                    image_response = requests.get(image_url)
                    song.image.save(
 os.path.basename(image_url),
 File(BytesIO(image_response.content)),
 )

                if self.verbosity >= self.NORMAL:
                    self.stdout.write(f" - {song}\n")
                self.imported_counter += 1
            else:
                if self.verbosity >= self.NORMAL:
                    self.stderr.write(
                        f"Errors importing song "
                        f"{song_dict['artist']} - 
                         {song_dict['title']}:\n"
                    )
                    self.stderr.write(f"{form.errors.as_json()}\n")
                self.skipped_counter += 1
  1. 我们将使用finalize()方法完成类:
    def finalize(self):
        if self.verbosity >= self.NORMAL:
            self.stdout.write(f"-------------------------\n")
            self.stdout.write(f"Songs imported: 
             {self.imported_counter}\n")
            self.stdout.write(f"Songs skipped: 
             {self.skipped_counter}\n\n")
  1. 要运行导入,请在命令行中调用以下命令:
(env)$ python manage.py import_music_from_lastfm_json --max_pages=3

它是如何工作的...

如前所述,脚本的参数可以是位置的,如果它们只列出一系列字符串,或者命名的,如果它们以--和变量名开头。命名的--max_pages参数将导入的数据限制为三页。如果要下载所有可用的热门曲目,请跳过它,或者明确传递 0(零)。

请注意,totalPages值中详细说明了大约有 4,500 页,这将需要很长时间和大量处理。

我们的脚本结构与以前的导入脚本类似:

  • prepare()方法用于设置

  • main()方法处理请求并处理响应

  • save_page()方法保存单个分页页面的歌曲

  • finalize()方法打印出导入统计信息

main()方法中,我们使用requests.get()来读取来自Last.fm的数据,传递params查询参数。响应对象具有名为json()的内置方法,它将 JSON 字符串转换为解析后的字典对象。从第一个请求中,我们了解到总页数,然后读取每一页并调用save_page()方法来解析信息并保存歌曲。

save_page()方法中,我们从曲目中读取值并构建模型表单所需的字典。我们验证表单。如果数据有效,则创建Song对象。

导入的一个有趣部分是下载和保存图像。在这里,我们还使用requests.get()来检索图像数据,然后我们通过BytesIO将其传递给File,这将相应地在image.save()方法中使用。 image.save()的第一个参数是一个文件名,无论如何都将被upload_to函数的值覆盖,并且仅对于文件扩展名是必需的。

如果使用--verbosity=1或更高的命令调用,我们将看到有关导入的详细信息,就像在以前的食谱中一样。

您可以在www.last.fm/api/了解有关如何使用Last.fm的更多信息。

另请参阅

  • 从本地 CSV 文件导入数据食谱

  • 从本地 Excel 文件导入数据食谱

  • 从外部 XML 文件导入数据食谱

从外部 XML 文件导入数据

正如我们在前面的食谱中展示的可以使用 JSON 做的事情一样,Last.fm文件还允许您以 XML 格式从其服务中获取数据。在这个食谱中,我们将向您展示如何做到这一点。

准备工作

按照以下步骤从Last.fm导入 XML 格式的数据:

  1. 让我们从我们在从本地 CSV 文件导入数据食谱中创建的music应用程序开始。

  2. 要使用Last.fm,您需要注册并获取 API 密钥。 API 密钥可以是

www.last.fm/api/account/create创建

  1. API 密钥必须在设置中设置为LAST_FM_API_KEY。我们建议

提供它来自秘密文件或环境变量,并将其绘制到您的设置中,如下所示:

# myproject/settings/_base.py
LAST_FM_API_KEY = get_secret("LAST_FM_API_KEY")
  1. 还要使用以下命令在虚拟环境中安装requestsdefusedxml库:
(env)$ pip install requests==2.22.0
(env)$ pip install defusedxml==0.6.0

  1. 让我们检查顶级独立曲目的 JSON 端点的结构(https://ws.audioscrobbler.com/2.0/?method=tag.gettoptracks&tag=indie&api_key=YOUR_API_KEY&format=xml),应该看起来像这样:
<?xml version="1.0" encoding="UTF-8" ?>
<lfm status="ok">
    <tracks tag="indie" page="1" perPage="50" 
 totalPages="4475" total="223728">
        <track rank="1">
            <name>Mr. Brightside</name>
            <duration>224</duration>
            <mbid>37d516ab-d61f-4bcb-9316-7a0b3eb845a8</mbid>
            <url>https://www.last.fm/music
            /The+Killers/_/Mr.+Brightside</url>
            <streamable fulltrack="0">0</streamable>
            <artist>
                <name>The Killers</name>
                <mbid>95e1ead9-4d31-4808-a7ac-32c3614c116b</mbid>
                <url>https://www.last.fm/music/The+Killers</url>
            </artist>
            <image size="small">https://lastfm.freetls.fastly.net/i
             /u/34s/2a96cbd8b46e442fc41c2b86b821562f.png</image>
            <image size="medium">
            https://lastfm.freetls.fastly.net/i
            /u/64s/2a96cbd8b46e442fc41c2b86b821562f.png</image>
            <image size="large">https://lastfm.freetls.fastly.net/i
            /u/174s/2a96cbd8b46e442fc41c2b86b821562f.png</image>
            <image size="extralarge">
                https://lastfm.freetls.fastly.net/i/u/300x300
                /2a96cbd8b46e442fc41c2b86b821562f.png
            </image>
        </track>
        ...
    </tracks>
</lfm>

如何做...

按照以下步骤创建Song模型和一个管理命令,该命令以 XML 格式将顶级曲目从Last.fm导入到数据库中:

  1. 如果尚未这样做,请在music应用程序中创建一个management目录,然后在其中创建一个commands子目录。在两个新目录中都添加空的__init__.py文件,使它们成为 Python 包。

  2. 添加一个名为import_music_from_lastfm_xml.py的文件,其中包含以下内容:

# myproject/apps/music/management/commands
# /import_music_from_lastfm_xml.py
from django.core.management.base import BaseCommand

class Command(BaseCommand):
    help = "Imports top songs from last.fm as XML."
    SILENT, NORMAL, VERBOSE, VERY_VERBOSE = 0, 1, 2, 3
    API_URL = "https://ws.audioscrobbler.com/2.0/"

    def add_arguments(self, parser):
        # Named (optional) arguments
        parser.add_argument("--max_pages", type=int, default=0)

    def handle(self, *args, **options):
        self.verbosity = options.get("verbosity", self.NORMAL)
        self.max_pages = options["max_pages"]
        self.prepare()
        self.main()
        self.finalize()
  1. 然后,在Command类的同一文件中,创建一个prepare()方法:
    def prepare(self):
        from django.conf import settings

        self.imported_counter = 0
        self.skipped_counter = 0
        self.params = {
            "method": "tag.gettoptracks",
            "tag": "indie",
            "api_key": settings.LAST_FM_API_KEY,
            "format": "xml",
            "page": 1,
        }
  1. 然后,在那里创建main()方法:
    def main(self):
        import requests
        from defusedxml import ElementTree

        response = requests.get(self.API_URL, params=self.params)
        if response.status_code != requests.codes.ok:
            self.stderr.write(f"Error connecting to {response.url}")
            return
        root = ElementTree.fromstring(response.content)

        pages = int(root.find("tracks").attrib.get("totalPages", 1))
        if self.max_pages > 0:
            pages = min(pages, self.max_pages)

        if self.verbosity >= self.NORMAL:
            self.stdout.write(f"=== Importing {pages} page(s) 
             of songs ===")

        self.save_page(root)

        for page_number in range(2, pages + 1):
            self.params["page"] = page_number
            response = requests.get(self.API_URL, params=self.params)
            if response.status_code != requests.codes.ok:
                self.stderr.write(f"Error connecting to {response.url}")
                return
            root = ElementTree.fromstring(response.content)
            self.save_page(root)
  1. 分页源的每个页面将由我们应该创建的save_page()方法保存,如下所示:
    def save_page(self, root):
        import os
        import requests
        from io import BytesIO
        from django.core.files import File
        from ...forms import SongForm

        for track_node in root.findall("tracks/track"):
            if not track_node:
                continue

            song_dict = {
                "artist": track_node.find("artist/name").text,
                "title": track_node.find("name").text,
                "url": track_node.find("url").text,
            }
            form = SongForm(data=song_dict)
            if form.is_valid():
                song = form.save()

                image_node = track_node.find("image[@size='medium']")
                if image_node is not None:
                    image_url = image_node.text
                    image_response = requests.get(image_url)
                    song.image.save(
 os.path.basename(image_url),
 File(BytesIO(image_response.content)),
 )

                if self.verbosity >= self.NORMAL:
                    self.stdout.write(f" - {song}\n")
                self.imported_counter += 1
            else:
                if self.verbosity >= self.NORMAL:
                    self.stderr.write(
                        f"Errors importing song "
                        f"{song_dict['artist']} - {song_dict['title']}:\n"
                    )
                    self.stderr.write(f"{form.errors.as_json()}\n")
                self.skipped_counter += 1
  1. 我们将使用finalize()方法完成课程:
    def finalize(self):
        if self.verbosity >= self.NORMAL:
            self.stdout.write(f"-------------------------\n")
            self.stdout.write(f"Songs imported: {self.imported_counter}\n")
            self.stdout.write(f"Songs skipped: {self.skipped_counter}\n\n")
  1. 要运行导入,请在命令行中调用以下内容:
(env)$ python manage.py import_music_from_lastfm_xml --max_pages=3

它是如何工作的...

该过程类似于 JSON 方法。使用requests.get()方法,我们从Last.fm读取数据,将查询参数作为params传递。响应的 XML 内容传递给defusedxml模块的ElementTree解析器,并返回root节点。

defusedxml模块是xml模块的更安全的替代品。它可以防止 XML 炸弹——一种允许攻击者使用几百字节的 XML 数据占用几 GB 内存的漏洞。

ElementTree节点具有find()findall()方法,您可以通过这些方法传递XPath查询来过滤特定的子节点。

以下是ElementTree支持的可用 XPath 语法表:

XPath 语法组件 含义
tag 这会选择具有给定标签的所有子元素。
* 这会选择所有子元素。
. 这会选择当前节点。
// 这会选择当前元素下所有级别的所有子元素。
.. 这会选择父元素。
[@attrib] 这会选择具有给定属性的所有元素。
[@attrib='value'] 这会选择具有给定值的给定属性的所有元素。
[tag] 这会选择具有名为 tag 的子元素的所有元素。仅支持直接子元素。
[position] 这会选择位于给定位置的所有元素。位置可以是整数(1是第一个位置),last()表达式(用于最后位置),或相对于最后位置的位置(例如,last()-1)。

因此,在main()方法中,使用root.find("tracks").attrib.get("totalPages", 1),我们读取页面的总数,如果数据不完整,则默认为 1。我们将保存第一页,然后逐个保存其他页面。

save_page()方法中,root.findall("tracks/track")返回一个迭代器,通过<tracks>节点下的<track>节点。使用track_node.find("image[@size='medium']"),我们获得中等大小的图像。同样,Song的创建是通过用于验证传入数据的模型表单完成的。

如果我们使用--verbosity=1或更高的命令调用,我们将看到有关导入歌曲的详细信息,就像在以前的食谱中一样。

还有更多...

您可以从以下链接了解更多信息:

另请参阅

  • 从本地 CSV 文件导入数据食谱

  • 从本地 Excel 文件导入数据食谱

  • 从外部 JSON 文件导入数据食谱

为搜索引擎准备分页站点地图

站点地图协议告诉搜索引擎有关网站上所有不同页面的信息。通常,它是一个单一的sitemap.xml文件,通知可以被索引以及频率。如果您的网站上有很多不同的页面,您还可以拆分和分页 XML 文件,以更快地呈现每个资源列表。

在这个食谱中,我们将向您展示如何创建一个分页站点地图,以在您的 Django 网站中使用。

准备工作

对于这个和其他食谱,我们需要扩展music应用程序并在那里添加列表和详细视图:

  1. 创建具有以下内容的views.py文件:
# myproject/apps/music/views.py
from django.views.generic import ListView, DetailView
from django.utils.translation import ugettext_lazy as _
from .models import Song

class SongList(ListView):
    model = Song

class SongDetail(DetailView):
    model = Song
  1. 创建具有以下内容的urls.py文件:
# myproject/apps/music/urls.py
from django.urls import path
from .views import SongList, SongDetail

app_name = "music"

urlpatterns = [
    path("", SongList.as_view(), name="song_list"),
    path("<uuid:pk>/", SongDetail.as_view(), name="song_detail"),
]
  1. 将该 URL 配置包含到项目的 URL 配置中:
# myproject/urls.py
from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path

urlpatterns = i18n_patterns(
    # …
 path("songs/", include("myproject.apps.music.urls", 
     namespace="music")),
)
  1. 为歌曲列表视图创建一个模板:
{# music/song_list.html #}
{% extends "base.html" %}
{% load i18n %}

{% block main %}
    <ul>
        {% for song in object_list %}
            <li><a href="{{ song.get_url_path }}">
             {{ song }}</a></li>
        {% endfor %}
    </ul>
{% endblock %}
  1. 然后,为歌曲详细视图创建一个:
{# music/song_detail.html #}
{% extends "base.html" %}
{% load i18n %}

{% block content %}
    {% with song=object %}
        <h1>{{ song }}</h1>
        {% if song.image %}
            <img src="img/{{ song.image.url }}" alt="{{ song }}" />
        {% endif %}
        {% if song.url %}
            <a href="{{ song.url }}" target="_blank" 
             rel="noreferrer noopener">
                {% trans "Check this song" %}
            </a>
        {% endif %}
    {% endwith %}
{% endblock %}

如何做...

要添加分页网站地图,请按照以下步骤操作:

  1. 在设置中的INSTALLED_APPS中包含django.contrib.sitemaps
# myproject/settings/_base.py
INSTALLED_APPS = [
    # …
    "django.contrib.sitemaps",
    # …
]
  1. 根据以下方式修改项目的urls.py
# myproject/urls.py
from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path
from django.contrib.sitemaps import views as sitemaps_views
from django.contrib.sitemaps import GenericSitemap
from myproject.apps.music.models import Song

class MySitemap(GenericSitemap):
 limit = 50

 def location(self, obj):
 return obj.get_url_path()

song_info_dict = {
 "queryset": Song.objects.all(), 
 "date_field": "modified",
}
sitemaps = {"music": MySitemap(song_info_dict, priority=1.0)}

urlpatterns = [
 path("sitemap.xml", sitemaps_views.index, 
     {"sitemaps": sitemaps}),
 path("sitemap-<str:section>.xml", sitemaps_views.sitemap, 
     {"sitemaps": sitemaps},
 name="django.contrib.sitemaps.views.sitemap"
    ),
]

urlpatterns += i18n_patterns(
    # …
    path("songs/", include("myproject.apps.music.urls", 
     namespace="music")),
)

它是如何工作的...

如果您查看http://127.0.0.1:8000/sitemap.xml,您将看到带有分页网站地图的索引:

<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    <sitemap>
        <loc>http://127.0.0.1:8000/sitemap-music.xml</loc>
    </sitemap>
    <sitemap>
        <loc>http://127.0.0.1:8000/sitemap-music.xml?p=2</loc>
    </sitemap>
    <sitemap>
        <loc>http://127.0.0.1:8000/sitemap-music.xml?p=3</loc>
    </sitemap>
</sitemapindex>

每个页面将显示最多 50 个条目,带有 URL、最后修改时间和优先级:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
    <url>
        <loc>http://127.0.0.1:8000/en/songs/b2d3627b-dbc7
         -4c11-a13e-03d86f32a719/</loc>
        <lastmod>2019-12-15</lastmod>
        <priority>1.0</priority>
    </url>
    <url>
        <loc>http://127.0.0.1:8000/en/songs/f5c386fd-1952
         -4ace-9848-717d27186fa9/</loc>
        <lastmod>2019-12-15</lastmod>
        <priority>1.0</priority>
    </url>
    <url>
        <loc>http://127.0.0.1:8000/en/songs/a59cbb5a-16e8
         -46dd-9498-d86e24e277a5/</loc>
        <lastmod>2019-12-15</lastmod>
        <priority>1.0</priority>
    </url>
    ...
</urlset>

当您的网站准备就绪并发布到生产环境时,您可以使用网站地图框架提供的ping_google管理命令通知Google 搜索引擎有关您的页面。在生产服务器上执行以下命令:

(env)$ python manage.py ping_google --settings=myproject.settings.production

还有更多...

您可以从以下链接了解更多信息:

  • 这里阅读有关网站地图协议的信息。

  • 这里阅读有关 Django 网站地图框架的更多信息

docs.djangoproject.com/en/3.0/ref/contrib/sitemaps/

另请参阅

  • 创建可过滤的 RSS 订阅示例

创建可过滤的 RSS 订阅

Django 带有一个聚合源框架,允许您创建真正简单的聚合RSS)和Atom源。RSS 和 Atom 源是具有特定语义的 XML 文档。它们可以订阅到 RSS 阅读器,如 Feedly,或者它们可以在其他网站、移动应用程序或桌面应用程序中进行聚合。在这个示例中,我们将创建一个提供有关歌曲信息的 RSS 源。此外,结果将可以通过 URL 查询参数进行过滤。

准备工作

首先,根据从本地 CSV 文件导入数据为搜索引擎准备分页网站地图的步骤创建music应用程序。具体来说,请按照准备工作部分中的步骤设置模型、表单、视图、URL 配置和模板。

对于列出歌曲的视图,我们将添加按艺术家过滤的功能,稍后 RSS 订阅也将使用该功能:

  1. forms.py中添加一个过滤表单。它将具有artist选择字段,其中所有艺术家名称都按字母顺序排序,忽略大小写:
# myproject/apps/music/forms.py
from django import forms
from django.utils.translation import ugettext_lazy as _
from .models import Song

# …

class SongFilterForm(forms.Form):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        artist_choices = [
            (artist, artist)
            for artist in sorted(
                Song.objects.values_list("artist", 
                 flat=True).distinct(),
                key=str.casefold
            )
        ]
        self.fields["artist"] = forms.ChoiceField(
 label=_("Artist"),
 choices=artist_choices,
 required=False,
 )
  1. 使用方法增强SongList视图来管理过滤:get()方法将处理过滤并显示结果,get_form_kwargs()方法将为过滤表单准备关键字参数,get_queryset()方法将按艺术家过滤歌曲:
# myproject/apps/music/views.py
from django.http import Http404
from django.views.generic import ListView, DetailView, FormView
from django.utils.translation import ugettext_lazy as _
from .models import Song
from .forms import SongFilterForm

class SongList(ListView, FormView):
    form_class = SongFilterForm
    model = Song

    def get(self, request, *args, **kwargs):
        form_class = self.get_form_class()
        self.form = self.get_form(form_class)

        self.object_list = self.get_queryset()
        allow_empty = self.get_allow_empty()
        if not allow_empty and len(self.object_list) == 0:
            raise Http404(_(u"Empty list and '%(class_name)s
             .allow_empty' is False.")
                          % {'class_name': 
                           self.__class__.__name__})

        context = self.get_context_data(object_list=
         self.object_list, form=self.form)
        return self.render_to_response(context)

    def get_form_kwargs(self):
        kwargs = {
            'initial': self.get_initial(),
            'prefix': self.get_prefix(),
        }
        if self.request.method == 'GET':
            kwargs.update({
                'data': self.request.GET,
            })
        return kwargs

    def get_queryset(self):
        queryset = super().get_queryset()
        if self.form.is_valid():
            artist = self.form.cleaned_data.get("artist")
            if artist:
                queryset = queryset.filter(artist=artist)
        return queryset
  1. 修改歌曲列表模板以添加过滤表单:
{# music/song_list.html #}
{% extends "base.html" %}
{% load i18n %}

{% block sidebar %}
 <form action="" method="get">
 {{ form.errors }}
 {{ form.as_p }}
 <button type="submit" class="btn btn-primary">
         {% trans "Filter" %}</button>
 </form>
{% endblock %}

{% block main %}
    <ul>
        {% for song in object_list %}
            <li><a href="{{ song.get_url_path }}">
             {{ song }}</a></li>
        {% endfor %}
    </ul>
{% endblock %}

如果您现在在浏览器中检查歌曲列表视图并按照,比如说,Lana Del Rey 进行歌曲过滤,您将看到以下结果:

过滤后的歌曲列表的 URL 将是http://127.0.0.1:8000/en/songs/?artist=Lana+Del+Rey

如何做...

现在,我们将向音乐应用程序添加 RSS 订阅:

  1. music应用程序中,创建feeds.py文件并添加以下内容:
# myproject/apps/music/feeds.py
from django.contrib.syndication.views import Feed
from django.urls import reverse

from .models import Song
from .forms import SongFilterForm

class SongFeed(Feed):
    description_template = "music/feeds/song_description.html"

    def get_object(self, request, *args, **kwargs):
        form = SongFilterForm(data=request.GET)
        obj = {}
        if form.is_valid():
            obj = {"query_string": request.META["QUERY_STRING"]}
            for field in ["artist"]:
                value = form.cleaned_data[field]
                obj[field] = value
        return obj

    def title(self, obj):
        the_title = "Music"
        artist = obj.get("artist")
        if artist:
            the_title = f"Music by {artist}"
        return the_title

    def link(self, obj):
        return self.get_named_url("music:song_list", obj)

    def feed_url(self, obj):
        return self.get_named_url("music:song_rss", obj)

    @staticmethod
    def get_named_url(name, obj):
        url = reverse(name)
        qs = obj.get("query_string", False)
        if qs:
            url = f"{url}?{qs}"
        return url

    def items(self, obj):
        queryset = Song.objects.order_by("-created")

        artist = obj.get("artist")
        if artist:
            queryset = queryset.filter(artist=artist)

        return queryset[:30]

    def item_pubdate(self, item):
        return item.created
  1. 为 RSS 源中的歌曲描述创建一个模板:
{# music/feeds/song_description.html #}
{% load i18n %}
{% with song=obj %}
    {% if song.image %}
        <img src="img/{{ song.image.url }}" alt="{{ song }}" />
    {% endif %}
    {% if song.url %}
        <a href="{{ song.url }}" target="_blank" 
         rel="noreferrer noopener">
            {% trans "Check this song" %}
        </a>
    {% endif %}
{% endwith %}
  1. 在应用程序的 URL 配置中插入 RSS 源:
# myproject/apps/music/urls.py
from django.urls import path

from .feeds import SongFeed
from .views import SongList, SongDetail

app_name = "music"

urlpatterns = [
    path("", SongList.as_view(), name="song_list"),
    path("<uuid:pk>/", SongDetail.as_view(), name="song_detail"),
 path("rss/", SongFeed(), name="song_rss"),
]
  1. 在歌曲列表视图的模板中,添加到 RSS 源的链接:
{# music/song_list.html #} 
{% url "music:songs_rss" as songs_rss_url %}
<p>
    <a href="{{ songs_rss_url }}?{{ request.META.QUERY_STRING }}">
        {% trans "Subscribe to RSS feed" %}
    </a>
</p> 

它是如何工作的...

如果您刷新http://127.0.0.1:8000/en/songs/?artist=Lana+Del+Rey上的过滤列表视图,您将看到指向http://127.0.0.1:8000/en/songs/rss/?artist=Lana+Del+Rey的订阅 RSS 订阅链接。这将是按艺术家筛选的最多 30 首歌曲的 RSS 订阅。

SongFeed类负责自动生成 RSS 源的 XML 标记。我们在那里指定了以下方法:

  • get_object()方法为Feed类定义了上下文字典,其他方法将使用它。

  • title()方法根据结果是否被过滤定义了源的标题。

  • link()方法返回列表视图的 URL,而feed_url()返回订阅的 URL。它们都使用一个辅助方法get_named_url(),该方法通过路径名和查询参数形成 URL。

  • items()方法返回歌曲的queryset,可以按艺术家进行筛选。

  • item_pubdate()方法返回歌曲的创建日期。

要查看我们正在扩展的Feed类的所有可用方法和属性,请参阅以下文档:docs.djangoproject.com/en/3.0/ref/contrib/syndication/#feed-class-reference

另请参阅

  • 从本地 CSV 文件导入数据示例

  • 为搜索引擎准备分页站点地图示例

使用 Django REST 框架创建 API

当您需要为您的模型创建 RESTful API 以便与第三方传输数据时,Django REST 框架可能是您可以使用的最佳工具。该框架有广泛的文档和基于 Django 的实现,有助于使其更易于维护。在这个示例中,您将学习如何使用 Django REST 框架,以允许您的项目合作伙伴、移动客户端或基于 Ajax 的网站访问您网站上的数据,以适当地创建、读取、更新和删除内容。

准备工作

首先,在虚拟环境中使用以下命令安装 Django REST 框架:

(env)$ pip install djangorestframework==3.11.0

在设置的INSTALLED_APPS中添加"rest_framework"

然后,增强我们在从本地 CSV 文件导入数据示例中定义的music应用程序。您还希望收集 Django REST 框架提供的静态文件,以使其提供的页面样式尽可能漂亮:

(env)$ python manage.py collectstatic

如何做...

要在我们的music应用程序中集成新的 RESTful API,请执行以下步骤:

  1. 在设置中为 Django REST 框架添加配置,如下所示:
# myproject/settings/_base.py
REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": [ "rest_framework.permissions
         .DjangoModelPermissionsOrAnonReadOnly"
    ],
    "DEFAULT_PAGINATION_CLASS": 
    "rest_framework.pagination.LimitOffsetPagination",
    "PAGE_SIZE": 50,
}
  1. music应用程序中,创建serializers.py文件,内容如下:
from rest_framework import serializers
from .models import Song

class SongSerializer(serializers.ModelSerializer):
    class Meta:
        model = Song
        fields = ["uuid", "artist", "title", "url", "image"]
  1. music应用程序的views.py文件中添加两个基于类的视图:
from rest_framework import generics

from .serializers import SongSerializer
from .models import Song

# …

class RESTSongList(generics.ListCreateAPIView):
    queryset = Song.objects.all()
    serializer_class = SongSerializer

    def get_view_name(self):
        return "Song List"

class RESTSongDetail(generics.RetrieveUpdateDestroyAPIView):
    queryset = Song.objects.all()
    serializer_class = SongSerializer

    def get_view_name(self):
        return "Song Detail"
  1. 最后,将新视图插入到项目 URL 配置中:
# myproject/urls.py
from django.urls import include, path
from myproject.apps.music.views import RESTSongList, RESTSongDetail

urlpatterns = [
    path("api-auth/", include("rest_framework.urls", 
     namespace="rest_framework")),
    path("rest-api/songs/", RESTSongList.as_view(), 
     name="rest_song_list"),
    path(
        "rest-api/songs/<uuid:pk>/", RESTSongDetail.as_view(), 
          name="rest_song_detail"
    ),
    # …
]

工作原理...

我们在这里创建的是一个音乐 API,您可以阅读分页的歌曲列表,创建新歌曲,并通过 ID 阅读、更改或删除单个歌曲。阅读是允许的,无需身份验证,但是您必须拥有具有适当权限的用户帐户才能添加、更改或删除歌曲。Django REST 框架为您提供基于 Web 的 API 文档,当您通过GET在浏览器中访问 API 端点时会显示出来。未登录时,框架会显示类似以下内容:

以下是您可以使用创建的 API 的方法:

URL HTTP 方法 描述
/rest-api/songs/ GET 按 50 页列出歌曲。
/rest-api/songs/ POST 如果请求的用户经过身份验证并被授权创建歌曲,则创建新歌曲。
/rest-api/songs/b328109b-``5ec0-4124-b6a9-e963c62d212c/ GET 获取 ID 为b328109b-5ec0-4124-b6a9-e963c62d212c的歌曲。
/rest-api/songs/b328109b-``5ec0-4124-b6a9-e963c62d212c/ PUT 如果用户经过身份验证并被授权更改歌曲,则更新 ID 为b328109b-5ec0-4124-b6a9-e963c62d212c的歌曲。
/rest-api/songs/b328109b-``5ec0-4124-b6a9-e963c62d212c/ DELETE 如果用户经过身份验证并被授权删除歌曲,则删除 ID 为b328109b-5ec0-4124-b6a9-e963c62d212c的歌曲。

您可能会问如何实际使用 API。例如,我们可以使用requests库从 Python 脚本中创建新歌曲,如下所示:

import requests

response = requests.post(
    url="http://127.0.0.1:8000/rest-api/songs/",
    data={
        "artist": "Luwten",
        "title": "Go Honey",
    },
    auth=("admin", "<YOUR_ADMIN_PASSWORD>"),
)
assert(response.status_code == requests.codes.CREATED)

也可以通过Postman应用程序来实现,该应用程序提供了一个用户友好的界面来提交请求,如下所示:

当登录时,您还可以通过框架生成的 API 文档下的集成表单尝试 API,如下截图所示:

让我们快速看一下我们编写的代码是如何工作的。在设置中,我们已经设置了访问权限取决于 Django 系统的权限。对于匿名请求,只允许阅读。其他访问选项包括允许任何用户拥有任何权限,只允许经过身份验证的用户拥有任何权限,允许工作人员用户拥有任何权限等等。完整列表可以在www.django-rest-framework.org/api-guide/permissions/上找到。

然后,在设置中,设置了分页。当前选项是将限制和偏移参数设置为 SQL 查询中的参数。其他选项是对静态内容使用页面编号进行分页,或者对实时数据使用游标分页。我们将默认分页设置为每页 50 个项目。

稍后,我们为歌曲定义了一个序列化程序。它控制将显示在输出中的数据并验证输入。在 Django REST 框架中,有各种序列化关系的方法,我们在示例中选择了最冗长的方法。

要了解如何序列化关系,请参阅www.django-rest-framework.org/api-guide/relations/上的文档。

在定义了序列化程序之后,我们创建了两个基于类的视图来处理 API 端点,并将它们插入到 URL 配置中。在 URL 配置中,我们还有一个规则(/api-auth/)用于可浏览的 API 页面,登录和注销。

另请参阅

  • 为搜索引擎准备分页站点地图食谱

  • 创建可过滤的 RSS 提要食谱

  • 在第十一章,测试中的使用 Django REST 框架创建的 API 进行测试食谱

第十章:花里胡哨

在本章中,我们将涵盖以下主题:

  • 使用 Django shell

  • 使用数据库查询表达式

  • 为了更好地支持国际化,对slugify()函数进行猴子补丁

  • 切换调试工具栏

  • 使用 ThreadLocalMiddleware

  • 使用信号通知管理员有关新条目的信息

  • 检查缺少的设置

介绍

在本章中,我们将介绍一些重要的要点,这些要点将帮助您更好地理解和利用 Django。我们将概述如何使用 Django shell 在编写文件之前对代码进行实验。您将了解到猴子补丁,也称为游击补丁,这是 Python 和 Ruby 等动态语言的强大功能。我们还将讨论全文搜索功能,并学习如何调试代码并检查其性能。然后,您将学习如何从任何模块中访问当前登录的用户(以及其他请求参数)。您还将学习如何处理信号并创建系统检查。准备好迎接有趣的编程体验!

技术要求

要使用本章的代码,您需要最新稳定版本的 Python、MySQL 或 PostgreSQL 数据库以及一个带有虚拟环境的 Django 项目。

您可以在 GitHub 存储库的ch10目录中找到本章的所有代码,网址为github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition

使用 Django shell

在激活虚拟环境并选择项目目录作为当前目录后,在命令行工具中输入以下命令:

(env)$ python manage.py shell

通过执行上述命令,您将进入一个交互式的 Python shell,为您的 Django 项目进行配置,在那里您可以玩弄代码,检查类,尝试方法或即时执行脚本。在本教程中,我们将介绍您在使用 Django shell 时需要了解的最重要的功能。

准备工作

您可以安装IPythonbpython,以为 Python shell 提供额外的接口选项,或者如果需要选择,可以同时安装两者。这些将突出显示 Django shell 输出的语法,并添加一些其他辅助功能。通过使用以下命令为虚拟环境安装它们:

(env)$ pip install ipython
(env)$ pip install bpython

如何做...

通过按照以下说明学习使用 Django shell 的基础知识:

  • 通过输入以下命令来运行 Django shell:
(env)$ python manage.py shell

如果您已安装了IPythonbpython,那么您安装的任何一个都将在您进入 shell 时自动成为默认接口。您还可以通过在前面的命令中添加-i <interface>选项来使用特定的接口。提示符将根据您使用的接口而更改。以下屏幕截图显示了IPython shell 可能的外观,以In [1]:作为提示开始:

如果您使用bpython,则 shell 将显示为带有>>>提示,以及在输入时进行代码高亮和文本自动完成,如下所示:

默认的 Python 接口 shell 如下所示,也使用>>>提示,但前言提供有关系统的信息:

现在您可以导入类、函数或变量,并对它们进行操作。例如,要查看已安装模块的版本,您可以导入该模块,然后尝试读取其__version__VERSIONversion属性(使用bpython显示,它还将演示其高亮和自动完成功能),如下所示:

  • 要获取模块、类、函数、方法、关键字或文档主题的全面描述,请使用help()函数。您可以传递一个包含特定实体路径的字符串,或者实体本身,如下所示:
>>> help("django.forms")

这将打开django.forms模块的帮助页面。使用箭头键上下滚动页面。按Q键返回到 shell。如果您运行help()而没有参数,它会打开一个交互式帮助页面。在那里,您可以输入模块、类、函数等的任何路径,并获取有关其功能和用法的信息。要退出交互式帮助,请按Ctrl + D

  • 以下是如何将实体传递给help()函数的示例:

这将打开一个ModelForm类的帮助页面,如下所示:

要快速查看模型实例可用的字段和值,可以使用__dict__属性。您可以使用pprint()函数以更可读的格式打印字典(不仅仅是一行长),如下面的屏幕截图所示。请注意,当我们使用__dict__时,我们不会得到多对多关系;但是,这可能足够快速概述字段和值:

  • 要获取对象的所有可用属性和方法,可以使用dir()函数,如下所示:

  • 要每行打印一个属性,可以使用以下屏幕截图中显示的代码:

  • Django shell 对于在将其放入模型方法、视图或管理命令之前尝试QuerySets或正则表达式非常有用。例如,要检查电子邮件验证正则表达式,可以在 Django shell 中输入以下内容:
>>> import re
>>> email_pattern = re.compile(r"[^@]+@[^@]+\.[^@]+")
>>> email_pattern.match("aidas@bendoraitis.lt")
<_sre.SRE_Match object at 0x1075681d0>
  • 如果您想尝试不同的QuerySets,请使用以下代码:
>>> from django.contrib.auth.models import User 
>>> User.objects.filter(groups__name="Editors")
[<User: admin>]
  • 要退出 Django shell,请按Ctrl + D,或输入以下命令:
>>> exit()

工作原理...

普通 Python shell 和 Django shell 之间的区别在于,当您运行 Django shell 时,manage.py会设置DJANGO_SETTINGS_MODULE环境变量,以便它指向项目的settings.py路径,然后 Django shell 中的所有代码都在项目的上下文中处理。通过使用第三方 IPython 或 bpython 接口,我们可以进一步增强默认的 Python shell,包括语法高亮、自动完成等。

另请参阅

使用数据库查询表达式配方

为更好的国际化支持修补 slugify()函数配方

使用数据库查询表达式

Django 对象关系映射(ORM)具有特殊的抽象构造,可用于构建复杂的数据库查询。它们称为查询表达式,它们允许您过滤数据、对其进行排序、注释新列并聚合关系。在这个配方中,您将看到这些如何在实践中使用。我们将创建一个应用程序,显示病毒视频,并计算每个视频被匿名用户或登录用户观看的次数。

准备工作

首先,创建一个viral_videos应用程序,其中包含一个ViralVideo模型,并设置系统默认记录到日志文件:

创建viral_videos应用程序并将其添加到设置中的INSTALLED_APPS下:

# myproject/settings/_base.py
INSTALLED_APPS = [
    # …
    "myproject.apps.core",
    "myproject.apps.viral_videos",
]

接下来,创建一个病毒视频的模型,其中包含通用唯一标识符UUID)作为主键,以及创建和修改时间戳、标题、嵌入代码、匿名用户的印象和经过身份验证用户的印象,如下所示:

# myproject/apps/viral_videos/models.py import uuid
from django.db import models
from django.utils.translation import ugettext_lazy as _

from myproject.apps.core.models import (
 CreationModificationDateBase,
 UrlBase,
)

class ViralVideo(CreationModificationDateBase, UrlBase):
    uuid = models.UUIDField(primary_key=True, default=None, 
     editable=False)
    title = models.CharField(_("Title"), max_length=200, blank=True)
    embed_code = models.TextField(_("YouTube embed code"), blank=True)
    anonymous_views = models.PositiveIntegerField(_("Anonymous 
     impressions"), default=0)
    authenticated_views = models.PositiveIntegerField(
        _("Authenticated impressions"), default=0
    )

    class Meta:
        verbose_name = _("Viral video")
        verbose_name_plural = _("Viral videos")

    def __str__(self):
        return self.title

    def get_url_path(self):
        from django.urls import reverse

        return reverse("viral_videos:viral_video_detail", 
         kwargs={"pk": self.pk})

    def save(self, *args, **kwargs):
        if self.pk is None:
            self.pk = uuid.uuid4()
        super().save(*args, **kwargs)

为新应用程序创建并运行迁移,以便您的数据库准备就绪:

(env)$ python manage.py makemigrations
(env)$ python manage.py migrate

将日志配置添加到设置中:

LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "handlers": {
        "file": {
            "level": "DEBUG",
            "class": "logging.FileHandler",
            "filename": os.path.join(BASE_DIR, "tmp", "debug.log"),
        }
    },
    "loggers": {"django": {"handlers": ["file"], "level": "DEBUG", 
     "propagate": True}},
}

这将调试信息记录到名为tmp/debug.log的临时文件中。

如何做...

为了说明查询表达式,让我们创建病毒视频详细视图,并将其插入到 URL 配置中,如下所示:

  1. views.py中创建病毒视频列表和详细视图如下:
# myproject/apps/viral_videos/views.py
import logging

from django.conf import settings
from django.db import models
from django.utils.timezone import now, timedelta
from django.shortcuts import render, get_object_or_404
from django.views.generic import ListView

from .models import ViralVideo

POPULAR_FROM = getattr(settings, "VIRAL_VIDEOS_POPULAR_FROM", 500)

logger = logging.getLogger(__name__)

class ViralVideoList(ListView):
    template_name = "viral_videos/viral_video_list.html"
    model = ViralVideo

def viral_video_detail(request, pk):
    yesterday = now() - timedelta(days=1)

    qs = ViralVideo.objects.annotate(
        total_views=models.F("authenticated_views") + 
         models.F("anonymous_views"),
        label=models.Case(
            models.When(total_views__gt=POPULAR_FROM, 
             then=models.Value("popular")),
            models.When(created__gt=yesterday, 
             then=models.Value("new")),
            default=models.Value("cool"),
            output_field=models.CharField(),
        ),
    )

    # DEBUG: check the SQL query that Django ORM generates
    logger.debug(f"Query: {qs.query}")

    qs = qs.filter(pk=pk)
    if request.user.is_authenticated:
        qs.update(authenticated_views=models
         .F("authenticated_views") + 1)
    else:
        qs.update(anonymous_views=models.F("anonymous_views") + 1)

    video = get_object_or_404(qs)

    return render(request, "viral_videos/viral_video_detail.html", 
     {"video": video})
  1. 为应用程序定义 URL 配置如下:
# myproject/apps/viral_videos/urls.py
from django.urls import path

from .views import ViralVideoList, viral_video_detail

app_name = "viral_videos"

urlpatterns = [
    path("", ViralVideoList.as_view(), name="viral_video_list"),
    path("<uuid:pk>/", viral_video_detail, 
     name="viral_video_detail"),
]
  1. 将应用程序的 URL 配置包含在项目的根 URL 配置中,如下所示:
# myproject/urls.py
from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path

urlpatterns = i18n_patterns(
path("viral-videos/", include("myproject.apps.viral_videos.urls", namespace="viral_videos")),
)
  1. 创建以下病毒视频列表视图的模板:
{# viral_videos/viral_video_list.html #}
{% extends "base.html" %}
{% load i18n %}

{% block content %}
    <h1>{% trans "Viral Videos" %}</h1>
    <ul>
        {% for video in object_list %}
            <li><a href="{{ video.get_url_path }}">
             {{ video.title }}</a></li>
        {% endfor %}
    </ul>
{% endblock %}
  1. 创建以下病毒视频详细视图的模板:
{# viral_videos/viral_video_detail.html #}
{% extends "base.html" %}
{% load i18n %}

{% block content %}
    <h1>{{ video.title }}
        <span class="badge">{{ video.label }}</span>
    </h1>
    <div>{{ video.embed_code|safe }}</div>
    <div>
        <h2>{% trans "Impressions" %}</h2>
        <ul>
            <li>{% trans "Authenticated views" %}:
                {{ video.authenticated_views }}
            </li>
            <li>{% trans "Anonymous views" %}:
                {{ video.anonymous_views }}
            </li>
            <li>{% trans "Total views" %}:
                {{ video.total_views }}
            </li>
        </ul>
    </div>
{% endblock %}
  1. 设置viral_videos应用程序的管理如下,并在完成后向数据库添加一些视频:
# myproject/apps/viral_videos/admin.py
from django.contrib import admin
from .models import ViralVideo

@admin.register(ViralVideo)
class ViralVideoAdmin(admin.ModelAdmin):
    list_display = ["title", "created", "modified"]

它是如何工作的...

您可能已经注意到视图中的logger.debug()语句。如果以DEBUG模式运行服务器并在浏览器中访问视频(例如,在本地开发中访问http://127.0.0.1:8000/en/viral-videos/2b14ffd3-d1f1-4699-a07b-1328421d8312/),则会在日志中打印类似以下的 SQL 查询(tmp/debug.log):

SELECT "viral_videos_viralvideo"."created", "viral_videos_viralvideo"."modified", "viral_videos_viralvideo"."uuid", "viral_videos_viralvideo"."title", "viral_videos_viralvideo"."embed_code", "viral_videos_viralvideo"."anonymous_views", "viral_videos_viralvideo"."authenticated_views", ("viral_videos_viralvideo"."authenticated_views" + "viral_videos_viralvideo"."anonymous_views") AS "total_views", CASE WHEN ("viral_videos_viralvideo"."authenticated_views" + "viral_videos_viralvideo"."anonymous_views") > 500 THEN 'popular' WHEN "viral_videos_viralvideo"."created" > '2019-12-21T05:01:58.775441+00:00'::timestamptz THEN 'new' ELSE 'cool' END 
 AS "label" FROM "viral_videos_viralvideo" WHERE "viral_videos_viralvideo"."uuid" = '2b14ffd3-d1f1-4699-a07b-1328421d8312'::uuid LIMIT 21; args=(500, 'popular', datetime.datetime(2019, 12, 21, 5, 1, 58, 775441, tzinfo=<UTC>), 'new', 'cool', UUID('2b14ffd3-d1f1-4699-a07b-1328421d8312'))

然后,在浏览器中,您将看到一个简单的页面,显示如下内容:

  • 视频的标题

  • 视频的标签

  • 嵌入式视频

  • 经过身份验证和匿名用户的观看次数,以及总观看次数

它将类似于以下图像:

Django QuerySets中的annotate()方法允许您向SELECT SQL 语句添加额外的列,以及为从QuerySets检索的对象创建的临时属性。使用models.F(),我们可以引用所选数据库表中的不同字段值。在此示例中,我们将创建total_views属性,该属性是经过身份验证和匿名用户查看的总和。

使用models.Case()models.When(),我们可以根据不同的条件返回值。为了标记这些值,我们使用models.Value()。在我们的示例中,我们将为 SQL 查询创建label列,并为QuerySet返回的对象创建属性。如果有超过 500 次印象,则将其设置为 popular,如果在过去的 24 小时内创建,则设置为 new,否则设置为 cool。

在视图的末尾,我们调用了qs.update()方法。它们会增加当前视频的authenticated_viewsanonymous_views,具体取决于查看视频的用户是否已登录。增加不是在 Python 级别进行的,而是在 SQL 级别进行的。这解决了所谓的竞争条件问题,即两个或更多访问者同时访问视图,尝试同时增加视图计数的问题。

另请参阅

  • 在 Django shell 中使用的方法

  • 第二章,模型和数据库结构中的使用 URL 相关方法创建模型 mixin的方法

  • 第二章,模型和数据库结构中的创建处理创建和修改日期的模型 mixin的方法

为了更好地支持国际化,对 slugify()函数进行猴子补丁

猴子补丁(或游击补丁)是一段代码,它在运行时扩展或修改另一段代码。不建议经常使用猴子补丁;但是,有时它们是修复复杂的第三方模块中的错误的唯一可能方法,而不是创建模块的单独分支。此外,猴子补丁可以用于准备功能或单元测试,而无需使用复杂和耗时的数据库或文件操作。

在这个示例中,您将学习如何使用第三方transliterate包中的函数来替换默认的slugify()函数,该函数更智能地处理 Unicode 字符到 ASCII 等效字符的转换,并包含许多语言包,根据需要提供更具体的转换。快速提醒,我们使用slugify()实用程序来创建对象标题或上传文件名的 URL 友好版本。处理时,该函数会删除任何前导和尾随空格,将文本转换为小写,删除非字母数字字符,并将空格转换为连字符。

准备就绪

让我们从这些小步骤开始:

  1. 按照以下方式在虚拟环境中安装transliterate
(env)$ pip install transliterate==1.10.2
  1. 然后,在项目中创建一个guerrilla_patches应用,并将其放在设置中的INSTALLED_APPS下。

如何做...

guerrilla_patches应用的models.py文件中,用transliterate包中的slugify函数覆盖django.utils.text中的slugify函数:

# myproject/apps/guerrilla_patches/models.py from django.utils import text
from transliterate import slugify

text.slugify = slugify

它是如何工作的...

默认的 Django slugify()函数不正确地处理德语变音符号。要自己看看,请尝试使用所有德语变音符号的非常长的德语单词进行 slugify。首先,在 Django shell 中运行以下代码,不使用 monkey patch:

(env)$ python manage.py shell
>>> from django.utils.text import slugify
>>> slugify("Heizölrückstoßabdämpfung")
'heizolruckstoabdampfung'

这在德语中是不正确的,因为字母ß被完全剥离,而不是被替换为ss,字母äöü被改为aou,而它们应该被替换为aeoeue

我们创建的 monkey patch 在初始化时加载了django.utils.text模块,并在核心slugify()函数的位置重新分配了transliteration.slugify。现在,如果您在 Django shell 中运行相同的代码,您将得到正确的结果,如下所示:

(env)$ python manage.py shell
>>> from django.utils.text import slugify
>>> slugify("Heizölrückstoßabdämpfung")
'heizoelrueckstossabdaempfung'

要了解如何使用transliterate模块,请参阅pypi.org/project/transliterate

还有更多...

在创建 monkey patch 之前,我们需要完全了解要修改的代码的工作原理。这可以通过分析现有代码并检查不同变量的值来完成。为此,有一个有用的内置 Python 调试器模块pdb,可以临时添加到 Django 代码(或任何第三方模块)中,在任何断点处停止开发服务器的执行。使用以下代码调试 Python 模块中不清楚的部分:

breakpoint()

这将启动交互式 shell,您可以在其中输入变量以查看它们的值。如果输入ccontinue,代码执行将继续直到下一个断点。如果输入qquit,管理命令将被中止。

您可以在docs.python.org/3/library/pdb.html了解更多 Python 调试器命令以及如何检查代码的回溯。

在开发服务器中查看变量值的另一种快速方法是通过引发带有变量作为消息的警告,如下所示:

raise Warning, some_variable

当您处于DEBUG模式时,Django 记录器将为您提供回溯和其他本地变量。

在将工作提交到存储库之前,请不要忘记删除调试代码。

如果您使用 PyCharm 交互式开发环境,可以在那里设置断点并直观地调试变量,而无需修改源代码。

另请参阅

  • 使用 Django shell示例

切换调试工具栏

在使用 Django 进行开发时,您可能希望检查请求标头和参数,检查当前模板上下文,或者测量 SQL 查询的性能。所有这些以及更多功能都可以通过Django Debug Toolbar实现。它是一组可配置的面板,显示有关当前请求和响应的各种调试信息。在本教程中,我们将指导您如何根据一个由书签工具设置的 cookie 的值来切换调试工具栏的可见性。书签工具是一个带有一小段 JavaScript 代码的书签,您可以在浏览器中的任何页面上运行它。

准备工作

要开始切换调试工具栏的可见性,请按照以下步骤进行:

  1. 在虚拟环境中安装 Django Debug Toolbar:
(env)$ pip install django-debug-toolbar==2.1

  1. 在设置的INSTALLED_APPS下添加"debug_toolbar"
# myproject/settings/_base.py
INSTALLED_APPS = [
    # …
    "debug_toolbar",
]

如何做...

按照以下步骤设置 Django Debug Toolbar,可以使用浏览器中的书签工具切换开启或关闭:

  1. 添加以下项目设置:
# myproject/settings/_base.py
DEBUG_TOOLBAR_CONFIG = {
    "DISABLE_PANELS": [],
    "SHOW_TOOLBAR_CALLBACK": 
    "myproject.apps.core.misc.custom_show_toolbar",
    "SHOW_TEMPLATE_CONTEXT": True,
}

DEBUG_TOOLBAR_PANELS = [
    "debug_toolbar.panels.versions.VersionsPanel",
    "debug_toolbar.panels.timer.TimerPanel",
    "debug_toolbar.panels.settings.SettingsPanel",
    "debug_toolbar.panels.headers.HeadersPanel",
    "debug_toolbar.panels.request.RequestPanel",
    "debug_toolbar.panels.sql.SQLPanel",
    "debug_toolbar.panels.templates.TemplatesPanel",
    "debug_toolbar.panels.staticfiles.StaticFilesPanel",
    "debug_toolbar.panels.cache.CachePanel",
    "debug_toolbar.panels.signals.SignalsPanel",
    "debug_toolbar.panels.logging.LoggingPanel",
    "debug_toolbar.panels.redirects.RedirectsPanel",
]
  1. core应用程序中,创建一个带有custom_show_toolbar()函数的misc.py文件,如下所示:
# myproject/apps/core/misc.py
def custom_show_toolbar(request):
    return "1" == request.COOKIES.get("DebugToolbar", False)
  1. 在项目的urls.py中,添加以下配置规则:
# myproject/urls.py
from django.conf.urls.i18n import i18n_patterns
from django.urls import include, path
from django.conf import settings
import debug_toolbar

urlpatterns = i18n_patterns(
    # …
)

urlpatterns = [
    path('__debug__/', include(debug_toolbar.urls)),
] + urlpatterns
  1. 打开 Chrome 或 Firefox 浏览器,转到书签管理器。然后,创建两个包含 JavaScript 的新书签。第一个链接将显示工具栏,看起来类似于以下内容:

JavaScript 代码如下:

javascript:(function(){document.cookie="DebugToolbar=1; path=/";location.reload();})();
  1. 第二个 JavaScript 链接将隐藏工具栏,看起来类似于以下内容:

这是完整的 JavaScript 代码:

javascript:(function(){document.cookie="DebugToolbar=0; path=/";location.reload();})();

工作原理...

DEBUG_TOOLBAR_PANELS设置定义了工具栏中要显示的面板。DEBUG_TOOLBAR_CONFIG字典定义了工具栏的配置,包括用于检查是否显示工具栏的函数的路径。

默认情况下,当您浏览项目时,Django Debug Toolbar 不会显示;但是,当您单击书签工具 Debug Toolbar On 时,DebugToolbar cookie 将被设置为1,页面将被刷新,您将看到带有调试面板的工具栏,例如,您将能够检查 SQL 语句的性能以进行优化,如下面的屏幕截图所示:

您还可以检查当前视图的模板上下文变量,如下面的屏幕截图所示:

单击第二个书签工具 Debug Toolbar Off,将类似地将DebugToolbar cookie 设置为0并刷新页面,再次隐藏工具栏。

另请参阅

  • 通过电子邮件获取详细的错误报告教程在第十三章维护

使用 ThreadLocalMiddleware

HttpRequest对象包含有关当前用户、语言、服务器变量、cookie、会话等的有用信息。事实上,HttpRequest在视图和中间件中提供,并且您可以将其(或其属性值)传递给表单、模型方法、模型管理器、模板等。为了简化生活,您可以使用所谓的ThreadLocalMiddleware,它将当前的HttpRequest对象存储在全局可访问的 Python 线程中。因此,您可以从模型方法、表单、信号处理程序和以前无法直接访问HttpRequest对象的其他位置访问它。在本教程中,我们将定义这个中间件。

准备工作

如果尚未这样做,请创建core应用程序并将其放在设置的INSTALLED_APPS下。

如何做...

执行以下两个步骤来设置ThreadLocalMiddleware,它可以在项目代码的任何函数或方法中获取当前的HttpRequest或用户:

  1. core应用程序中添加一个middleware.py文件,内容如下:
# myproject/apps/core/middleware.py
from threading import local

_thread_locals = local()

def get_current_request():
    """
    :returns the HttpRequest object for this thread
    """
    return getattr(_thread_locals, "request", None)

def get_current_user():
    """
    :returns the current user if it exists or None otherwise """
    request = get_current_request()
    if request:
        return getattr(request, "user", None)

class ThreadLocalMiddleware(object):
    """
    Middleware to add the HttpRequest to thread local storage
    """

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        _thread_locals.request = request
        return self.get_response(request)
  1. 将此中间件添加到设置中的MIDDLEWARE中:
# myproject/settings/_base.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
    "django.middleware.locale.LocaleMiddleware",
    "debug_toolbar.middleware.DebugToolbarMiddleware",
 "myproject.apps.core.middleware.ThreadLocalMiddleware",
]

它是如何工作的...

ThreadLocalMiddleware 处理每个请求,并将当前的 HttpRequest 对象存储在当前线程中。Django 中的每个请求-响应周期都是单线程的。我们创建了两个函数:get_current_request()get_current_user()。这些函数可以从任何地方使用,以分别获取当前的 HttpRequest 对象或当前用户。

例如,您可以使用此中间件来开发和使用 CreatorMixin,它将保存当前用户作为新模型对象的创建者,如下所示:

# myproject/apps/core/models.py
from django.conf import settings
from django.db import models
from django.utils.translation import gettext_lazy as _

class CreatorBase(models.Model):
    """
    Abstract base class with a creator
    """

    creator = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        verbose_name=_("creator"),
        editable=False,
        blank=True,
        null=True,
        on_delete=models.SET_NULL,
    )

    class Meta:
        abstract = True

    def save(self, *args, **kwargs):
        from .middleware import get_current_user

        if not self.creator:
 self.creator = get_current_user()
        super().save(*args, **kwargs)

    save.alters_data = True

另请参阅

  • 第二章,模型和数据库结构中的创建一个具有与 URL 相关方法的模型混合教程

  • 第二章,模型和数据库结构中的创建一个处理创建和修改日期的模型混合教程

  • 第二章,模型和数据库结构中的创建一个处理元标签的模型混合教程

  • 第二章,模型和数据库结构中的创建一个处理通用关系的模型混合教程

使用信号通知管理员有关新条目

Django 框架包括信号的概念,类似于 JavaScript 中的事件。有一些内置的信号。您可以使用它们在模型初始化之前和之后触发操作,保存或删除实例,迁移数据库模式,处理请求等。此外,您可以在可重用的应用程序中创建自己的信号,并在其他应用程序中处理它们。在本教程中,您将学习如何使用信号在特定模型保存时向管理员发送电子邮件。

准备工作

让我们从我们在使用数据库查询表达式教程中创建的 viral_videos 应用程序开始。

如何做...

按照以下步骤为管理员创建通知:

  1. 创建一个名为 signals.py 的文件,内容如下:
# myproject/apps/viral_videos/signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.template.loader import render_to_string

from .models import ViralVideo

@receiver(post_save, sender=ViralVideo)
def inform_administrators(sender, **kwargs):
    from django.core.mail import mail_admins

    instance = kwargs["instance"]
    created = kwargs["created"]

    if created:
        context = {"title": instance.title, "link": 
         instance.get_url()}
        subject = render_to_string(
            "viral_videos/email/administrator/subject.txt", context
        )
        plain_text_message = render_to_string(
            "viral_videos/email/administrator/message.txt", context
        )
        html_message = render_to_string(
            "viral_videos/email/administrator/message.html", 
              context
        )

        mail_admins(
            subject=subject.strip(),
            message=plain_text_message,
            html_message=html_message,
            fail_silently=True,
        )
  1. 然后我们需要创建一些模板。首先是电子邮件主题的模板:
{# viral_videos/email/administrator/subject.txt #}
New Viral Video Added
  1. 然后创建一个纯文本消息的模板,类似于以下内容:
{# viral_videos/email/administrator/message.txt #}
A new viral video called "{{ title }}" has been created.
You can preview it at {{ link }}.
  1. 然后创建一个 HTML 消息的模板如下:
{# viral_videos/email/administrator/message.html #}
<p>A new viral video called "{{ title }}" has been created.</p>
<p>You can <a href="{{ link }}">preview it here</a>.</p>
  1. 创建一个名为 apps.py 的文件,内容如下:
# myproject/apps/viral_videos/apps.py
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _

class ViralVideosAppConfig(AppConfig):
    name = "myproject.apps.viral_videos"
    verbose_name = _("Viral Videos")

    def ready(self):
        from .signals import inform_administrators
  1. 使用以下内容更新 __init__.py 文件:
# myproject/apps/viral_videos/__init__.py
default_app_config = "myproject.apps.viral_videos.apps.ViralVideosAppConfig"

确保在项目设置中设置了类似以下内容的 ADMINS

# myproject/settings/_base.py
ADMINS = [("Administrator", "admin@example.com")]

它是如何工作的...

ViralVideosAppConfig 应用配置类具有 ready() 方法,当项目的所有模型加载到内存中时将调用该方法。根据 Django 文档,信号允许特定发送者通知一组接收者发生了某个动作。因此,在 ready() 方法中,我们导入 inform_administrators() 函数。

通过 @receiver 装饰器,inform_administrators() 被注册为 post_save 信号的接收者,并且我们将其限制为仅处理 ViralVideo 模型为 sender 的信号。因此,每当我们保存 ViralVideo 对象时,将调用 receiver 函数。inform_administrators() 函数检查视频是否是新创建的。如果是,它会向在设置中列出的系统管理员发送电子邮件。

我们使用模板生成 subjectplain_text_messagehtml_message 的内容,以便我们可以在我们的应用程序中为每个定义默认模板。如果我们将我们的 viral_videos 应用程序公开可用,那些将其引入其自己项目的人可以根据需要自定义模板,也许将它们包装在公司电子邮件模板包装器中。

您可以在官方文档 docs.djangoproject.com/en/3.0/topics/signals/ 中了解有关 Django 信号的更多信息。

另请参阅

  • 在第一章, Getting Started with Django 3.0中的创建应用程序配置配方

  • 使用数据库查询表达式的配方

  • 检查缺失设置的配方

检查缺失设置

从 Django 1.7 开始,您可以使用一个可扩展的系统检查框架,它取代了旧的validate管理命令。在这个配方中,您将学习如何创建一个检查,以查看ADMINS设置是否已设置。同样,您还可以检查您正在使用的 API 是否设置了不同的密钥或访问令牌。

准备工作

让我们从在使用数据库查询表达式配方中创建并在上一个配方中扩展的viral_videos应用程序开始。

如何做...

要使用系统检查框架,请按照以下步骤进行:

  1. 创建checks.py文件,内容如下:
# myproject/apps/viral_videos/checks.py
from textwrap import dedent

from django.core.checks import Warning, register, Tags

@register(Tags.compatibility)
def settings_check(app_configs, **kwargs):
    from django.conf import settings

    errors = []

    if not settings.ADMINS:
        errors.append(
            Warning(
                dedent("""
                    The system admins are not set in the project 
                     settings
                """),
                obj=settings,
                hint=dedent("""
                    In order to receive notifications when new 
                     videos are created, define system admins 
                     in your settings, like:

                    ADMINS = (
                        ("Admin", "administrator@example.com"),
                    )
                """),
                id="viral_videos.W001",
            )
        )

    return errors
  1. 在应用程序配置的ready()方法中导入检查,如下所示:
# myproject/apps/viral_videos/apps.py
from django.apps import AppConfig
from django.utils.translation import ugettext_lazy as _

class ViralVideosAppConfig(AppConfig):
    name = "myproject.apps.viral_videos"
    verbose_name = _("Viral Videos")

    def ready(self):
        from .signals import inform_administrators
        from .checks import settings_check
  1. 要尝试刚刚创建的检查,删除或注释掉ADMINS设置,然后在虚拟环境中运行check管理命令:
(env)$ python manage.py check
System check identified some issues:

WARNINGS:
<Settings "myproject.settings.dev">: (viral_videos.W001)
The system admins are not set in the project settings

HINT:
In order to receive notifications when new videos are
created, define system admins in your settings, like:

ADMINS = (
    ("Admin", "administrator@example.com"),
)

System check identified 1 issue (0 silenced).

它是如何工作的...

系统检查框架在模型、字段、数据库、管理身份验证配置、内容类型和安全设置中有一堆检查,如果项目中的某些内容设置不正确,它会引发错误或警告。此外,您可以创建自己的检查,类似于我们在这个配方中所做的。

我们已经注册了settings_check()函数,如果项目中没有定义ADMINS设置,则返回一个带有Warning的列表。

除了来自django.core.checks模块的Warning实例外,返回的列表还可以包含DebugInfoErrorCritical内置类的实例,或者继承自django.core.checks.CheckMessage的任何其他类。在调试、信息和警告级别记录会静默失败,而在错误和严重级别记录会阻止项目运行。

在这个例子中,通过将Tags.compatibility参数传递给@register装饰器,将检查标记为兼容性检查。Tags中提供的其他选项包括以下内容:

  • admin用于与管理员站点相关的检查

  • caches用于与服务器缓存相关的检查

  • database用于与数据库配置相关的检查

  • models用于与模型、模型字段和管理器相关的检查

  • security用于与安全相关的检查

  • 信号用于与信号声明和处理程序相关的检查

  • staticfiles用于静态文件检查

  • templates用于与模板相关的检查

  • translation用于与字符串翻译相关的检查

  • url用于与 URL 配置相关的检查

在官方文档中了解有关系统检查框架的更多信息docs.djangoproject.com/en/3.0/topics/checks/​。

另请参阅

  • 在第一章, Getting Started with Django 3.0中的创建应用程序配置配方

  • 使用数据库查询表达式的配方

  • 使用信号通知管理员有关新条目的配方

第十一章:测试

在本章中,我们将涵盖以下主题:

  • 使用 mock 测试视图

  • 使用 Selenium 测试用户界面

  • 使用 Django REST 框架创建 API 的测试

  • 确保测试覆盖率

介绍

为了确保代码的质量和正确性,您应该进行自动化软件测试。 Django 为您提供了编写网站测试套件的工具。 测试套件会自动检查您的网站及其组件,以确保一切正常运行。 当您修改代码时,可以运行测试以检查您的更改是否对应用程序的行为产生了负面影响。

自动化软件测试领域有各种划分和术语。 为了本书的目的,我们将测试划分为以下类别:

  • 单元测试指的是严格针对代码的单个部分或单元的测试。 最常见的情况是,一个单元对应于单个文件或模块,单元测试会尽力验证逻辑和行为是否符合预期。

  • 集成测试进一步进行,处理两个或多个单元彼此协作的方式。 这种测试不像单元测试那样细粒度,并且通常是在假设所有单元测试都已通过的情况下编写的。 因此,集成测试仅涵盖了必须对单元正确地彼此协作的行为集。

  • 组件接口测试是集成测试的一种高阶形式,其中单个组件从头到尾进行验证。 这种测试以一种对提供组件行为的基础逻辑无知的方式编写,因此逻辑可以更改而不修改行为,测试仍将通过。

  • 系统测试验证了构成系统的所有组件的端到端集成,通常对应于完整的用户流程。

  • 操作接受测试检查系统的所有非功能方面是否正常运行。 验收测试检查业务逻辑,以找出项目是否按照最终用户的观点正常工作。

技术要求

要使用本章中的代码,您需要最新稳定版本的 Python,一个 MySQL 或 PostgreSQL 数据库,以及一个带有虚拟环境的 Django 项目。

您可以在 GitHub 存储库的ch11目录中找到本章的所有代码:github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition

使用 mock 测试视图

在本示例中,我们将看看如何编写单元测试。 单元测试检查单个函数或方法是否返回正确的结果。 我们将查看likes应用程序,并编写测试,检查对json_set_like()视图的发布是否对未经身份验证的用户返回失败响应,并对经过身份验证的用户返回成功结果。 我们将使用Mock对象来模拟HttpRequestAnonymousUser对象。

准备工作

让我们从在第四章实现点赞小部件食谱中的locationslikes应用程序开始。

我们将使用mock库,自 Python 3.3 以来一直作为内置的unittest.mock可用。

如何操作...

我们将通过以下步骤使用mock测试点赞操作:

  1. likes应用中创建tests模块

  2. 在本模块中,创建一个名为test_views.py的文件,内容如下:

# myproject/apps/likes/tests/test_views.py
import json
from unittest import mock
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from myproject.apps.locations.models import Location

class JSSetLikeViewTest(TestCase):
    @classmethod
    def setUpClass(cls):
        super(JSSetLikeViewTest, cls).setUpClass()

        cls.location = Location.objects.create(
            name="Park Güell",
            description="If you want to see something spectacular, 
            come to Barcelona, Catalonia, Spain and visit Park 
            Güell. Located on a hill, Park Güell is a public 
            park with beautiful gardens and organic 
            architectural elements.",
            picture="locations/2020/01/20200101012345.jpg",  
            # dummy path
        )
        cls.content_type = 
         ContentType.objects.get_for_model(Location)
        cls.superuser = User.objects.create_superuser(
            username="admin", password="admin", 
             email="admin@example.com"
        )

    @classmethod
    def tearDownClass(cls):
        super(JSSetLikeViewTest, cls).tearDownClass()
        cls.location.delete()
        cls.superuser.delete()

    def test_authenticated_json_set_like(self):
        from ..views import json_set_like

        mock_request = mock.Mock()
        mock_request.user = self.superuser
        mock_request.method = "POST"

        response = json_set_like(mock_request, 
         self.content_type.pk, self.location.pk)
        expected_result = json.dumps(
            {"success": True, "action": "add", "count": 
             Location.objects.count()}
        )
        self.assertJSONEqual(response.content, expected_result)

    @mock.patch("django.contrib.auth.models.User")
    def test_anonymous_json_set_like(self, MockUser):
        from ..views import json_set_like

        anonymous_user = MockUser()
        anonymous_user.is_authenticated = False

        mock_request = mock.Mock()
        mock_request.user = anonymous_user
        mock_request.method = "POST"

        response = json_set_like(mock_request, 
        self.content_type.pk, self.location.pk)
        expected_result = json.dumps({"success": False})
        self.assertJSONEqual(response.content, expected_result)
  1. 运行likes应用的测试,如下所示:
(env)$ python manage.py test myproject.apps.likes --settings=myproject.settings.test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
..
----------------------------------------------------------------------
Ran 2 tests in 0.268s
OK
Destroying test database for alias 'default'...

工作原理...

当您运行likes应用的测试时,首先会创建一个临时测试数据库。然后,会调用setUpClass()方法。稍后,将执行以test开头的方法,最后会调用tearDownClass()方法。对于每个通过的测试,您将在命令行工具中看到一个点(.),对于每个失败的测试,将会有一个字母 F,对于测试中的每个错误,您将看到字母 E。最后,您将看到有关失败和错误测试的提示。因为我们目前在likes应用的套件中只有两个测试,所以您将在结果中看到两个点。

setUpClass()中,我们创建一个位置和一个超级用户。此外,我们找出Location模型的ContentType对象。我们将需要它用于json_set_like()视图,该视图为不同对象设置或移除喜欢。作为提醒,该视图看起来类似于以下内容,并返回一个 JSON 字符串作为结果:

def json_set_like(request, content_type_id, object_id):
    # all the view logic goes here…
    return JsonResponse(result)

test_authenticated_json_set_like()test_anonymous_json_set_like()方法中,我们使用Mock对象。这些对象可以具有任何属性或方法。Mock对象的每个未定义属性或方法都是另一个Mock对象。因此,在 shell 中,您可以尝试链接属性,如下所示:

>>> from unittest import mock
>>> m = mock.Mock()
>>> m.whatever.anything().whatsoever
<Mock name='mock.whatever.anything().whatsoever' id='4320988368'>

在我们的测试中,我们使用Mock对象来模拟HttpRequest对象。对于匿名用户,MockUser被生成为标准 Django User对象的一个补丁,通过@mock.patch()装饰器。对于经过身份验证的用户,我们仍然需要真实的User对象,因为视图使用用户的 ID 来获取Like对象。

因此,我们调用json_set_like()函数,并检查返回的 JSON 响应是否正确:

  • 如果访问者未经身份验证,则响应中返回{"success": false}

  • 对于经过身份验证的用户,它返回类似{"action": "add", "count": 1, "success": true}的内容

最后,调用tearDownClass()类方法,从测试数据库中删除位置和超级用户。

还有更多...

要测试使用HttpRequest对象的内容,您还可以使用 Django 请求工厂。您可以在docs.djangoproject.com/en/3.0/topics/testing/advanced/#the-request-factory上阅读如何使用它。

另请参阅

  • 在第四章,模板和 JavaScript中的实现“喜欢”小部件食谱中

  • 使用 Selenium 测试用户界面食谱

  • 使用 Django REST 框架创建 API 的测试食谱

  • 确保测试覆盖食谱

使用 Selenium 测试用户界面

操作接受测试检查业务逻辑,以了解项目是否按预期工作。在这个食谱中,您将学习如何使用Selenium编写接受测试,它允许您模拟前端的活动,如填写表单或在浏览器中单击特定的 DOM 元素。

准备工作

让我们从第四章,模板和 JavaScript中的实现“喜欢”小部件食谱中的locationslikes应用开始。

对于这个食谱,我们将使用 Selenium 库与Chrome浏览器和ChromeDriver来控制它。让我们准备一下:

  1. www.google.com/chrome/下载并安装 Chrome 浏览器。

  2. 在 Django 项目中创建一个drivers目录。从sites.google.com/a/chromium.org/chromedriver/下载 ChromeDriver 的最新稳定版本,解压缩并将其放入新创建的drivers目录中。

  3. 在虚拟环境中安装 Selenium,如下所示:

(env)$ pip install selenium

如何做...

我们将通过 Selenium 测试基于 Ajax 的点赞功能,执行以下步骤:

  1. 在项目设置中,添加一个TESTS_SHOW_BROWSER设置:
# myproject/settings/_base.py
TESTS_SHOW_BROWSER = True
  1. 在您的locations应用中创建tests模块,并在其中添加一个test_frontend.py文件,内容如下:
# myproject/apps/locations/tests/test_frontend.py
import os
from io import BytesIO
from time import sleep

from django.core.files.storage import default_storage
from django.test import LiveServerTestCase
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import User
from django.conf import settings
from django.test import override_settings
from django.urls import reverse
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.support.ui import WebDriverWait
from myproject.apps.likes.models import Like
from ..models import Location

SHOW_BROWSER = getattr(settings, "TESTS_SHOW_BROWSER", False)

@override_settings(DEBUG=True)
class LiveLocationTest(LiveServerTestCase):
    @classmethod
    def setUpClass(cls):
        super(LiveLocationTest, cls).setUpClass()
        driver_path = os.path.join(settings.BASE_DIR, "drivers", 
        "chromedriver")
        chrome_options = Options()
        if not SHOW_BROWSER:
 chrome_options.add_argument("--headless")
        chrome_options.add_argument("--window-size=1200,800")

        cls.browser = webdriver.Chrome(
            executable_path=driver_path, options=chrome_options
        )
        cls.browser.delete_all_cookies()

        image_path = cls.save_test_image("test.jpg")
        cls.location = Location.objects.create(
            name="Park Güell",
            description="If you want to see something spectacular, 
             come to Barcelona, Catalonia, Spain and visit Park 
             Güell. Located on a hill, Park Güell is a public 
             park with beautiful gardens and organic 
             architectural elements.",
            picture=image_path,  # dummy path
        )
        cls.username = "admin"
        cls.password = "admin"
        cls.superuser = User.objects.create_superuser(
            username=cls.username, password=cls.password, 
             email="admin@example.com"
        )

    @classmethod
    def tearDownClass(cls):
        super(LiveLocationTest, cls).tearDownClass()
        cls.browser.quit()
        cls.location.delete()
        cls.superuser.delete()

    @classmethod
    def save_test_image(cls, filename):
        from PIL import Image

        image = Image.new("RGB", (1, 1), 0)
        image_buffer = BytesIO()
        image.save(image_buffer, format="JPEG")
        path = f"tests/{filename}"
        default_storage.save(path, image_buffer)
        return path

    def wait_a_little(self):
        if SHOW_BROWSER:
 sleep(2)

    def test_login_and_like(self):
        # login
        login_path = reverse("admin:login")
        self.browser.get(
            f"{self.live_server_url}{login_path}?next=
          {self.location.get_url_path()}"
        )
        username_field = 
        self.browser.find_element_by_id("id_username")
        username_field.send_keys(self.username)
        password_field = 
        self.browser.find_element_by_id("id_password")
        password_field.send_keys(self.password)
        self.browser.find_element_by_css_selector
        ('input[type="submit"]').click()
        WebDriverWait(self.browser, timeout=10).until(
            lambda x: 
       self.browser.find_element_by_css_selector(".like-button")
        )
        # click on the "like" button
        like_button = 
       self.browser.find_element_by_css_selector(".like-button")
        is_initially_active = "active" in 
         like_button.get_attribute("class")
        initial_likes = int(
            self.browser.find_element_by_css_selector
             (".like-badge").text
        )

        self.assertFalse(is_initially_active)
        self.assertEqual(initial_likes, 0)

        self.wait_a_little()

        like_button.click()
        WebDriverWait(self.browser, timeout=10).until(
            lambda x:  
            int(self.browser.find_element_by_css_selector
             (".like-badge").text) != initial_likes
        )
        likes_in_html = int(
            self.browser.find_element_by_css_selector
             (".like-badge").text
        )
        likes_in_db = Like.objects.filter(

       content_type=ContentType.objects.get_for_model(Location),
            object_id=self.location.pk,
        ).count()
        self.assertEqual(likes_in_html, 1)
        self.assertEqual(likes_in_html, likes_in_db)

        self.wait_a_little()

        self.assertGreater(likes_in_html, initial_likes)

        # click on the "like" button again to switch back to the 
        # previous state
        like_button.click()
        WebDriverWait(self.browser, timeout=10).until(
            lambda x: int(self.browser.find_element_by_css_selector
            (".like-badge").text) == initial_likes
        )

        self.wait_a_little()
  1. 运行locations应用的测试,如下所示:
(env)$ python manage.py test myproject.apps.locations --settings=myproject.settings.test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 4.284s

OK
Destroying test database for alias 'default'...

它是如何工作的...

当我们运行这些测试时,我们将看到一个 Chrome 窗口打开,显示管理登录屏幕的 URL,例如

http://localhost:63807/en/admin/login/?next=/en/locations/176255a9-9c07-4542-8324-83ac0d21b7c3/

用户名和密码字段将填写为 admin,然后您将被重定向到 Park Güell 位置的详细页面,URL 如下

http://localhost:63807/en/locations/176255a9-9c07-4542-8324-83ac0d21b7c3/。在那里,您将看到点赞按钮被点击两次,导致点赞和取消点赞操作。

如果我们将TESTS_SHOW_BROWSER设置为False(或将其全部删除)并再次运行测试,测试将以最小的等待时间在后台进行,而不会打开浏览器窗口。

让我们看看这在测试套件中是如何工作的。我们定义一个扩展LiveServerTestCase的类。这将创建一个测试套件,该测试套件将在一个随机未使用的端口(例如63807)下运行一个本地服务器。默认情况下,LiveServerTestCase以非 DEBUG 模式运行服务器。但是,我们使用override_settings()装饰器将其切换到 DEBUG 模式,以便使静态文件可访问而无需收集它们,并在任何页面上发生错误时显示错误回溯。setUpClass()类方法将在所有测试开始时执行,tearDownClass()类方法将在测试运行后执行。在中间,测试将执行所有以test开头的套件方法。

当我们开始测试时,会创建一个新的测试数据库。在setUpClass()中,我们创建一个浏览器对象,一个位置和一个超级用户。然后,执行test_login_and_like()方法,该方法打开管理登录页面,找到用户名字段,输入管理员的用户名,找到密码字段,输入管理员的密码,找到提交按钮,并点击它。然后,它等待最多 10 秒,直到页面上可以找到具有.like-button CSS 类的 DOM 元素。

正如您可能记得的在第四章中实现点赞小部件的教程,模板和 JavaScript,我们的小部件由两个元素组成:

  • 一个点赞按钮

  • 显示点赞总数的徽章

如果点击按钮,您的Like实例将通过 Ajax 调用添加或从数据库中删除。此外,徽章计数将更新以反映数据库中的点赞数。

在测试中,我们检查按钮的初始状态(是否具有.active CSS 类),检查初始点赞数,并模拟点击按钮。我们等待最多 10 秒,直到徽章中的计数发生变化。然后,我们检查徽章中的计数是否与数据库中位置的总点赞数匹配。我们还将检查徽章中的计数如何发生变化(增加)。最后,我们将再次模拟点击按钮,以切换回先前的状态。

最后,调用tearDownClass()方法,关闭浏览器并从测试数据库中删除位置和超级用户。

另请参阅

  • 在第四章中实现点赞小部件的教程,模板和 JavaScript

  • 使用模拟测试视图教程

  • 使用 Django REST 框架创建 API 的测试教程

  • 确保测试覆盖率教程

使用 Django REST 框架创建的 API 的测试

您应该已经了解如何编写单元测试和操作接受测试。在这个教程中,我们将介绍RESTful API 的组件接口测试,这是我们在本书中早些时候创建的。

如果您不熟悉 RESTful API 是什么以及 API 的用途,您可以在www.restapitutorial.com/上了解更多。

准备工作

让我们从第九章中的使用 Django REST 框架创建 API*配方中的music应用开始。

操作步骤...

要测试 RESTful API,请执行以下步骤:

  1. music应用中创建一个tests模块。在tests模块中,创建一个名为test_api.py的文件,并创建SongTests类。该类将具有setUpClass()tearDownClass()方法,如下所示:
# myproject/apps/music/tests/test_api.py
from django.contrib.auth.models import User
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from ..models import Song

class SongTests(APITestCase):
    @classmethod
    def setUpClass(cls):
        super().setUpClass()

        cls.superuser = User.objects.create_superuser(
            username="admin", password="admin", 
             email="admin@example.com"
        )

        cls.song = Song.objects.create(
            artist="Lana Del Rey",
            title="Video Games - Remastered",
            url="https://open.spotify.com/track/5UOo694cVvj
             cPFqLFiNWGU?si=maZ7JCJ7Rb6WzESLXg1Gdw",
        )

        cls.song_to_delete = Song.objects.create(
            artist="Milky Chance",
            title="Stolen Dance",
            url="https://open.spotify.com/track/3miMZ2IlJ
             iaeSWo1DohXlN?si=g-xMM4m9S_yScOm02C2MLQ",
        )

    @classmethod
    def tearDownClass(cls):
        super().tearDownClass()

        cls.song.delete()
        cls.superuser.delete()
  1. 添加一个 API 测试,检查列出歌曲:
    def test_list_songs(self):
        url = reverse("rest_song_list")
        data = {}
        response = self.client.get(url, data, format="json")

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data["count"], Song.objects.count())
  1. 添加一个 API 测试,检查单个歌曲的详细信息:
    def test_get_song(self):
        url = reverse("rest_song_detail", kwargs={"pk": self.song.pk})
        data = {}
        response = self.client.get(url, data, format="json")

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data["uuid"], str(self.song.pk))
        self.assertEqual(response.data["artist"], self.song.artist)
        self.assertEqual(response.data["title"], self.song.title)
        self.assertEqual(response.data["url"], self.song.url)
  1. 添加一个 API 测试,检查成功创建新歌曲:
 def test_create_song_allowed(self):
        # login
        self.client.force_authenticate(user=self.superuser)

        url = reverse("rest_song_list")
        data = {
            "artist": "Capital Cities",
            "title": "Safe And Sound",
            "url": "https://open.spotify.com/track/40Fs0YrUGu
              wLNQSaHGVfqT?si=2OUawusIT-evyZKonT5GgQ",
        }
        response = self.client.post(url, data, format="json")

        self.assertEqual(response.status_code, 
         status.HTTP_201_CREATED)

        song = Song.objects.filter(pk=response.data["uuid"])
        self.assertEqual(song.count(), 1)

        # logout
        self.client.force_authenticate(user=None)
  1. 添加一个尝试在没有身份验证的情况下创建歌曲并因此失败的测试:
 def test_create_song_restricted(self):
        # make sure the user is logged out
        self.client.force_authenticate(user=None)

        url = reverse("rest_song_list")
        data = {
            "artist": "Men I Trust",
            "title": "Tailwhip",
            "url": "https://open.spotify.com/track/2DoO0sn4S
              bUrz7Uay9ACTM?si=SC_MixNKSnuxNvQMf3yBBg",
        }
        response = self.client.post(url, data, format="json")

        self.assertEqual(response.status_code, 
         status.HTTP_403_FORBIDDEN)
  1. 添加一个检查成功更改歌曲的测试:
def test_change_song_allowed(self):
        # login
        self.client.force_authenticate(user=self.superuser)

        url = reverse("rest_song_detail", kwargs=
         {"pk": self.song.pk})

        # change only title
        data = {
            "artist": "Men I Trust",
            "title": "Tailwhip",
            "url": "https://open.spotify.com/track/2DoO0sn4S
              bUrz7Uay9ACTM?si=SC_MixNKSnuxNvQMf3yBBg",
        }
        response = self.client.put(url, data, format="json")

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.data["uuid"], str(self.song.pk))
        self.assertEqual(response.data["artist"], data["artist"])
        self.assertEqual(response.data["title"], data["title"])
        self.assertEqual(response.data["url"], data["url"])

        # logout
        self.client.force_authenticate(user=None)
  1. 添加一个检查由于缺少身份验证而导致更改失败的测试:
def test_change_song_restricted(self):
        # make sure the user is logged out
        self.client.force_authenticate(user=None)

        url = reverse("rest_song_detail", kwargs=
         {"pk": self.song.pk})

        # change only title
        data = {
            "artist": "Capital Cities",
            "title": "Safe And Sound",
            "url": "https://open.spotify.com/track/40Fs0YrU
             GuwLNQSaHGVfqT?si=2OUawusIT-evyZKonT5GgQ",
        }
        response = self.client.put(url, data, format="json")

        self.assertEqual(response.status_code, 
         status.HTTP_403_FORBIDDEN)
  1. 添加一个检查歌曲删除失败的测试:
    def test_delete_song_restricted(self):
        # make sure the user is logged out
        self.client.force_authenticate(user=None)

        url = reverse("rest_song_detail", kwargs=
         {"pk": self.song_to_delete.pk})

        data = {}
        response = self.client.delete(url, data, format="json")

        self.assertEqual(response.status_code, 
         status.HTTP_403_FORBIDDEN)
  1. 添加一个检查成功删除歌曲的测试:
  def test_delete_song_allowed(self):
        # login
        self.client.force_authenticate(user=self.superuser)

        url = reverse("rest_song_detail", kwargs=
         {"pk": self.song_to_delete.pk})

        data = {}
        response = self.client.delete(url, data, format="json")

        self.assertEqual(response.status_code, 
         status.HTTP_204_NO_CONTENT)

        # logout
        self.client.force_authenticate(user=None)
  1. 运行music应用的测试,如下所示:
(env)$python manage.py test myproject.apps.music --settings=myproject.settings.test
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
........
----------------------------------------------------------------------
Ran 8 tests in 0.370s

OK
Destroying test database for alias 'default'...

它是如何工作的...

这个 RESTful API 测试套件扩展了APITestCase类。再次,我们有setUpClass()tearDownClass()类方法,它们将在不同测试之前和之后执行。此外,测试套件具有APIClient类型的 client 属性,可用于模拟 API 调用。客户端提供所有标准 HTTP 调用的方法:get()post()put()patch()delete()head()options()

在我们的测试中,我们使用GETPOSTDELETE请求。此外,客户端还具有根据登录凭据、令牌或User对象强制对用户进行身份验证的方法。在我们的测试中,我们正在进行第三种身份验证:直接将用户传递给force_authenticate()方法。

代码的其余部分是不言自明的。

另请参阅

  • 第九章中的使用 Django REST 框架创建 API*配方,导入和导出数据

  • 使用模拟测试视图配方

  • 使用 Selenium 测试用户界面配方

  • 确保测试覆盖率配方

确保测试覆盖率

Django 允许快速原型设计和从想法到实现的项目构建。但是,为了确保项目稳定且可用于生产,您应该尽可能多地对功能进行测试。通过测试覆盖率,您可以检查项目代码的测试覆盖率。让我们看看您可以如何做到这一点。

准备工作

为您的项目准备一些测试。

在您的虚拟环境中安装coverage实用程序:

(env)$ pip install coverage~=5.0.1

操作步骤...

这是如何检查项目的测试覆盖率的:

  1. 为覆盖率实用程序创建一个名为setup.cfg的配置文件,内容如下:
# setup.cfg
[coverage:run]
source = .
omit =
    media/*
    static/*
    tmp/*
    drivers/*
    locale/*
    myproject/site_static/*
    myprojext/templates/*
  1. 如果您使用 Git 版本控制,请确保在.gitignore文件中有这些行:
# .gitignore
htmlcov/
.coverage
.coverage.*
coverage.xml
*.cover
  1. 创建一个名为run_tests_with_coverage.sh的 shell 脚本,其中包含运行测试并报告结果的命令:
# run_tests_with_coverage.sh
#!/usr/bin/env bash
coverage erase
coverage run manage.py test --settings=myproject.settings.test
coverage report
  1. 为该脚本添加执行权限:
(env)$ chmod +x run_tests_with_coverage.sh
  1. 运行脚本:
(env)$ ./run_tests_with_coverage.sh 
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
...........
----------------------------------------------------------------------
Ran 11 tests in 12.940s

OK
Destroying test database for alias 'default'...
Name Stmts Miss Cover
-----------------------------------------------------------------------------------------------
manage.py 12 2 83%
myproject/__init__.py 0 0 100%
myproject/apps/__init__.py 0 0 100%
myproject/apps/core/__init__.py 0 0 100%
myproject/apps/core/admin.py 16 10 38%
myproject/apps/core/context_processors.py 3 0 100%
myproject/apps/core/model_fields.py 48 48 0%
myproject/apps/core/models.py 87 29 67%
myproject/apps/core/templatetags/__init__.py 0 0 100%
myproject/apps/core/templatetags/utility_tags.py 171 135 21%

the statistics go on…

myproject/settings/test.py 5 0 100%
myproject/urls.py 10 0 100%
myproject/wsgi.py 4 4 0%
-----------------------------------------------------------------------------------------------
TOTAL 1363 712 48%

它是如何工作的...

覆盖率实用程序运行测试并检查有多少行代码被测试覆盖。在我们的示例中,我们编写的测试覆盖了 48%的代码。如果项目稳定性对您很重要,那么在有时间的时候,尽量接近 100%。

在覆盖配置中,我们跳过了静态资产、模板和其他非 Python 文件。

另请参阅

  • 使用模拟测试视图配方

  • 使用 Selenium 测试用户界面配方

  • 使用 Django REST 框架创建的 API 进行测试配方

第十二章:部署

在本章中,我们将涵盖以下内容:

  • 发布可重用的 Django 应用程序

  • 在 Apache 上使用 mod_wsgi 进行暂存环境的部署

  • 在 Apache 上使用 mod_wsgi 进行生产环境的部署

  • 在 Nginx 和 Gunicorn 上部署暂存环境

  • 在生产环境中使用 Nginx 和 Gunicorn 进行部署

介绍

一旦您有了一个可用的网站或可重用的应用程序,您就会希望将其公开。部署网站是 Django 开发中最困难的活动之一,因为有许多需要解决的问题:

  • 管理 Web 服务器

  • 配置数据库

  • 提供静态和媒体文件

  • 处理 Django 项目

  • 配置缓存

  • 设置发送电子邮件

  • 管理域名

  • 安排后台任务和定时作业

  • 设置持续集成

  • 其他任务,取决于您的项目规模和复杂性

在更大的团队中,所有这些任务都是由 DevOps 工程师完成的,他们需要像深入了解网络和计算机架构、管理 Linux 服务器、bash 脚本编写、使用 vim 等技能。

专业网站通常有开发暂存生产环境。它们每个都有特定的目的。开发环境用于创建项目。生产环境是托管公共网站的服务器(或服务器)。暂存环境在技术上类似于生产环境,但用于在发布新功能和优化之前进行检查。

技术要求

要使用本章的代码,您需要最新稳定版本的 Python、MySQL 或 PostgreSQL,以及一个带有虚拟环境的 Django 项目。

您可以在 GitHub 存储库的ch12目录中找到本章的所有代码,网址为github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition

发布可重用的 Django 应用程序

Django 文档中有一个关于如何打包可重用应用程序的教程,以便以后可以在任何虚拟环境中使用 pip 进行安装。请访问docs.djangoproject.com/en/3.0/intro/reusable-apps/​。

然而,还有另一种(可能更好的)打包和发布可重用的 Django 应用程序的方法,使用该工具为不同的编码项目创建模板,例如新的 Django CMS 网站、Flask 网站或 jQuery 插件。其中一个可用的项目模板是cookiecutter-djangopackage。在这个教程中,您将学习如何使用它来分发可重用的likes应用程序。

准备工作

使用虚拟环境创建一个新项目,并在其中安装cookiecutter,如下所示:

(env)$ pip install cookiecutter~=1.7.0

如何做...

要发布您的likes应用程序,请按照以下步骤进行:

  1. 按照以下步骤启动一个新的 Django 应用项目:
(env)$ cookiecutter https://github.com/pydanny/cookiecutter-djangopackage.git

或者,由于这是一个托管在 GitHub 上的cookiecutter模板,我们可以使用简写语法,如下所示:

(env)$ cookiecutter gh:pydanny/cookiecutter-djangopackage
  1. 回答问题以创建应用程序模板,如下所示:
full_name [Your full name here]: Aidas Bendoraitis
email [you@example.com]: aidas@bendoraitis.lt
github_username [yourname]: archatas
project_name [Django Package]: django-likes
repo_name [dj-package]: django-likes
app_name [django_likes]: likes
app_config_name [LikesConfig]: 
project_short_description [Your project description goes here]: Django app for liking anything on your website.
models [Comma-separated list of models]: Like
django_versions [1.11,2.1]: master
version [0.1.0]: 
create_example_project [N]: 
Select open_source_license:
1 - MIT
2 - BSD
3 - ISCL
4 - Apache Software License 2.0
5 - Not open source
Choose from 1, 2, 3, 4, 5 [1]: 

这将创建一个基本的文件结构,用于可发布的 Django 包,类似于以下内容:

django-likes/
├── docs/
│   ├── Makefile
│   ├── authors.rst
│   ├── conf.py
│   ├── contributing.rst
│   ├── history.rst
│   ├── index.rst
│   ├── installation.rst
│   ├── make.bat
│   ├── readme.rst
│   └── usage.rst
├── likes/
│   ├── static/
│   │   ├── css/
│   │   │   └── likes.css
│   │   ├── img/
│   │   └── js/
│   │       └── likes.js
│   ├── templates/
│   │   └── likes/
│   │       └── base.html
│   └── test_utils/
│       ├── test_app/
|       │   ├── migrations/
│       │   │   └── __init__.py
│       │   ├── __init__.py
│       │   ├── admin.py
│       │   ├── apps.py
│       │   └── models.html
│       ├── __init__.py
│       ├── admin.py
│       ├── apps.py
│       ├── models.py
│       ├── urls.py
│       └── views.py
├── tests/
│   ├── __init__.py
│   ├── README.md
│   ├── requirements.txt
│   ├── settings.py
│   ├── test_models.py
│   └── urls.py
├── .coveragerc
├── .editorconfig
├── .gitignore
├── .travis.yml
├── AUTHORS.rst
├── CONTRIBUTING.rst
├── HISTORY.rst
├── LICENSE
├── MANIFEST.in
├── Makefile
├── README.rst
├── manage.py
├── requirements.txt
├── requirements_dev.txt
├── requirements_test.txt
├── runtests.py
├── setup.cfg
├── setup.py*
└── tox.ini
  1. likes应用程序的文件从您正在使用的 Django 项目复制到django-likes/likes目录。在cookiecutter创建相同文件的情况下,内容需要合并,而不是覆盖。例如,likes/__init__.py文件需要包含一个版本字符串,以便在后续步骤中与setup.py正常工作,如下所示:
# django-likes/likes/__init__.py __version__ = '0.1.0'
  1. 重新安排依赖项,以便不再从 Django 项目导入,并且所有使用的函数和类都在此应用程序内部。例如,在likes应用程序中,我们依赖于core应用程序中的一些混合。我们需要将相关代码直接复制到django-likes应用程序的文件中。

或者,如果有很多依赖代码,我们可以将core应用程序作为一个不耦合的包发布,但然后我们必须单独维护它。

  1. 将可重用的应用程序项目添加到 GitHub 的 Git 存储库中,使用之前输入的repo_name

  2. 浏览不同的文件并完成许可证、README、文档、配置和其他文件。

  3. 确保应用程序通过cookiecutter模板测试:

(env)$ pip install -r requirements_test.txt
(env)$ python runtests.py 
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
.
----------------------------------------------------------------------
Ran 1 test in 0.001s

OK
Destroying test database for alias 'default'...
  1. 如果您的软件包是闭源的,可以创建一个可共享的 ZIP 存档作为发布,如下所示:
(env)$ python setup.py sdist

这将创建一个django-likes/dist/django-likes-0.1.0.tar.gz文件,然后可以使用pip安装或卸载到任何项目的虚拟环境中,如下所示:

(env)$ pip install django-likes-0.1.0.tar.gz
(env)$ pip uninstall django-likes
  1. 如果您的软件包是开源的,可以将您的应用程序注册并发布到 Python 包索引(PyPI):
(env)$ python setup.py register
(env)$ python setup.py publish
  1. 此外,为了宣传,通过在www.djangopackages.com/packages/add/提交表单,将您的应用程序添加到 Django 包中。

它是如何工作的...

Cookiecutter在 Django 应用程序项目模板的不同部分中填写请求的数据,如果您只是按下Enter而不输入任何内容,则使用方括号中给出的默认值。结果,您将得到setup.py文件,准备好分发到 Python 包索引、Sphinx 文档、MIT 作为默认许可证、项目的通用文本编辑器配置、包含在您的应用程序中的静态文件和模板,以及其他好东西。

另请参阅

  • 第一章, Getting Started with Django 3.0中的创建项目文件结构教程

  • 第一章, Getting Started with Django 3.0中的使用 Docker 容器处理 Django、Gunicorn、Nginx 和 PostgreSQL教程

  • 第一章, Getting Started with Django 3.0中的使用 pip 处理项目依赖教程

  • 第四章, Templates and JavaScript中的实现 Like 小部件教程

  • 第十一章, Testing中的使用模拟测试视图教程

在 Apache 上使用 mod_wsgi 进行暂存环境部署

在这个教程中,我将向您展示如何创建一个脚本,将您的项目部署到计算机上的虚拟机上的暂存环境。该项目将使用带有mod_wsgi模块的Apache网络服务器。对于安装,我们将使用AnsibleVagrantVirtualBox。如前所述,有很多细节需要注意,通常需要几天时间来开发类似于此的最佳部署脚本。

准备工作

查看部署清单,并确保您的配置符合列在docs.djangoproject.com/en/3.0/howto/deployment/checklist/上的所有安全建议。至少确保在运行以下内容时,您的项目配置不会引发警告:

(env)$ python manage.py check --deploy --settings=myproject.settings.staging

安装最新稳定版本的 Ansible、Vagrant 和 VirtualBox。您可以从以下官方网站获取它们:

在 macOS X 上,您可以使用HomeBrew安装它们:

$ brew install ansible
$ brew cask install virtualbox
$ brew cask install vagrant

如何做...

首先,我们需要为服务器上使用的不同服务创建一些配置模板。暂存和生产部署过程都将使用它们:

  1. 在您的 Django 项目中,创建一个deployment目录,并在其中创建一个ansible_templates目录。

  2. 为时区配置创建一个 Jinja 模板文件:

{# deployment/ansible_templates/timezone.j2 #} {{ timezone }}
  1. 在设置 SSL 证书之前,为 Apache 域配置创建一个 Jinja 模板文件:
{# deployment/ansible_templates/apache_site-pre.conf.j2 #} <VirtualHost *:80>
    ServerName {{ domain_name }}
    ServerAlias {{ domain_name }} www.{{ domain_name }}

    DocumentRoot {{ project_root }}/public_html
    DirectoryIndex index.html

    ErrorLog ${APACHE_LOG_DIR}/error.log
    CustomLog ${APACHE_LOG_DIR}/access.log combined

    AliasMatch ^/.well-known/(.*) "/var/www/letsencrypt/$1"

    <Directory "/var/www/letsencrypt">
        Require all granted
    </Directory>

    <Directory "/">
        Require all granted
    </Directory>

</VirtualHost>
  1. 为 Apache 域配置创建一个 Jinja 模板文件deployment/ansible_templates/apache_site.conf.j2,还包括 SSL 证书。对于此文件,从raw.githubusercontent.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition/master/ch12/myproject_virtualenv/src/django-myproject/deployment-apache/ansible_templates/apache_site.conf.j2复制内容。

  2. 创建一个用于 PostgreSQL 配置文件deployment/ansible_templates/postgresql.j2的模板,内容来自github.com/postgres/postgres/blob/REL_10_STABLE/src/backend/utils/misc/postgresql.conf.sample。稍后,您可以在那里调整配置以匹配服务器需求。

  3. 创建一个用于 PostgreSQL 权限配置文件的模板(目前非常宽松,但稍后可以根据需要进行调整):

{# deployment/ansible_templates/pg_hba.j2 #} # TYPE  DATABASE        USER            CIDR-ADDRESS    METHOD
local   all             all                             ident
host    all             all             ::0/0           md5
host    all             all             0.0.0.0/32      md5
host    {{ db_name }}   {{ db_user }}   127.0.0.1/32    md5
  1. 为 Postfix 电子邮件服务器配置创建一个模板:
{# deployment/ansible_templates/postfix.j2 #} # See /usr/share/postfix/main.cf.dist for a commented, more  
# complete version

# Debian specific:  Specifying a file name will cause the first
# line of that file to be used as the name.  The Debian default
# is /etc/mailname.
# myorigin = /etc/mailname

smtpd_banner = $myhostname ESMTP $mail_name (Ubuntu)
biff = no

# appending .domain is the MUA's job.
append_dot_mydomain = no

# Uncomment the next line to generate "delayed mail" warnings
# delay_warning_time = 4h

readme_directory = no

# TLS parameters
smtpd_tls_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem
smtpd_tls_key_file=/etc/ssl/private/ssl-cert-snakeoil.key
smtpd_use_tls=yes
smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache

# See /usr/share/doc/postfix/TLS_README.gz in the postfix-doc 
# package for information on enabling SSL in 
# the smtp client.

smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = {{ domain_name }}
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
mydestination = $myhostname, localhost, localhost.localdomain, ,  
 localhost
relayhost =
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
mailbox_size_limit = 0
recipient_delimiter = +
inet_interfaces = all
inet_protocols = all
virtual_alias_domains = {{ domain_name }}
virtual_alias_maps = hash:/etc/postfix/virtual
  1. 创建一个用于电子邮件转发配置的模板:
{# deployment/ansible_templates/virtual.j2 #} # /etc/postfix/virtual

hello@{{ domain_name }} admin@example.com
@{{ domain_name }} admin@example.com
  1. 创建一个用于memcached配置的模板:
{# deployment/ansible_templates/memcached.j2 #} # memcached default config file
# 2003 - Jay Bonci <jaybonci@debian.org>
# This configuration file is read by the start-memcached script 
# provided as part of the Debian GNU/Linux 
# distribution.

# Run memcached as a daemon. This command is implied, and is not
# needed for the daemon to run. See the README.Debian that 
# comes with this package for more information.
-d

# Log memcached's output to /var/log/memcached
logfile /var/log/memcached.log

# Be verbose
# -v

# Be even more verbose (print client commands as well)
# -vv

# Use 1/16 of server RAM for memcached
-m {{ (ansible_memtotal_mb * 0.0625) | int }}

# Default connection port is 11211
-p 11211

# Run the daemon as root. The start-memcached will default to 
# running as root if no -u command is present 
# in this config file
-u memcache

# Specify which IP address to listen on. The default is to 
# listen on all IP addresses
# This parameter is one of the only security measures that 
# memcached has, so make sure it's listening on 
# a firewalled interface.
-l 127.0.0.1

# Limit the number of simultaneous incoming connections. 
# The daemon default is 1024
# -c 1024

# Lock down all paged memory. Consult with the README and 
# homepage before you do this
# -k

# Return error when memory is exhausted (rather than 
# removing items)
# -M

# Maximize core file limit
# -r
  1. 最后,为secrets.json文件创建一个 Jinja 模板:
{# deployment/ansible_templates/secrets.json.j2 #} {
    "DJANGO_SECRET_KEY": "{{ django_secret_key }}",
    "DATABASE_ENGINE": "django.contrib.gis.db.backends.postgis",
    "DATABASE_NAME": "{{ db_name }}",
    "DATABASE_USER": "{{ db_user }}",
    "DATABASE_PASSWORD": "{{ db_password }}",
    "EMAIL_HOST": "{{ email_host }}",
    "EMAIL_PORT": "{{ email_port }}",
    "EMAIL_HOST_USER": "{{ email_host_user }}",
    "EMAIL_HOST_PASSWORD": "{{ email_host_password }}"
} 

现在,让我们来处理特定于 staging 环境的 Vagrant 和 Ansible 脚本:

  1. .gitignore文件中,添加忽略一些 Vagrant 和 Ansible 特定文件的行:
# .gitignore # Secrets
secrets.jsonsecrets.yml

# Vagrant / Ansible
.vagrant
*.retry
  1. 创建两个目录,deployment/stagingdeployment/staging/ansible

  2. 在那里创建一个Vagrantfile文件,其中包含以下脚本,用于设置一个带有 Ubuntu 18 的虚拟机,并在其中运行 Ansible 脚本:

# deployment/staging/ansible/Vagrantfile
VAGRANTFILE_API_VERSION = "2"

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
  config.vm.box = "bento/ubuntu-18.04"
  config.vm.box_version = "201912.14.0"
  config.vm.box_check_update = false
  config.ssh.insert_key=false
  config.vm.provider "virtualbox" do |v|
    v.memory = 512
    v.cpus = 1
    v.name = "myproject"
  end
  config.vm.network "private_network", ip: "192.168.50.5"
  config.vm.provision "ansible" do |ansible|
    ansible.limit = "all"
    ansible.playbook = "setup.yml"
    ansible.inventory_path = "./hosts/vagrant"
    ansible.host_key_checking = false
    ansible.verbose = "vv"
    ansible.extra_vars = { ansible_python_interpreter: 
    "/usr/bin/python3" }
  end
end

  1. 创建一个包含vagrant文件的hosts目录,其中包含以下内容:
# deployment/staging/ansible/hosts/vagrant
[servers]
192.168.50.5
  1. 在那里创建一个vars.yml文件,其中包含将在安装脚本和 Jinja 模板中使用的变量:
# deployment/staging/ansible/vars.yml
---
# a unix path-friendly name (IE, no spaces or special characters)
project_name: myproject

user_username: "{{ project_name }}"

# the base path to install to. You should not need to change this.
install_root: /home

project_root: "{{ install_root }}/{{ project_name }}"

# the python module path to your project's wsgi file
wsgi_module: myproject.wsgi

# any directories that need to be added to the PYTHONPATH.
python_path: "{{ project_root }}/src/{{ project_name }}"

# the git repository URL for the project
project_repo: git@github.com:archatas/django-myproject.git

# The value of your django project's STATIC_ROOT settings.
static_root: "{{ python_path }}/static"
media_root: "{{ python_path }}/media"

locale: en_US.UTF-8
timezone: Europe/Berlin

domain_name: myproject.192.168.50.5.xip.io
django_settings: myproject.settings.staging

letsencrypt_email: ""
wsgi_file_name: wsgi_staging.py
  1. 此外,我们还需要一个secrets.yml文件,其中包含密码和认证密钥等秘密值。首先,创建一个sample_secrets.yml文件,其中不包含敏感信息,只有变量名称,然后将其复制到secrets.yml中,并填写秘密信息。前者将受版本控制,而后者将被忽略:
# deployment/staging/ansible/sample_secrets.yml # Django Secret Key
django_secret_key: "change-this-to-50-characters-
 long-random-string"

# PostgreSQL database settings
db_name: "myproject"
db_user: "myproject"
db_password: "change-this-to-a-secret-password"
db_host: "localhost"
db_port: "5432"

# Email SMTP settings
email_host: "localhost"
email_port: "25"
email_host_user: ""
email_host_password: ""

# a private key that has access to the repository URL
ssh_github_key: ~/.ssh/id_rsa_github
  1. 现在,在deployment/staging/ansible/setup.yml创建一个 Ansible 脚本(所谓的playbook),用于安装所有依赖项和配置服务。从raw.githubusercontent.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition/master/ch12/myproject_virtualenv/src/django-myproject/deployment-apache/staging/ansible/setup.yml复制此文件的内容。

  2. 然后在deployment/staging/ansible/deploy.yml创建另一个 Ansible 脚本,用于处理 Django 项目。从raw.githubusercontent.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition/master/ch12/myproject_virtualenv/src/django-myproject/deployment-apache/staging/ansible/deploy.yml复制此文件的内容。

  3. 并创建一个 bash 脚本,您可以执行以启动部署:

# deployment/staging/ansible/setup_on_virtualbox.sh #!/usr/bin/env bash
echo "=== Setting up the local staging server ==="
date

cd "$(dirname "$0")"
vagrant up --provision
  1. 为 bash 脚本添加执行权限并运行它:
$ chmod +x setup_on_virtualbox.sh
$ ./setup_on_virtualbox.sh
  1. 如果脚本出现错误,很可能需要重新启动虚拟机才能生效。您可以通过ssh连接到虚拟机,切换到 root 用户,然后按以下步骤重新启动:
$ vagrant ssh
Welcome to Ubuntu 18.04.3 LTS (GNU/Linux 4.15.0-72-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

 System information as of Wed Jan 15 04:44:42 CET 2020

 System load:  0.21              Processes:           126
 Usage of /:   4.0% of 61.80GB   Users logged in:     1
 Memory usage: 35%               IP address for eth0: 10.0.2.15
 Swap usage:   4%                IP address for eth1: 192.168.50.5

0 packages can be updated.
0 updates are security updates.

*** System restart required ***

This system is built by the Bento project by Chef Software
More information can be found at https://github.com/chef/bento
Last login: Wed Jan 15 04:43:32 2020 from 192.168.50.1
vagrant@myproject:~$ sudo su
root@myproject:/home/vagrant#
reboot
Connection to 127.0.0.1 closed by remote host.
Connection to 127.0.0.1 closed.
  1. 要浏览 Django 项目目录,ssh到虚拟机并将用户更改为myproject
$ vagrant ssh
Welcome to Ubuntu 18.04.3 LTS (GNU/Linux 4.15.0-74-generic x86_64)
# … 
vagrant@myproject:~$ sudo su - myproject
(env) myproject@myproject:~$ pwd
/home/myproject
(env) myproject@myproject:~$ ls
commands db_backups logs public_html src env

工作原理...

VirtualBox 允许您在计算机上拥有多个具有不同操作系统的虚拟机。Vagrant 是一个工具,允许您创建这些虚拟机,并使用脚本下载和安装操作系统。Ansible 是一个基于 Python 的实用程序,它从.yaml配置文件中读取指令,并在远程服务器上执行它们。

我们刚刚编写的部署脚本执行以下操作:

  • 在 VirtualBox 中创建一个虚拟机并安装 Ubuntu 18

  • 将虚拟机的 IP 分配为192.168.50.5

  • 为虚拟机设置主机名

  • 升级 Linux 软件包

  • 为服务器设置本地化设置

  • 安装所有 Linux 依赖项,包括 Python,Apache,PostgreSQL,Postfix,Memcached 等

  • 为 Django 项目创建一个 Linux 用户和home目录

  • 为 Django 项目创建虚拟环境

  • 创建 PostgreSQL 数据库用户和数据库

  • 配置 Apache web 服务器

  • 安装自签名 SSL 证书

  • 配置 Memcached 缓存服务

  • 配置 Postfix 邮件服务器

  • 克隆 Django 项目存储库

  • 安装 Python 依赖项

  • 创建secrets.json文件

  • 迁移数据库

  • 收集静态文件

  • 重新启动 Apache

现在 Django 网站将可以在https://www.myproject.192.168.50.5.xip.io上访问,并显示一个 Hello, World!页面。请注意,一些浏览器,如 Chrome,可能不希望打开具有自签名 SSL 证书的网站,并且会将其作为安全措施进行阻止。

xip.io 是一个通配符 DNS 服务,将特定于 IP 的子域指向 IP,并允许您将其用于 SSL 证书或其他需要域的网站功能。

如果要尝试不同的配置或附加命令,逐步进行小步骤的更改是合理的。对于某些部分,您需要在虚拟机上直接测试,然后再将任务转换为 Ansible 指令。

有关如何使用 Ansible 的信息,请查看官方文档docs.ansible.com/ansible/latest/index.html。它显示了大量有用的指令示例,适用于大多数用例。

如果出现任何服务错误,ssh到虚拟机,切换到 root 用户,并检查该服务的日志。通过谷歌错误消息可以更接近一个可用的系统。

要重建虚拟机,请使用以下命令:

$ vagrant destroy
$ vagrant up --provision

另请参阅

  • 第一章, Django 3.0 入门中的创建虚拟环境项目文件结构的步骤

  • 第一章, Django 3.0 入门中的使用 pip 处理项目依赖的步骤

  • 第一章, Django 3.0 入门中的为 Git 用户动态设置 STATIC_URL的步骤

  • 在生产环境中使用 Apache 和 mod_wsgi 部署的步骤

  • 在暂存环境中使用 Nginx 和 Gunicorn 部署的步骤

  • 在生产环境中使用 Nginx 和 Gunicorn 部署的步骤

  • 第十三章, 维护中的创建和恢复 PostgreSQL 数据库备份的步骤

  • 第十三章, 维护中的为常规任务设置 cron 作业的步骤

在生产环境中使用 Apache 和 mod_wsgi 部署

Apache 是最流行的 Web 服务器之一。如果您还必须在同一服务器上运行一些需要 Apache 的服务器管理、监控、分析、博客、电子商务等服务,那么将 Django 项目部署在 Apache 下是有意义的。

在本教程中,我们将继续从上一个教程中继续工作,并实现一个 Ansible 脚本(playbook),以在Apache上使用mod_wsgi模块设置生产环境。

准备工作

确保在运行以下命令时,项目配置不会引发警告:

(env)$ python manage.py check --deploy -- 
 settings=myproject.settings.production

确保您拥有最新的稳定版本的 Ansible。

选择一个服务器提供商,在那里创建一个具有通过 SSH 的根访问权限的专用服务器,并使用私钥和公钥进行身份验证。我选择的提供商是 DigitalOcean(www.digitalocean.com/),我在那里创建了一个带有 Ubuntu 18 的专用服务器(Droplet)。我可以通过其 IP142.93.167.30连接到服务器,使用新的 SSH 私钥和公钥对~/.ssh/id_rsa_django_cookbook~/.ssh/id_rsa_django_cookbook.pub

在本地,我们需要通过创建或修改~/.ssh/config文件来配置 SSH 连接,内容如下:

# ~/.ssh/config
Host *
    ServerAliveInterval 240
    AddKeysToAgent yes
    UseKeychain yes

Host github
    Hostname github.com
    IdentityFile ~/.ssh/id_rsa_github

Host myproject-apache
    Hostname 142.93.167.30
    User root
    IdentityFile ~/.ssh/id_rsa_django_cookbook

现在,我们应该能够使用以下命令作为 root 用户通过 SSH 连接到专用服务器:

$ ssh myproject-apache

在您的域配置中,将您的域的DNS A 记录指向专用服务器的 IP 地址。在我们的情况下,我们将只使用myproject.142.93.167.30.xip.io来展示如何为 Django 网站设置服务器的 SSL 证书。

如前所述,xip.io 是一个通配符 DNS 服务,它将特定于 IP 的子域指向 IP,并允许您将其用于需要域的 SSL 证书或其他网站功能。

如何操作...

要为生产创建部署脚本,请执行以下步骤:

  1. 确保具有我们在上一个在 Apache 上使用 mod_wsgi 部署到暂存环境教程中创建的用于服务配置的 Jinja 模板的deployment/ansible_templates目录。

  2. 为 Ansible 脚本创建deployment/productiondeployment/production/ansible目录。

  3. 在那里,创建一个包含以下内容的hosts目录和remote文件:

# deployment/production/ansible/hosts/remote
[servers]
myproject-apache

[servers:vars]
ansible_python_interpreter=/usr/bin/python3
  1. 在那里创建一个vars.yml文件,其中包含将在安装脚本和 Jinja 模板中使用的变量:
# deployment/production/ansible/vars.yml
---
# a unix path-friendly name (IE, no spaces or special characters)
project_name: myproject

user_username: "{{ project_name }}"

# the base path to install to. You should not need to change this.
install_root: /home

project_root: "{{ install_root }}/{{ project_name }}"

# the python module path to your project's wsgi file
wsgi_module: myproject.wsgi

# any directories that need to be added to the PYTHONPATH.
python_path: "{{ project_root }}/src/{{ project_name }}"

# the git repository URL for the project
project_repo: git@github.com:archatas/django-myproject.git

# The value of your django project's STATIC_ROOT settings.
static_root: "{{ python_path }}/static"
media_root: "{{ python_path }}/media"

locale: en_US.UTF-8
timezone: Europe/Berlin

domain_name: myproject.142.93.167.30.xip.io
django_settings: myproject.settings.production

# letsencrypt settings
letsencrypt_email: hello@myproject.com
wsgi_file_name: wsgi_production.py
  1. 此外,我们还需要一个secrets.yml文件,其中包含密码和身份验证密钥等秘密值。首先创建一个sample_secrets.yml文件,其中不包含敏感信息,只有变量名称,然后将其复制到secrets.yml并填写秘密信息。前者将受版本控制,而后者将被忽略:
# deployment/production/ansible/sample_secrets.yml # Django Secret Key
django_secret_key: "change-this-to-50-characters-
 long-random-string"

# PostgreSQL database settings
db_name: "myproject"
db_user: "myproject"
db_password: "change-this-to-a-secret-password"
db_host: "localhost"
db_port: "5432"

# Email SMTP settings
email_host: "localhost"
email_port: "25"
email_host_user: ""
email_host_password: ""

# a private key that has access to the repository URL
ssh_github_key: ~/.ssh/id_rsa_github
  1. 现在,在deployment/production/ansible/setup.yml创建一个 Ansible 脚本(playbook),用于安装所有依赖项和配置服务。从raw.githubusercontent.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition/master/ch12/myproject_virtualenv/src/django-myproject/deployment-apache/production/ansible/setup.yml复制此文件的内容。

  2. 然后创建另一个 Ansible 脚本,deployment/production/ansible/deploy.yml,用于处理 Django 项目。从raw.githubusercontent.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition/master/ch12/myproject_virtualenv/src/django-myproject/deployment-apache/production/ansible/deploy.yml复制此文件的内容。

  3. 创建一个可以执行以开始部署的 bash 脚本:

# deployment/production/ansible/setup_remotely.sh #!/usr/bin/env bash
echo "=== Setting up the production server ==="
date

cd "$(dirname "$0")"
ansible-playbook setup.yml -i hosts/remote
  1. 为 bash 脚本添加执行权限并运行它:
$ chmod +x setup_remotely.sh
$ ./setup_remotely.sh
  1. 如果脚本出现错误,则可能需要重新启动专用服务器才能生效。 您可以通过ssh连接到服务器并按以下方式重新启动:
$ ssh myproject-apache
Welcome to Ubuntu 18.04.3 LTS (GNU/Linux 4.15.0-74-generic x86_64)

 * Documentation: https://help.ubuntu.com
 * Management: https://landscape.canonical.com
 * Support: https://ubuntu.com/advantage

 System information as of Wed Jan 15 11:39:51 CET 2020

 System load: 0.08 Processes: 104
 Usage of /: 8.7% of 24.06GB Users logged in: 0
 Memory usage: 35% IP address for eth0: 142.93.167.30
 Swap usage: 0%

 * Canonical Livepatch is available for installation.
 - Reduce system reboots and improve kernel security. Activate at:
 https://ubuntu.com/livepatch

0 packages can be updated.
0 updates are security updates.

*** System restart required ***

Last login: Sun Jan 12 12:23:35 2020 from 178.12.115.146
root@myproject:~# reboot
Connection to 142.93.167.30 closed by remote host.
Connection to 142.93.167.30 closed.
  1. 创建另一个仅用于更新 Django 项目的 bash 脚本:
# deployment/production/ansible/deploy_remotely.sh #!/usr/bin/env bash
echo "=== Deploying project to production server ==="
date

cd "$(dirname "$0")"
ansible-playbook deploy.yml -i hosts/remote
  1. 为此 bash 脚本添加执行权限:
$ chmod +x deploy_remotely.sh

工作原理...

Ansible 脚本(playbook)是幂等的。 这意味着您可以多次执行它,您将始终获得相同的结果:安装并运行 Django 网站的最新专用服务器。 如果服务器出现任何技术硬件问题,并且具有数据库和媒体文件的备份,您可以相对快速地在另一个专用服务器上安装相同的配置。

生产部署脚本执行以下操作:

  • 为虚拟机设置主机名

  • 升级 Linux 软件包

  • 为服务器设置本地化设置

  • 安装包括 Python、Apache、PostgreSQL、Postfix、Memcached 等在内的所有 Linux 依赖项

  • 为 Django 项目创建 Linux 用户和home目录

  • 为 Django 项目创建虚拟环境

  • 创建 PostgreSQL 数据库用户和数据库

  • 配置 Apache Web 服务器

  • 安装Let's Encrypt SSL 证书

  • 配置 Memcached 缓存服务

  • 配置 Postfix 电子邮件服务器

  • 克隆 Django 项目存储库

  • 安装 Python 依赖项

  • 创建secrets.json文件

  • 迁移数据库

  • 收集静态文件

  • 重新启动 Apache

第一次需要安装服务和依赖项时运行setup_remotely.sh脚本。 稍后,如果只需要更新 Django 项目,可以使用deploy_remotely.sh。 如您所见,安装与暂存服务器上的安装非常相似,但是为了保持灵活性和更易调整,我们将其单独保存在deployment/production目录中。

理论上,您可以完全跳过暂存环境,但最好在虚拟机中首先尝试部署过程,而不是直接在远程服务器上进行实验。

另请参阅

  • 第一章中的创建虚拟环境项目文件结构食谱,开始使用 Django 3.0

  • 第一章中的使用 pip 处理项目依赖项食谱,开始使用 Django 3.0

  • 第一章中的为 Git 用户动态设置 STATIC_URL食谱,开始使用 Django 3.0

  • 在暂存环境中使用 Apache 和 mod_wsgi 部署食谱

  • 在暂存环境中使用 Nginx 和 Gunicorn 部署食谱

  • 在生产环境中使用 Nginx 和 Gunicorn 部署食谱

  • 创建和恢复 PostgreSQL 数据库备份食谱

  • 为常规任务设置 cron 作业食谱

在暂存环境中使用 Nginx 和 Gunicorn 进行部署

使用 mod_wsgi 的 Apache 是部署的一个良好且稳定的方法,但是当您需要高性能时,建议使用NginxGunicorn来为您的 Django 网站提供服务。 Gunicorn 是运行 WSGI 脚本的 Python 服务器。 Nginx 是一个 Web 服务器,它解析域配置并将请求传递给 Gunicorn。

在这个食谱中,我将向您展示如何创建一个脚本,将您的项目部署到计算机上的虚拟机的暂存环境中。 为此,我们将使用AnsibleVagrantVirtualBox。 如前所述,需要牢记许多细节,通常需要几天时间来开发类似于此的最佳部署脚本。

准备就绪

通过部署清单,确保您的配置通过了[https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/]中的所有安全建议。至少确保在运行以下内容时,您的项目配置不会引发警告:

(env)$ python manage.py check --deploy --
 settings=myproject.settings.staging

安装最新稳定版本的 Ansible、Vagrant 和 VirtualBox。您可以从以下官方网站获取它们:

在 macOS X 上,您可以使用HomeBrew安装所有这些:

$ brew install ansible
$ brew cask install virtualbox
$ brew cask install vagrant

如何做...

首先,我们需要为服务器上使用的不同服务创建一些配置模板。这些将被部署程序使用:分段和生产。

  1. 在 Django 项目中,创建一个deployment目录,并在其中创建一个ansible_templates目录。

  2. 为时区配置创建一个 Jinja 模板文件:

{# deployment/ansible_templates/timezone.j2 #} {{ timezone }}
  1. 在设置 SSL 证书之前,为 Nginx 域配置创建一个 Jinja 模板文件:
{# deployment/ansible_templates/nginx-pre.j2 #} server{
    listen 80;
    server_name {{ domain_name }};

    location /.well-known/acme-challenge {
        root /var/www/letsencrypt;
        try_files $uri $uri/ =404;
    }
    location / {
        root /var/www/letsencrypt;
    }
}

  1. deployment/ansible_templates/nginx.j2中为我们的 Nginx 域配置创建一个 Jinja 模板文件,包括 SSL 证书。对于此文件,请从[https://raw.githubusercontent.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition/master/ch12/myproject_virtualenv/src/django-myproject/deployment-nginx/ansible_templates/nginx.j2]复制内容。

  2. 为 Gunicorn 服务配置创建一个模板:

# deployment/ansible_templates/gunicorn.j2
[Unit]
Description=Gunicorn daemon for myproject website
After=network.target

[Service]
PIDFile=/run/gunicorn/pid
Type=simple
User={{ user_username }}
Group=www-data
RuntimeDirectory=gunicorn
WorkingDirectory={{ python_path }}
ExecStart={{ project_root }}/env/bin/gunicorn --pid /run/gunicorn/pid --log-file={{ project_root }}/logs/gunicorn.log --workers {{ ansible_processor_count | int }} --bind 127.0.0.1:8000 {{ project_name }}.wsgi:application --env DJANGO_SETTINGS_MODULE={{ django_settings }} --max-requests 1000
ExecReload=/bin/kill -s HUP $MAINPID
ExecStop=/bin/kill -s TERM $MAINPID
PrivateTmp=true

[Install]
WantedBy=multi-user.target
  1. deployment/ansible_templates/postgresql.j2中为 PostgreSQL 配置文件创建一个模板,其中包含来自[https://github.com/postgres/postgres/blob/REL_10_STABLE/src/backend/utils/misc/postgresql.conf.sample]的内容。稍后您可以在此文件中调整配置。

  2. 为 PostgreSQL 权限配置文件创建一个模板(当前非常宽松,但您可以根据需要稍后进行调整):

{# deployment/ansible_templates/pg_hba.j2 #} # TYPE  DATABASE        USER            CIDR-ADDRESS    METHOD
local   all             all                             ident
host    all             all             ::0/0           md5
host    all             all             0.0.0.0/32      md5
host    {{ db_name }}   {{ db_user }}   127.0.0.1/32    md5
  1. 为 Postfix 邮件服务器配置创建一个模板:
{# deployment/ansible_templates/postfix.j2 #} # See /usr/share/postfix/main.cf.dist for a commented, more 
# complete version

# Debian specific:  Specifying a file name will cause the first
# line of that file to be used as the name.  The Debian default
# is /etc/mailname.
# myorigin = /etc/mailname

smtpd_banner = $myhostname ESMTP $mail_name (Ubuntu)
biff = no

# appending .domain is the MUA's job.
append_dot_mydomain = no

# Uncomment the next line to generate "delayed mail" warnings
#delay_warning_time = 4h

readme_directory = no

# TLS parameters
smtpd_tls_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem
smtpd_tls_key_file=/etc/ssl/private/ssl-cert-snakeoil.key
smtpd_use_tls=yes
smtpd_tls_session_cache_database = btree:${data_directory}/smtpd_scache
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache

# See /usr/share/doc/postfix/TLS_README.gz in the postfix-doc 
# package for information on enabling SSL 
# in the smtp client.

smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = {{ domain_name }}
alias_maps = hash:/etc/aliases
alias_database = hash:/etc/aliases
mydestination = $myhostname, localhost, localhost.localdomain, , 
 localhost
relayhost =
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
mailbox_size_limit = 0
recipient_delimiter = +
inet_interfaces = all
inet_protocols = all
virtual_alias_domains = {{ domain_name }}
virtual_alias_maps = hash:/etc/postfix/virtual
  1. 为电子邮件转发配置创建一个模板:
{# deployment/ansible_templates/virtual.j2 #} # /etc/postfix/virtual

hello@{{ domain_name }} admin@example.com
@{{ domain_name }} admin@example.com
  1. memcached配置创建一个模板:
{# deployment/ansible_templates/memcached.j2 #} # memcached default config file
# 2003 - Jay Bonci <jaybonci@debian.org>
# This configuration file is read by the start-memcached script 
# provided as part of the Debian GNU/Linux distribution.

# Run memcached as a daemon. This command is implied, and is not 
# needed for the daemon to run. See the README.Debian 
# that comes with this package for more information.
-d

# Log memcached's output to /var/log/memcached
logfile /var/log/memcached.log

# Be verbose
# -v

# Be even more verbose (print client commands as well)
# -vv

# Use 1/16 of server RAM for memcached
-m {{ (ansible_memtotal_mb * 0.0625) | int }}

# Default connection port is 11211
-p 11211

# Run the daemon as root. The start-memcached will default to 
# running as root if no -u command is present 
# in this config file
-u memcache

# Specify which IP address to listen on. The default is to 
# listen on all IP addresses
# This parameter is one of the only security measures that 
# memcached has, so make sure it's listening 
# on a firewalled interface.
-l 127.0.0.1

# Limit the number of simultaneous incoming connections. The 
# daemon default is 1024
# -c 1024

# Lock down all paged memory. Consult with the README and homepage 
# before you do this
# -k

# Return error when memory is exhausted (rather than 
# removing items)
# -M

# Maximize core file limit
# -r
  1. 最后,为secrets.json文件创建一个 Jinja 模板:
{# deployment/ansible_templates/secrets.json.j2 #} {
    "DJANGO_SECRET_KEY": "{{ django_secret_key }}",
    "DATABASE_ENGINE": "django.contrib.gis.db.backends.postgis",
    "DATABASE_NAME": "{{ db_name }}",
    "DATABASE_USER": "{{ db_user }}",
    "DATABASE_PASSWORD": "{{ db_password }}",
    "EMAIL_HOST": "{{ email_host }}",
    "EMAIL_PORT": "{{ email_port }}",
    "EMAIL_HOST_USER": "{{ email_host_user }}",
    "EMAIL_HOST_PASSWORD": "{{ email_host_password }}"
} 

现在让我们来处理针对分段环境的 Vagrant 和 Ansible 脚本:

  1. .gitignore文件中,添加以下行以忽略一些与 Vagrant 和 Ansible 特定的文件:
# .gitignore # Secrets
secrets.jsonsecrets.yml

# Vagrant / Ansible
.vagrant
*.retry
  1. 创建deployment/stagingdeployment/staging/ansible目录。

  2. deployment/staging/ansible目录中,创建一个Vagrantfile文件,其中包含以下脚本,以在其中设置一个带有 Ubuntu 18 的虚拟机并在其中运行 Ansible 脚本:

# deployment/staging/ansible/Vagrantfile
VAGRANTFILE_API_VERSION = "2"

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
  config.vm.box = "bento/ubuntu-18.04"
  config.vm.box_version = "201912.14.0"
  config.vm.box_check_update = false
  config.ssh.insert_key=false
  config.vm.provider "virtualbox" do |v|
    v.memory = 512
    v.cpus = 1
    v.name = "myproject"
  end
  config.vm.network "private_network", ip: "192.168.50.5"
  config.vm.provision "ansible" do |ansible|
    ansible.limit = "all"
    ansible.playbook = "setup.yml"
    ansible.inventory_path = "./hosts/vagrant"
    ansible.host_key_checking = false
    ansible.verbose = "vv"
    ansible.extra_vars = { ansible_python_interpreter: 
    "/usr/bin/python3" }
  end
end

  1. 创建一个hosts目录,其中包含一个vagrant文件,其中包含以下内容:
# deployment/staging/ansible/hosts/vagrant
[servers]
192.168.50.5
  1. 在那里创建一个vars.yml文件,其中包含将在安装脚本和 Jinja 模板中使用的变量:
# deployment/staging/ansible/vars.yml
---
# a unix path-friendly name (IE, no spaces or special characters)
project_name: myproject

user_username: "{{ project_name }}"

# the base path to install to. You should not need to change this.
install_root: /home

project_root: "{{ install_root }}/{{ project_name }}"

# the python module path to your project's wsgi file
wsgi_module: myproject.wsgi

# any directories that need to be added to the PYTHONPATH.
python_path: "{{ project_root }}/src/{{ project_name }}"

# the git repository URL for the project
project_repo: git@github.com:archatas/django-myproject.git

# The value of your django project's STATIC_ROOT settings.
static_root: "{{ python_path }}/static"
media_root: "{{ python_path }}/media"

locale: en_US.UTF-8
timezone: Europe/Berlin

domain_name: myproject.192.168.50.5.xip.io
django_settings: myproject.settings.staging

letsencrypt_email: ""
  1. 我们还需要一个包含秘密值的secrets.yml文件,例如密码和身份验证密钥。首先,创建一个sample_secrets.yml文件,其中不包含敏感信息,而只包含变量名称,然后将其复制到secrets.yml并填写秘密信息。前者将受版本控制,而后者将被忽略:
# deployment/staging/ansible/sample_secrets.yml # Django Secret Key
django_secret_key: "change-this-to-50-characters-long-random-string"

# PostgreSQL database settings
db_name: "myproject"
db_user: "myproject"
db_password: "change-this-to-a-secret-password"
db_host: "localhost"
db_port: "5432"

# Email SMTP settings
email_host: "localhost"
email_port: "25"
email_host_user: ""
email_host_password: ""

# a private key that has access to the repository URL
ssh_github_key: ~/.ssh/id_rsa_github
  1. 现在在deployment/staging/ansible/setup.yml创建一个 Ansible 脚本(playbook)以安装所有依赖项并配置服务。从raw.githubusercontent.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition/master/ch12/myproject_virtualenv/src/django-myproject/deployment-nginx/staging/ansible/setup.yml复制此文件的内容。

  2. 然后在deployment/staging/ansible/deploy.yml创建另一个 Ansible 脚本以处理 Django 项目。从raw.githubusercontent.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition/master/ch12/myproject_virtualenv/src/django-myproject/deployment-nginx/staging/ansible/deploy.yml复制此文件的内容。

  3. 创建一个 bash 脚本,您可以执行以开始部署:

# deployment/staging/ansible/setup_on_virtualbox.sh #!/usr/bin/env bash
echo "=== Setting up the local staging server ==="
date

cd "$(dirname "$0")"
vagrant up --provision
  1. 为 bash 脚本添加执行权限并运行它:
$ chmod +x setup_on_virtualbox.sh
$ ./setup_on_virtualbox.sh
  1. 如果脚本出现错误,则可能需要重新启动虚拟机才能生效。您可以通过ssh连接到虚拟机,切换到 root 用户,然后按以下步骤重新启动:
$ vagrant ssh
Welcome to Ubuntu 18.04.3 LTS (GNU/Linux 4.15.0-72-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/advantage

 System information as of Wed Jan 15 04:44:42 CET 2020

 System load:  0.21              Processes:           126
 Usage of /:   4.0% of 61.80GB   Users logged in:     1
 Memory usage: 35%               IP address for eth0: 10.0.2.15
 Swap usage:   4%                IP address for eth1: 192.168.50.5

0 packages can be updated.
0 updates are security updates.

*** System restart required ***

This system is built by the Bento project by Chef Software
More information can be found at https://github.com/chef/bento
Last login: Wed Jan 15 04:43:32 2020 from 192.168.50.1
vagrant@myproject:~$ sudo su
root@myproject:/home/vagrant#
reboot
Connection to 127.0.0.1 closed by remote host.
Connection to 127.0.0.1 closed.
  1. 浏览 Django 项目目录,ssh到虚拟机并将用户更改为myproject如下:
$ vagrant ssh
Welcome to Ubuntu 18.04.3 LTS (GNU/Linux 4.15.0-74-generic x86_64)
# … 
vagrant@myproject:~$ sudo su - myproject
(env) myproject@myproject:~$ pwd
/home/myproject
(env) myproject@myproject:~$ ls
commands db_backups logs public_html src env

工作原理...

VirtualBox 允许您在计算机上拥有具有不同操作系统的多个虚拟机。Vagrant 是一个工具,它创建这些虚拟机,并允许您下载和安装操作系统。Ansible 是一个基于 Python 的实用程序,它从.yaml配置文件中读取指令,并在远程服务器上执行它们。

我们刚刚编写的部署脚本执行以下操作:

  • 在 VirtualBox 中创建一个虚拟机并安装 Ubuntu 18

  • 为虚拟机分配 IP192.168.50.5

  • 为虚拟机设置主机名

  • 升级 Linux 软件包

  • 为服务器设置本地化设置

  • 安装所有 Linux 依赖项,包括 Python、Nginx、PostgreSQL、Postfix、Memcached 等

  • 为 Django 项目创建一个 Linux 用户和home目录

  • 为 Django 项目创建一个虚拟环境

  • 创建 PostgreSQL 数据库用户和数据库

  • 配置 Nginx Web 服务器

  • 安装自签名 SSL 证书

  • 配置 Memcached 缓存服务

  • 配置 Postfix 邮件服务器

  • 克隆 Django 项目存储库

  • 安装 Python 依赖项

  • 设置 Gunicorn

  • 创建secrets.json文件

  • 迁移数据库

  • 收集静态文件

  • 重新启动 Nginx

现在 Django 网站将可以在https://www.myproject.192.168.50.5.xip.io访问,并显示一个 Hello, World!页面。请注意,包括 Chrome 在内的一些浏览器可能不希望打开具有自签名 SSL 证书的网站,并将其作为安全措施阻止。

xip.io 是一个通配符 DNS 服务,将 IP 特定子域指向 IP,并允许您用于 SSL 证书或其他需要域的网站功能。

如果您想尝试不同的配置或附加命令,逐步以小步骤进行更改是合理的。对于某些部分,您需要在虚拟机上直接测试,然后再将任务转换为 Ansible 指令。

有关如何使用 Ansible 的信息,请查看官方文档docs.ansible.com/ansible/latest/index.html。它显示了大多数用例的许多有用的指令示例。

如果您在任何服务中遇到任何错误,请ssh到虚拟机,切换到 root 用户,并检查该服务的日志。谷歌错误消息将使您更接近一个可用的系统。

要重建虚拟机,请使用以下命令:

$ vagrant destroy
$ vagrant up --provision

另请参阅

  • 创建虚拟环境项目文件结构配方在第一章,使用 Django 3.0 入门

  • 使用 pip 处理项目依赖关系配方在第一章,使用 Django 3.0 入门

  • 为 Git 用户动态设置 STATIC_URL配方在第一章,使用 Django 3.0 入门

  • 在 Apache 上使用 mod_wsgi 部署用于暂存环境配方

  • 在 Apache 上使用 mod_wsgi 部署用于生产环境配方

  • 在生产环境上使用 Nginx 和 Gunicorn 部署配方

  • 创建和恢复 PostgreSQL 数据库备份配方

  • 为常规任务设置 cron 作业配方

在生产环境中使用 Nginx 和 Gunicorn 部署

在这个配方中,我们将继续从上一个配方中工作,并实现一个Ansible脚本(playbook)来设置一个带有NginxGunicorn的生产环境。

准备就绪

检查您的项目配置是否在运行以下命令时不会引发警告:

(env)$ python manage.py check --deploy --settings=myproject.settings.production

确保使用最新的稳定版本的 Ansible。

选择服务器提供商,并通过私钥和公钥认证创建具有ssh根访问权限的专用服务器。我选择的提供商是 DigitalOcean (www.digitalocean.com/)。在 DigitalOcean 控制面板上,我创建了一个带有 Ubuntu 18 的专用服务器(Droplet)。我可以通过其 IP 46.101.136.102使用新的 SSH 私钥和公钥对~/.ssh/id_rsa_django_cookbook~/.ssh/id_rsa_django_cookbook.pub连接到服务器。

在本地,我们需要通过创建或修改~/.ssh/config文件来配置 SSH 连接,内容如下:

# ~/.ssh/config
Host *
    ServerAliveInterval 240
    AddKeysToAgent yes
    UseKeychain yes

Host github
    Hostname github.com
    IdentityFile ~/.ssh/id_rsa_github

Host myproject-nginx
    Hostname 46.101.136.102
    User root
    IdentityFile ~/.ssh/id_rsa_django_cookbook

现在我们应该能够使用以下命令作为 root 用户通过ssh连接到专用服务器:

$ ssh myproject-nginx

在您的域配置中,将您的域的DNS A 记录指向专用服务器的 IP 地址。在我们的情况下,我们将只使用myproject.46.101.136.102.xip.io来演示如何为 Django 网站设置服务器的 SSL 证书。

如何做...

要为生产创建部署脚本,请执行以下步骤:

  1. 确保有一个deployment/ansible_templates目录,其中包含我们在前一篇在暂存环境中使用 Nginx 和 Gunicorn 部署配方中创建的用于服务配置的 Jinja 模板。

  2. 为 Ansible 脚本创建deployment/productiondeployment/production/ansible目录。

  3. 创建一个hosts目录,其中包含一个包含以下内容的remote文件:

# deployment/production/ansible/hosts/remote
[servers]
myproject-nginx

[servers:vars]
ansible_python_interpreter=/usr/bin/python3
  1. 在那里创建一个vars.yml文件,其中包含将在安装脚本和 Jinja 模板中使用的变量:
# deployment/production/ansible/vars.yml
---
# a unix path-friendly name (IE, no spaces or special characters)
project_name: myproject

user_username: "{{ project_name }}"

# the base path to install to. You should not need to change this.
install_root: /home

project_root: "{{ install_root }}/{{ project_name }}"

# the python module path to your project's wsgi file
wsgi_module: myproject.wsgi

# any directories that need to be added to the PYTHONPATH.
python_path: "{{ project_root }}/src/{{ project_name }}"

# the git repository URL for the project
project_repo: git@github.com:archatas/django-myproject.git

# The value of your django project's STATIC_ROOT settings.
static_root: "{{ python_path }}/static"
media_root: "{{ python_path }}/media"

locale: en_US.UTF-8
timezone: Europe/Berlin

domain_name: myproject.46.101.136.102.xip.io
django_settings: myproject.settings.production

# letsencrypt settings
letsencrypt_email: hello@myproject.com
  1. 我们还需要一个secrets.yml文件,其中包含诸如密码和身份验证密钥之类的秘密值。首先,创建一个sample_secrets.yml文件,其中不包含敏感信息,而只包含变量名称,然后将其复制到secrets.yml并填写秘密信息。前者将受版本控制,而后者将被忽略:
# deployment/production/ansible/sample_secrets.yml # Django Secret Key
django_secret_key: "change-this-to-50-characters-long-random-string"

# PostgreSQL database settings
db_name: "myproject"
db_user: "myproject"
db_password: "change-this-to-a-secret-password"
db_host: "localhost"
db_port: "5432"

# Email SMTP settings
email_host: "localhost"
email_port: "25"
email_host_user: ""
email_host_password: ""

# a private key that has access to the repository URL
ssh_github_key: ~/.ssh/id_rsa_github
  1. 现在在deployment/production/ansible/setup.yml创建一个 Ansible 脚本(playbook)以安装所有依赖项并配置服务。从raw.githubusercontent.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition/master/ch12/myproject_virtualenv/src/django-myproject/deployment-nginx/production/ansible/setup.yml复制此文件的内容。

  2. 然后在deployment/production/ansible/deploy.yml创建另一个 Ansible 脚本以处理 Django 项目。从raw.githubusercontent.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition/master/ch12/myproject_virtualenv/src/django-myproject/deployment-nginx/production/ansible/deploy.yml复制此文件的内容。

  3. 创建一个 bash 脚本,您可以执行以开始部署:

# deployment/production/ansible/setup_remotely.sh #!/usr/bin/env bash
echo "=== Setting up the production server ==="
date

cd "$(dirname "$0")"
ansible-playbook setup.yml -i hosts/remote
  1. 为 bash 脚本添加执行权限并运行它:
$ chmod +x setup_remotely.sh
$ ./setup_remotely.sh
  1. 如果脚本出现错误,很可能是专用服务器需要重新启动才能生效。您可以通过ssh连接到服务器并按以下方式重新启动来执行此操作:
$ ssh myproject-nginx
Welcome to Ubuntu 18.04.3 LTS (GNU/Linux 4.15.0-74-generic x86_64)

 * Documentation: https://help.ubuntu.com
 * Management: https://landscape.canonical.com
 * Support: https://ubuntu.com/advantage

 System information as of Wed Jan 15 11:39:51 CET 2020

 System load: 0.08 Processes: 104
 Usage of /: 8.7% of 24.06GB Users logged in: 0
 Memory usage: 35% IP address for eth0: 142.93.167.30
 Swap usage: 0%

 * Canonical Livepatch is available for installation.
 - Reduce system reboots and improve kernel security. Activate at:
 https://ubuntu.com/livepatch

0 packages can be updated.
0 updates are security updates.

*** System restart required ***

Last login: Sun Jan 12 12:23:35 2020 from 178.12.115.146
root@myproject:~# reboot
Connection to 142.93.167.30 closed by remote host.
Connection to 142.93.167.30 closed.
  1. 创建另一个仅用于更新 Django 项目的 bash 脚本:
# deployment/production/ansible/deploy_remotely.sh #!/usr/bin/env bash
echo "=== Deploying project to production server ==="
date

cd "$(dirname "$0")"
ansible-playbook deploy.yml -i hosts/remote
  1. 为 bash 脚本添加执行权限:
$ chmod +x deploy_remotely.sh

它是如何工作的...

Ansible 脚本(playbook)是幂等的。这意味着您可以多次执行它,您将始终获得相同的结果,即安装并运行 Django 网站的最新专用服务器。如果服务器出现任何技术硬件问题,并且有数据库和媒体文件的备份,您可以相对快速地在另一个专用服务器上安装相同的配置。

生产部署脚本执行以下操作:

  • 为虚拟机设置主机名

  • 升级 Linux 软件包

  • 为服务器设置本地化设置

  • 安装所有 Linux 依赖项,如 Python、Nginx、PostgreSQL、Postfix、Memcached 等

  • 为 Django 项目创建 Linux 用户和home目录

  • 为 Django 项目创建虚拟环境

  • 创建 PostgreSQL 数据库用户和数据库

  • 配置 Nginx Web 服务器

  • 安装Let's Encrypt SSL 证书

  • 配置 Memcached 缓存服务

  • 配置 Postfix 邮件服务器

  • 克隆 Django 项目存储库

  • 安装 Python 依赖项

  • 设置 Gunicorn

  • 创建secrets.json文件

  • 迁移数据库

  • 收集静态文件

  • 重新启动 Nginx

如您所见,安装与暂存服务器上的安装非常相似,但是为了保持灵活性和更易调整,我们将其分别保存在deployment/production目录中。

理论上,您可以完全跳过暂存环境,但是在虚拟机中尝试部署过程比直接在远程服务器上进行实验更实际。

另请参阅

  • 在第一章《使用 Django 3.0 入门》中的创建虚拟环境项目文件结构配方

  • 在第一章《使用 Django 3.0 入门》中的使用 pip 处理项目依赖项配方

  • 在第一章《使用 Django 3.0 入门》中的为 Git 用户动态设置 STATIC_URL配方

  • 在 Apache 上使用 mod_wsgi 部署暂存环境配方

  • 在生产环境中使用 Apache 和 mod_wsgi 部署配方

  • 在 Nginx 和 Gunicorn 上部署暂存环境配方

  • 创建和恢复 PostgreSQL 数据库备份配方

  • 为常规任务设置 cron 作业配方

第十三章:维护

在本章中,我们将涵盖以下主题:

  • 创建和恢复 MySQL 数据库备份

  • 创建和恢复 PostgreSQL 数据库备份

  • 为常规任务设置 cron 作业

  • 记录事件以进行进一步审查

  • 通过电子邮件获取详细的错误报告

介绍

此时,您应该已经开发和发布了一个或多个 Django 项目。在开发周期的最后阶段,我们将看看如何维护您的项目并监视它们以进行优化。敬请关注最后的细节和片段!

技术要求

要使用本章的代码,您需要最新稳定版本的 Python、MySQL 或 PostgreSQL 数据库以及一个带有虚拟环境的 Django 项目。

您可以在 GitHub 存储库的ch13目录中找到本章的所有代码:github.com/PacktPublishing/Django-3-Web-Development-Cookbook-Fourth-Edition

创建和恢复 MySQL 数据库备份

为了网站的稳定性,能够从硬件故障和黑客攻击中恢复是非常重要的。因此,您应该始终进行备份并确保它们有效。您的代码和静态文件通常会驻留在版本控制中,可以从中恢复,但数据库和媒体文件应定期备份。

在这个配方中,我们将向您展示如何为 MySQL 数据库创建备份。

准备工作

确保您的 Django 项目正在运行一个 MySQL 数据库。将该项目部署到远程生产(或暂存)服务器。

如何做到...

要备份和恢复您的 MySQL 数据库,请执行以下步骤:

  1. 在项目的主目录下的commands目录中,创建一个 bash 脚本:backup_mysql_db.sh。按照以下方式开始脚本,包括变量和函数定义:
/home/myproject/commands/backup_mysql_db.sh
#!/usr/bin/env bash
SECONDS=0
export DJANGO_SETTINGS_MODULE=myproject.settings.production
PROJECT_PATH=/home/myproject
REPOSITORY_PATH=${PROJECT_PATH}/src/myproject
LOG_FILE=${PROJECT_PATH}/logs/backup_mysql_db.log
DAY_OF_THE_WEEK=$(LC_ALL=en_US.UTF-8 date +"%w-%A")
DAILY_BACKUP_PATH=${PROJECT_PATH}/db_backups/${DAY_OF_THE_WEEK}.sql
LATEST_BACKUP_PATH=${PROJECT_PATH}/db_backups/latest.sql
error_counter=0

echoerr() { echo "$@" 1>&2; }

cd ${PROJECT_PATH}
mkdir -p logs
mkdir -p db_backups

source env/bin/activate
cd ${REPOSITORY_PATH}

DATABASE=$(echo "from django.conf import settings; print(settings.DATABASES['default']['NAME'])" | python manage.py shell -i python)
USER=$(echo "from django.conf import settings; print(settings.DATABASES['default']['USER'])" | python manage.py shell -i python)
PASSWORD=$(echo "from django.conf import settings; print(settings.DATABASES['default']['PASSWORD'])" | python manage.py shell -i python)

EXCLUDED_TABLES=(
django_session
)

IGNORED_TABLES_STRING=''
for TABLE in "${EXCLUDED_TABLES[@]}"; do
    IGNORED_TABLES_STRING+=" --ignore-table=${DATABASE}.${TABLE}"
done
  1. 然后,添加命令来创建数据库结构和数据的转储:
echo "=== Creating DB Backup ===" > ${LOG_FILE}
date >> ${LOG_FILE}

echo "- Dump structure" >> ${LOG_FILE}
mysqldump -u "${USER}" -p"${PASSWORD}" --single-transaction --no-data "${DATABASE}" > "${DAILY_BACKUP_PATH}" 2>> ${LOG_FILE}
function_exit_code=$?
if [[ $function_exit_code -ne 0 ]]; then
    {
        echoerr "Command mysqldump for dumping database structure 
         failed with exit code ($function_exit_code)."
        error_counter=$((error_counter + 1))
    } >> "${LOG_FILE}" 2>&1
fi

echo "- Dump content" >> ${LOG_FILE}
# shellcheck disable=SC2086
mysqldump -u "${USER}" -p"${PASSWORD}" "${DATABASE}" ${IGNORED_TABLES_STRING} >> "${DAILY_BACKUP_PATH}" 2>> ${LOG_FILE}
function_exit_code=$?
if [[ $function_exit_code -ne 0 ]]; then
    {
        echoerr "Command mysqldump for dumping database content 
         failed with exit code ($function_exit_code)."
        error_counter=$((error_counter + 1))
    } >> "${LOG_FILE}" 2>&1
fi

  1. 添加命令来压缩数据库转储并创建一个符号链接latest.sql.gz
echo "- Create a *.gz archive" >> ${LOG_FILE}
gzip --force "${DAILY_BACKUP_PATH}"
function_exit_code=$?
if [[ $function_exit_code -ne 0 ]]; then
    {
        echoerr "Command gzip failed with exit code 
         ($function_exit_code)."
        error_counter=$((error_counter + 1))
    } >> "${LOG_FILE}" 2>&1
fi

echo "- Create a symlink latest.sql.gz" >> ${LOG_FILE}
if [ -e "${LATEST_BACKUP_PATH}.gz" ]; then
    rm "${LATEST_BACKUP_PATH}.gz"
fi
ln -s "${DAILY_BACKUP_PATH}.gz" "${LATEST_BACKUP_PATH}.gz"
function_exit_code=$?
if [[ $function_exit_code -ne 0 ]]; then
    {
        echoerr "Command ln failed with exit code 
         ($function_exit_code)."
        error_counter=$((error_counter + 1))
    } >> "${LOG_FILE}" 2>&1
fi

  1. 通过记录执行前面命令所花费的时间来完成脚本:
duration=$SECONDS
echo "------------------------------------------" >> ${LOG_FILE}
echo "The operation took $((duration / 60)) minutes and $((duration % 60)) seconds." >> ${LOG_FILE}
exit $error_counter
  1. 在同一目录中,创建一个名为restore_mysql_db.sh的 bash 脚本,内容如下:
# home/myproject/commands/restore_mysql_db.sh
#!/usr/bin/env bash
SECONDS=0
PROJECT_PATH=/home/myproject
REPOSITORY_PATH=${PROJECT_PATH}/src/myproject
LATEST_BACKUP_PATH=${PROJECT_PATH}/db_backups/latest.sql
export DJANGO_SETTINGS_MODULE=myproject.settings.production

cd "${PROJECT_PATH}"
source env/bin/activate

echo "=== Restoring DB from a Backup ==="

echo "- Fill the database with schema and data"
cd "${REPOSITORY_PATH}"
zcat "${LATEST_BACKUP_PATH}.gz" | python manage.py dbshell

duration=$SECONDS
echo "------------------------------------------"
echo "The operation took $((duration / 60)) minutes and $((duration % 60)) seconds."
  1. 使这两个脚本都可执行:
$ chmod +x *.sh
  1. 运行数据库备份脚本:
$ ./backup_mysql_db.sh
  1. 运行数据库恢复脚本(如果在生产中请谨慎):
$ ./restore_mysql_db.sh

它是如何工作的...

备份脚本将在/home/myproject/db_backups/目录下创建备份文件,并将日志保存在/home/myproject/logs/backup_mysql_db.log,类似于这样:

=== Creating DB Backup ===
Fri Jan 17 02:12:14 CET 2020
- Dump structure
mysqldump: [Warning] Using a password on the command line interface can be insecure.
- Dump content
mysqldump: [Warning] Using a password on the command line interface can be insecure.
- Create a *.gz archive
- Create a symlink latest.sql.gz
------------------------------------------
The operation took 0 minutes and 2 seconds.

如果操作成功,脚本将返回退出代码0;否则,退出代码将是执行脚本时的错误数量。日志文件将显示错误消息。

db_backups目录中,将有一个带有星期几的压缩 SQL 备份,例如0-Sunday.sql.gz1-Monday.sql.gz等,以及另一个文件,实际上是一个符号链接,名为latest.sql.gz。基于工作日的备份允许您在正确设置 cron 作业时拥有最近 7 天的备份,并且符号链接允许您快速或自动将最新备份传输到另一台计算机上通过 SSH。

请注意,我们从 Django 设置中获取数据库凭据,然后在 bash 脚本中使用它们。

我们正在转储除了会话表之外的所有数据,因为会话本来就是临时的,而且占用内存很多。

当我们运行restore_mysql_db.sh脚本时,我们会得到如下输出:

=== Restoring DB from a Backup ===
- Fill the database with schema and data
mysql: [Warning] Using a password on the command line interface can be insecure.
------------------------------------------
The operation took 0 minutes and 2 seconds.

另请参阅

  • 第十二章部署中的在 Apache 上使用 mod_wsgi 部署生产环境配方

  • 第十二章部署中的在 Nginx 和 Gunicorn 上部署生产环境配方

  • 创建和恢复 PostgreSQL 数据库备份配方

  • 为常规任务设置 cron 作业配方

创建和恢复 PostgreSQL 数据库备份

在本食谱中,您将学习如何备份 PostgreSQL 数据库,并在硬件故障或黑客攻击发生时恢复它们。

准备工作

确保已经运行了一个带有 PostgreSQL 数据库的 Django 项目。将该项目部署到远程暂存或生产服务器。

操作方法

要备份和恢复 MySQL 数据库,请执行以下步骤:

  1. 在项目的主目录下的commands目录中,创建一个名为backup_postgresql_db.sh的 bash 脚本。开始脚本时,定义变量和函数,如下所示:
/home/myproject/commands/backup_postgresql_db.sh
#!/usr/bin/env bash
SECONDS=0
PROJECT_PATH=/home/myproject
REPOSITORY_PATH=${PROJECT_PATH}/src/myproject
LOG_FILE=${PROJECT_PATH}/logs/backup_postgres_db.log
DAY_OF_THE_WEEK=$(LC_ALL=en_US.UTF-8 date +"%w-%A")
DAILY_BACKUP_PATH=${PROJECT_PATH}/db_backups/${DAY_OF_THE_WEEK}.backup
LATEST_BACKUP_PATH=${PROJECT_PATH}/db_backups/latest.backup
error_counter=0

echoerr() { echo "$@" 1>&2; }

cd ${PROJECT_PATH}
mkdir -p logs
mkdir -p db_backups

source env/bin/activate
cd ${REPOSITORY_PATH}

DATABASE=$(echo "from django.conf import settings; print(settings.DATABASES['default']['NAME'])" | python manage.py shell -i python)

  1. 然后,添加一个命令以创建数据库转储:
echo "=== Creating DB Backup ===" > ${LOG_FILE}
date >> ${LOG_FILE}

echo "- Dump database" >> ${LOG_FILE}
pg_dump --format=p --file="${DAILY_BACKUP_PATH}" ${DATABASE}
function_exit_code=$?
if [[ $function_exit_code -ne 0 ]]; then
    {
        echoerr "Command pg_dump failed with exit code 
         ($function_exit_code)."
        error_counter=$((error_counter + 1))
    } >> "${LOG_FILE}" 2>&1
fi

  1. 添加命令以压缩数据库转储并创建一个名为latest.backup.gz的符号链接:
echo "- Create a *.gz archive" >> ${LOG_FILE}
gzip --force "${DAILY_BACKUP_PATH}"
function_exit_code=$?
if [[ $function_exit_code -ne 0 ]]; then
    {
        echoerr "Command gzip failed with exit code 
         ($function_exit_code)."
        error_counter=$((error_counter + 1))
    } >> "${LOG_FILE}" 2>&1
fi

echo "- Create a symlink latest.backup.gz" >> ${LOG_FILE}
if [ -e "${LATEST_BACKUP_PATH}.gz" ]; then
    rm "${LATEST_BACKUP_PATH}.gz"
fi
ln -s "${DAILY_BACKUP_PATH}.gz" "${LATEST_BACKUP_PATH}.gz"
function_exit_code=$?
if [[ $function_exit_code -ne 0 ]]; then
    {
        echoerr "Command ln failed with exit code 
         ($function_exit_code)."
        error_counter=$((error_counter + 1))
    } >> "${LOG_FILE}" 2>&1
fi

  1. 通过记录执行前一个命令所花费的时间来完成脚本:
duration=$SECONDS
echo "------------------------------------------" >> ${LOG_FILE}
echo "The operation took $((duration / 60)) minutes and $((duration % 60)) seconds." >> ${LOG_FILE}
exit $error_counter

  1. 在同一目录中,创建一个名为restore_postgresql_db.sh的 bash 脚本,内容如下:
# /home/myproject/commands/restore_postgresql_db.sh
#!/usr/bin/env bash
SECONDS=0
PROJECT_PATH=/home/myproject
REPOSITORY_PATH=${PROJECT_PATH}/src/myproject
LATEST_BACKUP_PATH=${PROJECT_PATH}/db_backups/latest.backup
export DJANGO_SETTINGS_MODULE=myproject.settings.production

cd "${PROJECT_PATH}"
source env/bin/activate

cd "${REPOSITORY_PATH}"

DATABASE=$(echo "from django.conf import settings; print(settings.DATABASES['default']['NAME'])" | python manage.py shell -i python)
USER=$(echo "from django.conf import settings; print(settings.DATABASES['default']['USER'])" | python manage.py shell -i python)
PASSWORD=$(echo "from django.conf import settings; print(settings.DATABASES['default']['PASSWORD'])" | python manage.py shell -i python)

echo "=== Restoring DB from a Backup ==="

echo "- Recreate the database"
psql --dbname=$DATABASE --command='SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname = current_database() AND pid <> pg_backend_pid();' 
dropdb $DATABASE
 createdb --username=$USER $DATABASE

echo "- Fill the database with schema and data"
zcat "${LATEST_BACKUP_PATH}.gz" | python manage.py dbshell

duration=$SECONDS
echo "------------------------------------------"
echo "The operation took $((duration / 60)) minutes and $((duration % 60)) seconds."

  1. 使这两个脚本都可执行:
$ chmod +x *.sh
  1. 运行数据库备份脚本:
$ ./backup_postgresql_db.sh
  1. 运行数据库恢复脚本(如果在生产中,请谨慎):
$ ./restore_postgresql_db.sh

工作原理

备份脚本将在/home/myproject/db_backups/下创建备份文件,并将日志保存在/home/myproject/logs/backup_postgresql_db.log中,类似于这样:

=== Creating DB Backup ===
Fri Jan 17 02:40:55 CET 2020
- Dump database
- Create a *.gz archive
- Create a symlink latest.backup.gz
------------------------------------------
The operation took 0 minutes and 1 seconds.

如果操作成功,脚本将返回退出代码0;否则,退出代码将是执行脚本时出现的错误数量。日志文件将显示错误消息。

db_backups目录中,将有一个带有星期几的压缩 SQL 备份文件,例如0-Sunday.backup.gz1-Monday.backup.gz等等,还有另一个文件,实际上是一个符号链接,名为latest.backup.gz。基于工作日的备份允许您在正确设置 cron 作业时拥有最近 7 天的备份,符号链接允许您通过 SSH 快速或自动将最新备份传输到另一台计算机。

请注意,我们从 Django 设置中获取数据库凭据,然后在 bash 脚本中使用它们。

当我们运行restore_postgresql_db.sh脚本时,我们会得到如下输出:

=== Restoring DB from a Backup ===
- Recreate the database
 pg_terminate_backend
----------------------
(0 rows)

- Fill the database with schema and data
SET
SET
SET
SET
SET
 set_config
------------

(1 row)

SET

…

ALTER TABLE
ALTER TABLE
ALTER TABLE
------------------------------------------
The operation took 0 minutes and 2 seconds.

另请参阅

  • 第十二章部署中的在 Apache 上使用 mod_wsgi 部署生产环境食谱

  • 第十二章部署中的在 Nginx 和 Gunicorn 上部署生产环境食谱

  • 创建和恢复 PostgreSQL 数据库备份食谱

  • 为常规任务设置 cron 作业食谱

为常规任务设置 cron 作业

通常,网站有一些后台管理任务需要定期执行,例如每周一次、每天一次或每小时一次。这可以通过使用定时任务(通常称为 cron 作业)来实现。这些是在服务器上在指定时间段后运行的脚本。在本食谱中,我们将创建两个 cron 作业:一个用于从数据库中清除会话,另一个用于备份数据库数据。两者都将在每晚运行。

准备工作

首先,将 Django 项目部署到远程服务器。然后,通过 SSH 连接到服务器。这些步骤假定您正在使用虚拟环境,但是可以为 Docker 项目创建类似的 cron 作业,并且甚至可以直接在应用程序容器中运行。提供了备用语法的代码文件,步骤基本相同。

操作方法

让我们创建这两个脚本,并通过以下步骤定期运行它们:

  1. 在生产或暂存服务器上,导航到项目用户的主目录,其中包含envsrc目录。

  2. 如果尚不存在,请按以下方式创建commandsdb_backupslogs文件夹,如下所示:

(env)$ mkdir commands db_backups logs
  1. commands目录中,创建一个clear_sessions.sh文件。您可以使用终端编辑器(如 vim 或 nano)编辑它,添加以下内容:
# /home/myproject/commands/clear_sessions.sh
#!/usr/bin/env bash
SECONDS=0
export DJANGO_SETTINGS_MODULE=myproject.settings.production
PROJECT_PATH=/home/myproject
REPOSITORY_PATH=${PROJECT_PATH}/src/myproject
LOG_FILE=${PROJECT_PATH}/logs/clear_sessions.log
error_counter=0

echoerr() { echo "$@" 1>&2; }

cd ${PROJECT_PATH}
mkdir -p logs

echo "=== Clearing up Outdated User Sessions ===" > ${LOG_FILE}
date >> ${LOG_FILE}

source env/bin/activate
cd ${REPOSITORY_PATH}
python manage.py clearsessions >> "${LOG_FILE}" 2>&1
function_exit_code=$?
if [[ $function_exit_code -ne 0 ]]; then
    {
        echoerr "Clearing sessions failed with exit code 
         ($function_exit_code)."
        error_counter=$((error_counter + 1))
    } >> "${LOG_FILE}" 2>&1
fi

duration=$SECONDS
echo "------------------------------------------" >> ${LOG_FILE}
echo "The operation took $((duration / 60)) minutes and $((duration % 60)) seconds." >> ${LOG_FILE}
exit $err
or_counter
  1. 使clear_sessions.sh文件可执行,如下所示:
$ chmod +x *.sh
  1. 假设您正在使用 PostgreSQL 作为项目的数据库。然后,在相同的目录中,按照上一个配方创建和恢复 PostgreSQL 数据库备份的说明创建一个备份脚本。

  2. 测试脚本以查看它们是否正确执行,方法是运行它们,然后检查日志目录中的*.log文件,如下所示:

$ ./clear_sessions.sh
$ ./backup_postgresql_db.sh
  1. 在远程服务器上的项目主目录中,创建一个crontab.txt文件,内容如下:
# /home/myproject/crontab.txt
MAILTO=""
HOME=/home/myproject
PATH=/home/myproject/env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin
SHELL=/bin/bash
00 01 * * * /home/myproject/commands/clear_sessions.sh
00 02 * * * /home/myproject/commands/backup_postgresql_db.sh
  1. 按照以下方式将crontab任务安装为myproject用户:
(env)$ crontab crontab.txt

工作原理...

使用当前设置,每天晚上clear_sessions.sh将在凌晨 1:00 执行,backup_postgresql_db.sh将在凌晨 2:00 执行。执行日志将保存在~/logs/clear_sessions.sh~/logs/backup_postgresql_db.log中。如果出现任何错误,您应该检查这些文件以获取回溯信息。

每天,clear_sessions.sh将执行clearsessions管理命令,正如其名称所暗示的那样,它将使用默认数据库设置从数据库中清除过期会话。

数据库备份脚本稍微复杂一些。每周的每一天,它都会创建一个备份文件,使用命名方案0-Sunday.backup.gz1-Monday.backup.gz等等。因此,您将能够恢复 7 天前或更晚备份的数据。

crontab 文件遵循特定的语法。每行包含特定的一天时间,由一系列数字表示,然后是在给定时刻运行的任务。时间分为五部分,用空格分隔,如下列表所示:

  • 分钟,从 0 到 59

  • 小时,从 0 到 23

  • 每月的日期,从 1 到 31

  • 月份,从 1 到 12

  • 每周的日期,从 0 到 7,其中 0 是星期日,1 是星期一,依此类推,7 又是星期日

星号(*)表示将使用每个时间段。因此,以下任务定义了clear_sessions.sh将在每个月的每一天,每个月和每周的每一天的 1:00 执行:

00 01 * * * /home/myproject/commands/clear_sessions.sh

您可以在en.wikipedia.org/wiki/Cron了解有关 crontab 的具体信息。

还有更多...

我们定义了将定期执行的命令,并且还激活了结果的记录,但是我们还不能确定 cron 作业是否成功执行,除非我们每天手动登录服务器并检查日志。为了解决单调的手动劳动问题,您可以使用Healthchecks服务(healthchecks.io/)自动监视 cron 作业。

使用 Healthchecks,您可以修改 crontab,以便在每次成功执行作业后 ping 特定 URL。如果脚本失败并以非零代码退出,Healthchecks 将知道它未成功执行。每天,您将通过电子邮件获取 cron 作业及其执行状态的概述。

另请参见

  • 在 Apache 上使用 mod_wsgi 部署生产环境配方在第十二章,部署

  • 在 Nginx 和 Gunicorn 上部署生产环境配方在第十二章,部署

  • 创建和恢复 MySQL 数据库备份配方

  • 创建和恢复 PostgreSQL 数据库备份配方

记录事件以进行进一步审查

在以前的配方中,您可以看到如何记录 bash 脚本的工作。但是您也可以记录发生在 Django 网站上的事件,例如用户注册、将产品添加到购物车、购买门票、银行交易、发送短信、服务器错误等。

您永远不应记录敏感信息,例如用户密码或信用卡详细信息。

此外,使用分析工具而不是 Python 记录来跟踪整体网站使用情况。

在本配方中,我们将指导您如何将有关您的网站的结构化信息记录到日志文件中。

准备工作

让我们从第四章,模板和 JavaScript中的实现喜欢小部件食谱开始。

在 Django 项目的虚拟环境中,安装django-structlog,如下所示:

(env)$ pip install django-structlog==1.3.5

如何做...

要在 Django 网站中设置结构化日志记录,请按照以下步骤进行:

  1. 在项目的设置中添加RequestMiddleware
# myproject/settings/_base.py MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
    "django.middleware.locale.LocaleMiddleware",
 "django_structlog.middlewares.RequestMiddleware",
]
  1. 同样在同一文件中,添加 Django 日志配置:
# myproject/settings/_base.py
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "json_formatter": {
            "()": structlog.stdlib.ProcessorFormatter,
            "processor": structlog.processors.JSONRenderer(),
        },
        "plain_console": {
            "()": structlog.stdlib.ProcessorFormatter,
            "processor": structlog.dev.ConsoleRenderer(),
        },
        "key_value": {
            "()": structlog.stdlib.ProcessorFormatter,
            "processor":  
             structlog.processors.KeyValueRenderer(key_order=
             ['timestamp', 'level', 'event', 'logger']),
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "plain_console",
        },
        "json_file": {
            "class": "logging.handlers.WatchedFileHandler",
            "filename": os.path.join(BASE_DIR, "tmp", "json.log"),
            "formatter": "json_formatter",
        },
        "flat_line_file": {
            "class": "logging.handlers.WatchedFileHandler",
            "filename": os.path.join(BASE_DIR, "tmp", 
           "flat_line.log"),
            "formatter": "key_value",
        },
    },
    "loggers": {
        "django_structlog": {
            "handlers": ["console", "flat_line_file", "json_file"],
            "level": "INFO",
        },
    }
}
  1. 还要在那里设置structlog配置:
# myproject/settings/_base.py
structlog.configure(
    processors=[
        structlog.stdlib.filter_by_level,
        structlog.processors.TimeStamper(fmt="iso"),
        structlog.stdlib.add_logger_name,
        structlog.stdlib.add_log_level,
        structlog.stdlib.PositionalArgumentsFormatter(),
        structlog.processors.StackInfoRenderer(),
        structlog.processors.format_exc_info,
        structlog.processors.UnicodeDecoder(),
        structlog.processors.ExceptionPrettyPrinter(),
        structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
    ],
    context_class=structlog.threadlocal.wrap_dict(dict),
    logger_factory=structlog.stdlib.LoggerFactory(),
    wrapper_class=structlog.stdlib.BoundLogger,
    cache_logger_on_first_use=True,
) 
  1. likes应用程序的views.py中,让我们记录将被喜欢或取消喜欢的对象:
# myproject/apps/likes/views.py
import structlog

from django.contrib.contenttypes.models import ContentType
from django.http import JsonResponse
from django.views.decorators.cache import never_cache
from django.views.decorators.csrf import csrf_exempt

from .models import Like
from .templatetags.likes_tags import liked_count

logger = structlog.get_logger("django_structlog")

@never_cache
@csrf_exempt
def json_set_like(request, content_type_id, object_id):
    """
    Sets the object as a favorite for the current user
    """
    result = {
        "success": False,
    }
    if request.user.is_authenticated and request.method == "POST":
        content_type = ContentType.objects.get(id=content_type_id)
        obj = content_type.get_object_for_this_type(pk=object_id)

        like, is_created = Like.objects.get_or_create(
            content_type=ContentType.objects.get_for_model(obj),
            object_id=obj.pk,
            user=request.user)
        if is_created:
            logger.info("like_created",  
            content_type_id=content_type.pk, 
            object_id=obj.pk)
        else:
            like.delete()
            logger.info("like_deleted",  
            content_type_id=content_type.pk, 
            object_id=obj.pk) 

        result = {
            "success": True,
            "action": "add" if is_created else "remove",
            "count": liked_count(obj),
        }

    return JsonResponse(result)

它是如何工作的...

当访问者浏览您的网站时,特定事件将记录在tmp/json.logtmp/flat_line.log文件中。django_structlog.middlewares.RequestMiddleware记录 HTTP 请求处理的开始和结束。此外,我们还记录了在我们的 Django 项目中创建或删除Like实例时的情况。

json.log文件包含以 JSON 格式记录的日志。这意味着您可以以编程方式解析、检查和分析它们:

{"request_id": "ad0ef355-77ef-4474-a91a-2d9549a0e15d", "user_id": 1, "ip": "127.0.0.1", "request": "<WSGIRequest: POST '/en/likes/7/1712dfe4-2e77-405c-aa9b-bfa64a1abe98/'>", "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36", "event": "request_started", "timestamp": "2020-01-18T04:27:00.556135Z", "logger": "django_structlog.middlewares.request", "level": "info"}
{"request_id": "ad0ef355-77ef-4474-a91a-2d9549a0e15d", "user_id": 1, "ip": "127.0.0.1", "content_type_id": 7, "object_id": "UUID('1712dfe4-2e77-405c-aa9b-bfa64a1abe98')", "event": "like_created", "timestamp": "2020-01-18T04:27:00.602640Z", "logger": "django_structlog", "level": "info"}
{"request_id": "ad0ef355-77ef-4474-a91a-2d9549a0e15d", "user_id": 1, "ip": "127.0.0.1", "code": 200, "request": "<WSGIRequest: POST '/en/likes/7/1712dfe4-2e77-405c-aa9b-bfa64a1abe98/'>", "event": "request_finished", "timestamp": "2020-01-18T04:27:00.604577Z", "logger": "django_structlog.middlewares.request", "level": "info"}

flat_line.log文件以更短的格式包含日志,这可能更容易手动阅读:

(env)$ tail -3 tmp/flat_line.log
timestamp='2020-01-18T04:27:03.437759Z' level='info' event='request_started' logger='django_structlog.middlewares.request' request_id='a74808ff-c682-4336-aeb9-f043f11a7316' user_id=1 ip='127.0.0.1' request=<WSGIRequest: POST '/en/likes/7/1712dfe4-2e77-405c-aa9b-bfa64a1abe98/'> user_agent='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3945.130 Safari/537.36'
timestamp='2020-01-18T04:27:03.489198Z' level='info' event='like_deleted' logger='django_structlog' request_id='a74808ff-c682-4336-aeb9-f043f11a7316' user_id=1 ip='127.0.0.1' content_type_id=7 object_id=UUID('1712dfe4-2e77-405c-aa9b-bfa64a1abe98')
timestamp='2020-01-18T04:27:03.491927Z' level='info' event='request_finished' logger='django_structlog.middlewares.request' request_id='a74808ff-c682-4336-aeb9-f043f11a7316' user_id=1 ip='127.0.0.1' code=200 request=<WSGIRequest: POST '/en/likes/7/1712dfe4-2e77-405c-aa9b-bfa64a1abe98/'>

另请参阅

  • 创建和恢复 MySQL 数据库备份食谱

  • 创建和恢复 PostgreSQL 数据库备份食谱

  • 为定期任务设置 cron 作业食谱

通过电子邮件获取详细的错误报告

为执行系统日志记录,Django 使用 Python 的内置日志记录模块或前一食谱中提到的structlog模块。默认的 Django 配置似乎相当复杂。在本食谱中,您将学习如何对其进行微调,以便在发生错误时以与 Django 在 DEBUG 模式下提供的完整 HTML 类似的方式发送错误电子邮件。

准备工作

定位虚拟环境中的 Django 项目。

如何做...

以下过程将向您发送有关错误的详细电子邮件:

  1. 如果您的项目尚未设置LOGGING设置,请先设置。找到 Django 日志实用程序文件,位于env/lib/python3.7/site-packages/django/utils/log.py。将DEFAULT_LOGGING字典复制到项目的设置中作为LOGGING字典。

  2. include_html设置添加到mail_admins处理程序。前两个步骤的结果应该类似于以下内容:

# myproject/settings/production.py
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'filters': {
        'require_debug_false': {
            '()': 'django.utils.log.RequireDebugFalse',
        },
        'require_debug_true': {
            '()': 'django.utils.log.RequireDebugTrue',
        },
    },
    'formatters': {
        'django.server': {
            '()': 'django.utils.log.ServerFormatter',
            'format': '[{server_time}] {message}',
            'style': '{',
        }
    },
    'handlers': {
        'console': {
            'level': 'INFO',
            'filters': ['require_debug_true'],
            'class': 'logging.StreamHandler',
        },
        'django.server': {
            'level': 'INFO',
            'class': 'logging.StreamHandler',
            'formatter': 'django.server',
        },
        'mail_admins': {
            'level': 'ERROR',
            'filters': ['require_debug_false'],
            'class': 'django.utils.log.AdminEmailHandler',
 'include_html': True,
        }
    },
    'loggers': {
        'django': {
            'handlers': ['console', 'mail_admins'],
            'level': 'INFO',
        },
        'django.server': {
            'handlers': ['django.server'],
            'level': 'INFO',
            'propagate': False,
        },
    }
}

它是如何工作的...

日志配置由四个部分组成:记录器、处理程序、过滤器和格式化程序。以下列表对它们进行了描述:

  • 记录器是日志系统的入口点。每个记录器都可以有一个日志级别:DEBUGINFOWARNINGERRORCRITICAL。当消息被写入记录器时,消息的日志级别将与记录器的级别进行比较。如果满足或超过记录器的日志级别,则将由处理程序进一步处理。否则,消息将被忽略。

  • 处理程序是定义记录器中每条消息发生的情况的引擎。它们可以被写入控制台,通过电子邮件发送给管理员,保存到日志文件,发送到 Sentry 错误记录服务等等。在我们的情况下,我们为mail_admins处理程序设置了include_html参数,因为我们希望在我们的 Django 项目中发生错误时获得包含回溯和本地变量的完整 HTML 的错误消息。

  • 过滤器提供对从记录器传递到处理程序的消息的额外控制。例如,在我们的情况下,仅当 DEBUG 模式设置为 false 时才会发送电子邮件。

  • 格式化程序用于定义如何将日志消息呈现为字符串。在本示例中未使用它们;但是,有关日志记录的更多信息,您可以参考官方文档docs.djangoproject.com/en/3.0/topics/logging/

还有更多...

我们刚刚定义的配置将发送有关发生在您的网站上的每个服务器错误的电子邮件。如果您的网站流量很大,比如数据库崩溃,您将收到大量电子邮件,这些邮件将淹没您的收件箱,甚至可能挂起您的电子邮件服务器。

为了避免这样的问题,您可以使用 Sentry (sentry.io/for/python/)。它会在服务器上跟踪所有服务器错误,并针对每种错误类型仅发送一封通知电子邮件给您。

另请参阅

  • 第十二章部署中的在 Apache 上使用 mod_wsgi 进行生产环境部署食谱

  • 第十二章部署中的在 Nginx 和 Gunicorn 上进行生产环境部署食谱

  • 用于进一步审查的日志事件食谱

posted @ 2024-05-20 16:48  绝不原创的飞龙  阅读(15)  评论(0编辑  收藏  举报