Django5-示例-全-

Django5 示例(全)

原文:zh.annas-archive.org/md5/99bf1ad631db8fa8da13851e986f0234

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Django 是一个开源的 Python Web 框架,它鼓励快速开发和清晰、实用的设计。它处理了 Web 开发中的许多繁琐工作,并为初学者程序员提供了一个相对平缓的学习曲线。Django 遵循 Python 的“电池包含”哲学,附带一套丰富且多功能性的模块,用于解决常见的 Web 开发问题。Django 的简单性与其强大的功能相结合,使其对新手和专家程序员都具有吸引力。Django 被设计为简单、灵活、可靠和可扩展。

现在,Django 被无数初创公司和大型组织如 Instagram、Spotify、Pinterest、Udemy、Robinhood 和 Coursera 所使用。在过去的几年里,Django 一直被全球开发者选为 Stack Overflow 年度开发者调查中最受欢迎的 Web 框架之一,这并非巧合。

本书将指导你通过使用 Django 开发专业 Web 应用的整个过程。本书侧重于通过从头构建多个项目来解释 Django Web 框架的工作原理。本书不仅涵盖了框架的最相关方面,还解释了如何将 Django 应用于各种不同的现实世界情况。

本书不仅教授 Django,还介绍了其他流行的技术,如 PostgreSQL、Redis、Celery、RabbitMQ 和 Memcached。你将在整本书中学习如何将这些技术集成到你的 Django 项目中,以创建高级功能并构建复杂的 Web 应用。

《Django 5 实例教程》将逐步引导你创建现实世界应用,解决常见问题,并实施最佳实践,采用易于跟随的步骤方法。

阅读本书后,你将很好地理解 Django 的工作原理以及如何构建完整的 Python Web 应用。

本书面向对象

本书应作为新接触 Django 的程序员入门指南。本书面向具有 Python 知识并希望以实用方式学习 Django 的开发者。也许你是 Django 的完全新手,或者你已经了解一些,但希望从中获得最大收益。本书将通过从头开始构建实际项目来帮助你掌握框架中最相关的领域。为了阅读本书,你需要熟悉编程概念。除了基本的 Python 知识外,还假设你有一些 HTML 和 JavaScript 的先验知识。

本书涵盖内容

本书涵盖了使用 Django 进行 Web 应用开发的多个主题。本书将指导你通过 17 个章节构建四个不同的功能齐全的 Web 应用。

  • 博客应用(第一章至第三章)

  • 图片书签网站(第四章至第七章)

  • 在线商店(第八章至第十一章)

  • 在线学习平台(第十二章至第十七章)

每一章都涵盖了几个 Django 功能。

第一章,构建博客应用程序,将通过博客应用程序向您介绍框架。您将创建基本的博客模型、视图、模板和 URL 来显示博客文章。您将学习如何使用 Django 的对象关系映射器ORM)构建 QuerySets,并配置 Django 管理站点。

第二章,增强博客的高级功能,将教授您如何为您的博客添加分页,以及如何实现 Django 基于类的视图。您将学习使用 Django 发送电子邮件,处理表单和模型表单。您还将实现博客文章的评论系统。

第三章,扩展您的博客应用程序,探讨了如何集成第三方应用程序。本章将指导您创建标签系统,并学习如何构建复杂的 QuerySets 来推荐相似的文章。本章将教授您如何创建自定义模板标签和过滤器。您还将学习如何使用站点地图框架并为您的文章创建 RSS 源。您将通过使用 PostgreSQL 的全文搜索功能构建搜索引擎来完成您的博客应用程序。

第四章,构建社交网站,解释了如何构建社交网站。您将学习如何实现用户认证视图,并学会使用 Django 认证框架。您将实现用户注册,并通过自定义资料模型扩展用户模型。

第五章,实现社交认证,涵盖了实现社交认证和使用消息框架。您将创建自定义认证后端,并将社交认证与 Google 集成,使用 OAuth 2.0。您将学习如何使用django-extensions通过 HTTPS 运行开发服务器,并自定义社交认证管道来自动化用户资料创建。

第六章,在您的网站上共享内容,将教授您如何将您的社交应用程序转变为图片书签网站。您将为模型定义多对多关系,并创建一个集成到您项目中的 JavaScript 书签小工具。本章将向您展示如何生成图片缩略图。您还将学习如何使用 JavaScript 和 Django 实现异步 HTTP 请求,并实现无限滚动分页。

第七章,跟踪用户行为,将向您展示如何构建用户关注系统。您将通过创建用户活动流应用程序来完成您的图片书签网站。您将学习如何在模型之间创建通用关系并优化 QuerySets。

您将使用信号并实现反规范化。您将使用 Django 调试工具栏来获取相关调试信息。最后,您将集成 Redis 到您的项目中以计数图片查看次数,并使用 Redis 创建最常查看图片的排名。

第八章,构建在线商店,探讨了如何创建在线商店。您将为产品目录构建模型,并使用 Django 会话创建购物车。您将为购物车构建上下文处理器,并学习如何管理客户订单。本章将教会您如何使用 Celery 和 RabbitMQ 发送异步通知。您还将学习如何使用 Flower 监控 Celery。

第九章,管理支付和订单,解释了如何将支付网关集成到您的商店中。您将集成 Stripe Checkout 并在您的应用程序中接收异步支付通知。您将在管理站点中实现自定义视图,并自定义管理站点以导出 CSV 文件格式的订单。您还将学习如何动态生成 PDF 发票。

第十章,扩展您的商店,将教会您如何创建一个优惠券系统以应用于购物车。您将更新 Stripe Checkout 集成以实现优惠券折扣,并将优惠券应用于订单。您将使用 Redis 存储通常一起购买的产品,并利用这些信息构建一个产品推荐引擎。

第十一章,为您的商店添加国际化,将向您展示如何将国际化添加到您的项目中。您将学习如何生成和管理翻译文件,以及如何在 Python 代码和 Django 模板中翻译字符串。您将使用 Rosetta 来管理翻译并实现按语言 URL。您将学习如何使用django-parler来翻译模型字段以及如何使用 ORM 进行翻译。最后,您将使用django-localflavor创建一个本地化的表单字段。

第十二章,构建电子学习平台,将指导您创建电子学习平台。您将为项目添加固定数据,并为内容管理系统创建初始模型。您将使用模型继承来创建多态内容的数据模型。您将学习如何通过构建一个用于排序对象的字段来创建自定义模型字段。您还将实现 CMS 的认证视图。

第十三章,创建内容管理系统,将教会您如何使用基于类的视图和混入创建 CMS。您将使用 Django 的组和权限系统来限制对视图的访问,并实现表单集来编辑课程内容。您还将创建一个拖放功能,使用 JavaScript 和 Django 重新排序课程模块及其内容。

第十四章,渲染和缓存内容,将向你展示如何实现课程目录的公共视图。你将创建一个学生注册系统并管理学生选课。你将创建为课程模块渲染不同类型内容的功能。你将学习如何使用 Django 缓存框架缓存内容,并为你的项目配置 Memcached 和 Redis 缓存后端。最后,你将学习如何使用管理站点监控 Redis。

第十五章,构建 API,探讨了使用 Django REST 框架为你的项目构建 RESTful API。你将学习如何为你的模型创建序列化器并构建自定义 API 视图。你将处理 API 认证并为 API 视图实现权限。

你将学习如何构建 API 视图集和路由器。本章还将教你如何使用 Requests 库来消费你的 API。

第十六章,构建聊天服务器,解释了如何使用 Django Channels 为学生创建一个实时聊天服务器。你将学习如何实现依赖于异步通信通过 WebSockets 的功能。你将使用 Python 创建 WebSocket 消费者并实现 JavaScript WebSocket 客户端。你将使用 Redis 设置频道层,并学习如何使你的 WebSocket 消费者完全异步。你还将通过将聊天消息持久化到数据库中实现聊天历史。

第十七章,上线,将向你展示如何为多个环境创建设置,以及如何使用 PostgreSQL、Redis、uWSGI、NGINX 和 Daphne 与 Docker Compose 设置生产环境。你将学习如何通过 HTTPS 安全地提供你的项目,并使用 Django 系统检查框架。本章还将教你如何构建自定义中间件和创建自定义管理命令。

为了充分利用本书

下载示例代码文件

本书代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Django-5-by-Example。我们还有其他来自我们丰富图书和视频目录的代码包,可在 https://github.com/PacktPublishing/找到。查看它们吧!

下载彩色图像

我们还提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。你可以从这里下载:https://packt.link/gbp/9781805125457。

使用的约定

本书使用了多种文本约定。

CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“编辑shop应用的models.py文件。”

代码块按以下方式设置:

from django.contrib import admin
from .models import Post 
admin.site.register(Post) 

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

INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
**'blog.apps.BlogConfig'**,
] 

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

python manage.py runserver 

粗体: 表示新术语、重要单词或屏幕上出现的单词。例如,菜单或对话框中的单词在文本中显示如下。例如:“从管理面板中选择系统信息。”

警告或重要说明看起来像这样。

技巧和窍门看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈: 请通过电子邮件发送至 feedback@packtpub.com,并在邮件主题中提及本书的标题。如果您对本书的任何方面有疑问,请通过电子邮件发送至 questions@packtpub.com

勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在本书中发现错误,我们将不胜感激,如果您能向我们报告,请访问 http://www.packtpub.com/submit-errata,点击提交勘误并填写表格。

盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件发送至 copyright@packtpub.com 并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为本书做出贡献,请访问 authors.packtpub.com

加入我们的 Discord 频道!

与其他用户、Django 开发专家和作者本人一起阅读本书。提问、为其他读者提供解决方案、通过 Ask Me Anything 会议与作者聊天,等等。扫描二维码或访问链接加入社区。

packt.link/Django5ByExample

二维码

访问本书的专用网站

前往 djangobyexample.com/ 了解更多关于本书以及过往读者对此的看法。

分享您的想法

读完 Django 5 By Example, 第五版 后,我们非常乐意听到您的想法!请点击此处直接进入本书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区都至关重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢在路上阅读,但又无法携带您的印刷书籍到处走吗?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。直接从您喜欢的技术书籍中搜索、复制并粘贴代码到您的应用程序中。

优惠远不止于此,您还可以获得独家折扣、时事通讯和丰富的免费内容,每天直接发送到您的收件箱。

按照以下简单步骤获取优惠:

  1. 扫描二维码或访问以下链接:

packt.link/free-ebook/9781805125457

  1. 提交您的购买证明。

  2. 就这些!我们将直接将免费 PDF 和其他优惠发送到您的电子邮件。

第一章:构建 博客应用程序

在这本书中,你将学习如何使用 Django 构建专业级别的网络项目。本章将引导你了解 Django 应用程序的基本构建块,从安装到部署。如果你还没有在你的机器上设置 Django,安装 Django 部分将指导你完成安装过程。

在我们开始第一个 Django 项目之前,让我们回顾一下你将要学习的内容。本章将为你提供一个框架的总体概述。它将引导你了解创建一个完全功能性的网络应用程序的不同主要组件:模型、模板、视图和 URL。你将了解 Django 的工作原理以及不同的框架组件如何交互。

你还将了解 Django 项目和应用程序之间的区别,并学习最重要的 Django 设置。你将构建一个简单的博客应用程序,允许用户浏览所有已发布的帖子并阅读单个帖子。你还将创建一个简单的管理界面来管理和发布帖子。在接下来的两章中,你将扩展博客应用程序以添加更多高级功能。

将本章视为构建完整 Django 应用的路线图。如果某些组件或概念一开始看起来不清楚,请不要担心。本书将详细探讨不同的框架组件。

本章将涵盖以下主题:

  • 安装 Python

  • 创建 Python 虚拟环境

  • 安装 Django

  • 创建和配置 Django 项目

  • 构建 Django 应用程序

  • 设计数据模型

  • 创建和应用模型迁移

  • 为你的模型设置管理站点

  • 使用 QuerySets 和模型管理器

  • 构建 视图、模板和 URL

  • 理解 Django 请求/响应周期

你将首先在你的机器上安装 Python。

本章的源代码可以在github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter01找到。

本章中使用的所有 Python 包都包含在章节源代码中的 requirements.txt 文件中。你可以按照以下部分的说明安装每个 Python 包,或者你可以使用命令 python -m pip install -r requirements.txt 一次性安装所有依赖。

安装 Python

Django 5.0 支持 Python 3.10、3.11 和 3.12。本书中的示例将使用 Python 3.12。

如果你使用的是 Linux 或 macOS,你可能已经安装了 Python。如果你使用的是 Windows,你可以从 python.org 网站下载 Python 安装程序。你可以从 www.python.org/downloads/ 下载适合你操作系统的 Python。

打开您机器的命令行 shell 提示符。如果您使用 macOS,请按 Command + 空格键 打开 Spotlight,并输入 Terminal 以打开 Terminal.app。如果您使用 Windows,请打开 开始 菜单,在搜索框中输入 powers。然后,单击 Windows PowerShell 应用程序以打开它。或者,您还可以使用更基本的命令提示符,通过在搜索框中输入 cmd 并单击 Command Prompt 应用程序来打开它。

通过在 shell 提示符中输入以下命令来验证您的机器上是否已安装 Python 3:

python3 --version 

如果您看到以下内容,则表示您的计算机上已安装 Python 3:

Python 3.12.3 

如果您遇到错误,请尝试使用 python 命令而不是 python3。如果您使用 Windows,建议您将 python 替换为 py 命令。

如果您的安装的 Python 版本低于 3.12,或者 Python 没有安装到您的计算机上,请从 www.python.org/downloads/ 下载 Python 3.12,并按照说明进行安装。在下载网站上,您可以找到适用于 Windows、macOS 和 Linux 的 Python 安装程序。

在本书的整个过程中,当在 shell 提示符中引用 Python 时,我们将使用 python 命令,尽管某些系统可能需要使用 python3。如果您使用 Linux 或 macOS,并且您的系统 Python 是 Python 2,您将需要使用 python3 来使用您安装的 Python 3 版本。请注意,Python 2 于 2020 年 1 月达到生命周期的终点,不应再使用。

在 Windows 中,python 是您默认 Python 安装的可执行文件,而 py 是 Python 启动器。Python 启动器是在 Python 3.3 中引入的。它会检测您的机器上安装了哪些 Python 版本,并自动委托给最新版本。

如果您使用 Windows,应使用 py 命令。您可以在 docs.python.org/3/using/windows.html#launcher 阅读有关 Windows Python 启动器的更多信息。

接下来,您将为您的项目创建一个 Python 环境,并安装必要的 Python 库。

创建 Python 虚拟环境

当您编写 Python 应用程序时,您通常会使用标准 Python 库中未包含的包和模块。您可能有需要不同版本相同模块的 Python 应用程序。然而,只能安装特定版本的模块系统范围。如果您为应用程序升级模块版本,可能会破坏需要该模块旧版本的其它应用程序。

为了解决这个问题,您可以使用 Python 虚拟环境。使用虚拟环境,您可以在隔离的位置安装 Python 模块,而不是在系统范围内安装它们。每个虚拟环境都有自己的 Python 二进制文件,并且可以在其 site-packages 目录中拥有自己独立的安装 Python 包集合。

自 3.3 版本以来,Python 内置了 venv 库,它提供了创建轻量级虚拟环境的支持。通过使用 Python venv 模块创建隔离的 Python 环境,您可以为不同的项目使用不同的包版本。使用 venv 的另一个优点是您不需要任何管理员权限来安装 Python 包。

如果您正在使用 Linux 或 macOS,请使用以下命令创建隔离环境:

python -m venv my_env 

如果您的系统自带 Python 2 而您已安装 Python 3,请记住使用 python3 而不是 python

如果您正在使用 Windows,请使用以下命令代替:

py -m venv my_env 

这将在 Windows 中使用 Python 启动器。

之前的命令将在名为 my_env 的新目录中创建一个 Python 环境。在您的虚拟环境激活期间安装的任何 Python 库都将进入 my_env/lib/python3.12/site-packages 目录。

如果您正在使用 Linux 或 macOS,请运行以下命令以激活您的虚拟环境:

source my_env/bin/activate 

如果您正在使用 Windows,请使用以下命令代替:

.\my_env\Scripts\activate 

shell 提示符将包括括号内的活动虚拟环境名称,如下所示:

(my_env) zenx@pc:~ zenx$ 

您可以使用 deactivate 命令随时停用您的环境。您可以在 docs.python.org/3/library/venv.html 找到有关 venv 的更多信息。

安装 Django

如果您已经安装了 Django 5.0,您可以跳过本节,直接跳转到 创建您的第一个项目 部分。

Django 作为 Python 模块提供,因此可以安装在任何 Python 环境中。如果您还没有安装 Django,以下是在您的机器上安装 Django 的快速指南。

使用 pip 安装 Django

pip 软件包管理系统是安装 Django 的首选方法。Python 3.12 预装了 pip,但您可以在 pip.pypa.io/en/stable/installation/ 找到 pip 安装说明。

在 shell 提示符下运行以下命令以使用 pip 安装 Django:

python -m pip install Django~=5.0.4 

这将在您的虚拟环境的 Python site-packages 目录中安装 Django 的最新 5.0 版本。

现在我们将检查 Django 是否已成功安装。在 shell 提示符下运行以下命令:

python -m django --version 

如果您得到以 5.0 开头的输出,则表示 Django 已成功安装在您的机器上。如果您得到消息 No module named Django,则表示 Django 没有安装在您的机器上。如果您在安装 Django 时遇到问题,您可以查看在 docs.djangoproject.com/en/5.0/intro/install/ 描述的不同安装选项。

本章中使用的所有 Python 包都包含在上文提到的章节源代码中的 requirements.txt 文件中。您可以根据以下章节中的说明安装每个 Python 包,或者使用命令 pip install -r requirements.txt 一次性安装所有依赖。

Django 概述

Django 是一个由一系列组件组成的框架,用于解决常见的 Web 开发问题。Django 组件松散耦合,这意味着它们可以独立管理。这有助于分离框架不同层的职责;数据库层不知道数据是如何显示的,模板系统不知道 Web 请求,等等。

Django 通过遵循 DRY不要重复自己)原则提供最大程度的代码重用性。Django 还促进了快速开发,并允许您利用 Python 的动态能力(如反射)使用更少的代码。

您可以在 docs.djangoproject.com/en/5.0/misc/design-philosophies/ 了解更多关于 Django 设计理念的信息。

主框架组件

Django 遵循 MTV模型-模板-视图)模式。它与众所周知的 MVC模型-视图-控制器)模式略有相似,其中模板充当视图,而框架本身充当控制器。

Django MTV 模式中的职责划分如下:

  • 模型:这定义了逻辑数据结构,是数据库和视图之间的数据处理器。

  • 模板:这是表示层。Django 使用纯文本模板系统,保留了浏览器渲染的所有内容。

  • 视图:通过模型与数据库通信,并将数据传输到模板进行查看。

框架本身充当控制器。它根据 Django URL 配置向适当的视图发送请求。

在开发任何 Django 项目时,您都将与模型、视图、模板和 URL 一起工作。在本章中,您将学习它们是如何结合在一起的。

Django 架构

图 1.1 展示了 Django 如何处理请求以及如何通过不同的主要 Django 组件(URL、视图、模型和模板)管理请求/响应周期:

图解 描述自动生成

图 1.1:Django 架构

这就是 Django 处理 HTTP 请求并生成响应的方式:

  1. 网络浏览器通过其 URL 请求页面,并将 HTTP 请求传递给 Django。

  2. Django 会遍历其配置的 URL 模式,并在第一个匹配请求 URL 的模式处停止。

  3. Django 执行与匹配的 URL 模式对应的视图。

  4. 视图可能使用数据模型从数据库中检索信息。

  5. 数据模型提供数据定义和行为。它们用于查询数据库。

  6. 视图渲染一个模板(通常是 HTML)以显示数据,并将其与 HTTP 响应一起返回。

我们将在本章的“请求/响应周期”部分回顾 Django 的请求/响应周期。

Django 还包括请求/响应过程中的钩子,这些钩子被称为中间件。为了简化,中间件被有意地省略了。你将在本书的不同示例中使用中间件,你将在第十七章“上线”中学习如何创建自定义中间件。

我们已经介绍了 Django 的基础元素以及它是如何处理请求的。让我们探索 Django 5 引入的新特性。

Django 5 的新特性

Django 5 引入了几个关键特性,这些特性将在本书的示例中使用。此版本还弃用了一些特性,并消除了之前已弃用的功能。Django 5.0 展示了以下新主要特性:

  • 管理站点中的分面过滤器:现在可以将分面过滤器添加到管理站点。启用后,在管理员对象列表中会显示应用过滤器的分面计数。本章节的“添加分面计数到过滤器”部分介绍了这一功能。

  • 简化表单字段渲染的模板:通过定义具有相关模板的字段组,表单字段渲染已被简化。这旨在使渲染 Django 表单字段相关元素的过程(如标签、小部件、帮助文本和错误)更加流畅。在第二章,增强博客和添加社交功能的“为评论表单创建模板”部分可以找到使用字段组的示例。

  • 数据库计算默认值:Django 添加了数据库计算的默认值。本章的“添加日期时间字段”部分展示了此功能的示例。

  • 数据库生成的模型字段:这是一种新类型的字段,允许你创建数据库生成的列。每次模型更改时,都会使用表达式自动设置字段值。字段值使用GENERATED ALWAYS SQL 语法设置。

  • 声明模型字段选择项的更多选项:支持选择项的字段不再需要访问.choices属性来访问枚举类型。可以直接使用映射或可调用对象而不是可迭代对象来扩展枚举类型。本书中具有枚举类型的选项已被更新以反映这些变化。这种实例可以在本章的“添加状态字段”部分找到。

Django 5 还带来了一些对异步支持方面的改进。异步服务器网关接口(ASGI)支持首次在 Django 3 中引入,并在 Django 4.1 中通过为基于类的视图提供异步处理程序和异步 ORM 接口得到改进。Django 5 为认证框架添加了异步函数,提供了异步信号分发的支持,并将异步支持添加到多个内置装饰器中。

Django 5.0 停止支持 Python 3.8 和 3.9。

您可以在 Django 5.0 版本的发布说明中阅读完整的更改列表,链接为 docs.djangoproject.com/en/5.0/releases/5.0/

作为基于时间的发布,Django 5.0 没有重大变化,这使得将 Django 4 应用程序升级到 5.0 版本变得简单直接。

如果您想快速将现有的 Django 项目升级到 5.0 版本,可以使用 django-upgrade 工具。此包通过应用修复器将您的项目文件重写至目标版本。您可以在 github.com/adamchainz/django-upgrade 找到使用 django-upgrade 的说明。

django-upgrade 工具的灵感来源于 pyupgrade 包。您可以使用 pyupgrade 自动升级 Python 新版本的语法。您可以在 github.com/asottile/pyupgrade 找到有关 pyupgrade 的更多信息。

创建您的第一个项目

您的第一个 Django 项目将包括一个博客应用程序。这将为您提供一个对 Django 的功能和功能的坚实基础介绍。

由于博客需要从基本内容管理到评论、帖子分享、搜索和帖子推荐等高级功能在内的广泛功能,因此它是构建完整 Django 项目的完美起点。本书的前三章将涵盖博客项目。

在本章中,我们将从创建 Django 项目和博客的 Django 应用程序开始。然后我们将创建我们的数据模型并将它们同步到数据库。最后,我们将为博客创建一个管理站点,并构建视图、模板和 URL。

图 1.2 展示了您将创建的博客应用程序页面:

图片

图 1.2:第一章内置功能图

博客应用程序将包括一系列帖子,包括帖子标题、发布日期、作者、帖子摘要以及阅读帖子的链接。帖子列表页面将通过 post_list 视图实现。您将在本章学习如何创建视图。

当读者点击帖子列表页面上的帖子链接时,他们将被重定向到单个(详细)视图的帖子。详细视图将显示标题、发布日期、作者和完整的帖子正文。

让我们从创建我们的博客 Django 项目开始。Django 提供了一个命令,允许您创建初始项目文件结构。

在您的 shell 提示符中运行以下命令:

django-admin startproject mysite 

这将创建一个名为 mysite 的 Django 项目。

为了防止冲突,请勿将项目命名为内置的 Python 或 Django 模块。

让我们看看生成的项目结构:

mysite/
    manage.py
    mysite/
      __init__.py
      asgi.py
      settings.py
      urls.py
      wsgi.py 

外部的 mysite/ 目录是我们项目的容器。它包含以下文件:

  • manage.py:这是一个用于与项目交互的命令行实用程序。您通常不需要编辑此文件。

  • mysite/:这是项目的 Python 包,它包含以下文件:

    • __init__.py:一个空文件,告诉 Python 将 mysite 目录视为一个 Python 模块。

    • asgi.py:这是配置以 ASGI 兼容的 Web 服务器作为 ASGI 应用程序运行项目的配置。ASGI 是异步 Web 服务器和应用程序的 Python 标准之一。

    • settings.py:这表示项目的设置和配置,并包含初始默认设置。

    • urls.py:这是您的 URL 模式所在的地方。这里定义的每个 URL 都映射到一个视图。

    • wsgi.py:这是配置以 WSGI 兼容的 Web 服务器作为 Web Server Gateway InterfaceWSGI)应用程序运行项目的配置。WSGI 是异步 Web 服务器和应用程序的 Python 标准之一。

应用初始数据库迁移

Django 应用程序需要一个数据库来存储数据。settings.py 文件包含项目在 DATABASES 设置中的数据库配置。默认配置是一个 SQLite3 数据库。SQLite 与 Python 3 一起打包,可以在任何 Python 应用程序中使用。SQLite 是一个轻量级数据库,您可以用它来与 Django 进行开发。如果您计划在生产环境中部署应用程序,您应该使用功能齐全的数据库,例如 PostgreSQL、MySQL 或 Oracle。您可以在 docs.djangoproject.com/en/5.0/topics/install/#database-installation 找到有关如何使用 Django 运行数据库的更多信息。

您的 settings.py 文件还包括一个名为 INSTALLED_APPS 的列表,其中包含默认添加到项目的常见 Django 应用程序。我们将在 项目设置 部分中介绍这些应用程序。

Django 应用程序包含映射到数据库表的数据模型。您将在 创建博客数据模型 部分中创建自己的模型。为了完成项目设置,您需要创建与 INSTALLED_APPS 设置中包含的默认 Django 应用程序模型关联的表。Django 提供了一个系统,可以帮助您管理数据库迁移。

打开 shell 提示符并运行以下命令:

cd mysite
python manage.py migrate 

您将看到以下行结束的输出:

Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
Applying admin.0002_logentry_remove_auto_add... OK
Applying admin.0003_logentry_add_action_flag_choices... OK
Applying contenttypes.0002_remove_content_type_name... OK
Applying auth.0002_alter_permission_name_max_length... OK
Applying auth.0003_alter_user_email_max_length... OK
Applying auth.0004_alter_user_username_opts... OK
Applying auth.0005_alter_user_last_login_null... OK
Applying auth.0006_require_contenttypes_0002... OK
Applying auth.0007_alter_validators_add_error_messages... OK
Applying auth.0008_alter_user_username_max_length... OK
Applying auth.0009_alter_user_last_name_max_length... OK
Applying auth.0010_alter_group_name_max_length... OK
Applying auth.0011_update_proxy_permissions... OK
Applying auth.0012_alter_user_first_name_max_length... OK
Applying sessions.0001_initial... OK 

上述行是 Django 应用的数据库迁移。通过应用初始迁移,INSTALLED_APPS设置中列出的应用程序的表将在数据库中创建。

你将在本章的创建和应用迁移部分了解更多关于migrate管理命令的信息。

运行开发服务器

Django 自带了一个轻量级的 Web 服务器,可以快速运行你的代码,无需花费时间配置生产服务器。当你运行 Django 开发服务器时,它会持续检查你的代码中的更改。它会自动重新加载,让你在代码更改后无需手动重新加载。然而,它可能不会注意到某些操作,例如将新文件添加到你的项目中,因此在这些情况下,你必须手动重新启动服务器。

通过在 shell 提示符中输入以下命令来启动开发服务器:

python manage.py runserver 

你应该看到类似以下内容:

Watching for file changes with StatReloader
Performing system checks...
System check identified no issues (0 silenced).
January 01, 2024 - 10:00:00
Django version 5.0, using settings 'mysite.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C. 

现在,在你的浏览器中打开http://127.0.0.1:8000/。你应该会看到一个页面,表明项目已成功运行,如图图 1.3所示:

图 1.3:Django 开发服务器的默认页面

上述截图表明 Django 正在运行。如果你查看你的控制台,你会看到浏览器执行的GET请求:

[01/Jan/2024 10:00:15] "GET / HTTP/1.1" 200 16351 

开发服务器会将每个 HTTP 请求记录在控制台。在运行开发服务器时发生的任何错误也会出现在控制台。

你可以自定义主机和端口运行 Django 开发服务器,或者告诉 Django 加载特定的设置文件,如下所示:

python manage.py runserver 127.0.0.1:8001 --settings=mysite.settings 

当你必须处理需要不同配置的多个环境时,你可以为每个环境创建不同的设置文件。

此服务器仅适用于开发,不适用于生产使用。要在生产环境中部署 Django,你应该将其作为 WSGI 应用程序使用 Web 服务器(如 Apache、Gunicorn 或 uWSGI)运行,或者作为 ASGI 应用程序使用服务器(如 Daphne 或 Uvicorn)。你可以在docs.djangoproject.com/en/5.0/howto/deployment/wsgi/找到有关如何使用不同 Web 服务器部署 Django 的更多信息。

第十七章上线,解释了如何为你的 Django 项目设置生产环境。

项目设置

让我们打开settings.py文件,看看项目的配置。Django 在这个文件中包含了一些设置,但这些只是所有可用 Django 设置的一部分。你可以在docs.djangoproject.com/en/5.0/ref/settings/看到所有设置及其默认值。

让我们回顾一下项目设置:

  • DEBUG是一个布尔值,用于开启和关闭项目的调试模式。如果设置为True,当你的应用程序抛出未捕获的异常时,Django 将显示详细的错误页面。当你迁移到生产环境时,请记住你必须将其设置为False。永远不要在生产环境中开启DEBUG模式部署网站,因为这会使敏感的项目相关数据暴露。

  • ALLOWED_HOSTS在调试模式开启或运行测试时不会应用。一旦你将网站迁移到生产环境并将DEBUG设置为False,你必须将你的域名/主机添加到这个设置中,以便它能够为你的 Django 网站提供服务。

  • INSTALLED_APPS是一个你必须为所有项目编辑的设置。此设置告诉 Django 哪些应用对此站点是活动的。默认情况下,Django 包括以下应用:

    • django.contrib.admin: 一个管理网站。

    • django.contrib.auth: 一个认证框架。

    • django.contrib.contenttypes: 一个处理内容类型的框架。

    • django.contrib.sessions: 一个会话框架。

    • django.contrib.messages: 一个消息框架。

    • django.contrib.staticfiles: 一个用于管理静态文件(如 CSS、JavaScript 文件和图像)的框架。

  • MIDDLEWARE是一个包含要执行的中间件的列表。

  • ROOT_URLCONF指示定义应用程序根 URL 模式的 Python 模块。

  • DATABASES是一个字典,包含项目中所有数据库的设置。必须始终有一个默认数据库。默认配置使用 SQLite3 数据库。

  • LANGUAGE_CODE定义了此 Django 站点的默认语言代码。

  • USE_TZ告诉 Django 激活/停用时区支持。Django 自带对时区感知日期时间的支持。当你使用startproject管理命令创建新项目时,此设置设置为True

如果你对这里看到的内容不太理解,不要担心。你将在接下来的章节中了解更多关于不同的 Django 设置。

项目和应用

在整本书中,你将反复遇到项目应用这两个术语。在 Django 中,一个项目被认为是一个带有一些设置的 Django 安装。一个应用是一组模型、视图、模板和 URL。应用与框架交互以提供特定功能,并且可以在各种项目中重用。你可以将项目视为你的网站,其中包含几个应用,如博客、维基或论坛,这些应用也可以被其他 Django 项目使用。

图 1.4显示了 Django 项目的结构:

图 1.4

图 1.4:Django 项目/应用结构

创建一个应用

让我们创建我们的第一个 Django 应用。我们将从头开始构建一个博客应用。

在项目根目录的 shell 提示符中运行以下命令:

python manage.py startapp blog 

这将创建应用的基本结构,其外观如下:

blog/
    __init__.py
    admin.py
    apps.py
    migrations/
        __init__.py
    models.py
    tests.py
    views.py 

这些文件如下:

  • __init__.py:这是一个空文件,告诉 Python 将 blog 目录视为一个 Python 模块。

  • admin.py:你可以在这里注册模型,以便将它们包含在 Django 管理站点中——使用此站点是可选的。

  • apps.py:这包括 blog 应用程序的主要配置。

  • migrations:此目录将包含应用程序的数据库迁移。迁移允许 Django 跟踪你的模型更改并相应地同步数据库。此目录包含一个空的 __init__.py 文件。

  • models.py:这包括应用程序的数据模型;所有 Django 应用程序都需要一个 models.py 文件,但它可以是空的。

  • tests.py:你可以在这里添加应用程序的测试。

  • views.py:应用程序的逻辑在这里;每个视图接收一个 HTTP 请求,处理它,并返回一个响应。

应用程序结构准备就绪后,我们可以开始构建博客的数据模型。

创建博客数据模型

请记住,Python 对象是一组数据和方法的集合。类是打包数据和功能蓝图。创建一个新的类创建了一种新的对象类型,允许你创建该类型的实例。

Django 模型是关于你的数据行为的信息来源。它由一个继承自 django.db.models.Model 的 Python 类组成。每个模型映射到单个数据库表,其中类的每个属性代表一个数据库字段。

当你创建一个模型时,Django 将为你提供一个实用的 API,以便轻松查询数据库中的对象。

我们将为我们的博客应用程序定义数据库模型。然后,我们将为模型生成数据库迁移以创建相应的数据库表。在应用迁移时,Django 将为应用程序 models.py 文件中定义的每个模型创建一个表。

创建文章模型

首先,我们将定义一个 Post 模型,这将使我们能够将博客文章存储在数据库中。

将以下行添加到 blog 应用程序的 models.py 文件中。新行以粗体突出显示:

from django.db import models
**class****Post****(models.Model):**
 **title = models.CharField(max_length=****250****)**
 **slug = models.SlugField(max_length=****250****)**
 **body = models.TextField()**
**def****__str__****(****self****):**
**return** **self.title** 

这是博客文章的数据模型。文章将有一个标题,一个称为 slug 的简短标签,以及正文。让我们看看这个模型的字段:

  • title:这是文章标题的字段。这是一个 CharField 字段,在 SQL 数据库中转换为 VARCHAR 列。

  • slug:这是一个 SlugField 字段,在 SQL 数据库中转换为 VARCHAR 列。slug 是一个只包含字母、数字、下划线或连字符的简短标签。标题为 Django Reinhardt: A legend of Jazz 的文章可能有一个 slug 如 django-reinhardt-legend-jazz。我们将在 第二章,使用高级功能增强博客 中使用 slug 字段来构建美观、SEO 友好的博客文章 URL。

  • body: 这是存储帖子主体的字段。这是一个TextField字段,在 SQL 数据库中对应一个TEXT列。

我们还向模型类添加了一个__str__()方法。这是默认的 Python 方法,用于返回一个包含对象人类可读表示的字符串。Django 将使用此方法在许多地方显示对象的名称,例如 Django 管理站点。

让我们看看模型及其字段是如何转换为数据库表和列的。以下图表显示了Post模型和 Django 在将模型同步到数据库时将创建的相应数据库表:

图片

图 1.5:初始 Post 模型和数据库表对应关系

Django 将为每个模型字段创建一个数据库列:titleslugbody。你可以看到每个字段类型如何对应于数据库数据类型。

默认情况下,Django 为每个模型添加一个自动递增的主键字段。该字段的类型在每个应用程序配置中指定,或在全局DEFAULT_AUTO_FIELD设置中指定。当使用startapp命令创建应用程序时,DEFAULT_AUTO_FIELD的默认值是BigAutoField。这是一个 64 位的整数,根据可用的 ID 自动递增。如果你没有为你的模型指定主键,Django 会自动添加这个字段。你也可以通过在字段上设置primary_key=True来定义模型中的一个字段作为主键。

我们将使用额外的字段和行为扩展Post模型。一旦完成,我们将通过创建数据库迁移并应用它来将其同步到数据库。

添加日期时间字段

我们将继续通过向Post模型添加不同的日期时间字段。每篇帖子将在特定的日期和时间发布。因此,我们需要一个字段来存储发布日期和时间。我们还想存储Post对象创建的日期和时间以及最后一次修改的日期和时间。

编辑blog应用程序的models.py文件,使其看起来像这样;新行以粗体突出显示:

from django.db import models
**from** **django.utils** **import** **timezone**
class Post(models.Model):
    title = models.CharField(max_length=250)
    slug = models.SlugField(max_length=250)
    body = models.TextField()
 **publish = models.DateTimeField(default=timezone.now)**
def __str__(self):
        return self.title 

我们已经向Post模型添加了一个publish字段。这是一个DateTimeField字段,在 SQL 数据库中对应一个DATETIME列。我们将用它来存储帖子发布的日期和时间。我们使用 Django 的timezone.now方法作为字段的默认值。注意,我们导入了timezone模块来使用这个方法。timezone.now返回一个时区感知格式的当前日期和时间。你可以将其视为标准 Python datetime.now方法的时区感知版本。

定义模型字段默认值的另一种方法是使用数据库计算出的默认值。Django 5 引入了这一特性,允许您使用底层数据库函数来生成默认值。例如,以下代码使用数据库服务器的当前日期和时间作为publish字段的默认值:

from django.db import models
from django.db.models.functions import Now
class Post(models.Model):
    # ...
    publish = models.DateTimeField(db_default=Now()) 

要使用数据库生成的默认值,我们使用db_default属性而不是default。在这个例子中,我们使用了Now数据库函数。它具有与default=timezone.now类似的作用,但它使用NOW()数据库函数来生成初始值。您可以在docs.djangoproject.com/en/5.0/ref/models/fields/#django.db.models.Field.db_default了解更多关于db_default属性的信息。您可以在docs.djangoproject.com/en/5.0/ref/models/database-functions/找到所有可用的数据库函数。

让我们继续之前的字段版本:

class Post(models.Model):
    # ...
    publish = models.DateTimeField(default=timezone.now) 

编辑blog应用的models.py文件,并添加以下加粗的行:

from django.db import models
from django.utils import timezone
class Post(models.Model):
    title = models.CharField(max_length=250)
    slug = models.SlugField(max_length=250)
    body = models.TextField()
    publish = models.DateTimeField(default=timezone.now)
 **created = models.DateTimeField(auto_now_add=****True****)**
 **updated = models.DateTimeField(auto_now=****True****)**
def __str__(self):
        return self.title 

我们已将以下字段添加到Post模型中:

  • created:这是一个DateTimeField字段。我们将用它来存储帖子创建的日期和时间。通过使用auto_now_add,日期将在创建对象时自动保存。

  • updated:这是一个DateTimeField字段。我们将用它来存储帖子最后更新的日期和时间。通过使用auto_now,日期将在保存对象时自动更新。

在 Django 模型中使用auto_now_addauto_now日期时间字段对于跟踪对象的创建和最后修改时间非常有好处。

定义默认排序顺序

博客帖子通常按逆时间顺序展示,首先显示最新的帖子。对于我们的模型,我们将定义一个默认排序顺序。除非查询中指定了特定顺序,否则这种排序将在从数据库检索对象时生效。

按照以下内容编辑blog应用的models.py文件。新行已加粗:

from django.db import models
from django.utils import timezone
class Post(models.Model):
    title = models.CharField(max_length=250)
    slug = models.SlugField(max_length=250)
    body = models.TextField()
    publish = models.DateTimeField(default=timezone.now)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)
**class****Meta****:**
 **ordering = [****'-publish'****]**
def __str__(self):
        return self.title 

我们在模型内部添加了一个Meta类。这个类定义了模型的元数据。我们使用ordering属性告诉 Django 应该按publish字段排序结果。这种排序将在没有在查询中提供特定顺序的情况下作为数据库查询的默认排序方式。我们通过在字段名前使用连字符来表示降序,即-publish。默认情况下,帖子将按逆时间顺序返回。

添加数据库索引

让我们为publish字段定义一个数据库索引。这将提高通过此字段进行查询过滤或排序结果时的性能。我们预计许多查询将利用此索引,因为我们默认使用publish字段来排序结果。

编辑blog应用的models.py文件,使其看起来如下;新的行以粗体突出显示:

from django.db import models
from django.utils import timezone
class Post(models.Model):
    title = models.CharField(max_length=250)
    slug = models.SlugField(max_length=250)
    body = models.TextField()
    publish = models.DateTimeField(default=timezone.now)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)
    class Meta:
        ordering = ['-publish']
 **indexes = [**
 **models.Index(fields=[****'-publish'****]),**
 **]**
def __str__(self):
        return self.title 

我们在模型的Meta类中添加了indexes选项。此选项允许你为你的模型定义数据库索引,这可能包括一个或多个字段,以升序或降序,或功能表达式和数据库函数。我们为publish字段添加了一个索引。我们使用字段名前的连字符来定义特定于降序的索引。此索引的创建将包含在我们稍后为我们的博客模型生成的数据库迁移中。

MySQL 不支持索引排序。如果你使用 MySQL 作为数据库,将创建一个降序索引作为普通索引。

你可以在docs.djangoproject.com/en/5.0/ref/models/indexes/找到有关如何为模型定义索引的更多信息。

激活应用

我们需要在项目中激活blog应用,以便 Django 能够跟踪应用并能够为其模型创建数据库表。

编辑settings.py文件,并将blog.apps.BlogConfig添加到INSTALLED_APPS设置中。它应该看起来像这样;新的行以粗体突出显示:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
**'blog.apps.BlogConfig'****,**
] 

BlogConfig类是应用配置。现在 Django 知道该应用对于此项目是激活的,并将能够加载应用模型。

添加状态字段

博客的一个常见功能是在发布之前将帖子保存为草稿。我们将在我们的模型中添加一个status字段,以便我们能够管理博客帖子的状态。我们将为帖子使用DraftPublished状态。

编辑blog应用的models.py文件,使其看起来如下。新的行以粗体突出显示:

from django.db import models
from django.utils import timezone
class Post(models.Model):
**class****Status****(models.TextChoices):**
 **DRAFT =** **'DF'****,** **'Draft'**
 **PUBLISHED =** **'PB'****,** **'Published'**
    title = models.CharField(max_length=250)
    slug = models.SlugField(max_length=250)
    body = models.TextField()
    publish = models.DateTimeField(default=timezone.now)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)
 **status = models.CharField(**
 **max_length=****2****,**
 **choices=Status,**
 **default=Status.DRAFT**
 **)**
class Meta:
        ordering = ['-publish']
        indexes = [
            models.Index(fields=['-publish']),
        ]
    def __str__(self):
        return self.title 

我们通过子类化models.TextChoices定义了枚举类Status。帖子状态的可用选项是DRAFTPUBLISHED。它们相应的值是DFPB,它们的标签或可读名称是DraftPublished

Django 提供了枚举类型,你可以通过子类化来简单地定义选择。这些是基于 Python 标准库中的enum对象。你可以在docs.python.org/3/library/enum.html了解更多关于enum的信息。

Django 枚举类型相对于enum有一些修改。你可以在docs.djangoproject.com/en/5.0/ref/models/fields/#enumeration-types了解这些差异。

我们可以通过访问 Post.Status.choices 来获取可用的选项,通过 Post.Status.names 来获取选项的名称,通过 Post.Status.labels 来获取可读性强的名称,以及通过 Post.Status.values 来获取选项的实际值。

我们还在模型中添加了一个新的 status 字段,它是一个 CharField 的实例。它包括一个 choices 参数,以限制字段的值只能为 Status 中的选项。我们还使用 default 参数为该字段设置了默认值。我们使用 DRAFT 作为该字段的默认选项。

在模型类内部定义选项并使用枚举类型是一种良好的实践。这将允许你轻松地在代码的任何地方引用选项标签、值或名称。你可以导入 Post 模型并使用 Post.Status.DRAFT 作为代码中任何地方的 Draft 状态的引用。

让我们看看如何与状态选项交互。

在 shell 提示符中运行以下命令以打开 Python shell:

python manage.py shell 

然后,输入以下行:

>>> from blog.models import Post
>>> Post.Status.choices 

你将获得具有值-标签对的 enum 选项,如下所示:

[('DF', 'Draft'), ('PB', 'Published')] 

输入以下行:

>>> Post.Status.labels 

你将得到 enum 成员的可读性名称,如下所示:

['Draft', 'Published'] 

输入以下行:

>>> Post.Status.values 

你将得到 enum 成员的值,如下所示。这些是可以存储在数据库中 status 字段的值:

['DF', 'PB'] 

输入以下行:

>>> Post.Status.names 

你将得到选项的名称,如下所示:

['DRAFT', 'PUBLISHED'] 

你可以使用 Post.Status.PUBLISHED 访问特定的查找枚举成员,并且你可以访问它的 .name.value 属性。

添加多对一关系

帖子总是由作者撰写的。我们将创建用户和帖子之间的关系,以指示哪个用户写了哪些帖子。Django 附带一个处理用户账户的认证框架。Django 认证框架位于 django.contrib.auth 包中,并包含一个 User 模型。为了定义用户和帖子之间的关系,我们将使用 AUTH_USER_MODEL 设置,该设置默认指向 auth.User。此设置允许你为项目指定不同的用户模型。

编辑 blog 应用程序的 models.py 文件,使其看起来如下。新行以粗体显示:

**from** **django.conf** **import** **settings**
from django.db import models
from django.utils import timezone
class Post(models.Model):
    class Status(models.TextChoices):
        DRAFT = 'DF', 'Draft'
        PUBLISHED = 'PB', 'Published'
    title = models.CharField(max_length=250)
    slug = models.SlugField(max_length=250)
 **author = models.ForeignKey(**
 **settings.AUTH_USER_MODEL,**
 **on_delete=models.CASCADE,**
 **related_name=****'blog_posts'**
 **)**
    body = models.TextField()
    publish = models.DateTimeField(default=timezone.now)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)
    status = models.CharField(
        max_length=2,
        choices=Status,
        default=Status.DRAFT
    )
    class Meta:
        ordering = ['-publish']
        indexes = [
            models.Index(fields=['-publish']),
        ]
    def __str__(self):
        return self.title 

我们已经导入了项目的设置,并在 Post 模型中添加了一个 author 字段。该字段定义了与默认用户模型的多对一关系,这意味着每篇帖子都是由一个用户撰写的,一个用户可以撰写任意数量的帖子。对于这个字段,Django 将在数据库中使用相关模型的键创建一个外键。

on_delete参数指定当引用的对象被删除时要采用的行为。这不仅仅适用于 Django;这是一个 SQL 标准。使用CASCADE,您指定当引用的用户被删除时,数据库也将删除所有相关的博客文章。您可以在docs.djangoproject.com/en/5.0/ref/models/fields/#django.db.models.ForeignKey.on_delete查看所有可能的选项。

我们使用related_name来指定反向关系的名称,从UserPost。这将使我们能够通过使用user.blog_posts的表示法轻松地从用户对象访问相关对象。我们将在稍后了解更多关于这一点。

Django 提供了不同类型的字段,您可以使用这些字段来定义您的模型。您可以在docs.djangoproject.com/en/5.0/ref/models/fields/找到所有字段类型。

Post模型现在已完成,我们现在可以将其同步到数据库。

创建并应用迁移

现在我们已经有了博客文章的数据模型,我们需要创建相应的数据库表。Django 提供了一个迁移系统,它跟踪对模型所做的更改,并使它们能够传播到数据库中。

migrate命令为INSTALLED_APPS中列出的所有应用程序应用迁移。它将数据库与当前模型和现有迁移同步。

首先,我们需要为我们的Post模型创建一个初始迁移。

在您项目的根目录下从 shell 提示符运行以下命令:

python manage.py makemigrations blog 

您应该得到以下类似的输出:

Migrations for 'blog':
    blog/migrations/0001_initial.py
        - Create model Post
        - Create index blog_post_publish_bb7600_idx on field(s)
          -publish of model post 

Django 刚刚在blog应用的migrations目录中创建了0001_initial.py文件。这个迁移包含创建Post模型数据库表的 SQL 语句以及为publish字段定义的数据库索引。

您可以通过查看文件内容来了解迁移是如何定义的。迁移指定了对其他迁移的依赖以及要在数据库中执行的操作以同步模型更改。

让我们看看 Django 将在数据库中执行的 SQL 代码以创建您的模型表。sqlmigrate命令接受迁移名称并返回它们的 SQL,而不执行它。

从 shell 提示符运行以下命令以检查您第一次迁移的 SQL 输出:

python manage.py sqlmigrate blog 0001 

输出应该如下所示:

BEGIN;
--
-- Create model Post
--
CREATE TABLE "blog_post" (
  "id" integer NOT NULL PRIMARY KEY AUTOINCREMENT,
  "title" varchar(250) NOT NULL,
  "slug" varchar(250) NOT NULL,
  "body" text NOT NULL,
  "publish" datetime NOT NULL,
  "created" datetime NOT NULL,
  "updated" datetime NOT NULL,
  "status" varchar(10) NOT NULL,
  "author_id" integer NOT NULL REFERENCES "auth_user" ("id") DEFERRABLE INITIALLY DEFERRED);
--
-- Create blog_post_publish_bb7600_idx on field(s) -publish of model post
--
CREATE INDEX "blog_post_publish_bb7600_idx" ON "blog_post" ("publish" DESC);
CREATE INDEX "blog_post_slug_b95473f2" ON "blog_post" ("slug");
CREATE INDEX "blog_post_author_id_dd7a8485" ON "blog_post" ("author_id");
COMMIT; 

实际输出取决于您使用的数据库。前面的输出是为 SQLite 生成的。如您在输出中看到的,Django 通过组合应用程序名称和模型的 lowercase 名称(blog_post)来生成表名,但您也可以在模型的Meta类中使用db_table属性为您的模型指定一个自定义数据库名称。

Django 会创建一个自动增长的id列,该列用作每个模型的键,但你也可以通过在你的模型字段中指定primary_key=True来覆盖此设置。默认的id列由一个自动增加的整数组成。此列对应于自动添加到你的模型中的id字段。

以下三个数据库索引被创建:

  • publish列上的降序索引。这是我们通过模型的Meta类的indexes选项显式定义的索引。

  • slug列上的索引,因为SlugField字段默认意味着索引。

  • author_id列上的索引,因为ForeignKey字段默认意味着索引。

让我们比较Post模型与其对应的数据库blog_post表:

表格 描述自动生成,置信度中等

图 1.6:完整的 Post 模型和数据库表对应关系

图 1.6显示了模型字段如何对应于数据库表列。

让我们使用新模型同步数据库。

在 shell 提示符中执行以下命令以应用现有的迁移:

python manage.py migrate 

你将得到以下行结束的输出:

Applying blog.0001_initial... OK 

我们刚刚为INSTALLED_APPS中列出的应用程序应用了迁移,包括blog应用程序。应用迁移后,数据库反映了模型的当前状态。

如果你编辑models.py文件以添加、删除或更改现有模型的字段,或者如果你添加了新模型,你必须使用makemigrations命令创建一个新的迁移。每个迁移都允许 Django 跟踪模型变化。然后,你必须使用migrate命令应用迁移,以保持数据库与你的模型同步。

为模型创建管理站点

现在,Post模型与数据库同步后,我们可以创建一个简单的管理站点来管理博客文章。

Django 自带一个非常有用的内置管理界面,用于编辑内容。Django 站点通过读取模型元数据并提供一个用于编辑内容的现成接口来动态构建。你可以直接使用它,配置你希望如何在其中显示你的模型。

django.contrib.admin应用程序已经包含在INSTALLED_APPS设置中,因此你不需要添加它。

创建超级用户

首先,你需要创建一个用户来管理管理站点。运行以下命令:

python manage.py createsuperuser 

你将看到以下输出。输入你想要的用户名、电子邮件和密码,如下所示:

Username (leave blank to use 'admin'): admin
Email address: admin@admin.com
Password: ********
Password (again): ******** 

然后,你将看到以下成功消息:

Superuser created successfully. 

我们刚刚创建了一个具有最高权限的管理员用户。

Django 管理站点

使用以下命令启动开发服务器:

python manage.py runserver 

在你的浏览器中打开http://127.0.0.1:8000/admin/。你应该看到管理登录页面,如图图 1.7所示:

图片 B21088_01_07.png

图 1.7:Django 管理站点登录屏幕

使用前一步骤中创建的用户凭据登录。你会看到如图 图 1.8 所示的管理站点索引页面:

图 1.8:Django 管理站点索引页面

在前面的截图中所看到的 GroupUser 模型是 Django 认证框架的一部分,位于 django.contrib.auth。如果你点击 Users,你会看到你之前创建的用户。

将模型添加到管理站点

让我们将你的博客模型添加到管理站点。编辑 blog 应用的 admin.py 文件,使其看起来像这样;新行以粗体突出显示:

from django.contrib import admin
**from** **.models** **import** **Post**
**admin.site.register(Post)** 

现在,在浏览器中重新加载管理站点。你应该会在网站上看到你的 Post 模型,如下所示:

图 1.9:包含在 Django 管理站点索引页面中的博客应用的帖子模型

这很简单,对吧?当你将模型注册到 Django 管理站点时,你将获得一个由 introspecting 你的模型生成的用户友好界面,它允许你以简单的方式列出、编辑、创建和删除对象。

点击 Posts 旁边的 添加 链接来添加一个新的帖子。你会注意到 Django 为你的模型动态生成的表单,如图 图 1.10 所示:

图 1.10:Django 管理站点帖子模型的编辑表单

Django 为每种类型的字段使用不同的表单小部件。即使是复杂的字段,如 DateTimeField,也使用简单的界面,如 JavaScript 日期选择器来显示。

填写表单并点击 保存 按钮。你应该会被重定向到帖子列表页面,并显示成功消息和刚刚创建的帖子,如图 图 1.11 所示:

图 1.11:添加成功消息的帖子模型的 Django 管理站点列表视图

自定义模型显示方式

现在,我们将看看如何自定义管理站点。

编辑你的 blog 应用的 admin.py 文件,并按以下方式更改它。新行以粗体突出显示:

from django.contrib import admin
from .models import Post
**@admin.register(****Post****)**
**class****PostAdmin****(admin.ModelAdmin):**
 **list_display = [****'title'****,** **'slug'****,** **'author'****,** **'publish'****,** **'status'****]** 

我们正在告诉 Django 管理站点,使用继承自 ModelAdmin 的自定义类来注册模型。在这个类中,我们可以包含有关如何在管理站点上显示模型以及如何与之交互的信息。

list_display 属性允许你设置你希望在管理对象列表页面上显示的模型字段。@admin.register() 装饰器执行与替换的 admin.site.register() 函数相同的功能,注册它装饰的 ModelAdmin 类。

让我们使用更多选项自定义 admin 模型。

编辑你的 blog 应用的 admin.py 文件,并按以下方式更改它。新行以粗体突出显示:

from django.contrib import admin
from .models import Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display = ['title', 'slug', 'author', 'publish', 'status']
 **list_filter = [****'status'****,** **'created'****,** **'publish'****,** **'author'****]**
 **search_fields = [****'****title'****,** **'body'****]**
 **prepopulated_fields = {****'slug'****: (****'title'****,)}**
 **raw_id_fields = [****'author'****]**
 **date_hierarchy =** **'publish'**
 **ordering = [****'status'****,** **'publish'****]** 

返回你的浏览器并重新加载帖子列表页面。现在,它看起来是这样的:

图片

图 1.12:Django 管理站点为帖子模型定制的列表视图

你可以看到在帖子列表页面上显示的字段是我们指定的 list_display 属性中的字段。列表页面现在包括一个右侧边栏,允许你通过 list_filter 属性中包含的字段过滤结果。对于 ForeignKey 字段如 author 的过滤器,只有当数据库中存在多个对象时才在侧边栏中显示。

页面上出现了一个搜索栏。这是因为我们使用 search_fields 属性定义了一个可搜索字段列表。在搜索栏下方,有一些导航链接,用于通过 date_hierarchy 属性定义的日期层次结构进行导航;默认情况下,帖子按 状态发布 列排序。我们使用 ordering 属性指定了默认排序标准。

接下来,点击 添加帖子 链接。你也会在这里注意到一些变化。当你输入新帖子的标题时,slug 字段会自动填充。你已经告诉 Django 使用 prepopulated_fields 属性,将 title 字段的输入预先填充到 slug 字段中:

图片

图 1.13:slug 模型现在在输入标题时自动预先填充

此外,author 字段现在显示为查找小部件,当有数千个用户时,这比输入选择下拉列表要好得多。这是通过 raw_id_fields 属性实现的,看起来像这样:

图片

图 1.14:用于选择与帖子模型作者字段相关联的对象的小部件

将面数添加到过滤器中

Django 5.0 将面过滤功能引入到管理站点,展示了面数。这些计数表示对应于每个特定过滤器的对象数量,使得在管理员更改列表视图中识别匹配对象变得更容易。接下来,我们将确保面过滤器始终显示在 PostAdmin 管理模型中。

编辑你的 blog 应用程序的 admin.py 文件,并添加以下加粗的行:

from django.contrib import admin
from .models import Post
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
    list_display = ['title', 'slug', 'author', 'publish', 'status']
    list_filter = ['status', 'created', 'publish', 'author']
    search_fields = ['title', 'body']
    prepopulated_fields = {'slug': ('title',)}
    raw_id_fields = ['author']
    date_hierarchy = 'publish'
    ordering = ['status', 'publish']
 **show_facets = admin.ShowFacets.ALWAYS** 

使用管理站点创建一些帖子,并访问 http://127.0.0.1:8000/admin/blog/post/。现在过滤器应包括总面数,如图 图 1.15 所示:

图片

图 1.15:包括面数的状态字段过滤器

通过几行代码,我们已经自定义了模型在管理站点上的显示方式。有大量的方式可以自定义和扩展 Django 管理站点;你将在本书的后面了解更多关于这些内容。

你可以在 docs.djangoproject.com/en/5.0/ref/contrib/admin/ 找到有关 Django 管理站点的更多信息。

使用查询集和管理器进行工作

现在我们已经有一个完全功能的管理站点来管理博客文章,这是一个学习如何以编程方式读取和写入数据库内容的好时机。

Django 的 对象关系映射器ORM)是一个强大的数据库抽象 API,它让你可以轻松地创建、检索、更新和删除对象。ORM 允许你使用 Python 的面向对象范式生成 SQL 查询。你可以将其视为一种以 Pythonic 方式与数据库交互的方法,而不是编写原始 SQL 查询。

ORM 将你的模型映射到数据库表中,并为你提供了一个简单的 Pythonic 接口来与数据库交互。ORM 会生成 SQL 查询并将结果映射到模型对象。Django ORM 与 MySQL、PostgreSQL、SQLite、Oracle 和 MariaDB 兼容。

记住,你可以在项目的 settings.py 文件的 DATABASES 设置中定义你项目的数据库。Django 可以同时与多个数据库一起工作,并且你可以编写数据库路由器来创建自定义的数据路由方案。

一旦你创建了你的数据模型,Django 会为你提供一个免费的 API 来与之交互。你可以在官方文档中找到模型 API 参考,链接为 docs.djangoproject.com/en/5.0/ref/models/

Django ORM 基于 QuerySets。QuerySet 是一组数据库查询,用于从你的数据库中检索对象。你可以应用过滤器到 QuerySets 上,根据给定的参数来缩小查询结果。QuerySet 等同于一个 SELECT SQL 语句,而过滤器是限制 SQL 子句,如 WHERELIMIT

接下来,你将学习如何构建和执行 QuerySets。

创建对象

在 shell 提示符中运行以下命令以打开 Python shell:

python manage.py shell 

然后,输入以下行:

>>> from django.contrib.auth.models import User
>>> from blog.models import Post
>>> user = User.objects.get(username='admin')
>>> post = Post(title='Another post',
...             slug='another-post',
...             body='Post body.',
...             author=user)
>>> post.save() 

让我们来分析一下这段代码的功能。

首先,我们通过用户名 admin 检索 user 对象:

>>> user = User.objects.get(username='admin') 

get() 方法允许我们从数据库中检索单个对象。此方法在幕后执行一个 SELECT SQL 语句。注意,此方法期望一个与查询匹配的结果。如果数据库没有返回结果,此方法将引发一个 DoesNotExist 异常,如果数据库返回多个结果,它将引发一个 MultipleObjectsReturned 异常。这两个异常都是正在执行查询的模型类的属性。

然后,我们创建一个具有自定义标题、别名和正文的 Post 实例,并将之前检索到的用户设置为帖子的作者:

>>> post = Post(title='Another post', slug='another-post', body='Post body.', author=user) 

这个对象是在内存中的,并没有持久化到数据库;我们创建了一个可以在运行时使用的 Python 对象,但并没有将其保存到数据库中。

最后,我们使用 save() 方法在数据库中保存 Post 对象:

>>> post.save() 

此操作在幕后执行一个 INSERT SQL 语句。

我们首先在内存中创建了一个对象,然后将其持久化到数据库中。然而,你可以使用create()方法在单个操作中创建对象并将其持久化到数据库,如下所示:

>>> Post.objects.create(title='One more post',
                    slug='one-more-post',
                    body='Post body.',
                    author=user) 

在某些情况下,你可能需要从数据库中检索对象或创建它(如果不存在)。get_or_create()方法通过检索对象或创建它(如果未找到)来简化此过程。此方法返回一个包含检索到的对象和一个表示是否创建了新对象的布尔值的元组。以下代码尝试检索用户名为user2User对象,如果不存在,则创建一个:

>>> user, created = User.objects.get_or_create(username='user2') 

更新对象

现在,将上一个Post对象的标题更改为不同的内容,并再次保存对象:

>>> post.title = 'New title'
>>> post.save() 

这次,save()方法执行了一个UPDATE SQL 语句。

你对模型对象所做的更改在调用save()方法之前不会持久化到数据库。

检索对象

你已经知道如何使用get()方法从数据库中检索单个对象。我们通过Post.objects.get()访问这个方法。每个 Django 模型至少有一个管理器,默认管理器称为objects。你可以通过你的模型管理器获取查询集对象。

要检索表中的所有对象,我们使用默认objects管理器的all()方法,如下所示:

>>> all_posts = Post.objects.all() 

这就是如何创建一个查询集,该查询集返回数据库中的所有对象。请注意,这个查询集尚未执行。Django 查询集是懒加载的,这意味着它们只有在被强制执行时才会被评估。这种行为使得查询集非常高效。如果你没有将查询集分配给变量,而是直接在 Python 命令行中写入它,那么查询集的 SQL 语句将被执行,因为你正在强制它生成输出:

>>> Post.objects.all()
<QuerySet [<Post: Who was Django Reinhardt?>, <Post: New title>]> 

过滤对象

要过滤查询集,你可以使用管理器的filter()方法。此方法允许你通过使用字段查找来指定 SQL WHERE 子句的内容。

例如,你可以使用以下方法通过其标题过滤Post对象:

>>> Post.objects.filter(title='Who was Django Reinhardt?') 

这个查询集将返回所有标题为谁是 Django Reinhardt?的帖子。让我们回顾一下使用此查询集生成的 SQL 语句。在 shell 中运行以下代码:

>>> posts = Post.objects.filter(title='Who was Django Reinhardt?')
>>> print(posts.query) 

通过打印查询集的query属性,我们可以获取它产生的 SQL:

SELECT "blog_post"."id", "blog_post"."title", "blog_post"."slug", "blog_post"."author_id", "blog_post"."body", "blog_post"."publish", "blog_post"."created", "blog_post"."updated", "blog_post"."status" FROM "blog_post" WHERE "blog_post"."title" = Who was Django Reinhardt? ORDER BY "blog_post"."publish" DESC 

生成的WHERE子句在标题列上执行精确匹配。ORDER BY子句指定了在Post模型的Meta选项中定义的默认顺序,因为我们没有在查询集中提供任何特定的排序。你将在稍后学习有关排序的内容。请注意,query属性不是查询集公共 API 的一部分。

使用字段查找

之前的查询集示例包含一个与精确匹配的过滤器查找。查询集接口为你提供了多种查找类型。使用两个下划线来定义查找类型,格式为 field__lookup。例如,以下查找产生一个精确匹配:

>>> Post.objects.filter(id__exact=1) 

当没有提供特定的查找类型时,默认的查找类型是 exact。以下查找与上一个查找等效:

>>> Post.objects.filter(id=1) 

让我们看看其他常见的查找类型。你可以使用 iexact 生成不区分大小写的查找:

>>> Post.objects.filter(title__iexact='who was django reinhardt?') 

你也可以使用包含测试来过滤对象。contains 查找转换为使用 LIKE 操作符的 SQL 查找:

>>> Post.objects.filter(title__contains='Django') 

相当的 SQL 子句是 WHERE title LIKE '%Django%'。还有一个不区分大小写版本,名为 icontains

>>> Post.objects.filter(title__icontains='django') 

你可以使用 in 查找检查给定的可迭代对象(通常是列表、元组或另一个查询集对象)。以下示例检索 id13 的帖子:

>>> Post.objects.filter(id__in=[1, 3]) 

以下示例展示了大于 (gt) 查找:

>>> Post.objects.filter(id__gt=3) 

相当的 SQL 子句是 WHERE ID > 3

以下示例展示了大于等于查找:

>>> Post.objects.filter(id__gte=3) 

这一个展示了小于查找:

>>> Post.objects.filter(id__lt=3) 

这展示了小于等于查找:

>>> Post.objects.filter(id__lte=3) 

可以使用 startswithistartswith 查找类型分别执行大小写敏感或不敏感的以...开头的查找:

>>> Post.objects.filter(title__istartswith='who') 

可以使用 endswithiendswith 查找类型分别执行大小写敏感或不敏感的以...结尾的查找:

>>> Post.objects.filter(title__iendswith='reinhardt?') 

对于日期查找,也有不同的查找类型。精确日期查找可以按以下方式执行:

>>> from datetime import date
>>> Post.objects.filter(publish__date=date(2024, 1, 31)) 

这展示了如何通过年份过滤 DateFieldDateTimeField 字段:

>>> Post.objects.filter(publish__year=2024) 

你还可以按月份进行过滤:

>>> Post.objects.filter(publish__month=1) 

你还可以按天进行过滤:

>>> Post.objects.filter(publish__day=1) 

你还可以将额外的查找链接到 dateyearmonthday。例如,以下是一个查找大于给定日期的值的查询:

>>> Post.objects.filter(publish__date__gt=date(2024, 1, 1)) 

要查找相关对象字段,你也使用双下划线表示法。例如,要检索用户名为 admin 的用户撰写的帖子,可以使用以下查询:

>>> Post.objects.filter(author__username='admin') 

你还可以为相关字段链接额外的查找。例如,要检索用户名为 ad 开头的任何用户撰写的帖子,可以使用以下查询:

>>> Post.objects.filter(author__username__starstwith='ad') 

你还可以按多个字段进行过滤。例如,以下查询集检索了由用户名为 admin 的作者在 2024 年发布的所有帖子:

>>> Post.objects.filter(publish__year=2024, author__username='admin') 

链接过滤器

过滤查询集的结果是另一个查询集对象。这允许你将查询集链接在一起。你可以通过链接多个过滤器来构建与上一个查询集等效的查询集:

>>> Post.objects.filter(publish__year=2024) \
>>>             .filter(author__username='admin') 

排除对象

你可以使用管理器的 exclude() 方法排除查询集中的某些结果。例如,你可以检索所有标题不以 Why 开头的 2024 年发布的帖子:

>>> Post.objects.filter(publish__year=2024) \
>>>             .exclude(title__startswith='Why') 

排序对象

默认排序在模型的 Meta 选项的 ordering 中定义。您可以使用管理器的 order_by() 方法覆盖默认排序。例如,您可以按 title 排序检索所有对象,如下所示:

>>> Post.objects.order_by('title') 

默认情况下,排序是升序的。您可以使用负号前缀来表示降序,如下所示:

>>> Post.objects.order_by('-title') 

您可以按多个字段排序。以下示例首先按 author 排序,然后按 title 排序:

>>> Post.objects.order_by('author', 'title') 

要随机排序,请使用字符串 '?',如下所示:

>>> Post.objects.order_by('?') 

限制 QuerySets

您可以使用 Python 数组切片语法的子集来限制 QuerySet 的结果数量。例如,以下 QuerySet 将结果限制为 5 个对象:

>>> Post.objects.all()[:5] 

这转换为 SQL 的 LIMIT 5 子句。请注意,不支持负索引。

>>> Post.objects.all()[3:6] 

前面的代码转换为 SQL 的 OFFSET 3 LIMIT 6 子句,以返回第四到第六个对象。

要检索单个对象,您可以使用索引而不是切片。例如,使用以下方法检索随机排序的帖子中的第一个对象:

>>> Post.objects.order_by('?')[0] 

对象计数

count() 方法计算与 QuerySet 匹配的对象总数,并返回一个整数。此方法转换为 SELECT COUNT(*) SQL 语句。以下示例返回 id 小于 3 的帖子总数:

>>> Post.objects.filter(id_lt=3).count()
2 

检查对象是否存在

exists() 方法允许您检查 QuerySet 是否包含任何结果。如果 QuerySet 包含任何项目,则此方法返回 True,否则返回 False。例如,您可以使用以下 QuerySet 检查是否有任何标题以 Why 开头的帖子:

>>> Post.objects.filter(title__startswith='Why').exists()
False 

删除对象

如果您想删除一个对象,可以使用对象实例的 delete() 方法,如下所示:

>>> post = Post.objects.get(id=1)
>>> post.delete() 

注意,删除对象也会删除任何与使用 on_delete 设置为 CASCADEForeignKey 对象定义的依赖关系。

使用 Q 对象进行复杂查找

使用 filter() 进行字段查找时,会与 SQL 的 AND 操作符连接。例如,filter(field1='foo', field2='bar') 将检索 field1foofield2bar 的对象。如果您需要构建更复杂的查询,例如包含 OR 语句的查询,可以使用 Q 对象。

Q 对象允许您封装一组字段查找。您可以通过将 Q 对象与 &(与)、|(或)和 ^(异或)运算符组合来构建语句。

例如,以下代码检索标题以字符串 whowhy(不区分大小写)开头的帖子:

>>> from django.db.models import Q
>>> starts_who = Q(title__istartswith='who')
>>> starts_why = Q(title__istartswith='why')
>>> Post.objects.filter(starts_who | starts_why) 

在这种情况下,我们使用 | 运算符构建一个 OR 语句。

您可以在 docs.djangoproject.com/en/5.0/topics/db/queries/#complex-lookups-with-q-objects 中了解更多关于 Q 对象的信息。

当 QuerySets 被评估时

创建查询集不会涉及任何数据库活动,直到它被评估。查询集通常会返回另一个未评估的查询集。你可以将任意数量的过滤器连接到一个查询集上,并且只有在查询集被评估时才会访问数据库。当查询集被评估时,它将转换为一个对数据库的 SQL 查询。

查询集仅在以下情况下才会被评估:

  • 第一次遍历它们时

  • 当你切片它们时,例如,Post.objects.all()[:3]

  • 当你序列化或缓存它们时

  • 当你调用 repr()len()

  • 当你显式调用 list()

  • 当你在语句中测试它们,例如 bool()orandif

更多关于查询集的信息

你将在本书的所有项目示例中使用查询集。你将在第三章“扩展你的博客应用程序”的“通过相似性检索帖子”部分中学习如何生成查询集的聚合。

你将在第七章“跟踪用户行为”的“优化涉及相关对象的查询集”部分中学习如何优化查询集。

查询集 API 参考文档位于 docs.djangoproject.com/en/5.0/ref/models/querysets/.

你可以在 docs.djangoproject.com/en/5.0/topics/db/queries/ 了解更多关于使用 Django ORM 进行查询的信息。

创建模型管理器

每个模型的默认管理器是 objects 管理器。此管理器检索数据库中的所有对象。然而,我们可以为模型定义自定义管理器。

让我们创建一个自定义管理器来检索所有具有 PUBLISHED 状态的帖子。

为你的模型添加或自定义管理器有两种方式:你可以向现有管理器添加额外的管理方法,或者通过修改管理器返回的初始查询集来创建一个新的管理器。第一种方法为你提供了一个查询集表示法,如 Post.objects.my_manager(),而后者为你提供了一个查询集表示法,如 Post.my_manager.all()

我们将选择第二种方法来实现一个管理器,它将允许我们使用 Post.published.all() 的表示法检索帖子。

编辑你的 blog 应用程序的 models.py 文件以添加自定义管理器,如下所示。新行以粗体显示:

**class****PublishedManager****(models.Manager):**
**def****get_queryset****(****self****):**
**return** **(**
**super****().get_queryset().****filter****(status=Post.Status.PUBLISHED)**
 **)**
class Post(models.Model):
    # model fields
# ...
 **objects = models.Manager()** **# The default manager.**
 **published = PublishedManager()** **# Our custom manager.**
class Meta:
        ordering = ['-publish']
        indexes = [
            models.Index(fields=['-publish']),
        ]
    def __str__(self):
        return self.title 

在模型中声明的第一个管理器将成为默认管理器。你可以使用 Meta 属性 default_manager_name 来指定不同的默认管理器。如果模型中没有定义管理器,Django 会自动为它创建 objects 默认管理器。如果你为你的模型声明了任何管理器,但还想保留 objects 管理器,你必须明确将其添加到你的模型中。在前面的代码中,我们已经将默认的 objects 管理器和自定义的 published 管理器添加到了 Post 模型中。

管理器的 get_queryset() 方法返回将要执行的查询集。我们已重写此方法以构建一个自定义查询集,该查询集通过状态过滤帖子,并返回一个只包含具有 PUBLISHED 状态的帖子的连续查询集。

我们现在已为 Post 模型定义了一个自定义管理器。让我们测试它!

在 shell 提示符中再次使用以下命令启动开发服务器:

python manage.py shell 

现在,你可以导入 Post 模型并检索所有标题以 Who 开头的已发布帖子,执行以下查询集:

>>> from blog.models import Post
>>> Post.published.filter(title__startswith='Who') 

要为这个查询集获取结果,请确保将 Post 对象的 status 字段设置为 PUBLISHED,其 title 以字符串 Who 开头。

构建 list 和 detail 视图

现在你已经了解了如何使用 ORM,你就可以构建 blog 应用程序的视图了。Django 视图只是一个接收网络请求并返回网络响应的 Python 函数。返回所需响应的所有逻辑都放在视图中。

首先,你将创建你的应用程序视图,然后你将为每个视图定义一个 URL 模式,最后,你将创建 HTML 模板以渲染视图生成的数据。每个视图将渲染一个模板,向其传递变量,并返回一个包含渲染输出的 HTTP 响应。

创建列表和详情视图

让我们先创建一个显示帖子列表的视图。

编辑 blog 应用程序的 views.py 文件,使其看起来像这样;新行以粗体突出显示:

from django.shortcuts import render
**from** **.models** **import** **Post**
**def****post_list****(****request****):**
 **posts = Post.published.****all****()**
**return** **render(**
 **request,**
**'blog/post/list.html'****,**
 **{****'posts'****: posts}**
 **)** 

这是我们非常第一个 Django 视图。post_list 视图将 request 对象作为唯一参数。所有视图都需要这个参数。

在这个视图中,我们使用之前创建的 published 管理器检索所有具有 PUBLISHED 状态的帖子。

最后,我们使用 Django 提供的 render() 快捷方式渲染给定模板的帖子列表。这个函数接受 request 对象、模板路径和上下文变量来渲染给定的模板。它返回一个包含渲染文本的 HttpResponse 对象(通常是 HTML 代码)。

render() 快捷方式会考虑请求上下文,因此任何由模板上下文处理器设置的变量都可以通过给定的模板访问。模板上下文处理器只是将变量设置到上下文中的可调用对象。你将在 第四章构建一个社交网站 中学习如何使用上下文处理器。

让我们创建一个第二个视图来显示单个帖子。将以下函数添加到 views.py 文件中:

from django.http import Http404
def post_detail(request, id):
    try:
        post = Post.published.get(id=id)
    except Post.DoesNotExist:
        raise Http404("No Post found.")
    return render(
        request,
        'blog/post/detail.html',
        {'post': post}
    ) 

这是 post_detail 视图。这个视图接受一个帖子的 id 参数。在视图中,我们尝试通过在 published 管理器上调用 get() 方法来检索具有给定 idPost 对象。如果模型抛出 DoesNotExist 异常,因为没有找到结果,我们将引发 Http404 异常以返回 HTTP 404 错误。

最后,我们使用 render() 快捷方式使用模板渲染检索到的帖子。

使用 get_object_or_404 快捷方式

Django 提供了一个快捷方式来在给定的模型管理器上调用 get(),并在找不到对象时引发一个 Http404 异常而不是 DoesNotExist 异常。

编辑 views.py 文件以导入 get_object_or_404 快捷方式,并按如下方式更改 post_detail 视图。新的代码以粗体显示:

from django.shortcuts import **get_object_or_404,** render
# ...
def post_detail(request, id):
 **post = get_object_or_404(**
 **Post,**
**id****=****id****,**
 **status=Post.Status.PUBLISHED**
 **)**
return render(
        request,
        'blog/post/detail.html',
        {'post': post}
    ) 

在详情视图中,我们现在使用 get_object_or_404() 快捷方式来检索所需的帖子。如果找不到对象,此函数将检索与给定参数匹配的对象或一个 HTTP 404(未找到)异常。

为您的视图添加 URL 模式

URL 模式允许您将 URL 映射到视图。一个 URL 模式由一个字符串模式、一个视图以及可选的名称组成,该名称允许您在项目范围内命名 URL。Django 会遍历每个 URL 模式,并在找到与请求 URL 匹配的第一个模式时停止。然后,Django 导入匹配 URL 模式的视图并执行它,传递 HttpRequest 类的实例和关键字或位置参数。

blog 应用程序的目录中创建一个 urls.py 文件,并将其中的以下行添加到该文件中:

from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
    # post views
    path('', views.post_list, name='post_list'),
    path('<int:id>/', views.post_detail, name='post_detail'),
] 

在前面的代码中,您使用 app_name 变量定义了一个应用程序命名空间。这允许您按应用程序组织 URL,并在引用时使用该名称。您使用 path() 函数定义了两个不同的模式。第一个 URL 模式不接收任何参数,并将其映射到 post_list 视图。第二个模式映射到 post_detail 视图,并仅接受一个参数 id,它由路径转换器 int 匹配的整数。

您可以使用尖括号来捕获 URL 中的值。在 URL 模式指定的任何 <parameter> 值都会被捕获为一个字符串。您可以使用路径转换器,例如 <int:year>,来特别匹配并返回一个整数。例如 <slug:post> 会特别匹配一个缩略词(只能包含字母、数字、下划线或连字符的字符串)。您可以在 Django 提供的所有路径转换器中查看 docs.djangoproject.com/en/5.0/topics/http/urls/#path-converters

如果使用 path() 和转换器不足以满足您的需求,您可以使用 re_path() 来代替,以使用 Python 正则表达式定义复杂的 URL 模式。您可以在 docs.djangoproject.com/en/5.0/ref/urls/#django.urls.re_path 了解更多关于使用正则表达式定义 URL 模式的信息。如果您之前没有使用过正则表达式,您可能首先想查看 正则表达式 HOWTO,它位于 docs.python.org/3/howto/regex.html

为每个应用程序创建一个 urls.py 文件是使您的应用程序可以被其他项目重用的最佳方式。

接下来,您需要在项目的主要 URL 模式中包含blog应用的 URL 模式。

编辑位于项目mysite目录中的urls.py文件,并使其看起来如下。新代码以粗体显示:

from django.contrib import admin
from django.urls import **include,** path
urlpatterns = [
    path('admin/', admin.site.urls),
 **path(****'blog/'****, include(****'blog.urls'****, namespace=****'blog'****)),**
] 

使用include定义的新 URL 模式引用了blog应用中定义的 URL 模式,以便它们在blog/路径下包含。您将这些模式包含在blog命名空间下。命名空间在整个项目中必须是唯一的。稍后,您可以通过使用命名空间后跟冒号和 URL 名称来轻松引用您的博客 URL,例如blog:post_listblog:post_detail。您可以在docs.djangoproject.com/en/5.0/topics/http/urls/#url-namespaces了解更多关于 URL 命名空间的信息。

为您的视图创建模板

您已经为blog应用创建了视图和 URL 模式。URL 模式将 URL 映射到视图,而视图决定哪些数据返回给用户。模板定义了数据的显示方式;它们通常是用 HTML 结合 Django 模板语言编写的。您可以在docs.djangoproject.com/en/5.0/ref/templates/language/找到有关 Django 模板语言的更多信息。

让我们向应用中添加模板以以用户友好的方式显示帖子。

在您的blog应用目录内创建以下目录和文件:

templates/
    blog/
        base.html
        post/
            list.html
            detail.html 

上述结构将是您模板的文件结构。base.html文件将包含网站的主要 HTML 结构,并将内容分为主要内容区域和侧边栏。list.htmldetail.html文件将继承自base.html文件,分别用于渲染博客文章列表和详情视图。

Django 有一个强大的模板语言,允许您指定数据如何显示。它基于模板标签模板变量模板过滤器

  • 模板标签控制模板的渲染,其形式如下:{% tag %}

  • 模板变量在模板渲染时会被替换成相应的值,其形式如下:{{ variable }}

  • 模板过滤器允许您修改用于显示的变量,其形式如下:{{ variable|filter }}

您可以在docs.djangoproject.com/en/5.0/ref/templates/builtins/查看所有内置的模板标签和过滤器。

创建基础模板

编辑base.html文件并添加以下代码:

{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% endblock %}</title>
<link href="{% static "css/blog.css" %}" rel="stylesheet">
</head>
<body>
<div id="content">
    {% block content %}
    {% endblock %}
  </div>
<div id="sidebar">
<h2>My blog</h2>
<p>This is my blog.</p>
</div>
</body>
</html> 

{% load static %} 告诉 Django 加载由 django.contrib.staticfiles 应用程序提供的 static 模板标签,该应用程序包含在 INSTALLED_APPS 设置中。加载它们后,您可以在整个模板中使用 {% static %} 模板标签。使用此模板标签,您可以包含静态文件,例如在 blog 应用的 static/ 目录下找到的 blog.css 文件。将此章节中提供的代码的 static/ 目录复制到与您的项目相同的位置,以将 CSS 样式应用到模板中。您可以在 github.com/PacktPublishing/Django-5-by-example/tree/master/Chapter01/mysite/blog/static 找到该目录的内容。

您可以看到有两个 {% block %} 标签。这告诉 Django 您想在那个区域定义一个块。继承此模板的模板可以用内容填充这些块。您定义了一个名为 title 的块和一个名为 content 的块。

创建帖子列表模板

让我们编辑 post/list.html 文件,使其看起来如下:

{% extends "blog/base.html" %}
{% block title %}My Blog{% endblock %}
{% block content %}
  <h1>My Blog</h1>
  {% for post in posts %}
    <h2>
<a href="{% url 'blog:post_detail' post.id %}">
        {{ post.title }}
      </a>
</h2>
<p class="date">
      Published {{ post.publish }} by {{ post.author }}
    </p>
    {{ post.body|truncatewords:30|linebreaks }}
  {% endfor %}
{% endblock %} 

使用 {% extends %} 模板标签,您告诉 Django 从 blog/base.html 模板继承。然后,您将 titlecontent 块的内容填充到基本模板中。您遍历帖子并显示它们的标题、日期、作者和正文,包括在标题中链接到帖子的详细 URL。我们使用 Django 提供的 {% url %} 模板标签构建 URL。

此模板标签允许您通过名称动态构建 URL。我们使用 blog:post_detail 来引用 blog 命名空间中的 post_detail URL。我们传递所需的 post.id 参数来为每个帖子构建 URL。

总是在您的模板中使用 {% url %} 模板标签来构建 URL,而不是编写硬编码的 URL。这将使您的 URL 更易于维护。

在帖子的正文中,我们应用了两个模板过滤器:truncatewords 将值截断到指定的单词数,而 linebreaks 将输出转换为 HTML 换行符。您可以连接任意数量的模板过滤器;每个过滤器都将应用于前一个过滤器生成的输出。

访问我们的应用程序

将初始帖子的状态更改为已发布,如图 1.16 所示,并创建一些新的帖子,也设置为已发布状态。

图 1.16:已发布帖子的状态字段

打开 shell 并执行以下命令以启动开发服务器:

python manage.py runserver 

在您的浏览器中打开 http://127.0.0.1:8000/blog/;您将看到一切都在运行。您应该看到如下内容:

图 1.17:帖子列表视图的页面

创建帖子详情模板

接下来,编辑 post/detail.html 文件:

{% extends "blog/base.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
  <h1>{{ post.title }}</h1>
<p class="date">
    Published {{ post.publish }} by {{ post.author }}
  </p>
  {{ post.body|linebreaks }}
{% endblock %} 

接下来,您可以回到浏览器并点击其中一个帖子标题,查看帖子的详情视图。您应该看到如下内容:

图片 B21088_01_18

图 1.18:帖子详情视图的页面

看看 URL - 它应该包含自动生成的帖子 ID,如 /blog/1/

请求/响应周期

让我们通过我们构建的应用程序来回顾 Django 的请求/响应周期。以下架构展示了 Django 处理 HTTP 请求和生成 HTTP 响应的简化示例:

时间线 描述自动生成,置信度中等

图 1.19:Django 的请求/响应周期

让我们回顾 Django 的请求/响应过程:

网络浏览器通过其 URL 请求页面,例如,https://domain.com/blog/33/。Web 服务器接收 HTTP 请求并将其传递给 Django。

Django 遍历在 URL 模式配置中定义的每个 URL 模式。框架按出现顺序将每个模式与给定的 URL 路径进行比较,并在第一个匹配请求 URL 的模式处停止。在这种情况下,模式 /blog/<id>/ 匹配路径 /blog/33/

Django 导入匹配 URL 模式视图,并执行它,传递 HttpRequest 类的实例和关键字或位置参数。视图使用模型从数据库检索信息。使用 Django ORM,查询集被转换为 SQL 并在数据库中执行。

视图使用 render() 函数渲染 HTML 模板,并将 Post 对象作为上下文变量传递。

视图默认以 text/html 内容类型返回渲染的内容作为 HttpResponse 对象。

您始终可以使用此架构作为 Django 处理请求的基本参考。出于简化的目的,此架构不包括 Django 中间件。您将在本书的不同示例中使用中间件,您将在第十七章 上线 中学习如何创建自定义中间件。

本章中使用的管理命令

在本章中,我们介绍了各种 Django 管理命令。您需要熟悉它们,因为它们将在本书的整个过程中被频繁使用。让我们回顾一下本章中我们已涵盖的命令。

要创建名为 mysite 的新 Django 项目的文件结构,我们使用了以下命令:

django-admin startproject mysite 

要创建名为 blog 的新 Django 应用程序的文件结构:

python manage.py startapp blog 

要应用所有数据库迁移:

python manage.py migrate 

要为 blog 应用程序的模型创建迁移:

python manage.py makemigrations blog 

要查看 blog 应用程序第一次迁移将执行的 SQL 语句:

python manage.py sqlmigrate blog 0001 

要运行 Django 开发服务器:

python manage.py runserver 

要指定主机/端口和设置文件运行开发服务器:

python manage.py runserver 127.0.0.1:8001 --settings=mysite.settings 

要运行 Django 壳:

python manage.py shell 

要使用 Django 认证框架创建超级用户:

python manage.py createsuperuser 

要查看可用的管理命令的完整列表,请查看 docs.djangoproject.com/en/5.0/ref/django-admin/

摘要

在本章中,你通过创建一个简单的博客应用程序学习了 Django 网络框架的基础知识。你设计了数据模型并将迁移应用到数据库中。你还创建了视图、模板和博客的 URL。

在下一章中,你将通过为你的帖子创建规范 URL 和构建 SEO 友好的 URL 来增强你的博客。你还将学习如何实现对象分页以及如何构建基于类的视图。你还将创建表单,让用户可以通过电子邮件推荐帖子并评论帖子。

其他资源

以下资源提供了与本章所涵盖主题相关的额外信息:

加入我们的 Discord 社区!

与其他用户、Django 开发专家以及作者本人一起阅读此书。提问、为其他读者提供解决方案、通过 Ask Me Anything 会话与作者聊天等。扫描二维码或访问链接加入社区。

https://packt.link/Django5ByExample

二维码

第二章:提升您的博客并添加社交功能

在上一章中,我们通过使用视图、模板和 URL 开发一个简单的博客应用程序,学习了 Django 的主要组件。在本章中,我们将通过添加现在许多博客平台都有的功能来扩展博客应用程序的功能。

在本章中,您将学习以下主题:

  • 为模型使用规范 URL

  • 为帖子创建 SEO 友好的 URL

  • 为帖子列表视图添加分页

  • 构建基于类的视图

  • 使用 Django 发送电子邮件

  • 使用 Django 表单通过电子邮件分享帖子

  • 使用模型表单添加帖子评论

功能概述

图 2.1展示了本章将要构建的视图、模板和功能表示:

图 2.1:第二章构建的功能图

在本章中,我们将为帖子列表页面添加分页功能以浏览所有帖子。我们还将学习如何使用 Django 构建基于类的视图,并将post_list视图转换为名为PostListView的基于类的视图。

我们将创建post_share视图,通过电子邮件分享帖子。我们将使用 Django 表单来分享帖子并通过简单邮件传输协议SMTP)发送电子邮件推荐。为了给帖子添加评论,我们将创建一个Comment模型来存储评论,并使用模型表单构建post_comment视图。

本章的源代码可以在github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter02找到。

本章中使用的所有 Python 包都包含在章节源代码的requirements.txt文件中。您可以在以下部分按照说明安装每个 Python 包,或者可以使用python -m pip install -r requirements.txt命令一次性安装所有依赖。

为模型使用规范 URL

一个网站可能有不同的页面显示相同的内容。在我们的应用程序中,每个帖子的内容初始部分既在帖子列表页面上显示,也在帖子详情页面上显示。规范 URL 是资源的首选 URL。您可以将其视为特定内容的代表性页面的 URL。您的网站上可能有不同的页面显示帖子,但只有一个 URL 用作帖子的主要 URL。

规范 URL 允许您指定页面的主副本的 URL。Django 允许您在模型中实现get_absolute_url()方法以返回对象的规范 URL。

我们将使用在应用程序的 URL 模式中定义的post_detail URL 来构建Post对象的规范 URL。Django 提供了不同的 URL 解析器函数,允许您使用它们的名称和任何所需的参数动态构建 URL。我们将使用django.urls模块的reverse()实用函数。

编辑blog应用的models.py文件以导入reverse()函数并将get_absolute_url()方法添加到Post模型中,如下所示。新的代码以粗体显示:

from django.conf import settings
from django.db import models
**from** **django.urls** **import** **reverse**
from django.utils import timezone
class PublishedManager(models.Manager):
    def get_queryset(self):
        return (
            super().get_queryset().filter(status=Post.Status.PUBLISHED)
        )
class Post(models.Model):
    # ...
class Meta:
        ordering = ['-publish']
        indexes = [
            models.Index(fields=['-publish']),
        ]
    def __str__(self):
        return self.title
**def****get_absolute_url****(****self****):**
**return** **reverse(**
**'blog:post_detail'****,**
 **args=[self.****id****]**
 **)** 

reverse()函数将使用在 URL 模式中定义的 URL 名称动态构建 URL。我们使用了blog命名空间,后面跟着一个冒号和post_detail URL 名称。记住,当从blog.urls包含 URL 模式时,blog命名空间在项目的urls.py主文件中定义。post_detail URL 在blog应用的urls.py文件中定义。

生成的字符串blog:post_detail可以在你的项目中全局使用来引用文章详细 URL。此 URL 有一个必需的参数,即要检索的博客文章的id。我们通过使用args=[self.id]Post对象的id作为位置参数包含在内。

你可以在docs.djangoproject.com/en/5.0/ref/urlresolvers/了解更多关于 URL 实用函数的信息。

让我们将模板中的文章详细 URL 替换为新的get_absolute_url()方法。

编辑blog/post/list.html文件并替换以下行:

<a href="{% url 'blog:post_detail' post.id %}"> 

将前面的行替换为以下行:

<a href="{**{ post.get_absolute_url }**}"> 

现在的blog/post/list.html文件应该看起来如下:

{% extends "blog/base.html" %}
{% block title %}My Blog{% endblock %}
{% block content %}
  <h1>My Blog</h1>
  {% for post in posts %}
    <h2>
<a href="{{ post.get_absolute_url }}">
        {{ post.title }}
      </a>
</h2>
<p class="date">
      Published {{ post.publish }} by {{ post.author }}
    </p>
    {{ post.body|truncatewords:30|linebreaks }}
  {% endfor %}
{% endblock %} 

打开 shell 提示符并执行以下命令以启动开发服务器:

python manage.py runserver 

在你的浏览器中打开http://127.0.0.1:8000/blog/。指向单个博客文章的链接仍然应该有效。Django 现在使用Post模型的get_absolute_url()方法构建文章 URL。

创建对 SEO 友好的文章 URL

目前博客文章详细视图的规范 URL 看起来像/blog/1/。我们将更改 URL 模式以创建对 SEO 友好的文章 URL。我们将使用发布日期和slug值来构建单个文章的 URL。通过组合日期,我们将文章详细 URL 设置为/blog/2024/1/1/who-was-django-reinhardt/。我们将为搜索引擎提供友好的 URL 以进行索引,包含文章的标题和日期。

要通过发布日期和slug的组合检索单个文章,我们需要确保没有文章可以存储在数据库中,其slugpublish日期与现有文章相同。我们将通过定义slug为文章发布日期的唯一值来防止Post模型存储重复的文章。

编辑models.py文件并在Post模型的slug字段中添加以下unique_for_date参数:

class Post(models.Model):
    # ...
    slug = models.SlugField(
        max_length=250,
 **unique_for_date=****'publish'**
    )
    # ... 

通过使用 unique_for_dateslug 字段现在必须对于存储在 publish 字段中的日期是唯一的。请注意,publish 字段是 DateTimeField 的一个实例,但唯一值的检查将仅针对日期(而不是时间)进行。Django 将防止你保存一个与给定发布日期的现有文章具有相同 slug 的新文章。我们现在确保 slug 对于发布日期是唯一的,因此我们现在可以通过 publishslug 字段检索单个文章。

我们已经更改了模型,所以,让我们创建迁移。请注意,unique_for_date 并不在数据库级别强制执行,因此不需要数据库迁移。然而,Django 使用迁移来跟踪所有模型更改。我们将创建一个迁移,只是为了保持迁移与当前模型状态的一致。

在 shell 提示符中运行以下命令:

python manage.py makemigrations blog 

你应该得到以下输出:

Migrations for 'blog':
    blog/migrations/0002_alter_post_slug.py
    - Alter field slug on post 

Django 已经在 blog 应用程序的 migrations 目录中创建了 0002_alter_post_slug.py 文件。

在 shell 提示符中执行以下命令以应用现有迁移:

python manage.py migrate 

你将得到一个以以下行结束的输出:

Applying blog.0002_alter_post_slug... OK 

Django 将认为所有迁移都已应用,并且模型是一致的。由于 unique_for_date 并不在数据库级别强制执行,因此数据库中不会执行任何操作。

修改 URL 模式

让我们修改 URL 模式以使用发布日期和 slug 为文章详情 URL。

编辑 blog 应用的 urls.py 文件并替换以下行:

path('<int:id>/', views.post_detail, name='post_detail'), 

将前面的行替换为以下行:

path(
    '**<int:year>/<int:month>/<int:day>/<slug:post>/**',
    views.post_detail,
    name='post_detail'
), 

urls.py 文件现在应该看起来像这样:

from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
    # Post views
    path('', views.post_list, name='post_list'),
    path(
        '**<int:year>/<int:month>/<int:day>/<slug:post>/**',
         views.post_detail,
         name='post_detail'
    ),
] 

post_detail 视图的 URL 模式接受以下参数:

  • year:这需要一个整数

  • month:这需要一个整数

  • day:这需要一个整数

  • post:这需要一个 slug(一个只包含字母、数字、下划线或连字符的字符串)

int 路径转换器用于 yearmonthday 参数,而 slug 路径转换器用于 post 参数。你可以在上一章中了解到路径转换器。你可以在 Django 提供的所有路径转换器中看到 docs.djangoproject.com/en/5.0/topics/http/urls/#path-converters

我们的文章现在有一个 SEO 友好的 URL,它是用每篇文章的日期和 slug 构建的。让我们相应地修改 post_detail 视图。

修改视图

我们将更改 post_detail 视图的参数以匹配新的 URL 参数并使用它们来检索相应的 Post 对象。

编辑 views.py 文件并修改 post_detail 视图如下:

def post_detail(request, **year, month, day, post**):
    post = get_object_or_404(
        Post,
        status=Post.Status.PUBLISHED**,**
 **slug=post,**
 **publish__year=year,**
 **publish__month=month,**
 **publish__day=day)**
return render(
        request,
        'blog/post/detail.html',
        {'post': post}
    ) 

我们已修改post_detail视图,以接受yearmonthdaypost参数,并检索具有给定 slug 和发布日期的已发布文章。通过在Post模型的slug字段中添加unique_for_date='publish',我们确保了对于给定日期只有一个具有 slug 的文章。因此,您可以使用日期和 slug 检索单个文章。

修改文章的规范 URL

我们还必须修改博客文章的规范 URL 参数,以匹配新的 URL 参数。

编辑blog应用的models.py文件,并按照以下方式编辑get_absolute_url()方法:

class Post(models.Model):
    # ...
def get_absolute_url(self):
        return reverse(
            'blog:post_detail',
            args=[
                **self.publish.year,**
 **self.publish.month,**
 **self.publish.day,**
 **self.slug**
            ]
        ) 

在 shell 提示符中键入以下命令以启动开发服务器:

python manage.py runserver 

接下来,您可以在浏览器中点击其中一个文章标题,查看文章的详细视图。您应该看到如下内容:

图 2.2:文章详细视图页面

您已经为博客文章设计了 SEO 友好的 URL。现在文章的 URL 看起来像这样:/blog/2024/1/1/who-was-django-reinhardt/

现在您已经实现了 SEO 友好的 URL,让我们专注于使用分页实现文章导航。

添加分页

当您开始向博客添加内容时,您可以在数据库中轻松存储数十或数百篇文章。而不是在单页上显示所有文章,您可能希望将文章列表分页显示在几个页面上,并包含导航链接到不同的页面。这种功能称为分页,您几乎可以在显示长列表项的每个 Web 应用程序中找到它。

例如,Google 使用分页将搜索结果分散在多个页面上。图 2.3展示了 Google 搜索结果页面的分页链接:

图标描述自动生成

图 2.3:Google 搜索结果页面的分页链接

Django 有一个内置的分页类,允许您轻松管理分页数据。您可以定义每页要返回的对象数量,并且可以检索用户请求的页面对应的文章。

在文章列表视图中添加分页

我们将向文章列表添加分页,以便用户可以轻松浏览博客上发布的所有文章。

编辑blog应用的views.py文件,导入 Django 的Paginator类,并按照以下方式修改post_list视图:

**from** **django.core.paginator** **import** **Paginator**
from django.shortcuts import get_object_or_404, render
from .models import Post
def post_list(request):
    **post_list** = Post.published.all()
**# Pagination with 3 posts per page**
 **paginator = Paginator(post_list,** **3****)**
 **page_number = request.GET.get(****'page'****,** **1****)**
 **posts = paginator.page(page_number)**
return render(
        request,
        'blog/post/list.html',
        {'posts': posts}
    ) 

让我们回顾一下我们添加到视图中的新代码:

  1. 我们使用每页要返回的对象数量实例化Paginator类。我们将每页显示三篇文章。

  2. 我们检索page GET HTTP 参数并将其存储在page_number变量中。此参数包含请求的页码。如果page参数不在请求的GET参数中,我们使用默认值1来加载结果的第一页。

  3. 我们通过调用 Paginatorpage() 方法来获取所需页面的对象。此方法返回一个 Page 对象,我们将其存储在 posts 变量中。

  4. 我们将 posts 对象传递给模板。

创建分页模板

我们需要为用户创建页面导航,以便浏览不同的页面。在本节中,我们将创建一个模板来显示分页链接,并将其设计为通用,以便我们可以在网站上为任何对象分页重用该模板。

templates/ 目录下创建一个新文件,并将其命名为 pagination.html。将以下 HTML 代码添加到文件中:

<div class="pagination">
<span class="step-links">
    {% if page.has_previous %}
      <a href="?page={{ page.previous_page_number }}">Previous</a>
    {% endif %}
    <span class="current">
      Page {{ page.number }} of {{ page.paginator.num_pages }}.
    </span>
    {% if page.has_next %}
      <a href="?page={{ page.next_page_number }}">Next</a>
    {% endif %}
  </span>
</div> 

这是一个通用的分页模板。该模板期望在上下文中有一个 Page 对象来渲染上一页和下一页的链接,并显示当前页和总页数。

让我们回到 blog/post/list.html 模板,并在 {% content %} 块的底部包含 pagination.html 模板,如下所示:

{% extends "blog/base.html" %}
{% block title %}My Blog{% endblock %}
{% block content %}
  <h1>My Blog</h1>
  {% for post in posts %}
    <h2>
<a href="{{ post.get_absolute_url }}">
        {{ post.title }}
      </a>
</h2>
<p class="date">
      Published {{ post.publish }} by {{ post.author }}
    </p>
    {{ post.body|truncatewords:30|linebreaks }}
  {% endfor %}
  **{% include "pagination.html" with page=posts %}**
{% endblock %} 

{% include %} 模板标签加载给定的模板,并使用当前的模板上下文来渲染它。我们使用 with 来向模板传递额外的上下文变量。分页模板使用 page 变量进行渲染,而我们从视图传递给模板的 Page 对象被称为 posts。我们使用 with page=posts 来传递分页模板期望的变量。您可以使用这种方法为任何类型的对象使用分页模板。

通过在 shell 提示符中输入以下命令来启动开发服务器:

python manage.py runserver 

在您的浏览器中打开 http://127.0.0.1:8000/admin/blog/post/ 并使用管理站点创建总共四篇不同的帖子。确保将所有帖子的状态设置为已发布

现在,在您的浏览器中打开 http://127.0.0.1:8000/blog/。您应该看到按倒序排列的前三篇帖子,然后在帖子列表底部的导航链接如下所示:

图片

图 2.4:包含分页的帖子列表页面

如果您点击下一页,您将看到最后一篇帖子。第二页的 URL 包含 ?page=2GET 参数。此参数由视图用于通过分页器加载请求的页面。

图片

图 2.5:结果页的第二页

太好了,分页链接按预期工作。

处理分页错误

现在分页功能已经正常工作,我们可以在视图中添加对分页错误的异常处理。视图使用的 page 参数可能被用于错误值,例如不存在的页码或无法用作页码的字符串值。我们将为这些情况实现适当的错误处理。

在浏览器中打开 http://127.0.0.1:8000/blog/?page=3。您应该看到以下错误页面:

图片

图 2.6:空页错误页面

当检索第3页时,Paginator对象抛出EmptyPage异常,因为它超出了范围。没有结果可以显示。让我们在我们的视图中处理这个错误。

编辑blog应用的views.py文件,添加必要的导入并修改post_list视图如下:

from django.core.paginator import **EmptyPage,** Paginator
from django.shortcuts import get_object_or_404, render
from .models import Post
def post_list(request):
    post_list = Post.published.all()
    # Pagination with 3 posts per page
    paginator = Paginator(post_list, 3)
    page_number = request.GET.get('page', 1)
    **try****:**
        posts = paginator.page(page_number)
    **except** **EmptyPage:**
**# If page_number is out of range get last page of results**
 **posts = paginator.page(paginator.num_pages)**
return render(
        request,
        'blog/post/list.html',
        {'posts': posts}
    ) 

我们添加了一个 try 和 except 块来管理检索页面时出现的EmptyPage异常。如果请求的页面超出范围,我们返回最后一页的结果。我们通过paginator.num_pages获取总页数。总页数与最后一页的页码相同。

再次在浏览器中打开http://127.0.0.1:8000/blog/?page=3。现在,异常由视图管理,并返回如下最后页的结果:

图片

图 2.7:结果的最后一页

我们的视图也应该处理当page参数传递的不是整数时的情况。

在浏览器中打开http://127.0.0.1:8000/blog/?page=asdf。你应该看到以下错误页面:

图片

图 2.8:PageNotAnInteger错误页面

在这种情况下,当检索页面asdf时,Paginator对象抛出PageNotAnInteger异常,因为页码只能是整数。让我们在我们的视图中处理这个错误。

编辑blog应用的views.py文件,添加必要的导入并修改post_list视图如下:

from django.shortcuts import get_object_or_404, render
from .models import Post
from django.core.paginator import EmptyPage**, PageNotAnInteger**, Paginator
def post_list(request):
    post_list = Post.published.all()
    # Pagination with 3 posts per page
    paginator = Paginator(post_list, 3)
    page_number = request.GET.get('page')
    try:
        posts = paginator.page(page_number)
    **except** **PageNotAnInteger:**
**# If page_number is not an integer get the first page**
 **posts = paginator.page(****1****)**
except EmptyPage:
        # If page_number is out of range get last page of results
        posts = paginator.page(paginator.num_pages)
    return render(
        request,
        'blog/post/list.html',
        {'posts': posts}
    ) 

我们添加了一个新的except块来管理检索页面时出现的PageNotAnInteger异常。如果请求的页面不是整数,我们返回结果的第一页。

再次在浏览器中打开http://127.0.0.1:8000/blog/?page=asdf。现在,异常由视图管理,并返回如下第一页的结果:

图片

图 2.9:结果的第一页

博客文章的分页现在已经完全实现。

你可以在docs.djangoproject.com/en/5.0/ref/paginator/了解更多关于Paginator类的信息。

学习了如何分页你的博客后,我们现在将转向将post_list视图转换为使用 Django 通用视图和内置分页构建的等效视图。

构建基于类的视图

我们使用基于函数的视图构建了博客应用。基于函数的视图简单而强大,但 Django 还允许你使用类来构建视图。

基于类的视图是实现视图作为 Python 对象而不是函数的另一种方式。由于视图是一个接收网络请求并返回网络响应的函数,你还可以将你的视图定义为类方法。Django 提供了你可以用来实现你自己的视图的基础视图类。所有这些类都继承自View类,该类处理 HTTP 方法调度和其他常见功能。

为什么使用基于类的视图

基于类的视图相对于基于函数的视图在特定用例中提供了一些优势。基于类的视图允许您:

  • 将与 HTTP 方法相关的代码,如GETPOSTPUT,组织在单独的方法中,而不是使用条件分支

  • 使用多重继承来创建可重用的视图类(也称为mixins

使用基于类的视图来列出帖子

要了解如何编写基于类的视图,我们将创建一个新的与post_list视图等效的基于类的视图。我们将创建一个继承自 Django 提供的通用ListView视图的类。ListView允许您列出任何类型的对象。

编辑blog应用的views.py文件,并向其中添加以下代码:

from django.views.generic import ListView
class PostListView(ListView):
    """
    Alternative post list view
    """
    queryset = Post.published.all()
    context_object_name = 'posts'
    paginate_by = 3
    template_name = 'blog/post/list.html' 

PostListView视图与之前构建的post_list视图类似。我们已经实现了一个继承自ListView类的基于类的视图。我们定义了一个具有以下属性的视图:

  • 我们使用queryset来使用自定义 QuerySet,而不是检索所有对象。我们可以在不定义queryset属性的情况下指定model = Post,Django 将为我们构建通用的Post.objects.all() QuerySet。

  • 我们使用上下文变量posts来表示查询结果。如果没有指定任何context_object_name,默认变量是object_list

  • 我们使用paginate_by定义结果分页,每页返回三个对象。

  • 我们使用自定义模板来渲染带有template_name的页面。如果您没有设置默认模板,ListView将默认使用blog/post_list.html

现在,编辑blog应用的urls.py文件,注释掉之前的post_list URL 模式,并使用PostListView类添加一个新的 URL 模式,如下所示:

urlpatterns = [
    # Post views
**#** path('', views.post_list, name='post_list'),
**path(****''****, views.PostListView.as_view(), name=****'post_list'****),**
    path(
        '<int:year>/<int:month>/<int:day>/<slug:post>/',
        views.post_detail,
        name='post_detail'
    ),
] 

为了使分页功能正常工作,我们必须使用传递给模板的正确页面对象。Django 的ListView通用视图通过一个名为page_obj的变量传递请求的页面。我们必须相应地编辑post/list.html模板,以包含使用正确变量的分页器,如下所示:

{% extends "blog/base.html" %}
{% block title %}My Blog{% endblock %}
{% block content %}
  <h1>My Blog</h1>
  {% for post in posts %}
    <h2>
<a href="{{ post.get_absolute_url }}">
        {{ post.title }}
      </a>
</h2>
<p class="date">
      Published {{ post.publish }} by {{ post.author }}
    </p>
    {{ post.body|truncatewords:30|linebreaks }}
  {% endfor %}
  **{% include "pagination.html" with page=page_obj %}**
{% endblock %} 

在您的浏览器中打开http://127.0.0.1:8000/blog/并验证分页链接是否按预期工作。分页链接的行为应该与之前的post_list视图相同。

在这种情况下,异常处理略有不同。如果您尝试加载超出范围的页面或传递page参数中的非整数值,视图将返回一个带有状态码404(页面未找到)的 HTTP 响应,如下所示:

图 2.10:HTTP 404 页面未找到响应

返回 HTTP 404状态码的异常处理由ListView视图提供。

这是一个如何编写基于类的视图的简单示例。您将在第十三章“创建内容管理系统”和随后的章节中了解更多关于基于类的视图的内容。

您可以在docs.djangoproject.com/en/5.0/topics/class-based-views/intro/阅读关于基于类的视图的介绍。

在学习如何使用基于类的视图和使用内置的对象分页后,我们将实现通过电子邮件分享帖子以吸引博客读者的功能。

通过电子邮件推荐帖子

我们将允许用户通过发送帖子推荐通过电子邮件与他人分享博客帖子。您将学习如何在 Django 中创建表单、处理数据提交以及发送电子邮件,为您的博客增添个性化特色。

花点时间思考一下,您如何可以使用视图URL模板来创建此功能,使用您在前一章中学到的知识。

为了允许用户通过电子邮件分享帖子,我们需要做以下事情:

  1. 创建一个表单,让用户填写他们的姓名、他们的电子邮件地址、收件人的电子邮件地址以及可选的评论

  2. views.py文件中创建一个视图来处理提交的数据并发送电子邮件

  3. 在博客应用的urls.py文件中为新的视图添加一个 URL 模式

  4. 创建一个模板来显示表单

使用 Django 创建表单

让我们从构建分享帖子的表单开始。Django 内置了一个表单框架,允许您轻松创建表单。表单框架使得定义表单字段、指定它们的显示方式以及指示如何验证输入数据变得简单。Django 表单框架提供了一个灵活的方式来在 HTML 中渲染表单并处理数据。

Django 提供了两个基础类来构建表单:

  • Form: 这允许您通过定义字段和验证来构建标准表单。

  • ModelForm: 这允许您构建与模型实例相关的表单。它提供了基础Form类的所有功能,但表单字段可以显式声明,或从模型字段自动生成。该表单可用于创建或编辑模型实例。

首先,在您的blog应用目录中创建一个forms.py文件,并将其中的以下代码添加到该文件中:

from django import forms
class EmailPostForm(forms.Form):
    name = forms.CharField(max_length=25)
    email = forms.EmailField()
    to = forms.EmailField()
    comments = forms.CharField(
        required=False,
        widget=forms.Textarea
    ) 

我们已经定义了我们的第一个 Django 表单。EmailPostForm表单从基础Form类继承。我们使用不同的字段类型来相应地验证数据。

表单可以位于您的 Django 项目的任何位置。惯例是将它们放置在每个应用的forms.py文件中。

表单包含以下字段:

  • name: 一个最大长度为25字符的CharField实例。我们将用它来表示发送帖子的个人姓名。

  • email: EmailField的一个实例。我们将使用发送帖子推荐的个人电子邮件。

  • to: EmailField的一个实例。我们将使用收件人的电子邮件地址,该收件人将收到一封推荐帖子的电子邮件。

  • commentsCharField 的一个实例。我们将使用它来包含在帖子推荐电子邮件中的评论。我们通过将 required 设置为 False 使此字段可选,并指定了一个自定义小部件来渲染该字段。

每种字段类型都有一个默认的小部件,它决定了字段在 HTML 中的渲染方式。name 字段是 CharField 的一个实例。此类字段以 <input type="text"> HTML 元素的形式渲染。默认小部件可以通过 widget 属性来覆盖。在 comments 字段中,我们使用 Textarea 小部件将其显示为 <textarea> HTML 元素,而不是默认的 <input> 元素。

字段验证也取决于字段类型。例如,emailto 字段是 EmailField 字段。这两个字段都需要一个有效的电子邮件地址;否则,字段验证将引发 forms.ValidationError 异常,表单将无法验证。表单字段验证还会考虑其他参数,例如 name 字段的最大长度为 25comments 字段为可选。

这些只是 Django 为表单提供的字段类型中的一部分。您可以在 docs.djangoproject.com/en/5.0/ref/forms/fields/ 找到所有可用字段类型的列表。

在视图中处理表单

我们已经定义了一个通过电子邮件推荐帖子的表单。现在,我们需要一个视图来创建表单的实例并处理表单提交。

编辑 blog 应用程序的 views.py 文件,并向其中添加以下代码:

from .forms import EmailPostForm
def post_share(request, post_id):
    # Retrieve post by id
    post = get_object_or_404(
        Post,
        id=post_id,
        status=Post.Status.PUBLISHED
    )
    if request.method == 'POST':
        # Form was submitted
        form = EmailPostForm(request.POST)
        if form.is_valid():
            # Form fields passed validation
            cd = form.cleaned_data
            # ... send email
else:
        form = EmailPostForm()
    return render(
 request,
        'blog/post/share.html',
        {
            'post': post,
            'form': form
        }
    ) 

我们定义了 post_share 视图,它接受 request 对象和 post_id 变量作为参数。我们使用 get_object_or_404() 快捷方式通过其 id 检索一个已发布的帖子。

我们使用相同的视图来显示初始表单和处理提交的数据。HTTP request 方法允许我们区分表单是否正在提交。一个 GET 请求表示需要向用户显示一个空表单,而一个 POST 请求表示表单正在提交。我们使用 request.method == 'POST' 来区分这两种情况。

这是显示表单和处理表单提交的过程:

  1. 当页面首次加载时,视图接收一个 GET 请求。在这种情况下,创建一个新的 EmailPostForm 实例并将其存储在 form 变量中。此表单实例将用于在模板中显示空表单:

    form = EmailPostForm() 
    
  2. 当用户填写表单并通过 POST 提交时,会使用 request.POST 中包含的提交数据创建一个表单实例:

    if request.method == 'POST':
        # Form was submitted
        form = EmailPostForm(request.POST) 
    
  3. 然后,使用表单的 is_valid() 方法验证提交的数据。此方法验证表单中引入的数据,如果所有字段都包含有效数据,则返回 True。如果有任何字段包含无效数据,则 is_valid() 返回 False。可以通过 form.errors 获取验证错误列表。

  4. 如果表单无效,表单将在模板中再次渲染,包括提交的数据。验证错误将在模板中显示。

  5. 如果表单有效,将通过form.cleaned_data检索验证后的数据。这个属性是表单字段及其值的字典。表单不仅验证数据,而且通过将其规范化为一致格式来清理数据。

如果你的表单数据无效,cleaned_data将只包含有效的字段。

我们已经实现了显示表单和处理表单提交的视图。现在我们将学习如何使用 Django 发送邮件,然后我们将将该功能添加到post_share视图中。

使用 Django 发送邮件

使用 Django 发送邮件非常直接。你需要有一个本地的 SMTP 服务器,或者你需要访问一个外部 SMTP 服务器,比如你的电子邮件服务提供商。

以下设置允许你定义 SMTP 配置以使用 Django 发送邮件:

  • EMAIL_HOST:SMTP 服务器主机;默认为localhost

  • EMAIL_PORT:SMTP 端口;默认为25

  • EMAIL_HOST_USER:SMTP 服务器的用户名

  • EMAIL_HOST_PASSWORD:SMTP 服务器的密码

  • EMAIL_USE_TLS:是否使用传输层安全TLS)安全连接

  • EMAIL_USE_SSL:是否使用隐式 TLS 安全连接

此外,你可以使用DEFAULT_FROM_EMAIL设置来指定发送 Django 邮件时的默认发送者。在这个例子中,我们将使用 Google 的 SMTP 服务器和一个标准的 Gmail 账户。

与环境变量一起工作

我们将向项目中添加 SMTP 配置设置,并从环境变量中加载 SMTP 凭据。通过使用环境变量,我们将避免在源代码中嵌入凭据。将配置与代码分离有多个原因:

  • 安全性:代码中的凭据或密钥可能导致意外泄露,尤其是如果你将代码推送到公共仓库时。

  • 灵活性:保持配置与代码分离将允许你在不同的环境中使用相同的代码库而无需任何更改。你将在第十七章“上线”中学习如何构建多个环境。

  • 可维护性:更改配置不需要修改代码,确保你的项目在各个版本之间保持一致性。

为了便于将配置与代码分离,我们将使用python-decouple。这个库简化了在项目中使用环境变量的操作。你可以在github.com/HBNetwork/python-decouple找到关于python-decouple的信息。

首先,通过运行以下命令使用pip安装python-decouple

python -m pip install python-decouple==3.8 

然后,在你的项目根目录内创建一个新文件,并将其命名为.env.env文件将包含环境变量的键值对。将以下行添加到新文件中:

EMAIL_HOST_USER=your_account@gmail.com
EMAIL_HOST_PASSWORD=
DEFAULT_FROM_EMAIL=My Blog <your_account@gmail.com> 

如果您有 Gmail 账户,请将your_account@gmail.com替换为您的 Gmail 账户。EMAIL_HOST_PASSWORD变量目前还没有值,我们稍后会添加它。DEFAULT_FROM_EMAIL变量将用于指定我们电子邮件的默认发送者。如果您没有 Gmail 账户,您可以使用电子邮件服务提供商的 SMTP 凭证。

如果您使用git仓库存储代码,请确保将.env文件包含在您的仓库的.gitignore文件中。这样做可以确保凭证不被包含在仓库中。

编辑您项目的settings.py文件,并向其中添加以下代码:

**from** **decouple** **import** **config**
# ...
**# Email server configuration**
**EMAIL_HOST =** **'smtp.gmail.com'**
**EMAIL_HOST_USER = config(****'EMAIL_HOST_USER'****)**
**EMAIL_HOST_PASSWORD = config(****'EMAIL_HOST_PASSWORD'****)**
**EMAIL_PORT =** **587**
**EMAIL_USE_TLS =** **True**
**DEFAULT_FROM_EMAIL = config(****'DEFAULT_FROM_EMAIL'****)** 

EMAIL_HOST_USEREMAIL_HOST_PASSWORDDEFAULT_FROM_EMAIL设置现在是从.env文件中定义的环境变量加载的。

提供的EMAIL_HOSTEMAIL_PORTEMAIL_USE_TLS设置是针对 Gmail 的 SMTP 服务器的。如果您没有 Gmail 账户,您可以使用电子邮件服务提供商的 SMTP 服务器配置。

除了 Gmail,您还可以使用一个专业、可扩展的电子邮件服务,允许您通过 SMTP 使用自己的域名发送电子邮件,例如 SendGrid (sendgrid.com/) 或 Amazon 简单电子邮件服务 (SES) (aws.amazon.com/ses/)。这两个服务都将要求您验证您的域名和发送者电子邮件账户,并提供 SMTP 凭证以发送电子邮件。django-anymail应用程序简化了将电子邮件服务提供商(如 SendGrid 或 Amazon SES)添加到您项目的任务。您可以在anymail.dev/en/stable/installation/找到django-anymail的安装说明,以及在anymail.dev/en/stable/esps/找到支持的电子邮件服务提供商列表。

如果您不能使用 SMTP 服务器,您可以在settings.py文件中添加以下设置,让 Django 将电子邮件写入控制台:

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 

使用此设置,Django 将输出所有电子邮件到 shell 而不是发送它们。这对于在没有 SMTP 服务器的情况下测试您的应用程序非常有用。

为了使用 Gmail 的 SMTP 服务器发送电子邮件,请确保您的 Gmail 账户中启用了两步验证。

在浏览器中打开myaccount.google.com/security,并为您账户启用两步验证,如图图 2.11所示:

图片

图 2.11:Google 账户的登录页面

然后,您需要创建一个应用密码,并使用它作为您的 SMTP 凭证。应用密码是一个 16 位数的密码,它允许一个不太安全的程序或设备访问您的 Google 账户。

要创建应用密码,请在浏览器中打开myaccount.google.com/apppasswords。您将看到以下屏幕:

图片

图 2.12:生成新的 Google 应用密码的表单

如果你无法访问 应用密码,可能是因为你的账户没有设置两步验证,你的账户是一个组织账户而不是标准 Gmail 账户,或者你开启了 Google 的高级保护。请确保使用标准 Gmail 账户,并为你的 Google 账户激活两步验证。你可以在support.google.com/accounts/answer/185833找到更多信息。

输入名称 Blog 并点击 创建 按钮,如下所示:

图 2.13:生成新的 Google 应用密码的表单

将生成一个新的密码并显示如下:

图 2.14:生成的 Google 应用密码

复制生成的应用密码。

接下来,编辑你的项目中的 .env 文件,并将应用密码添加到 EMAIL_HOST_PASSWORD 变量中,如下所示:

EMAIL_HOST_USER=your_account@gmail.com
EMAIL_HOST_PASSWORD=**xxxxxxxxxxxxxxxx**
DEFAULT_FROM_EMAIL=My Blog <your_account@gmail.com> 

通过在系统 shell 提示符中运行以下命令来打开 Python shell:

python manage.py shell 

在 Python shell 中执行以下代码:

>>> from django.core.mail import send_mail
>>> send_mail('Django mail',
... 'This e-mail was sent with Django.',
... 'your_account@gmail.com',
...           ['your_account@gmail.com'],
...           fail_silently=False) 

send_mail() 函数需要主题、消息、发件人和收件人列表作为必需参数。通过设置可选参数 fail_silently=False,我们告诉它如果无法发送电子邮件则抛出异常。如果你看到的输出是 1,则表示你的电子邮件已成功发送。

如果你遇到 CERTIFICATE_VERIFY_FAILED 错误,请使用以下命令安装 certify 模块:pip install --upgrade certifi。如果你使用的是 macOS,请在 shell 中运行以下命令来安装 certify 并允许 Python 访问 macOS 根证书:

/Applications/Python\ 3.12/Install\ Certificates.command 

检查你的收件箱。你应该已经收到了如图 图 2.15 所示的电子邮件:

图 2.15:在 Gmail 中显示发送的测试电子邮件

你刚刚用 Django 发出了第一封电子邮件!你可以在docs.djangoproject.com/en/5.0/topics/email/找到更多关于使用 Django 发送电子邮件的信息。

让我们将此功能添加到 post_share 视图中。

在视图中发送电子邮件

编辑 blog 应用程序中的 views.py 文件中的 post_share 视图,如下所示:

# ...
**from** **django.core.mail** **import** **send_mail**
# ...
def post_share(request, post_id):
    # Retrieve post by id
    post = get_object_or_404(
        Post,
        id=post_id,
        status=Post.Status.PUBLISHED
    )
    **sent =** **False**
if request.method == 'POST':
        # Form was submitted
        form = EmailPostForm(request.POST)
        if form.is_valid():
            # Form fields passed validation
            cd = form.cleaned_data
            **post_url = request.build_absolute_uri(**
 **post.get_absolute_url()**
 **)**
 **subject = (**
**f"****{cd[****'name'****]}** **(****{cd[****'email'****]}****) "**
**f"recommends you read** **{post.title}****"**
 **)**
 **message = (**
**f"Read** **{post.title}** **at** **{post_url}****\n\n"**
**f"****{cd[****'name'****]}****\'s comments:** **{cd[****'comments'****]}****"**
 **)**
 **send_mail(**
 **subject=subject,**
 **message=message,**
 **from_email=****None****,**
 **recipient_list=[cd[****'to'****]]**
 **)**
 **sent =** **True**
else:
        form = EmailPostForm()
    return render(
        request,
        'blog/post/share.html',
        {
            'post': post,
            'form': form**,**
**'sent'****: sent**
        }
    ) 

在前面的代码中,我们声明了一个初始值为 Falsesent 变量。在邮件发送后,我们将此变量设置为 True。我们将在模板中稍后使用 sent 变量来显示当表单成功提交时的成功消息。

由于我们必须在电子邮件中包含一个指向帖子的链接,我们使用帖子的 get_absolute_url() 方法检索帖子的绝对路径。我们使用此路径作为 request.build_absolute_uri() 的输入来构建一个完整的 URL,包括 HTTP 协议和主机名。

我们使用经过验证的表单的清理数据创建电子邮件的主题和消息正文。最后,我们将电子邮件发送到表单中 to 字段包含的电子邮件地址。在 from_email 参数中,我们传递 None 值,因此将使用 DEFAULT_FROM_EMAIL 设置的值作为发件人。

现在视图已经完成,我们必须为它添加一个新的 URL 模式。

打开你的blog应用的urls.py文件,并添加post_share URL 模式,如下所示:

from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
    # Post views
# path('', views.post_list, name='post_list'),
    path('', views.PostListView.as_view(), name='post_list'),
    path(
        '<int:year>/<int:month>/<int:day>/<slug:post>/',
        views.post_detail,
        name='post_detail'),
    **path(****'<int:post_id>/share/'****, views.post_share, name=****'post_share'****),**
] 

在模板中渲染表单

在创建表单、编写视图和添加 URL 模式后,唯一缺少的是视图的模板。

blog/templates/blog/post/目录下创建一个新文件,并将其命名为share.html

将以下代码添加到新的share.html模板中:

{% extends "blog/base.html" %}
{% block title %}Share a post{% endblock %}
{% block content %}
  {% if sent %}
    <h1>E-mail successfully sent</h1>
<p>
      "{{ post.title }}" was successfully sent to {{ form.cleaned_data.to }}.
    </p>
  {% else %}
    <h1>Share "{{ post.title }}" by e-mail</h1>
<form method="post">
      {{ form.as_p }}
      {% csrf_token %}
      <input type="submit" value="Send e-mail">
</form>
  {% endif %}
{% endblock %} 

这是用于显示通过电子邮件分享帖子的表单以及显示发送电子邮件后的成功消息的模板。我们通过{% if sent %}区分这两种情况。

为了显示表单,我们定义了一个 HTML 表单元素,表明它必须通过POST方法提交:

<form method="post"> 

我们已经包含了表单实例{{ form.as_p }}。我们告诉 Django 使用as_p方法通过 HTML 段落<p>元素渲染表单字段。我们也可以使用as_ul将其渲染为无序列表,或者使用as_table将其渲染为 HTML 表格。

我们已经添加了一个{% csrf_token %}模板标签。此标签引入了一个带有自动生成的令牌的隐藏字段,以避免跨站请求伪造CSRF)攻击。这些攻击包括恶意网站或程序在网站上对用户执行不受欢迎的操作。你可以在owasp.org/www-community/attacks/csrf找到有关 CSRF 的更多信息。

{% csrf_token %}模板标签生成一个隐藏字段,其渲染方式如下:

<input type='hidden' name='csrfmiddlewaretoken' value='26JjKo2lcEtYkGoV9z4XmJIEHLXN5LDR' /> 

默认情况下,Django 会在所有POST请求中检查 CSRF 令牌。请记住,在所有通过POST提交的表单中包含csrf_token标签。

编辑blog/post/detail.html模板,使其看起来像这样:

{% extends "blog/base.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
  <h1>{{ post.title }}</h1>
<p class="date">
    Published {{ post.publish }} by {{ post.author }}
  </p>
  {{ post.body|linebreaks }}
  **<****p****>**
**<****a****href****=****"{% url "blog:post_share" post.id %}"****>**
**Share this post**
**</****a****>**
**</****p****>**
{% endblock %} 

我们已经添加了一个指向post_share URL 的链接。该 URL 通过 Django 提供的{% url %}模板标签动态构建。我们使用名为blog的命名空间和名为post_share的 URL。我们将帖子id作为参数传递以构建 URL。

打开 shell 提示符并执行以下命令以启动开发服务器:

python manage.py runserver 

在你的浏览器中打开http://127.0.0.1:8000/blog/,然后点击任何帖子标题以查看帖子详情页面。

在帖子正文中,你应该看到你刚刚添加的链接,如图图 2.16所示:

![图片 B21088_02_16.png]

图 2.16:帖子详情页面,包括分享帖子的链接

点击分享此帖子,你应该看到包括通过电子邮件分享此帖子的表单的页面,如下所示:

图形用户界面,文本,应用程序,自动生成的描述

图 2.17:通过电子邮件分享帖子的页面

表单的 CSS 样式包含在static/css/blog.css文件中的示例代码中。当你点击发送电子邮件按钮时,表单将被提交并验证。如果所有字段都包含有效数据,你将得到以下成功消息:

文本描述自动生成,置信度中等

图 2.18:通过电子邮件分享帖子的成功消息

将帖子发送到您的电子邮件地址并检查您的收件箱。您收到的电子邮件应该看起来像这样:

图 2.19:在 Gmail 中显示的测试电子邮件发送

如果您提交包含无效数据的表单,表单将被重新渲染,包括所有验证错误:

图形用户界面,文本,应用程序,团队描述自动生成

图 2.20:显示无效数据错误的分享帖子表单

大多数现代浏览器都会阻止您提交包含空或错误字段的表单。这是因为浏览器在提交表单之前会根据它们的属性验证字段。在这种情况下,表单将不会提交,浏览器将为错误字段显示错误消息。要使用现代浏览器测试 Django 表单验证,您可以通过在 HTML <form> 元素中添加novalidate属性来跳过浏览器表单验证,例如<form method="post" novalidate>。您可以将此属性添加到防止浏览器验证字段并测试您自己的表单验证。测试完成后,请删除novalidate属性以保持浏览器表单验证。

通过电子邮件分享帖子的功能现已完成。您可以在docs.djangoproject.com/en/5.0/topics/forms/找到更多关于处理表单的信息。

创建一个评论系统

我们将继续扩展我们的博客应用,添加一个允许用户对帖子进行评论的评论系统。为了构建评论系统,我们需要以下内容:

  • 用于存储用户对帖子评论的评论模型

  • 一个 Django 表单,允许用户提交评论并管理数据验证

  • 一个视图来处理表单并将新评论保存到数据库中

  • 一份评论列表和用于添加新评论的 HTML 表单,该表单可以包含在帖子详情模板中

创建评论模型

让我们先构建一个用于存储用户对帖子评论的模型。

打开您的blog应用的models.py文件并添加以下代码:

class Comment(models.Model):
    post = models.ForeignKey(
        Post,
        on_delete=models.CASCADE,
        related_name='comments'
 )
    name = models.CharField(max_length=80)
    email = models.EmailField()
    body = models.TextField()
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)
    active = models.BooleanField(default=True)
    class Meta:
        ordering = ['created']
        indexes = [
            models.Index(fields=['created']),
        ]
    def __str__(self):
        return f'Comment by {self.name} on {self.post}' 

这是Comment模型。我们添加了一个ForeignKey字段来将每个评论与单个帖子关联。这种多对一关系在Comment模型中定义,因为每个评论都将针对一个帖子,每个帖子可能有多个评论。

related_name 属性允许您命名从相关对象返回此对象的关联属性。我们可以使用 comment.post 来检索评论对象的帖子,使用 post.comments.all() 来检索与帖子对象关联的所有评论。如果您不定义 related_name 属性,Django 将使用模型名称的小写形式,后跟 _set(即 comment_set)来命名相关对象与模型对象的关联关系,其中此关系已被定义。

您可以在 docs.djangoproject.com/en/5.0/topics/db/examples/many_to_one/ 了解更多关于多对一关系的信息。

我们已定义 active 布尔字段来控制评论的状态。此字段将允许我们通过管理站点手动停用不适当的评论。我们使用 default=True 来表示所有评论默认为活动状态。

我们已定义 created 字段来存储评论创建的日期和时间。通过使用 auto_now_add,创建对象时日期将自动保存。在模型的 Meta 类中,我们添加了 ordering = ['created'] 以默认按时间顺序排序评论,并添加了 created 字段的升序索引。这将提高使用 created 字段进行数据库查找或排序结果的性能。

我们构建的 Comment 模型与数据库未同步。我们需要生成一个新的数据库迁移来创建相应的数据库表。

从 shell 提示符运行以下命令:

python manage.py makemigrations blog 

您应该看到以下输出:

Migrations for 'blog':
  blog/migrations/0003_comment.py
    - Create model Comment 

Django 在 blog 应用程序的 migrations/ 目录中生成了一个 0003_comment.py 文件。我们需要创建相关的数据库模式并将更改应用到数据库中。

运行以下命令以应用现有迁移:

python manage.py migrate 

您将得到包含以下行的输出:

Applying blog.0003_comment... OK 

迁移已应用,并在数据库中创建了 blog_comment 表。

在管理站点添加评论

接下来,我们将添加新的模型到管理站点,以便通过简单的界面管理评论。

打开 blog 应用的 admin.py 文件,导入 Comment 模型,并添加以下 ModelAdmin 类:

from .models import **Comment,** Post
**@admin.register(****Comment****)**
**class****CommentAdmin****(admin.ModelAdmin):**
 **list_display = [****'name'****,** **'email'****,** **'post'****,** **'created'****,** **'active'****]**
 **list_filter = [****'active'****,** **'created'****,** **'updated'****]**
 **search_fields = [****'name'****,** **'email'****,** **'body'****]** 

打开 shell 提示符并执行以下命令以启动开发服务器:

python manage.py runserver 

在您的浏览器中打开 http://127.0.0.1:8000/admin/。您应该能看到包含在 BLOG 部分的新的模型,如图 图 2.21 所示:

图片

图 2.21:Django 管理索引页上的博客应用程序模型

模型现在已在管理站点上注册。

Comments 行中,点击 Add。您将看到添加新评论的表单:

图片

图 2.22:在 Django 管理站点添加新评论的表单

现在我们可以使用管理站点来管理 Comment 实例。

从模型创建表单

我们需要构建一个表单,让用户可以对博客帖子进行评论。记住,Django 有两个基类可以用来创建表单:FormModelForm。我们使用 Form 类允许用户通过电子邮件分享帖子。现在,我们将使用 ModelForm 来利用现有的 Comment 模型并为其动态构建一个表单。

编辑 blog 应用的 forms.py 文件并添加以下行:

from .models import Comment
class CommentForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = ['name', 'email', 'body'] 

要从一个模型创建表单,我们只需在表单的 Meta 类中指定要为哪个模型构建表单。Django 将会反射模型并动态地构建相应的表单。

每个模型字段类型都有一个对应的默认表单字段类型。模型字段的属性会被考虑进表单验证中。默认情况下,Django 为模型中的每个字段创建一个表单字段。然而,我们可以通过使用 fields 属性显式地告诉 Django 哪些字段要包含在表单中,或者使用 exclude 属性定义哪些字段要排除。在 CommentForm 表单中,我们明确包含了 nameemailbody 字段。这些是唯一会被包含在表单中的字段。

你可以在 docs.djangoproject.com/en/5.0/topics/forms/modelforms/ 找到更多关于从模型创建表单的信息。

在视图中处理 ModelForms

对于通过电子邮件分享帖子,我们使用了相同的视图来显示表单并管理其提交。我们使用 HTTP 方法来区分这两种情况:GET 用于显示表单,POST 用于提交。在这种情况下,我们将评论表单添加到帖子详情页,并且我们将构建一个单独的视图来处理表单提交。处理表单的新视图将允许用户在评论存储到数据库后返回到帖子详情视图。

编辑 blog 应用的 views.py 文件并添加以下代码:

from django.core.mail import send_mail
from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.shortcuts import get_object_or_404, render
**from** **django.views.decorators.http** **import** **require_POST**
from django.views.generic import ListView
from .forms import **CommentForm,** EmailPostForm
from .models import Post
# ...
**@require_POST**
**def****post_comment****(****request, post_id****):**
 **post = get_object_or_404(**
 **Post,**
**id****=post_id,**
 **status=Post.Status.PUBLISHED**
 **)**
 **comment =** **None**
**# A comment was posted**
 **form = CommentForm(data=request.POST)**
**if** **form.is_valid():**
**# Create a Comment object without saving it to the database**
 **comment = form.save(commit=****False****)**
**# Assign the post to the comment**
 **comment.post = post**
**# Save the comment to the database**
 **comment.save()**
**return** **render(**
 **request,**
**'blog/post/comment.html'****,**
 **{**
**'post'****: post,**
**'form'****: form,**
**'comment'****: comment**
 **}**
 **)** 

我们定义了一个 post_comment 视图,它接受 request 对象和 post_id 变量作为参数。我们将使用这个视图来管理帖子提交。我们期望表单将通过 HTTP POST 方法提交。我们使用 Django 提供的 require_POST 装饰器来仅允许对这个视图的 POST 请求。Django 允许你限制视图允许的 HTTP 方法。如果你尝试用任何其他 HTTP 方法访问视图,Django 将会抛出一个 HTTP 405(方法不被允许)错误。

在这个视图中,我们实现了以下操作:

  1. 我们使用 get_object_or_404() 快捷方式通过 id 获取一个已发布的帖子。

  2. 我们定义了一个初始值为 Nonecomment 变量。当创建评论对象时,这个变量将用来存储评论对象。

  3. 我们使用提交的 POST 数据实例化表单,并使用 is_valid() 方法进行验证。如果表单无效,模板将带有验证错误被渲染。

  4. 如果表单有效,我们将通过调用表单的 save() 方法创建一个新的 Comment 对象,并将其分配给 comment 变量,如下所示:

    comment = form.save(commit=False) 
    
  5. save() 方法创建与表单关联的模型实例并将其保存到数据库。如果您使用 commit=False 调用它,则模型实例将被创建但不会保存到数据库。这允许我们在最终保存之前修改对象。

    save() 方法对 ModelForm 实例可用,但对 Form 实例不可用,因为它们没有链接到任何模型。

  6. 我们将帖子分配给创建的评论:

    comment.post = post 
    
  7. 我们通过调用其 save() 方法将新的评论保存到数据库:

    comment.save() 
    
  8. 我们渲染 blog/post/comment.html 模板,将 postformcomment 对象传递到模板上下文中。此模板尚不存在;我们将在稍后创建它。

让我们为这个视图创建一个 URL 模式。

编辑 blog 应用的 urls.py 文件,并向其中添加以下 URL 模式:

from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
    # Post views
# path('', views.post_list, name='post_list'),
    path('', views.PostListView.as_view(), name='post_list'),
    path(
        '<int:year>/<int:month>/<int:day>/<slug:post>/',
        views.post_detail,
        name='post_detail'
 ),
    path('<int:post_id>/share/', views.post_share, name='post_share'),
    **path(**
**'<int:post_id>/comment/'****, views.post_comment, name=****'post_comment'**
 **),**
] 

我们已经实现了管理评论提交及其对应 URL 的视图。让我们创建必要的模板。

创建评论表单的模板

我们将创建一个用于评论表单的模板,我们将在两个地方使用它:

  • 在与 post_detail 视图关联的帖子详情模板中,让用户发布评论。

  • 在与 post_comment 视图关联的发表评论模板中,如果有任何表单错误,将再次显示表单。

我们将创建表单模板,并使用 {% include %} 模板标签将其包含在另外两个模板中。

templates/blog/post/ 目录中,创建一个新的 includes/ 目录。在此目录内添加一个新文件,并将其命名为 comment_form.html

文件结构应如下所示:

templates/
  blog/
    post/
      includes/
        comment_form.html
      detail.html
      list.html
      share.html 

编辑新的 blog/post/includes/comment_form.html 模板,并添加以下代码:

<h2>Add a new comment</h2>
<form action="{% url "blog:post_comment" post.id %}" method="post">
  {{ form.as_p }}
  {% csrf_token %}
  <p><input type="submit" value="Add comment"></p>
</form> 

在这个模板中,我们使用 {% url %} 模板标签动态构建 HTML <form> 元素的 action URL。我们构建将处理表单的 post_comment 视图的 URL。我们显示以段落形式渲染的表单,并包含 {% csrf_token %} 以实现 CSRF 保护,因为此表单将以 POST 方法提交。

blog 应用的 templates/blog/post/ 目录中创建一个新文件,并将其命名为 comment.html

文件结构现在应如下所示:

templates/
  blog/
    post/
      includes/
        comment_form.html
      comment.html
      detail.html
      list.html
      share.html 

编辑新的 blog/post/comment.html 模板,并添加以下代码:

{% extends "blog/base.html" %}
{% block title %}Add a comment{% endblock %}
{% block content %}
  {% if comment %}
    <h2>Your comment has been added.</h2>
<p><a href="{{ post.get_absolute_url }}">Back to the post</a></p>
  {% else %}
    {% include "blog/post/includes/comment_form.html" %}
  {% endif %}
{% endblock %} 

这是帖子评论视图的模板。在这个视图中,我们期望表单通过 POST 方法提交。模板涵盖了两种不同的场景:

  • 如果提交的表单数据有效,comment 变量将包含创建的 comment 对象,并将显示成功消息。

  • 如果提交的表单数据无效,comment 变量将为 None。在这种情况下,我们将显示评论表单。我们使用 {% include %} 模板标签来包含我们之前创建的 comment_form.html 模板。

在帖子详情视图中添加评论

为了完成评论功能,我们将添加评论列表和评论表单到 post_detail 视图中。

编辑 blog 应用程序的 views.py 文件并按照以下方式编辑 post_detail 视图:

def post_detail(request, year, month, day, post):
    post = get_object_or_404(
        Post,
        status=Post.Status.PUBLISHED,
        slug=post,
        publish__year=year,
        publish__month=month,
        publish__day=day
    )
    **# List of active comments for this post**
 **comments = post.comments.****filter****(active=****True****)**
**# Form for users to comment**
 **form = CommentForm()**
return render(
        request,
        'blog/post/detail.html',
        {
            'post': post**,**
**'comments'****: comments,**
**'form'****: form**
        }
    ) 

让我们回顾一下我们添加到 post_detail 视图中的代码:

  • 我们添加了一个查询集来检索帖子的所有活跃评论,如下所示:

    comments = post.comments.filter(active=True) 
    
  • 此查询集是通过使用 post 对象构建的。我们不是直接为 Comment 模型构建查询集,而是利用 post 对象来检索相关的 Comment 对象。我们使用在 Comment 模型中先前定义的 comments 管理器来处理相关的 Comment 对象,使用 ForeignKey 字段的 related_name 属性来关联 Post 模型。

  • 我们还创建了一个评论表单的实例,form = CommentForm()

在帖子详情模板中添加评论

我们需要编辑 blog/post/detail.html 模板以实施以下更改:

  • 显示帖子的评论总数

  • 显示评论列表

  • 显示用户添加新评论的表单

我们将首先添加帖子的评论总数。

编辑 blog/post/detail.html 模板并按照以下方式更改:

{% extends "blog/base.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
  <h1>{{ post.title }}</h1>
<p class="date">
    Published {{ post.publish }} by {{ post.author }}
  </p>
  {{ post.body|linebreaks }}
  <p>
<a href="{% url "blog:post_share" post.id %}">
      Share this post
    </a>
</p>
**{% with comments.count as total_comments %}**
**<****h2****>**
 **{{ total_comments }} comment{{ total_comments|pluralize }}**
**</****h2****>**
 **{% endwith %}**
{% endblock %} 

我们在模板中使用 Django 对象关系映射器ORM),执行 comments.count() 查询集。请注意,Django 模板语言在调用方法时不使用括号。{% with %} 标签允许您将值分配给一个新变量,该变量将在模板中可用,直到遇到 {% endwith %} 标签。

{% with %} 模板标签对于避免多次访问数据库或调用昂贵的方法非常有用。

我们使用 pluralize 模板过滤器来根据 total_comments 值显示单词 “comment” 的复数后缀。模板过滤器将它们应用到的变量的值作为输入,并返回一个计算后的值。我们将在 第三章扩展您的博客应用程序 中了解更多关于模板过滤器的内容。

pluralize 模板过滤器如果值不同于 1,则返回带有字母 “s” 的字符串。根据帖子的活跃评论数量,前面的文本将渲染为 0 条评论1 条评论N 条评论

现在,让我们将活跃评论的列表添加到帖子详情模板中。

编辑 blog/post/detail.html 模板并实施以下更改:

{% extends "blog/base.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
  <h1>{{ post.title }}</h1>
<p class="date">
    Published {{ post.publish }} by {{ post.author }}
  </p>
  {{ post.body|linebreaks }}
  <p>
<a href="{% url "blog:post_share" post.id %}">
      Share this post
    </a>
</p>
  {% with comments.count as total_comments %}
    <h2>
      {{ total_comments }} comment{{ total_comments|pluralize }}
    </h2>
  {% endwith %}
  **{% for comment in comments %}**
**<****div****class****=****"comment"****>**
**<****p****class****=****"info"****>**
 **Comment {{ forloop.counter }} by {{ comment.name }}**
 **{{ comment.created }}**
**</****p****>**
 **{{ comment.body|linebreaks }}**
**</****div****>**
 **{% empty %}**
**<****p****>****There are no comments.****</****p****>**
 **{% endfor %}**
{% endblock %} 

我们添加了一个 {% for %} 模板标签来遍历帖子评论。如果 comments 列表为空,我们显示一条消息,告知用户此帖子没有评论。我们使用 {{ forloop.counter }} 变量来列举评论,该变量包含每次迭代的循环计数。对于每个帖子,我们显示发布者的名字、日期和评论内容。

最后,让我们将评论表单添加到模板中。

编辑 blog/post/detail.html 模板,并按照以下方式包含评论表单模板:

{% extends "blog/base.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
  <h1>{{ post.title }}</h1>
<p class="date">
    Published {{ post.publish }} by {{ post.author }}
  </p>
  {{ post.body|linebreaks }}
  <p>
<a href="{% url "blog:post_share" post.id %}">
      Share this post
    </a>
</p>
  {% with comments.count as total_comments %}
    <h2>
      {{ total_comments }} comment{{ total_comments|pluralize }}
    </h2>
  {% endwith %}
  {% for comment in comments %}
    <div class="comment">
<p class="info">
        Comment {{ forloop.counter }} by {{ comment.name }}
        {{ comment.created }}
      </p>
      {{ comment.body|linebreaks }}
    </div>
  {% empty %}
    <p>There are no comments.</p>
  {% endfor %}
  **{% include "blog/post/includes/comment_form.html" %}**
{% endblock %} 

在你的浏览器中打开 http://127.0.0.1:8000/blog/ 并点击帖子标题以查看帖子详情页面。你会看到类似 图 2.23 的内容:

图片

图 2.23:帖子详情页面,包括添加评论的表单

使用有效数据填写评论表单并点击 添加评论。你应该看到以下页面:

图片

图 2.24:添加评论成功页面

点击 返回帖子 链接。你应该被重定向回帖子详情页面,你应该能够看到你刚刚添加的评论,如下所示:

图片

图 2.25:帖子详情页面,包括评论

向帖子添加一条评论。评论应按时间顺序显示在帖子内容下方,如下所示:

图片

图 2.26:帖子详情页面上的评论列表

在你的浏览器中打开 http://127.0.0.1:8000/admin/blog/comment/。你会看到包含你创建的评论列表的管理页面,如下所示:

图片

图 2.27:管理站点上的评论列表

点击其中一个帖子的名称来编辑它。按照以下方式取消选择 活动 复选框,然后点击 保存 按钮:

图片

图 2.28:在管理站点上编辑评论

你将被重定向到评论列表。活动列将显示非活动图标,如图 图 2.29 所示:

图片

图 2.29:管理站点上的活动/非活动评论

如果你返回到帖子详情视图,你会注意到不再显示非活动评论,它也不会计入帖子的总活动评论数:

图片

图 2.30:在帖子详情页面上显示的单个活动评论

多亏了 active 字段,你可以停用不适当的评论,并避免在帖子中显示它们。

使用简化的模板进行表单渲染

你使用了 {{ form.as_p }} 来使用 HTML 段落渲染表单。这是一个渲染表单的非常直接的方法,但可能有时你需要使用自定义 HTML 标记来渲染表单。

要使用自定义 HTML 渲染表单字段,你可以直接访问每个表单字段,或者遍历表单字段,如下例所示:

{% for field in form %}
  <div class="my-div">
    {{ field.errors }}
    {{ field.label_tag }} {{ field }}
    <div class="help-text">{{ field.help_text|safe }}</div>
</div>
{% endfor %} 

在此代码中,我们使用{{ field.errors }}来渲染表单的任何字段错误,{{ field.label_tag }}来渲染表单 HTML 标签,{{ field }}来渲染实际字段,以及{{ field.help_text|safe }}来渲染字段的帮助文本 HTML。

此方法有助于自定义表单的渲染方式,但你可能需要为特定字段添加某些 HTML 元素或将某些字段包含在容器中。Django 5.0 引入了字段组和字段组模板。字段组简化了标签、小部件、帮助文本和字段错误的渲染。让我们使用这个新功能来自定义评论表单。

我们将使用自定义 HTML 标记来重新定位nameemail表单字段,使用额外的 HTML 元素。

编辑blog/post/includes/comment_form.html模板,并按以下方式修改。新的代码以粗体显示:

<h2>Add a new comment</h2>
<form action="{% url "blog:post_comment" post.id %}" method="post">
**<****div****class****=****"left"****>**
 **{{ form.name.as_field_group }}**
**</****div****>**
**<****div****class****=****"left"****>**
 **{{ form.email.as_field_group }}**
**</****div****>**
 **{{ form.body.as_field_group }}**
  {% csrf_token %}
  <p><input type="submit" value="Add comment"></p>
</form> 

我们为nameemail字段添加了带有自定义 CSS 类的<div>容器,使两个字段都浮动到左边。as_field_group方法渲染每个字段,包括帮助文本和错误。此方法默认使用django/forms/field.html模板。你可以在github.com/django/django/blob/stable/5.0.x/django/forms/templates/django/forms/field.html中查看此模板的内容。你还可以创建自定义字段模板,并通过将template_name属性添加到任何表单字段来重复使用它们。你可以在docs.djangoproject.com/en/5.0/topics/forms/#reusable-field-group-templates上了解更多关于可重复使用的表单模板的信息。

打开一篇博客文章,查看评论表单。现在表单应该看起来像图 2.31

图 2.31:带有新 HTML 标记的评论表单

nameemail字段现在并排显示。字段组允许你轻松自定义表单渲染。

摘要

在本章中,你学习了如何为模型定义规范 URL。你为博客文章创建了 SEO 友好的 URL,并为你的文章列表实现了对象分页。你还学习了如何使用 Django 表单和模型表单。你创建了一个通过电子邮件推荐文章的系统,并为你的博客创建了一个评论系统。

在下一章中,你将为博客创建一个标签系统。你将学习如何构建复杂的查询集以通过相似性检索对象。你将学习如何创建自定义模板标签和过滤器。你还将为你的博客文章构建自定义的网站地图和源,并为你的文章实现全文搜索功能。

其他资源

以下资源提供了与本章所涵盖主题相关的额外信息:

第三章:扩展您的博客应用程序

上一章介绍了表单的基础知识以及评论系统的创建。您还学习了如何使用 Django 发送电子邮件。在本章中,您将通过添加博客平台上常用的其他功能来扩展您的博客应用程序,例如标签、推荐相似帖子、为读者提供 RSS 订阅源以及允许他们搜索帖子。通过构建这些功能,您将学习到 Django 的新组件和功能。

本章将涵盖以下主题:

  • 使用django-taggit实现标签功能

  • 通过相似度检索帖子

  • 创建用于显示最新帖子及评论最多帖子的自定义模板标签和过滤器

  • 向网站添加网站地图

  • 为博客帖子创建订阅源

  • 安装 PostgreSQL

  • 使用固定值将数据导入和导出到数据库

  • 使用 Django 和 PostgreSQL 实现全文搜索引擎

功能概述

图 3.1展示了本章将要构建的视图、模板和功能表示:

图 3.1:第三章内置功能图

在本章中,我们将构建添加标签到帖子的功能。我们将扩展post_list视图以按标签过滤帖子。在post_detail视图中加载单个帖子时,我们将根据共同标签检索相似帖子。我们还将创建自定义模板标签以显示包含帖子总数、最新发布的帖子以及评论最多的帖子的侧边栏。

我们将添加支持使用 Markdown 语法编写帖子并将内容转换为 HTML。我们将使用PostSitemap类为博客创建一个网站地图,并在LatestPostsFeed类中实现一个 RSS 订阅源。最后,我们将通过post_search视图实现一个搜索引擎,并使用 PostgreSQL 全文搜索功能。

本章的源代码可以在github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter03找到。

本章中使用的所有 Python 包都包含在章节源代码中的requirements.txt文件中。您可以在以下部分中按照说明安装每个 Python 包,或者您可以使用命令python -m pip install -r requirements.txt一次性安装所有依赖项。

使用django-taggit实现标签功能

在博客中,一个非常常见的功能是使用标签对帖子进行分类。标签允许您以非层次结构的方式使用简单的关键词对内容进行分类。标签只是一个可以分配给帖子的标签或关键词。我们将通过将第三方 Django 标签应用程序集成到项目中来创建一个标签系统。

django-taggit是一个可重用的应用程序,它主要提供您一个Tag模型和一个管理器,以便轻松地将标签添加到任何模型。您可以在github.com/jazzband/django-taggit查看其源代码。

让我们在我们的博客中添加标签功能。首先,您需要通过运行以下命令使用pip安装django-taggit

python -m pip install django-taggit==5.0.1 

然后,打开mysite项目的settings.py文件,并将taggit添加到您的INSTALLED_APPS设置中,如下所示:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    **'taggit'****,**
'blog.apps.BlogConfig',
] 

将 Django 包放在顶部,第三方包放在中间,本地应用放在INSTALLED_APPS的末尾是一种良好的实践。

打开您的blog应用的models.py文件,并使用以下代码将django-taggit提供的TaggableManager管理器添加到Post模型中:

**from** **taggit.managers** **import** **TaggableManager**
class Post(models.Model):
    # ...
**tags = TaggableManager()** 

tags管理器将允许您向Post对象添加、检索和删除标签。

以下模式显示了django-taggit定义的数据模型,用于创建标签并存储相关的标签对象:

图描述:自动生成,置信度低

图 3.2:django-taggit 的标签模型

Tag模型用于存储标签。它包含一个name和一个slug字段。

TaggedItem模型用于存储相关的标签对象。它有一个指向相关Tag对象的ForeignKey字段。它包含一个指向ContentType对象的ForeignKey和一个用于存储相关标签对象idIntegerFieldcontent_typeobject_id字段结合形成与项目中任何模型之间的通用关系。这允许您在标签实例和应用程序中的任何其他模型实例之间创建关系。您将在第七章跟踪用户行为中了解通用关系。

在 shell 提示符中运行以下命令以为您模型的更改创建迁移:

python manage.py makemigrations blog 

您应该得到以下输出:

Migrations for 'blog':
  blog/migrations/0004_post_tags.py
    - Add field tags to post 

现在,运行以下命令以创建django-taggit模型所需的数据库表,并同步您的模型更改:

python manage.py migrate 

您将看到以下输出,表明迁移已应用:

Applying taggit.0001_initial... OK
Applying taggit.0002_auto_20150616_2121... OK
Applying taggit.0003_taggeditem_add_unique_index... OK
Applying taggit.0004_alter_taggeditem_content_type_alter_taggeditem_tag... OK
Applying taggit.0005_auto_20220424_2025... OK
Applying taggit.0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx... OK
Applying blog.0004_post_tags... OK 

数据库现在与taggit模型同步,我们可以开始使用django-taggit的功能了。

现在我们来探索如何使用tags管理器。

在系统 shell 提示符中运行以下命令以打开 Django shell:

python manage.py shell 

运行以下代码以检索一篇帖子(ID 为1的帖子):

>>> from blog.models import Post
>>> post = Post.objects.get(id=1) 

然后,向其添加一些标签并检索其标签以检查它们是否已成功添加:

>>> post.tags.add('music', 'jazz', 'django')
>>> post.tags.all()
<QuerySet [<Tag: jazz>, <Tag: music>, <Tag: django>]> 

最后,删除一个标签并再次检查标签列表:

>>> post.tags.remove('django')
>>> post.tags.all()
<QuerySet [<Tag: jazz>, <Tag: music>]> 

使用我们定义的管理器添加、检索或删除模型中的标签非常简单。

使用以下命令从 shell 提示符启动开发服务器:

python manage.py runserver 

在您的浏览器中打开127.0.0.1:8000/admin/taggit/tag/

您将看到包含taggit应用程序的Tag对象列表的管理页面。

图片

图 3.3:Django 管理站点上的标签更改列表视图

点击jazz标签。您将看到以下内容:

图片

图 3.4:Django 管理站点上的标签编辑视图

导航到http://127.0.0.1:8000/admin/blog/post/1/change/以编辑 ID 为 1 的帖子。

您将看到帖子现在包括一个新的标签字段,如下所示,您可以在其中轻松编辑标签:

图片

图 3.5:帖子对象的关联标签字段

现在,您需要编辑您的博客帖子以显示标签。

打开blog/post/list.html模板,并添加以下以粗体显示的 HTML 代码:

{% extends "blog/base.html" %}
{% block title %}My Blog{% endblock %}
{% block content %}
  <h1>My Blog</h1>
  {% for post in posts %}
    <h2>
<a href="{{ post.get_absolute_url }}">
        {{ post.title }}
      </a>
</h2>
**<****p****class****=****"tags"****>****Tags: {{ post.tags.all|join:", " }}****</****p****>**
<p class="date">
      Published {{ post.publish }} by {{ post.author }}
    </p>
    {{ post.body|truncatewords:30|linebreaks }}
  {% endfor %}
  {% include "pagination.html" with page=page_obj %}
{% endblock %} 

join模板过滤器与 Python 的字符串join()方法类似。您可以使用特定的字符或字符串将一系列项目连接成一个字符串。例如,一个如['music', 'jazz', 'piano']的标签列表通过join()分隔符','连接后,被转换成一个单独的字符串'music, jazz, piano'

在您的浏览器中打开http://127.0.0.1:8000/blog/。您应该能够看到每个帖子标题下的标签列表:

图片

图 3.6:帖子列表项,包括相关标签

接下来,我们将编辑post_list视图,让用户列出带有特定标签的所有帖子。

打开您的blog应用的views.py文件,从django-taggit导入Tag模型,并将post_list视图修改为可选地通过标签过滤帖子,如下所示。新代码以粗体显示:

**from** **taggit.models** **import** **Tag**
def post_list(request**, tag_slug=****None**):
    post_list = Post.published.all()
    **tag =** **None**
**if** **tag_slug:**
 **tag = get_object_or_404(Tag, slug=tag_slug)**
 **post_list = post_list.****filter****(tags__in=[tag])**
# Pagination with 3 posts per page
    paginator = Paginator(post_list, 3)
    page_number = request.GET.get('page', 1)
    try:
        posts = paginator.page(page_number)
    except PageNotAnInteger:
        # If page_number is not an integer get the first page
        posts = paginator.page(1)
    except EmptyPage:
        # If page_number is out of range get last page of results
        posts = paginator.page(paginator.num_pages)
    return render(
 request,
 'blog/post/list.html',
 {
 'posts': posts**,**
 **'****tag'****: tag**
        }
    ) 

post_list视图现在的工作方式如下:

  1. 它接受一个可选的tag_slug参数,默认值为None。此参数将被传递到 URL 中。

  2. 在视图中,我们构建初始的查询集,检索所有已发布的帖子,如果提供了一个标签缩写,我们使用get_object_or_404()快捷方式获取具有给定缩写的Tag对象。

  3. 然后,我们通过包含给定标签的帖子过滤帖子列表。由于这是一个多对多关系,我们必须通过给定列表中的标签过滤帖子,在这个例子中,列表中只有一个元素。我们使用__in字段查找。当多个模型对象与多个其他模型对象相关联时,会发生多对多关系。在我们的应用中,一个帖子可以有多个标签,一个标签可以与多个帖子相关联。您将在第六章在您的网站上共享内容中学习如何创建多对多关系。您可以在docs.djangoproject.com/en/5.0/topics/db/examples/many_to_many/了解更多关于多对多关系的信息。

  4. 最后,render()函数现在将新的tag变量传递给模板。

请记住,查询集是懒加载的。检索帖子的查询集只有在您在渲染模板时遍历post_list时才会被评估。

打开你的 blog 应用程序的 urls.py 文件,注释掉基于类的 PostListView URL 模式,并取消注释 post_list 视图,如下所示:

**path(****''****, views.post_list, name=****'post_list'****),**
**#** path('', views.PostListView.as_view(), name='post_list'), 

将以下额外的 URL 模式添加到按标签列出文章的功能中:

path(
    'tag/<slug:tag_slug>/', views.post_list, name='post_list_by_tag'
    ), 

如您所见,这两种模式都指向同一个视图,但它们有不同的名称。第一个模式将调用没有任何可选参数的 post_list 视图,而第二个模式将调用带有 tag_slug 参数的视图。您使用 slug 路径转换器将参数匹配为小写字母、ASCII 字母或数字,以及连字符和下划线字符。

blog 应用的 urls.py 文件现在应该看起来像这样:

from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
    # Post views
    path('', views.post_list, name='post_list'),
    # path('', views.PostListView.as_view(), name='post_list'),
    path(
        'tag/<slug:tag_slug>/', views.post_list, name='post_list_by_tag'
 ),
    path(
        '<int:year>/<int:month>/<int:day>/<slug:post>/',
        views.post_detail,
        name='post_detail'
 ),
    path('<int:post_id>/share/', views.post_share, name='post_share'),
    path(
        '<int:post_id>/comment/', views.post_comment, name='post_comment'
 ),
] 

由于你正在使用 post_list 视图,编辑 blog/post/list.html 模板并修改分页以使用 posts 对象:

{% include "pagination.html" with page=**posts** %} 

将以下加粗的行添加到 blog/post/list.html 模板中:

{% extends "blog/base.html" %}
{% block title %}My Blog{% endblock %}
{% block content %}
  <h1>My Blog</h1>
 **{% if tag %}**
**<****h2****>****Posts tagged with "{{ tag.name }}"****</****h2****>**
 **{% endif %}**
  {% for post in posts %}
    <h2>
<a href="{{ post.get_absolute_url }}">
        {{ post.title }}
      </a>
</h2>
<p class="tags">Tags: {{ post.tags.all|join:", " }}</p>
<p class="date">
      Published {{ post.publish }} by {{ post.author }}
    </p>
    {{ post.body|truncatewords:30|linebreaks }}
  {% endfor %}
  {% include "pagination.html" with page=posts %}
{% endblock %} 

如果用户正在访问博客,他们将看到所有文章的列表。如果他们通过特定标签的文章进行过滤,他们将看到他们正在过滤的标签。

现在,编辑 blog/post/list.html 模板并更改显示标签的方式,如下。新行被加粗:

{% extends "blog/base.html" %}
{% block title %}My Blog{% endblock %}
{% block content %}
  <h1>My Blog</h1>
  {% if tag %}
    <h2>Posts tagged with "{{ tag.name }}"</h2>
  {% endif %}
  {% for post in posts %}
    <h2>
<a href="{{ post.get_absolute_url }}">
        {{ post.title }}
      </a>
</h2>
<p class="tags">
      Tags:
 **{% for tag in post.tags.all %}**
**<****a****href****=****"{% url "****blog:post_list_by_tag****"** **tag.slug** **%}">**
 **{{ tag.name }}**
**</****a****>****{% if not forloop.last %}, {% endif %}**
 **{% endfor %}**
</p>
<p class="date">
      Published {{ post.publish }} by {{ post.author }}
    </p>
    {{ post.body|truncatewords:30|linebreaks }}
  {% endfor %}
  {% include "pagination.html" with page=posts %}
{% endblock %} 

在前面的代码中,我们遍历显示自定义链接到 URL 的文章的所有标签,以通过该标签过滤文章。我们使用 {% url "blog:post_list_by_tag" tag.slug %} 构建该 URL,使用 URL 名称和 slug 标签作为其参数。您使用逗号分隔标签。

在你的浏览器中打开 http://127.0.0.1:8000/blog/tag/jazz/。你会看到按该标签过滤的文章列表,如下所示:

图片

图 3.7:按标签“jazz”过滤的文章

通过相似性检索文章

现在我们已经实现了博客文章的标签功能,你可以用标签做很多有趣的事情。标签允许你以非层次结构的方式对文章进行分类。关于类似主题的文章将具有几个共同的标签。我们将构建一个功能来显示具有共享标签数量的相似文章。这样,当用户阅读一篇文章时,我们可以建议他们阅读其他相关的文章。

为了检索特定文章的相似文章,你需要执行以下步骤:

  1. 获取当前文章的所有标签。

  2. 获取所有带有任何这些标签的文章。

  3. 从该列表中排除当前文章,以避免推荐相同的文章。

  4. 按与当前文章共享的标签数量对结果进行排序。

  5. 如果有两个或更多文章具有相同数量的标签,建议最新的文章。

  6. 限制查询到您想要推荐的帖子数量。

这些步骤被转换为一个复杂的 QuerySet。让我们编辑 post_detail 视图以包含基于相似性的文章建议。

打开你的 blog 应用程序的 views.py 文件,并在其顶部添加以下导入:

from django.db.models import Count 

这是 Django ORM 的Count聚合函数。此函数将允许您执行标签的聚合计数。django.db.models包括以下聚合函数:

  • Avg:平均值

  • Max:最大值

  • Min:最小值

  • Count:对象总数

您可以在docs.djangoproject.com/en/5.0/topics/db/aggregation/了解有关聚合的信息。

打开您的blog应用的views.py文件,并将以下行添加到post_detail视图中。新行以粗体突出显示:

def post_detail(request, year, month, day, post):
    post = get_object_or_404(
        Post,
        status=Post.Status.PUBLISHED,
        slug=post,
        publish__year=year,
        publish__month=month,
        publish__day=day
    )
    # List of active comments for this post
    comments = post.comments.filter(active=True)
    # Form for users to comment
    form = CommentForm()
**# List of similar posts**
 **post_tags_ids = post.tags.values_list(****'id'****, flat=****True****)**
 **similar_posts = Post.published.****filter****(**
 **tags__in=post_tags_ids**
 **).exclude(****id****=post.****id****)**
 **similar_posts = similar_posts.annotate(**
 **same_tags=Count(****'tags'****)**
 **).order_by(****'-same_tags'****,** **'-publish'****)[:****4****]**
return render(
        request,
        'blog/post/detail.html',
        {
            'post': post,
            'comments': comments,
            'form': form**,**
**'similar_posts'****: similar_posts**
        }
    ) 

以下代码如下:

  1. 您检索当前帖子标签的 Python ID 列表。values_list()查询集返回给定字段的值组成的元组。您传递flat=True给它以获取单个值,例如[1, 2, 3, ...],而不是一个元组,如[(1,), (2,), (3,) ...]

  2. 您将获得包含以下任何标签的所有帖子,但不包括当前帖子本身。

  3. 您使用Count聚合函数生成一个计算字段——same_tags——它包含与查询的所有标签共享的标签数量。

  4. 您按共享标签的数量(降序)和publish排序结果,以显示具有相同共享标签数量的帖子中的最新帖子。您截取结果以检索前四个帖子。

  5. 您将similar_posts对象传递到render()函数的上下文字典中。

现在,编辑blog/post/detail.html模板,并添加以下以粗体突出显示的代码:

{% extends "blog/base.html" %}
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
  <h1>{{ post.title }}</h1>
<p class="date">
    Published {{ post.publish }} by {{ post.author }}
  </p>
  {{ post.body|linebreaks }}
  <p>
<a href="{% url "blog:post_share" post.id %}">
      Share this post
    </a>
</p>
**<****h2****>****Similar posts****</****h2****>**
 **{% for post in similar_posts %}**
**<****p****>**
**<****a****href****=****"{{ post.get_absolute_url }}"****>****{{ post.title }}****</****a****>**
**</****p****>**
 **{% empty %}**
 **There are no similar posts yet.**
 **{% endfor %}**
  {% with comments.count as total_comments %}
    <h2>
      {{ total_comments }} comment{{ total_comments|pluralize }}
    </h2>
  {% endwith %}
  {% for comment in comments %}
    <div class="comment">
<p class="info">
        Comment {{ forloop.counter }} by {{ comment.name }}
        {{ comment.created }}
      </p>
      {{ comment.body|linebreaks }}
    </div>
  {% empty %}
    <p>There are no comments yet.</p>
  {% endfor %}
  {% include "blog/post/includes/comment_form.html" %}
{% endblock %} 

帖子详情页面应该看起来像这样:

图 3.8:帖子详情页面,包括类似帖子列表

在浏览器中打开http://127.0.0.1:8000/admin/blog/post/,编辑一个没有标签的帖子,并添加musicjazz标签,如下所示:

图 3.9:将“jazz”和“music”标签添加到帖子中

编辑另一个帖子并添加jazz标签,如下所示:

图 3.10:将“jazz”标签添加到帖子中

第一篇帖子的帖子详情页面现在应该看起来像这样:

图 3.11:帖子详情页面,包括类似帖子列表

页面相似帖子部分推荐的帖子根据与原始帖子共享的标签数量降序排列。

现在,我们能够成功地向读者推荐类似帖子。django-taggit还包括一个similar_objects()管理器,您可以使用它通过共享标签检索对象。您可以在django-taggit.readthedocs.io/en/latest/api.html查看所有django-taggit管理器。

您也可以像在blog/post/list.html模板中做的那样,以相同的方式将标签列表添加到您的帖子详情模板中。

创建自定义模板标签和过滤器

Django 提供了各种内置模板标签,例如{% if %}{% block %}。你在第一章构建博客应用程序第二章使用高级功能增强你的博客中使用了不同的模板标签。你可以在docs.djangoproject.com/en/5.0/ref/templates/builtins/找到内置模板标签和过滤器的完整参考。

Django 还允许你创建自己的模板标签来执行自定义操作。当你需要向模板添加核心 Django 模板标签集未涵盖的功能时,自定义模板标签非常有用。这可以是一个执行 QuerySet 或任何你想要在模板间重用的服务器端处理的标签。例如,我们可以构建一个模板标签来显示博客上最新发布的帖子列表。我们可以将这个列表包含在侧边栏中,使其始终可见,无论处理请求的是哪个视图。

实现自定义模板标签

Django 提供了以下辅助函数,这些函数允许你轻松创建模板标签:

  • simple_tag: 处理给定数据并返回一个字符串

  • inclusion_tag: 处理给定数据并返回一个渲染的模板

模板标签必须位于 Django 应用程序内部。

在你的blog应用程序目录中,创建一个新的目录,命名为templatetags,并向其中添加一个空的__init__.py文件。在同一个文件夹中创建另一个文件,命名为blog_tags.py。博客应用程序的文件结构应该如下所示:

blog/
    __init__.py
    models.py
    ...
    **templatetags/**
**__init__.py**
**blog_tags.py** 

你命名文件的方式很重要,因为你将使用这个模块的名称来在模板中加载标签。

创建一个简单的模板标签

让我们从创建一个简单的标签来检索博客上已发布的总帖子数开始。

编辑你刚刚创建的templatetags/blog_tags.py文件,并添加以下代码:

from django import template
from ..models import Post
register = template.Library()
@register.simple_tag
def total_posts():
    return Post.published.count() 

我们已经创建了一个简单的模板标签,它返回博客上发布的帖子数量。

每个包含模板标签的模块都需要定义一个名为register的变量,以使其成为一个有效的标签库。这个变量是template.Library的一个实例,它用于注册应用程序的模板标签和过滤器。

在前面的代码中,我们定义了一个名为total_posts的标签,它使用了一个简单的 Python 函数。我们向函数添加了@register.simple_tag装饰器,以将其注册为一个简单标签。Django 将使用函数的名称作为标签名称。

如果你想用不同的名称注册它,你可以通过指定一个name属性来实现,例如@register.simple_tag(name='my_tag')

在添加新的模板标签模块后,你需要重新启动 Django 开发服务器,以便在模板中使用新的标签和过滤器。

在使用自定义模板标签之前,我们必须使用{% load %}标签使它们对模板可用。如前所述,我们需要使用包含我们的模板标签和过滤器的 Python 模块的名称。

编辑blog/templates/base.html模板,并在其顶部添加{% load blog_tags %}以加载你的模板标签模块。然后,使用你创建的标签显示你的总帖子数,如下所示。新的行以粗体显示:

**{% load blog_tags %}**
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% endblock %}</title>
<link href="{% static "css/blog.css" %}" rel="stylesheet">
</head>
<body>
<div id="content">
    {% block content %}
    {% endblock %}
  </div>
<div id="sidebar">
<h2>My blog</h2>
<p>
      This is my blog.
      **I've written {% total_posts %} posts so far.**
</p>
</div>
</body>
</html> 

你需要重新启动服务器以跟踪项目中新添加的文件。使用Ctrl + C停止开发服务器,然后使用以下命令重新运行它:

python manage.py runserver 

在你的浏览器中打开http://127.0.0.1:8000/blog/。你应该在网站的侧边栏中看到帖子的总数,如下所示:

图片

图 3.12:包含在侧边栏中的总帖子数

如果你看到以下错误信息,那么很可能你没有重新启动开发服务器:

图片

图 3.13:当模板标签库未注册时的错误信息

模板标签允许你在任何视图中处理任何数据并将其添加到任何模板中。你可以执行查询集或处理任何数据以在模板中显示结果。

创建包含模板标签

我们将创建另一个标签以在博客侧边栏中显示最新帖子。这次,我们将实现一个包含模板标签。使用包含模板标签,你可以渲染一个模板,该模板使用模板标签返回的上下文变量。

编辑templatetags/blog_tags.py文件,并添加以下代码:

@register.inclusion_tag('blog/post/latest_posts.html')
def show_latest_posts(count=5):
    latest_posts = Post.published.order_by('-publish')[:count]
    return {'latest_posts': latest_posts} 

在前面的代码中,我们使用@register.inclusion_tag装饰器注册了模板标签。我们使用blog/post/latest_posts.html指定了将使用返回值渲染的模板。模板标签将接受一个可选的count参数,默认值为5。此参数允许我们指定要显示的帖子数。我们使用此变量来限制查询Post.published.order_by('-publish')[:count]的结果。

注意,该函数返回一个包含变量的字典而不是一个简单的值。包含标签必须返回一个包含值的字典,该字典用作渲染指定模板的上下文。我们刚刚创建的模板标签允许我们指定要显示的帖子可选数量,格式为{% show_latest_posts 3 %}

现在,在blog/post/目录下创建一个新的模板文件,并将其命名为latest_posts.html

编辑新的blog/post/latest_posts.html模板,并添加以下代码到其中:

<ul>
  {% for post in latest_posts %}
    <li>
<a href="{{ post.get_absolute_url }}">{{ post.title }}</a>
</li>
  {% endfor %}
</ul> 

在前面的代码中,你使用由你的模板标签返回的latest_posts变量添加了一个无序列表。现在,编辑blog/base.html模板,并添加新的模板标签以显示最后三篇帖子,如下所示。新的行以粗体显示:

{% load blog_tags %}
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% endblock %}</title>
<link href="{% static "css/blog.css" %}" rel="stylesheet">
</head>
<body>
<div id="content">
    {% block content %}
    {% endblock %}
  </div>
<div id="sidebar">
<h2>My blog</h2>
<p>
      This is my blog.
      I've written {% total_posts %} posts so far.
    </p>
**<****h3****>****Latest posts****</****h3****>**
 **{% show_latest_posts 3 %}**
</div>
</body>
</html> 

调用模板标签时,传递要显示的帖子数,并使用给定上下文在适当位置渲染模板。

接下来,返回你的浏览器并刷新页面。侧边栏现在应该看起来像这样:

图片

图 3.14:博客侧边栏,包括最新发布的帖子

创建一个返回 QuerySet 的模板标签

最后,我们将创建一个简单的模板标签,它返回一个值。我们将结果存储在一个可重用的变量中,而不是直接输出。我们将创建一个标签来显示最多评论的帖子。

编辑templatetags/blog_tags.py文件,并添加以下导入和模板标签:

from django.db.models import Count
@register.simple_tag
def get_most_commented_posts(count=5):
    return Post.published.annotate(
        total_comments=Count('comments')
    ).order_by('-total_comments')[:count] 

在前面的模板标签中,你使用annotate()函数构建一个 QuerySet,以聚合每个帖子的总评论数。你使用Count聚合函数将评论数存储在每个Post对象的计算字段total_comments中。你按计算字段降序排列 QuerySet。你还提供了一个可选的count变量来限制返回的对象总数。

除了Count之外,Django 还提供了聚合函数AvgMaxMinSum。你可以在docs.djangoproject.com/en/5.0/topics/db/aggregation/了解更多关于聚合函数的信息。

接下来,编辑blog/base.html模板,并添加以下加粗显示的代码:

{% load blog_tags %}
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% endblock %}</title>
<link href="{% static "css/blog.css" %}" rel="stylesheet">
</head>
<body>
<div id="content">
    {% block content %}
    {% endblock %}
  </div>
<div id="sidebar">
<h2>My blog</h2>
<p>
      This is my blog.
      I've written {% total_posts %} posts so far.
    </p>
<h3>Latest posts</h3>
    {% show_latest_posts 3 %}
**<****h3****>****Most commented posts****</****h3****>**
 **{% get_most_commented_posts as most_commented_posts %}**
**<****ul****>**
 **{% for post in most_commented_posts %}**
**<****li****>**
**<****a****href****=****"{{ post.get_absolute_url }}"****>****{{ post.title }}****</****a****>**
**</****li****>**
 **{% endfor %}**
**</****ul****>**
</div>
</body>
</html> 

在前面的代码中,我们使用as参数和变量名来存储结果,创建一个自定义变量。对于模板标签,我们使用{% get_most_commented_posts as most_commented_posts %}将模板标签的结果存储在一个名为most_commented_posts的新变量中。然后,我们使用 HTML 无序列表元素显示返回的帖子。

现在打开你的浏览器并刷新页面,查看最终结果。它应该看起来像以下这样:

图片

图 3.15:帖子列表视图,包括包含最新和最多评论帖子的完整侧边栏

你现在对如何构建自定义模板标签有了清晰的认识。你可以在docs.djangoproject.com/en/5.0/howto/custom-template-tags/了解更多相关信息。

实现自定义模板过滤器

Django 提供了一系列内置的模板过滤器,允许你在模板中更改变量。这些是 Python 函数,它们接受一个或两个参数,即应用过滤器的变量的值,以及一个可选的参数。它们返回一个可以显示或由另一个过滤器处理的值。

过滤器的编写方式为{{ variable|my_filter }}。带有参数的过滤器编写方式为{{ variable|my_filter:"foo" }}。例如,您可以使用capfirst过滤器将值的第一个字符转换为大写,如{{ value|capfirst }}。如果valuedjango,输出将是Django。您可以将任意数量的过滤器应用于变量,例如{{ variable|filter1|filter2 }},每个过滤器都将应用于前一个过滤器生成的输出。

您可以在docs.djangoproject.com/en/5.0/ref/templates/builtins/#built-in-filter-reference找到 Django 内置模板过滤器的列表。

创建支持 Markdown 语法的模板过滤器

我们将创建一个自定义过滤器,使您能够在博客帖子中使用 Markdown 语法,然后在模板中将帖子主体转换为 HTML。

Markdown 是一种非常简单易用的纯文本格式化语法,旨在转换为 HTML。您可以使用简单的 Markdown 语法编写帖子,并自动将其转换为 HTML 代码。学习 Markdown 语法比学习 HTML 容易得多。通过使用 Markdown,您可以轻松地让其他非技术贡献者为您撰写博客帖子。您可以在daringfireball.net/projects/markdown/basics上学习 Markdown 格式的基础知识。

首先,在 shell 提示符中使用以下命令通过pip安装 Python 的markdown模块:

python -m pip install markdown==3.6 

然后,编辑templatetags/blog_tags.py文件,并包含以下代码:

import markdown
from django.utils.safestring import mark_safe
@register.filter(name='markdown')
def markdown_format(text):
    return mark_safe(markdown.markdown(text)) 

我们以与模板标签相同的方式注册模板过滤器。为了避免函数名与markdown模块之间的名称冲突,我们将函数命名为markdown_format,并将过滤器命名为markdown,以便在模板中使用,例如{{ variable|markdown }}

Django 会转义由过滤器生成的 HTML 代码;HTML 实体的字符被替换为其 HTML 编码字符。例如,<p>被转换为&lt;p&gt;(小于符号,p 字符,大于符号)。

我们使用 Django 提供的mark_safe函数将结果标记为安全的 HTML,以便在模板中渲染。默认情况下,Django 不会信任任何 HTML 代码,并在将其放入输出之前将其转义。唯一的例外是标记为安全的变量。这种行为阻止 Django 输出可能危险的 HTML,并允许您为返回安全的 HTML 创建例外。

在 Django 中,默认情况下,HTML 内容会被转义以提高安全性。请谨慎使用mark_safe,仅对您控制的内容使用。避免在由非工作人员用户提交的任何内容上使用mark_safe,以防止安全漏洞。

编辑blog/post/detail.html模板,并添加以下加粗的新代码:

{% extends "blog/base.html" %}
**{% load blog_tags %}**
{% block title %}{{ post.title }}{% endblock %}
{% block content %}
  <h1>{{ post.title }}</h1>
<p class="date">
    Published {{ post.publish }} by {{ post.author }}
  </p>
  {{ post.body**|markdown** }}
  <p>
<a href="{% url "blog:post_share" post.id %}">
      Share this post
    </a>
</p>
<h2>Similar posts</h2>
  {% for post in similar_posts %}
    <p>
<a href="{{ post.get_absolute_url }}">{{ post.title }}</a>
</p>
  {% empty %}
    There are no similar posts yet.
  {% endfor %}
  {% with comments.count as total_comments %}
    <h2>
      {{ total_comments }} comment{{ total_comments|pluralize }}
    </h2>
  {% endwith %}
  {% for comment in comments %}
    <div class="comment">
<p class="info">
        Comment {{ forloop.counter }} by {{ comment.name }}
        {{ comment.created }}
      </p>
      {{ comment.body|linebreaks }}
    </div>
  {% empty %}
    <p>There are no comments yet.</p>
  {% endfor %}
  {% include "blog/post/includes/comment_form.html" %}
{% endblock %} 

我们已将 {{ post.body }} 模板变量的 linebreaks 过滤器替换为 markdown 过滤器。此过滤器不仅将换行符转换为 <p> 标签;它还将 Markdown 格式转换为 HTML。

在数据库中以 Markdown 格式存储文本,而不是 HTML,是一种明智的安全策略。Markdown 限制了注入恶意内容的能力。这种方法确保任何文本格式化仅在渲染模板时安全地转换为 HTML。

编辑 blog/post/list.html 模板并添加以下以粗体显示的新代码:

{% extends "blog/base.html" %}
**{% load blog_tags %}**
{% block title %}My Blog{% endblock %}
{% block content %}
  <h1>My Blog</h1>
  {% if tag %}
    <h2>Posts tagged with "{{ tag.name }}"</h2>
  {% endif %}
  {% for post in posts %}
    <h2>
<a href="{{ post.get_absolute_url }}">
        {{ post.title }}
      </a>
</h2>
<p class="tags">
      Tags:
      {% for tag in post.tags.all %}
        <a href="{% url "blog:post_list_by_tag" tag.slug %}">
          {{ tag.name }}
        </a>
        {% if not forloop.last %}, {% endif %}
      {% endfor %}
    </p>
<p class="date">
      Published {{ post.publish }} by {{ post.author }}
    </p>
    {{ post.body**|markdown|truncatewords_html:30** }}
  {% endfor %}
  {% include "pagination.html" with page=posts %}
{% endblock %} 

我们已将新的 markdown 过滤器添加到 {{ post.body }} 模板变量中。此过滤器将 Markdown 内容转换为 HTML。

因此,我们将之前的 truncatewords 过滤器替换为 truncatewords_html 过滤器。此过滤器在特定数量的单词后截断字符串,避免未关闭的 HTML 标签。

现在,在浏览器中打开 http://127.0.0.1:8000/admin/blog/post/add/ 并创建一个具有以下正文的新的帖子:

This is a post formatted with markdown
--------------------------------------
*This is emphasized* and **this is more emphasized**.
Here is a list:
* One
* Two
* Three
And a [link to the Django website](https://www.djangoproject.com/). 

表格应看起来像这样:

图 3.16:Markdown 内容渲染为 HTML 的帖子

在浏览器中打开 http://127.0.0.1:8000/blog/ 并查看新帖子是如何渲染的。您应该看到以下输出:

图 3.17:Markdown 内容渲染为 HTML 的帖子

如您在 图 3.17 中所见,自定义模板过滤器对于自定义格式化非常有用。您可以在 docs.djangoproject.com/en/5.0/howto/custom-template-tags/#writing-custom-template-filters 找到有关自定义过滤器的更多信息。

向网站添加站点地图

Django 内置了站点地图框架,允许您动态地为您的网站生成站点地图。站点地图是一个 XML 文件,它告诉搜索引擎您的网站页面、它们的关联性以及它们更新的频率。使用站点地图将使您的网站在搜索引擎排名中更加可见,因为它有助于爬虫索引您的网站内容。

Django 站点地图框架依赖于 django.contrib.sites,这允许您将对象关联到与您的项目一起运行的具体网站。当您想使用单个 Django 项目运行多个网站时,这非常有用。为了安装站点地图框架,我们需要在您的项目中激活 sitessitemap 应用程序。我们将为博客构建一个包含所有已发布帖子链接的站点地图。

编辑项目的 settings.py 文件,并将 django.contrib.sitesdjango.contrib.sitemaps 添加到 INSTALLED_APPS 设置中。同时,定义一个新的站点 ID 设置,如下所示。新代码以粗体显示:

# ...
**SITE_ID =** **1**
# Application definition
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    **'django.contrib.sites'****,**
**'django.contrib.sitemaps'****,**
'django.contrib.staticfiles',
    'taggit',
    'blog.apps.BlogConfig',
] 

现在,从 shell 提示符运行以下命令以在数据库中创建 Django 站点应用程序的表:

python manage.py migrate 

您应该看到包含以下行的输出:

Applying sites.0001_initial... OK
Applying sites.0002_alter_domain_unique... OK 

sites 应用程序现在已与数据库同步。

接下来,在您的 blog 应用程序目录内创建一个新文件,并将其命名为 sitemaps.py。打开文件,并向其中添加以下代码:

from django.contrib.sitemaps import Sitemap
from .models import Post
class PostSitemap(Sitemap):
    changefreq = 'weekly'
    priority = 0.9
def items(self):
        return Post.published.all()
    def lastmod(self, obj):
        return obj.updated 

我们通过继承 sitemaps 模块的 Sitemap 类定义了一个自定义 sitemap。changefreqpriority 属性表示您的帖子页面的更改频率及其在网站中的相关性(最大值为 1)。

items() 方法返回包含在此 sitemap 中的对象的 QuerySet。默认情况下,Django 会为每个对象调用 get_absolute_url() 方法以检索其 URL。请记住,我们在 第二章通过高级功能增强您的博客 中实现了此方法,以定义帖子的规范 URL。如果您想为每个对象指定 URL,您可以在您的 sitemap 类中添加一个 location 方法。

lastmod 方法接收 items() 返回的每个对象,并返回对象最后修改的时间。

changefreqpriority 属性可以是方法或属性。您可以在官方 Django 文档中查看完整的 sitemap 参考,文档位于 docs.djangoproject.com/en/5.0/ref/contrib/sitemaps/

我们已创建 sitemap。现在我们只需要为它创建一个 URL。

编辑 mysite 项目的 main urls.py 文件,并添加 sitemap,如下所示。新行以粗体显示:

from django.contrib import admin
**from** **django.contrib.sitemaps.views** **import** **sitemap**
from django.urls import include, path
**from** **blog.sitemaps** **import** **PostSitemap**
**sitemaps = {**
**'posts'****: PostSitemap,**
**}**
urlpatterns = [
    path('admin/', admin.site.urls),
    path('blog/', include('blog.urls', namespace='blog')),
 **path(**
**'sitemap.xml'****,**
 **sitemap,**
 **{****'sitemaps'****: sitemaps},**
 **name=****'django.contrib.sitemaps.views.sitemap'**
 **)**
] 

在前面的代码中,我们包含了所需的导入并定义了一个 sitemaps 字典。可以为网站定义多个 sitemap。我们定义了一个匹配 sitemap.xml 模式的 URL 模式,并使用了 Django 提供的 sitemap 视图。sitemaps 字典被传递给 sitemap 视图。

从 shell 提示符启动开发服务器,使用以下命令:

python manage.py runserver 

在浏览器中打开 http://127.0.0.1:8000/sitemap.xml。您将看到包括所有已发布帖子的 XML 输出,如下所示:

<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
<url>
<loc>http://example.com/blog/2024/1/2/markdown-post/</loc>
<lastmod>2024-01-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>http://example.com/blog/2024/1/2/notes-on-duke-ellington/</loc>
<lastmod>2024-01-02</lastmod>
<changefreq>weekly</changefreqa>
<priority>0.9</priority>
</url>
<url>
<loc>http://example.com/blog/2024/1/2/who-was-miles-davis/</loc>
<lastmod>2024-01-02</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>http://example.com/blog/2024/1/1/who-was-django-reinhardt/</loc>
<lastmod>2024-01-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>http://example.com/blog/2024/1/1/another-post/</loc>
<lastmod>2024-01-01</lastmod>
<changefreq>weekly</changefreq>
<priority>0.9</priority>
</url>
</urlset> 

通过调用其 get_absolute_url() 方法构建每个 Post 对象的 URL。

lastmod 属性对应于您在 sitemap 中指定的帖子 updated 日期字段,changefreqpriority 属性也来自 PostSitemap 类。

用于构建 URL 的域是 example.com。此域名来自数据库中存储的 Site 对象。当您将网站的框架与数据库同步时,创建了此默认对象。您可以在 docs.djangoproject.com/en/5.0/ref/contrib/sites/ 了解更多关于 sites 框架的信息。

在浏览器中打开 http://127.0.0.1:8000/admin/sites/site/。您应该看到类似以下的内容:

图 3.18:网站的框架中 Site 模型的 Django 管理列表视图

图 3.18 包含了网站框架的列表显示管理视图。在这里,您可以设置网站框架及其依赖的应用程序要使用的域名或主机。要生成本地环境中的 URL,将域名更改为localhost:8000,如图图 3.19所示,并保存它:

图 3.19:网站框架的网站模型 Django 管理编辑视图

再次在浏览器中打开http://127.0.0.1:8000/sitemap.xml。现在在您的网站地图中显示的 URL 将使用新的主机名,看起来像http://localhost:8000/blog/2024/1/22/markdown-post/。链接现在在您的本地环境中可访问。在生产环境中,您将必须使用您网站的域名来生成绝对 URL。

创建博客帖子的源

Django 有一个内置的聚合源框架,您可以使用它以创建网站框架类似的方式动态生成 RSS 或 Atom 源。一个网络源是一种数据格式(通常是 XML),它为用户提供最新更新的内容。用户可以使用源聚合器订阅源,这是一种用于读取源和获取新内容通知的软件。

在您的blog应用程序目录中创建一个新文件,并将其命名为feeds.py。向其中添加以下行:

import markdown
from django.contrib.syndication.views import Feed
from django.template.defaultfilters import truncatewords_html
from django.urls import reverse_lazy
from .models import Post
class LatestPostsFeed(Feed):
    title = 'My blog'
    link = reverse_lazy('blog:post_list')
    description = 'New posts of my blog.'
def items(self):
        return Post.published.all()[:5]
    def item_title(self, item):
        return item.title
    def item_description(self, item):
        return truncatewords_html(markdown.markdown(item.body), 30)
    def item_pubdate(self, item):
        return item.publish 

在前面的代码中,我们通过继承聚合框架的Feed类来定义了一个源。titlelinkdescription属性分别对应于 RSS 元素中的<title><link><description>

我们使用reverse_lazy()生成link属性的 URL。reverse()方法允许您通过名称构建 URL 并传递可选参数。我们在第二章通过高级功能增强您的博客中使用了reverse()

reverse_lazy()实用函数是reverse()的延迟评估版本。它允许在项目的 URL 配置加载之前使用 URL 反转。

items()方法检索要包含在源中的对象。我们检索最后五篇发布的帖子以包含在源中。

item_title()item_description()item_pubdate()方法将接收items()返回的每个对象,并为每个项目返回标题、描述和发布日期。

item_description()方法中,我们使用markdown()函数将 Markdown 内容转换为 HTML,并使用truncatewords_html()模板过滤器函数在 30 个单词后截断帖子的描述,以避免未关闭的 HTML 标签。

现在,编辑blog/urls.py文件,导入LatestPostsFeed类,并在新的 URL 模式中实例化源,如下所示。新行以粗体显示:

from django.urls import path
from . import views
**from** **.feeds** **import** **LatestPostsFeed**
app_name = 'blog'
urlpatterns = [
    # Post views
    path('', views.post_list, name='post_list'),
    # path('', views.PostListView.as_view(), name='post_list'),
    path(
        'tag/<slug:tag_slug>/', views.post_list, name='post_list_by_tag'
 ),
    path(
        '<int:year>/<int:month>/<int:day>/<slug:post>/',
        views.post_detail,
        name='post_detail'
 ),
    path('<int:post_id>/share/', views.post_share, name='post_share'),
    path(
        '<int:post_id>/comment/', views.post_comment, name='post_comment'
 ),
    **path(****'feed/'****, LatestPostsFeed(), name=****'post_feed'****),**
] 

在您的浏览器中导航到http://127.0.0.1:8000/blog/feed/。现在您应该能看到 RSS 源,包括最后五篇博客帖子:

<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<title>My blog</title>
<link>http://localhost:8000/blog/</link>
<description>New posts of my blog.</description>
<atom:link href="http://localhost:8000/blog/feed/" rel="self"/>
<language>en-us</language>
<lastBuildDate>Tue, 02 Jan 2024 16:30:00 +0000</lastBuildDate>
<item>
<title>Markdown post</title>
<link>http://localhost:8000/blog/2024/1/2/markdown-post/</link>
<description>This is a post formatted with ...</description>
<guid>http://localhost:8000/blog/2024/1/2/markdown-post/</guid>
</item>
    ...
  </channel>
</rss> 

如果您使用 Chrome,您将看到 XML 代码。如果您使用 Safari,它将要求您安装 RSS 源阅读器。

让我们安装一个 RSS 桌面客户端,以便使用用户友好的界面查看 RSS 源。我们将使用 Fluent Reader,这是一个多平台 RSS 阅读器。

github.com/yang991178/fluent-reader/releases下载适用于 Linux、macOS 或 Windows 的 Fluent Reader。

安装 Fluent Reader 并打开它。您将看到以下屏幕:

包含图表的图片,自动生成描述

图 3.20:没有 RSS 源信息的 Fluent Reader

点击窗口右上角的设置图标。您将看到一个屏幕,可以添加 RSS 源,如下所示:

自动生成描述

图 3.21:在 Fluent Reader 中添加 RSS 源

添加源字段中输入http://127.0.0.1:8000/blog/feed/并点击添加按钮。

您将在表单下方看到一个新的条目,其中包含博客的 RSS 源,如下所示:

图形用户界面,文本,应用程序,电子邮件,自动生成描述

图 3.22:Fluent Reader 中的 RSS 源

现在,回到 Fluent Reader 的主屏幕。您应该能够看到包含在博客 RSS 源中的文章,如下所示:

图形用户界面,文本,自动生成描述

图 3.23:Fluent Reader 中的博客 RSS 源

点击一篇文章以查看描述:

图 3.24:Fluent Reader 中的文章描述

点击窗口右上角第三个图标以加载文章页面的全部内容:

图 3.25:Fluent Reader 中文章的完整内容

最后一步是将 RSS 订阅链接添加到博客的侧边栏。

打开blog/base.html模板并添加以下加粗的代码:

{% load blog_tags %}
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% endblock %}</title>
<link href="{% static "css/blog.css" %}" rel="stylesheet">
</head>
<body>
<div id="content">
    {% block content %}
    {% endblock %}
  </div>
<div id="sidebar">
<h2>My blog</h2>
<p>
      This is my blog.
      I've written {% total_posts %} posts so far.
    </p>
**<****p****>**
**<****a****href****=****"{% url "****blog:post_feed****" %}">**
 **Subscribe to my RSS feed**
**</****a****>**
**</****p****>**
<h3>Latest posts</h3>
    {% show_latest_posts 3 %}
    <h3>Most commented posts</h3>
    {% get_most_commented_posts as most_commented_posts %}
    <ul>
      {% for post in most_commented_posts %}
        <li>
<a href="{{ post.get_absolute_url }}">{{ post.title }}</a>
</li>
      {% endfor %}
    </ul>
</div>
</body>
</html> 

现在在您的浏览器中打开http://127.0.0.1:8000/blog/并查看侧边栏。新的链接将用户带到博客的源:

图 3.26:侧边栏中添加的 RSS 订阅链接

您可以在docs.djangoproject.com/en/5.0/ref/contrib/syndication/了解更多关于 Django 聚合订阅框架的信息。

向博客添加全文搜索

接下来,我们将向博客添加搜索功能。在数据库中使用用户输入搜索数据是 Web 应用的常见任务。Django ORM 允许您使用例如contains过滤器(或其不区分大小写的版本icontains)执行简单的匹配操作。您可以使用以下查询来查找正文包含单词framework的文章:

from blog.models import Post
Post.objects.filter(body__contains='framework') 

但是,如果您想执行复杂的搜索查询,通过相似度检索结果,或者根据它们在文本中出现的频率或不同字段的重要性(例如,标题中出现的术语与正文中出现的术语的相关性)来加权术语,您将需要使用全文搜索引擎。当考虑大量文本块时,仅使用字符串上的操作构建查询是不够的。全文搜索在尝试匹配搜索条件时会检查实际单词与存储内容之间的对比。

Django 提供了一个基于 PostgreSQL 数据库全文搜索功能的强大搜索功能。django.contrib.postgres模块提供了 PostgreSQL 提供的功能,这些功能是 Django 支持的其他数据库所不具备的。您可以在www.postgresql.org/docs/16/textsearch.html了解 PostgreSQL 的全文搜索支持。

虽然 Django 是一个数据库无关的 Web 框架,但它提供了一个模块,支持 PostgreSQL 提供的部分丰富功能集,而 Django 支持的其他数据库则没有提供。

我们目前为mysite项目使用 SQLite 数据库。SQLite 对全文搜索的支持有限,Django 默认也不支持。然而,PostgreSQL 非常适合全文搜索,我们可以使用django.contrib.postgres模块来利用 PostgreSQL 的全文搜索功能。我们将把数据从 SQLite 迁移到 PostgreSQL,以利用其全文搜索特性。

SQLite 对于开发目的来说是足够的。然而,对于生产环境,您将需要一个更强大的数据库,例如 PostgreSQL、MariaDB、MySQL 或 Oracle。

PostgreSQL 提供了一个 Docker 镜像,使得部署具有标准配置的 PostgreSQL 服务器变得非常容易。

安装 Docker

Docker 是一个流行的开源容器化平台。它使开发者能够将应用程序打包到容器中,简化了构建、运行、管理和分发应用程序的过程。

首先,下载并安装适用于您操作系统的 Docker。您可以在docs.docker.com/get-docker/找到关于在 Linux、macOS 和 Windows 上下载和安装 Docker 的说明。安装包括 Docker 桌面和 Docker 命令行界面工具。

安装 PostgreSQL

在您的 Linux、macOS 或 Windows 机器上安装 Docker 后,您可以轻松地拉取 PostgreSQL Docker 镜像。从 shell 中运行以下命令:

docker pull postgres:16.2 

这将下载 PostgreSQL Docker 镜像到您的本地机器。您可以在hub.docker.com/_/postgres找到有关官方 PostgreSQL Docker 镜像的信息。您可以在www.postgresql.org/download/找到其他 PostgreSQL 软件包和安装程序。

在 shell 中执行以下命令以启动 PostgreSQL Docker 容器:

docker run --name=blog_db -e POSRGRES_DB=blog -e POSTGRES_USER=blog -e POSTGRES_PASSWORD=xxxxx -p 5432:5432 -d postgres:16.2 

xxxxx 替换为你数据库用户的所需密码。

此命令启动一个 PostgreSQL 实例。--name 选项用于为容器分配一个名称,在本例中为 blog_db-e 选项用于定义实例的环境变量。我们设置了以下环境变量:

  • POSTGRES_DB:PostgreSQL 数据库的名称。如果未定义,则使用 POSTGRES_USER 的值作为数据库名称。

  • POSTGRES_USER:与 POSTGRES_PASSWORD 一起使用来定义用户名和密码。用户以超级用户权限创建。

  • POSTGRES_PASSWORD:设置 PostgreSQL 的超级用户密码。

-p 选项用于将 PostgreSQL 运行的 5432 端口发布到同一主机接口端口。这允许外部应用程序访问数据库。-d 选项用于 分离模式,它将在后台运行 Docker 容器。

打开 Docker Desktop 应用程序。你应该看到新容器正在运行,如图 3.27 所示:

图 3.27:在 Docker Desktop 中运行的 PostgreSQL 实例

你将看到新创建的 blog_db 容器,状态为 运行中。在 操作 下,你可以停止或重启服务。你也可以删除容器。请注意,删除容器也将删除数据库及其包含的所有数据。你将在 第十七章上线 中学习如何使用 Docker 在本地文件系统中持久化 PostgreSQL 数据。

你还需要安装 Python 的 psycopg PostgreSQL 适配器。在 shell 提示符中运行以下命令来安装它:

python -m pip install psycopg==3.1.18 

接下来,我们将将 SQLite 数据库中的现有数据迁移到新的 PostgreSQL 实例。

导出现有数据

在切换 Django 项目的数据库之前,我们需要从 SQLite 数据库中导出现有数据。我们将导出数据,将项目的数据库切换到 PostgreSQL,并将数据导入到新数据库中。

Django 提供了一种简单的方法来将数据库中的数据加载和导出到称为 fixtures 的文件中。Django 支持使用 JSON、XML 或 YAML 格式的 fixtures。我们将创建一个包含数据库中所有数据的 fixtures。

dumpdata 命令将数据从数据库导出到标准输出,默认情况下以 JSON 格式序列化。结果数据结构包括有关模型及其字段的信息,以便 Django 能够将其加载到数据库中。

你可以通过提供应用程序名称给命令,或者指定使用 app.Model 格式的单个模型来输出数据来限制输出到应用程序的模型。你也可以使用 --format 标志来指定格式。默认情况下,dumpdata 将序列化数据输出到标准输出。然而,你可以使用 --output 标志来指定输出文件。--indent 标志允许你指定缩进。有关 dumpdata 参数的更多信息,请运行 python manage.py dumpdata --help

从 shell 提示符执行以下命令:

python manage.py dumpdata --indent=2 --output=mysite_data.json 

所有现有数据已以 JSON 格式导出到一个名为 mysite_data.json 的新文件中。你可以查看文件内容以查看包括所有不同模型的数据对象的 JSON 结构。如果你在运行命令时遇到编码错误,请包含 -Xutf8 标志如下以激活 Python UTF-8 模式:

python -Xutf8 manage.py dumpdata --indent=2 --output=mysite_data.json 

我们现在将切换 Django 项目的数据库,然后我们将数据导入到新的数据库中。

切换项目中的数据库

现在你将把 PostgreSQL 数据库配置添加到你的项目设置中。

编辑你的项目中的 settings.py 文件并修改 DATABASES 设置以使其看起来如下。新的代码被加粗:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.**postgresql**',
        'NAME': **config(****'DB_NAME'****),**
**'USER'****: config(****'DB_USER'****),**
**'PASSWORD'****: config(****'DB_PASSWORD'****),**
**'HOST'****: config(****'DB_HOST'****),**
    }
} 

数据库引擎现在是 postgresql。数据库凭据现在通过 python-decouple 从环境变量中加载。

让我们向环境变量添加值。编辑你的项目中的 .env 文件并添加以下加粗的行:

EMAIL_HOST_USER=your_account@gmail.com
EMAIL_HOST_PASSWORD=xxxxxxxxxxxx
DEFAULT_FROM_EMAIL=My Blog <your_account@gmail.com>
**DB_NAME=blog**
**DB_USER=blog**
**DB_PASSWORD=xxxxx**
**DB_HOST=localhost** 

xxxxxx 替换为你启动 PostgreSQL 容器时使用的密码。新的数据库是空的。

执行以下命令将所有数据库迁移应用到新的 PostgreSQL 数据库:

python manage.py migrate 

你将看到一个输出,包括所有已应用的迁移,如下所示:

Operations to perform:
  Apply all migrations: admin, auth, blog, contenttypes, sessions, sites, taggit
Running migrations:
  Applying contenttypes.0001_initial... OK
  Applying auth.0001_initial... OK
  Applying admin.0001_initial... OK
  Applying admin.0002_logentry_remove_auto_add... OK
  Applying admin.0003_logentry_add_action_flag_choices... OK
  Applying contenttypes.0002_remove_content_type_name... OK
  Applying auth.0002_alter_permission_name_max_length... OK
  Applying auth.0003_alter_user_email_max_length... OK
  Applying auth.0004_alter_user_username_opts... OK
  Applying auth.0005_alter_user_last_login_null... OK
  Applying auth.0006_require_contenttypes_0002... OK
  Applying auth.0007_alter_validators_add_error_messages... OK
  Applying auth.0008_alter_user_username_max_length... OK
  Applying auth.0009_alter_user_last_name_max_length... OK
  Applying auth.0010_alter_group_name_max_length... OK
  Applying auth.0011_update_proxy_permissions... OK
  Applying auth.0012_alter_user_first_name_max_length... OK
  Applying taggit.0001_initial... OK
  Applying taggit.0002_auto_20150616_2121... OK
  Applying taggit.0003_taggeditem_add_unique_index... OK
  Applying taggit.0004_alter_taggeditem_content_type_alter_taggeditem_tag... OK
  Applying taggit.0005_auto_20220424_2025... OK
  Applying taggit.0006_rename_taggeditem_content_type_object_id_taggit_tagg_content_8fc721_idx... OK
  Applying blog.0001_initial... OK
  Applying blog.0002_alter_post_slug... OK
  Applying blog.0003_comment... OK
  Applying blog.0004_post_tags... OK
  Applying sessions.0001_initial... OK
  Applying sites.0001_initial... OK
  Applying sites.0002_alter_domain_unique... OK 

PostgreSQL 数据库现在与你的数据模型同步,你可以运行指向新数据库的 Django 项目。让我们通过加载之前从 SQLite 导出的数据来使数据库达到相同的状态。

将数据加载到新的数据库中

我们将把之前生成的数据固定文件加载到我们的新 PostgreSQL 数据库中。

运行以下命令将之前导出的数据加载到 PostgreSQL 数据库中:

python manage.py loaddata mysite_data.json 

你将看到以下输出:

Installed 104 object(s) from 1 fixture(s) 

对象的数量可能会根据数据库中创建的用户、帖子、评论和其他对象而有所不同。

从 shell 提示符使用以下命令启动开发服务器:

python manage.py runserver 

在你的浏览器中打开 http://127.0.0.1:8000/admin/blog/post/ 以验证所有帖子是否已加载到新的数据库中。你应该看到所有帖子,如下所示:

图 3.28:管理网站上帖子列表

简单搜索查询

在我们的项目中启用了 PostgreSQL 后,我们可以通过利用 PostgreSQL 的全文搜索功能来构建一个强大的搜索引擎。我们将从基本的搜索查找开始,并逐步引入更复杂的功能,例如词干提取、排名或查询加权,以构建一个全面的全文搜索引擎。

编辑您项目的settings.py文件,并将django.contrib.postgres添加到INSTALLED_APPS设置中,如下所示:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.sites',
    'django.contrib.sitemaps',
    'django.contrib.staticfiles',
 **'django.contrib.postgres'****,**
'taggit',
    'blog.apps.BlogConfig',
] 

在系统 shell 提示符中运行以下命令以打开 Django shell:

python manage.py shell 

现在,您可以使用search查询集查找对单个字段进行搜索。

在 Python shell 中运行以下代码:

>>> from blog.models import Post
>>> Post.objects.filter(title__search='django')
<QuerySet [<Post: Who was Django Reinhardt?>]> 

此查询使用 PostgreSQL 为title字段创建一个搜索向量,并从术语django创建一个搜索查询。结果是通过将查询与向量匹配来获得的。

对多个字段进行搜索

您可能想要对多个字段进行搜索。在这种情况下,您需要定义一个SearchVector对象。让我们构建一个允许您对Post模型的titlebody字段进行搜索的向量。

在 Python shell 中运行以下代码:

>>> from django.contrib.postgres.search import SearchVector
>>> from blog.models import Post
>>>
>>> Post.objects.annotate(
...     search=SearchVector('title', 'body'),
... ).filter(search='django')
<QuerySet [<Post: Markdown post>, <Post: Who was Django Reinhardt?>]> 

使用annotate和定义包含两个字段的SearchVector,您提供了一个功能,可以将查询与帖子的titlebody进行匹配。

全文搜索是一个密集的过程。如果您要搜索超过几百行,您应该定义一个与您使用的搜索向量匹配的功能索引。Django 为您的模型提供了一个SearchVectorField字段。您可以在docs.djangoproject.com/en/5.0/ref/contrib/postgres/search/#performance了解更多信息。

构建搜索视图

现在,您将创建一个自定义视图,允许用户搜索帖子。首先,您需要一个搜索表单。编辑blog应用的forms.py文件,并添加以下表单:

class SearchForm(forms.Form):
    query = forms.CharField() 

您将使用query字段让用户输入搜索词。编辑blog应用的views.py文件,并向其中添加以下代码:

# ...
**from** **django.contrib.postgres.search** **import** **SearchVector**
from .forms import CommentForm, EmailPostForm**, SearchForm**
# ...
**def****post_search****(****request****):**
**form = SearchForm()**
**query =** **None**
**results = []**
**if****'query'****in** **request.GET:**
**form = SearchForm(request.GET)**
**if** **form.is_valid():**
**query = form.cleaned_data[****'query'****]**
**results = (**
 **Post.published.annotate(**
**search=SearchVector(****'title'****,** **'body'****),**
**)**
 **.****filter****(search=query)**
 **)**
**return** **render(**
**request,**
**'blog/post/search.html'****,**
**{**
**'form'****: form,**
**'query'****: query,**
**'results'****: results**
**}**
 **)** 

在前面的视图中,首先,我们实例化SearchForm表单。为了检查表单是否已提交,我们在request.GET字典中查找query参数。我们使用GET方法而不是POST方法发送表单,以便生成的 URL 包含query参数,便于分享。当表单提交时,我们使用提交的GET数据实例化它,并验证表单数据是否有效。如果表单有效,我们使用由titlebody字段构建的SearchVector实例来搜索已发布的帖子。

搜索视图现在已准备就绪。我们需要创建一个模板来显示当用户进行搜索时的表单和结果。

templates/blog/post/目录内创建一个新文件,命名为search.html,并向其中添加以下代码:

{% extends "blog/base.html" %}
{% load blog_tags %}
{% block title %}Search{% endblock %}
{% block content %}
  {% if query %}
    <h1>Posts containing "{{ query }}"</h1>
<h3>
      {% with results.count as total_results %}
        Found {{ total_results }} result{{ total_results|pluralize }}
      {% endwith %}
    </h3>
    {% for post in results %}
      <h4>
<a href="{{ post.get_absolute_url }}">
          {{ post.title }}
        </a>
</h4>
      {{ post.body|markdown|truncatewords_html:12 }}
    {% empty %}
      <p>There are no results for your query.</p>
    {% endfor %}
    <p><a href="{% url "blog:post_search" %}">Search again</a></p>
  {% else %}
    <h1>Search for posts</h1>
<form method="get">
      {{ form.as_p }}
      <input type="submit" value="Search">
</form>
  {% endif %}
{% endblock %} 

与搜索视图一样,我们通过query参数的存在来区分表单是否已提交。在查询提交之前,我们显示表单和提交按钮。当搜索表单提交时,我们显示执行查询、结果总数以及与搜索查询匹配的帖子列表。

最后,编辑blog应用的urls.py文件并添加以下加粗的 URL 模式:

urlpatterns = [
    # Post views
    path('', views.post_list, name='post_list'),
    # path('', views.PostListView.as_view(), name='post_list'),
    path(
        'tag/<slug:tag_slug>/', views.post_list, name='post_list_by_tag'
 ),
    path(
        '<int:year>/<int:month>/<int:day>/<slug:post>/',
        views.post_detail,
        name='post_detail'
 ),
    path('<int:post_id>/share/', views.post_share, name='post_share'),
    path(
        '<int:post_id>/comment/', views.post_comment, name='post_comment'
 ),
    path('feed/', LatestPostsFeed(), name='post_feed'),
    **path(****'search/'****, views.post_search, name=****'post_search'****),**
] 

接下来,在浏览器中打开http://127.0.0.1:8000/blog/search/。您应该看到以下搜索表单:

图片

图 3.29:带有查询字段的表单,用于搜索帖子

输入一个查询并点击搜索按钮。您将看到搜索查询的结果,如下所示:

图片

图 3.30:对“jazz”术语的搜索结果

恭喜!您已经为您的博客创建了一个基本的搜索引擎。

词干提取和排名结果

词干提取是将单词还原为其词干、词根或基本形式的过程。搜索引擎使用词干提取来将索引词还原为词干,以便能够匹配屈折或派生词。例如,“music”、“musical”和“musicality”这些词可以被搜索引擎视为相似词。词干提取过程将每个搜索标记规范化为一个词素,这是一个词汇意义的单位,是构成一系列通过屈折相关联的词的基础。当创建搜索查询时,“music”、“musical”和“musicality”将转换为“music”。

Django 提供了一个SearchQuery类,用于将术语转换为搜索查询对象。默认情况下,术语会通过词干提取算法进行传递,这有助于您获得更好的匹配。

PostgreSQL 搜索引擎还会移除停用词,例如“a”、“the”、“on”和“of”。停用词是语言中常用词的集合。在创建搜索查询时,由于它们出现得太频繁,因此被认为与搜索不相关,所以会被移除。您可以在github.com/postgres/postgres/blob/master/src/backend/snowball/stopwords/english.stop找到 PostgreSQL 用于英语语言的停用词列表。

我们还希望按相关性对结果进行排序。PostgreSQL 提供了一个排名函数,它根据查询术语出现的频率以及它们之间的接近程度来排序结果。

编辑blog应用的views.py文件并添加以下导入:

from django.contrib.postgres.search import (
 SearchVector,
    SearchQuery,
    SearchRank
) 

然后,按照以下方式编辑post_search视图。新代码加粗显示:

def post_search(request):
    form = SearchForm()
    query = None
    results = []
    if 'query' in request.GET:
        form = SearchForm(request.GET)
        if form.is_valid():
            query = form.cleaned_data['query']
            **search_vector = SearchVector(****'title'****,** **'body'****)**
**search_query = SearchQuery(query)**
            results = (
                Post.published.annotate(
                    search=**search_vector,**
**rank=SearchRank(search_vector, search_query)**
                )
                .filter(search=**search_query**)
 **.order_by(****'-rank'****)**
            )
    return render(
 request,
 'blog/post/search.html',
 {
 'form': form,
 'query': query,
 'results': results
 }
    ) 

在前面的代码中,我们创建了一个SearchQuery对象,通过它过滤结果,并使用SearchRank按相关性排序结果。

您可以在浏览器中打开http://127.0.0.1:8000/blog/search/并测试不同的搜索以测试词干提取和排名。以下是一个按帖子标题和正文中单词django出现次数进行排名的示例:

图片

图 3.31:搜索术语“django”的结果

在不同语言中执行词干提取和移除停用词

我们可以设置SearchVectorSearchQuery以在任意语言中执行词干提取并移除停用词。我们可以向SearchVectorSearchQuery传递一个config属性来使用不同的搜索配置。这允许我们使用不同的语言解析器和词典。以下示例在西班牙语中执行词干提取并移除停用词:

search_vector = SearchVector('title', 'body'**, config=****'spanish'**)
search_query = SearchQuery(query**, config=****'spanish'**)
results = (
    Post.published.annotate(
        search=search_vector,
        rank=SearchRank(search_vector, search_query)
    )
    .filter(search=search_query)
    .order_by('-rank')
) 

你可以在github.com/postgres/postgres/blob/master/src/backend/snowball/stopwords/spanish.stop找到 PostgreSQL 使用的西班牙语停用词词典。

查询加权

我们可以增强特定的向量,以便在按相关性排序结果时赋予它们更高的权重。例如,我们可以使用此方法为标题匹配而非内容匹配的帖子赋予更高的相关性。

编辑blog应用的views.py文件,并按如下方式修改post_search视图。新代码以粗体显示:

def post_search(request):
    form = SearchForm()
    query = None
    results = []
    if 'query' in request.GET:
        form = SearchForm(request.GET)
        if form.is_valid():
            query = form.cleaned_data['query']
            search_vector = SearchVector(
                'title'**, weight=****'A'**
 **) + SearchVector(****'body'****, weight=****'B'****)**
            search_query = SearchQuery(query)
            results = (
                Post.published.annotate(
                    search=search_vector,
                    rank=SearchRank(search_vector, search_query)
                )
                .filter(**rank__gte=****0.3**)
                .order_by('-rank')
            )
    return render(
 request,
 'blog/post/search.html',
 {
 'form': form,
 'query': query,
 'results': results
 }
    ) 

在前面的代码中,我们为使用titlebody字段构建的搜索向量应用了不同的权重。默认权重是DCBA,分别对应数字0.10.20.41.0。我们将title搜索向量(A)的权重设置为1.0,将body向量(B)的权重设置为0.4。标题匹配将优于正文内容匹配。我们过滤结果,只显示排名高于0.3的结果。

使用三元组相似度搜索

另一种搜索方法是三元组相似度。三元组是三个连续字符的组合。你可以通过计算两个字符串共享的三元组数量来衡量它们的相似度。这种方法在衡量许多语言的单词相似度方面非常有效。

要在 PostgreSQL 中使用三元组,你首先需要安装pg_trgm数据库扩展。Django 提供了创建 PostgreSQL 扩展的数据库迁移操作。让我们添加一个迁移,在数据库中创建该扩展。

首先,在 shell 提示符中执行以下命令以创建一个空迁移:

python manage.py makemigrations --name=trigram_ext --empty blog 

这将创建一个空的迁移文件用于blog应用。你将看到以下输出:

Migrations for 'blog':
  blog/migrations/0005_trigram_ext.py 

编辑文件blog/migrations/0005_trigram_ext.py,并添加以下以粗体显示的行:

**from** **django.contrib.postgres.operations** **import** **TrigramExtension**
from django.db import migrations
class Migration(migrations.Migration):
    dependencies = [
        ('blog', '0004_post_tags'),
    ]
    operations = [
        **TrigramExtension()**
    ] 

你已经将TrigramExtension操作添加到数据库迁移中。此操作执行 SQL 语句CREATE EXTENSION pg_trgm以在 PostgreSQL 中创建扩展。

你可以在docs.djangoproject.com/en/5.0/ref/contrib/postgres/operations/找到更多关于数据库迁移操作的信息。

现在执行以下命令来执行迁移:

python manage.py migrate blog 

你将看到以下输出:

Running migrations:
  Applying blog.0005_trigram_ext... OK 

数据库中已创建 pg_trgm 扩展。让我们修改 post_search 以搜索三字母组合。

编辑您的 blog 应用程序的 views.py 文件并添加以下导入:

from django.contrib.postgres.search import TrigramSimilarity 

然后,按照以下方式修改 post_search 视图。新代码以粗体突出显示:

def post_search(request):
    form = SearchForm()
    query = None
    results = []
    if 'query' in request.GET:
        form = SearchForm(request.GET)
        if form.is_valid():
            query = form.cleaned_data['query']
            results = (
                Post.published.annotate(
                    **similarity=TrigramSimilarity(****'title'****, query),**
                )
                .filter(**similarity__gt=****0.1**)
                .order_by(**'-similarity'**)
            )
    return render(
 request,
 'blog/post/search.html',
 {
 'form': form,
 'query': query,
 'results': results
 }
    ) 

在您的浏览器中打开 http://127.0.0.1:8000/blog/search/ 并测试不同的三字母组合搜索。以下示例显示了在 django 术语中的假设错误,显示了 yango 的搜索结果:

图片

图 3.32:术语“yango”的搜索结果

我们已经将一个强大的搜索引擎添加到了博客应用程序中。

您可以在 docs.djangoproject.com/en/5.0/ref/contrib/postgres/search/ 找到有关 Django 和 PostgreSQL 全文搜索的更多信息。

摘要

在本章中,您通过将第三方应用程序集成到项目中实现了标签系统。您使用复杂的查询集生成了帖子推荐。您还学习了如何创建自定义 Django 模板标签和过滤器,以向模板提供自定义功能。您还创建了一个搜索引擎网站地图以供搜索引擎抓取您的网站,以及一个 RSS 订阅源供用户订阅您的博客。然后,您使用 PostgreSQL 的全文搜索引擎为您的博客构建了一个搜索引擎。

在下一章中,您将学习如何使用 Django 认证框架构建社交网站,以及如何实现用户账户功能和自定义用户资料。

使用 AI 扩展您的项目

完成博客应用程序后,您可能有很多想法来为您的博客添加新的功能。本节旨在通过 ChatGPT 的帮助,为您提供将新功能纳入项目的见解。ChatGPT 是由 OpenAI 创建的复杂 AI 大型语言模型LLM),它根据收到的提示生成类似人类的响应。在本节中,您将面临一个扩展项目的任务,并附有 ChatGPT 的示例提示以协助您。

chat.openai.com/ 与 ChatGPT 互动。在完成本书中的每个 Django 项目后,您将在 第七章跟踪用户行为第十一章为您的商店添加国际化,以及 第十七章上线 中找到类似的指导。

让我们借助 ChatGPT 进一步丰富你的博客。你的博客目前允许通过标签过滤帖子。将这些标签添加到我们的站点地图中可以显著提高博客的 SEO 优化。使用提供的提示github.com/PacktPublishing/Django-5-by-example/blob/main/Chapter03/prompts/task.md将标签页面添加到站点地图。这个挑战是完善你的项目并深化你对 Django 理解的好机会,同时学习如何与 ChatGPT 交互。

ChatGPT 准备协助解决代码问题。只需分享你的代码以及你遇到的所有错误,ChatGPT 可以帮助你识别和解决这些问题。

其他资源

以下资源提供了与本章涵盖主题相关的额外信息:

第四章:构建一个社交网站

在上一章中,你学习了如何实现标签系统以及如何推荐相似帖子。你实现了自定义模板标签和过滤器。你还学习了如何为你的网站创建网站地图和源,并使用 PostgreSQL 构建了一个全文搜索引擎。

在本章中,你将学习如何开发用户账户功能以创建一个社交网站,包括用户注册、密码管理、个人资料编辑和认证。我们将在接下来的几章中实现这个网站的社会功能,让用户分享图片并相互互动。用户将能够将任何互联网上的图片添加到书签并与其他用户分享。他们还将能够看到他们关注的用户在平台上的活动,以及他们喜欢/不喜欢他们分享的图片。

本章将涵盖以下主题:

  • 创建登录视图

  • 使用 Django 认证框架

  • 为 Django 登录、注销、密码更改和密码重置视图创建模板

  • 创建用户注册视图

  • 使用自定义个人资料模型扩展用户模型

  • 配置项目以支持媒体文件上传

功能概述

图 4.1展示了本章将要构建的视图、模板和功能:

图片

图 4.1:第四章中构建的功能图

在本章中,你将创建一个新的项目,并使用 Django 在django.contrib.auth包中提供的登录、注销、密码更改和密码恢复视图。你将为认证视图创建模板,并创建一个dashboard视图,用户在成功认证后可以访问。你将使用register视图实现用户注册。最后,你将通过自定义Profile模型扩展用户模型,并创建edit视图以允许用户编辑他们的个人资料。

本章的源代码可以在github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter04找到。

本章中使用的所有 Python 包都包含在章节源代码中的requirements.txt文件中。你可以按照以下章节中的说明安装每个 Python 包,或者你可以使用命令python -m pip install -r requirements.txt一次性安装所有需求。

创建一个社交网站项目

我们将创建一个社交应用程序,允许用户分享他们在互联网上找到的图片。这个项目是相关的,因为它将帮助你了解如何将社交功能集成到你的网站中,以及如何使用 Django 和 JavaScript 实现高级功能。

对于我们的图片分享网站,我们需要构建以下元素:

  • 一个用户注册、登录、编辑个人资料、更改或重置密码的认证系统

  • 社交认证,使用 Google 等服务登录

  • 显示共享图片的功能以及一个系统,允许用户从任何网站分享图片

  • 一个活动流,允许用户查看他们关注的用户上传的内容

  • 一个关注系统,允许用户在网站上相互关注

本章将解决列表上的第一个问题。其余的问题将在第五章到第七章中介绍。

开始社交网站项目

我们将首先为项目设置虚拟环境并创建初始项目结构。

打开终端并使用以下命令为您的项目创建虚拟环境:

mkdir env
python -m venv env/bookmarks 

如果您使用的是 Linux 或 macOS,请运行以下命令以激活您的虚拟环境:

source env/bookmarks/bin/activate 

如果您使用的是 Windows,请使用以下命令代替:

.\env\bookmarks\Scripts\activate 

壳提示将显示您的活动虚拟环境,如下所示:

(bookmarks)laptop:~ zenx$ 

使用以下命令在您的虚拟环境中安装 Django:

python -m pip install Django~=5.0.4 

运行以下命令以创建一个新的项目:

django-admin startproject bookmarks 

初始项目结构已创建。使用以下命令进入您的项目目录并创建一个名为 account 的新应用程序:

cd bookmarks/
django-admin startapp account 

请记住,您应该通过将应用程序的名称添加到 settings.py 文件中的 INSTALLED_APPS 设置中来将新应用程序添加到您的项目中。

编辑 settings.py 并在 INSTALLED_APPS 列表中添加以下加粗行,在所有其他已安装的应用程序之前:

INSTALLED_APPS = [
**'account.apps.AccountConfig'****,**
'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
] 

Django 按照在 INSTALLED_APPS 设置中出现的顺序在应用程序模板目录中查找模板。django.contrib.admin 应用包含标准认证模板,我们将在 account 应用中覆盖这些模板。通常,我们将自己的应用程序放在列表的末尾。在这种情况下,我们将应用程序放在 INSTALLED_APPS 设置的第一位,以确保我们的自定义认证模板将被使用,而不是 django.contrib.admin 中包含的认证模板。

运行以下命令以将数据库与 INSTALLED_APPS 设置中包含的默认应用程序的模型同步:

python manage.py migrate 

您将看到所有初始 Django 数据库迁移都将应用。对应于已安装应用程序的 Django 模型的数据库表已创建。接下来,我们将使用 Django 认证框架将认证系统构建到我们的项目中。

使用 Django 认证框架

Django 内置了认证框架,可以处理用户认证、会话、权限和用户组。认证系统包括用于常见用户操作的视图,如登录、登出、密码更改和密码重置。

认证框架位于django.contrib.auth,并被其他 Django contrib包使用。请记住,我们在第一章构建博客应用中已经使用了认证框架来为博客应用创建一个超级用户,以便访问管理站点。

当我们使用startproject命令创建一个新的 Django 项目时,认证框架包含在我们项目的默认设置中。它由django.contrib.auth应用和位于我们项目MIDDLEWARE设置中的以下两个中间件类组成:

  • AuthenticationMiddleware:使用会话将用户与请求关联

  • SessionMiddleware:处理请求之间的当前会话

中间件是具有在请求或响应阶段全局执行的方法的类。您将在本书的多个地方使用中间件类,您将在第十七章上线中学习如何创建自定义中间件。

认证框架还包括以下模型,这些模型在django.contrib.auth.models中定义:

  • User:一个具有基本字段的用户模型;此模型的主要字段是usernamepasswordemailfirst_namelast_nameis_active

  • Group:一个用于对用户进行分类的用户组模型

  • Permission:为用户或组执行某些操作的标志

该框架还包括默认的认证视图和表单,您将在稍后使用。

创建登录视图

我们将使用 Django 认证框架开始本节,允许用户登录网站。我们将创建一个视图,执行以下操作以登录用户:

  1. 向用户展示登录表单

  2. 获取用户在提交表单时提供的用户名和密码

  3. 将用户与数据库中存储的数据进行认证

  4. 检查用户是否活跃

  5. 将用户登录到网站并启动一个认证会话

我们将首先创建登录表单。

account应用目录中创建一个新的forms.py文件,并向其中添加以下行:

from django import forms
class LoginForm(forms.Form):
    username = forms.CharField()
    password = forms.CharField(widget=forms.PasswordInput) 

此表单将用于对数据库中的用户进行认证。PasswordInput小部件用于渲染password HTML 元素。这将包括type="password"在 HTML 中,以便浏览器将其视为密码输入。

编辑account应用的views.py文件,并向其中添加以下代码:

from django.contrib.auth import authenticate, login
from django.http import HttpResponse
from django.shortcuts import render
from .forms import LoginForm
def user_login(request):
    if request.method == 'POST':
        form = LoginForm(request.POST)
        if form.is_valid():
            cd = form.cleaned_data
            user = authenticate(
                request,
                username=cd['username'],
                password=cd['password']
            )
            if user is not None:
                if user.is_active:
                    login(request, user)
                    return HttpResponse('Authenticated successfully')
                else:
                    return HttpResponse('Disabled account')
            else:
                return HttpResponse('Invalid login')
    else:
        form = LoginForm()
    return render(request, 'account/login.html', {'form': form}) 

这是基本登录视图所执行的操作:

当使用GET请求调用user_login视图时,将使用form = LoginForm()实例化一个新的登录表单。然后,该表单被传递到模板中。

当用户通过POST提交表单时,将执行以下操作:

使用form = LoginForm(request.POST)使用提交的数据实例化表单。

使用form.is_valid()对表单进行验证。如果它无效,表单错误将在模板中稍后显示(例如,如果用户没有填写其中一个字段)。

如果提交的数据有效,则使用authenticate()方法对数据库中的用户进行认证。此方法接受request对象、usernamepassword参数,如果用户成功认证,则返回User对象,否则返回None。如果用户未成功认证,则返回一个包含无效登录消息的原始HttpResponse

如果用户成功认证,通过访问is_active属性检查用户状态。这是 Django 用户模型的一个属性。如果用户不是活跃的,则返回一个包含账户已禁用消息的HttpResponse

如果用户是活跃的,用户将登录到网站。通过调用login()方法将用户设置在会话中。返回一个认证成功的消息。

注意authenticate()login()之间的区别:authenticate()验证用户的凭据,并在验证通过后返回代表已验证用户的User对象。相比之下,login()通过将已验证的User对象纳入当前会话上下文中,将用户设置在当前会话中。

现在,我们将为这个视图创建一个 URL 模式:

account应用程序目录中创建一个新的urls.py文件,并向其中添加以下代码:

from django.urls import path
from . import views
urlpatterns = [
    path('login/', views.user_login, name='login'),
] 

编辑位于你的bookmarks项目目录中的主urls.py文件,导入include,并添加account应用程序的 URL 模式,如下所示。新的代码以粗体显示:

from django.contrib import admin
from django.urls import **include,** path
urlpatterns = [
    path('admin/', admin.site.urls),
    **path(****'account/'****, include(****'account.urls'****)),**
] 

现在可以通过 URL 访问登录视图。

让我们为这个视图创建一个模板。由于项目中还没有模板,我们将首先创建一个基础模板,该模板将被登录模板扩展:

account应用程序目录内创建以下文件和目录:

templates/
    account/
        login.html
    base.html 

编辑base.html模板,并向其中添加以下代码:

{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% endblock %}</title>
<link href="{% static "css/base.css" %}" rel="stylesheet">
</head>
<body>
<div id="header">
<span class="logo">Bookmarks</span>
</div>
<div id="content">
    {% block content %}
    {% endblock %}
  </div>
</body>
</html> 

这将是网站的基模板。就像你在上一个项目中做的那样,在主模板中包含 CSS 样式。你可以在本章的代码中找到这些静态文件。将章节源代码中的account应用程序的static/目录复制到你的项目中的相同位置,以便你可以使用静态文件。你可以在这个目录的github.com/PacktPublishing/Django-5-by-Example/tree/master/Chapter04/bookmarks/account/static找到目录内容。

基模板定义了一个title块和一个content块,这些块可以通过扩展它的模板填充内容。

让我们填写登录表单的模板。

打开account/login.html模板,并向其中添加以下代码:

{% extends "base.html" %}
{% block title %}Log-in{% endblock %}
{% block content %}
  <h1>Log-in</h1>
<p>Please, use the following form to log-in:</p>
<form method="post">
    {{ form.as_p }}
    {% csrf_token %}
    <p><input type="submit" value="Log in"></p>
</form>
{% endblock %} 

此模板包括在视图中实例化的表单。由于您的表单将通过POST提交,因此您将包括{% csrf_token %}模板标签以进行跨站请求伪造CSRF)保护。您在第二章通过高级功能增强您的博客中学习了 CSRF 保护。

数据库中还没有用户。您需要首先创建一个超级用户,以便访问管理站点来管理其他用户。

在 shell 提示符中执行以下命令:

python manage.py createsuperuser 

您将看到以下输出。按照以下方式输入您想要的用户名、电子邮件和密码:

Username (leave blank to use 'admin'): admin
Email address: admin@admin.com
Password: ********
Password (again): ******** 

然后,您将看到以下成功消息:

Superuser created successfully. 

使用以下命令运行开发服务器:

python manage.py runserver 

在您的浏览器中打开http://127.0.0.1:8000/admin/。使用您刚刚创建的用户凭据访问管理站点。您将看到 Django 管理站点,包括 Django 认证框架的UserGroup模型。

它看起来如下:

图 4.2:包括用户和组的 Django 管理站点索引页面

用户行中,点击添加链接。

使用管理站点创建新用户如下:

图 4.3:Django 管理站点的添加用户表单

输入用户详细信息并点击保存按钮以将新用户保存到数据库中。

然后,在个人信息中,按照以下方式填写名字姓氏电子邮件地址字段,然后点击保存按钮以保存更改:

图 4.4:Django 管理站点的用户编辑表单

在您的浏览器中打开http://127.0.0.1:8000/account/login/。您应该看到渲染的模板,包括登录表单:

图形用户界面,文本,应用程序  自动生成的描述

图 4.5:用户登录页面

输入无效凭据并提交表单。您应该得到以下无效登录响应:

图 4.6:无效登录的纯文本响应

输入有效凭据;您将得到以下认证成功响应:

图 4.7:成功的认证纯文本响应

现在,您已经学会了如何认证用户并创建自己的认证视图。您可以构建自己的认证视图,但 Django 提供了现成的认证视图,您可以利用它们。

使用 Django 的内置认证视图

Django 在认证框架中包含了几种表单和视图,您可以直接使用。我们创建的登录视图是一个很好的练习,可以帮助您理解 Django 中用户认证的过程。然而,在大多数情况下,您可以使用默认的 Django 认证视图。

Django 提供了以下基于类的视图来处理认证。所有这些视图都位于django.contrib.auth.views

  • LoginView: 处理登录表单并登录用户

  • LogoutView: 注销用户

Django 提供了以下视图来处理密码更改:

  • PasswordChangeView: 处理更改用户密码的表单

  • PasswordChangeDoneView: 用户在成功更改密码后将被重定向到的成功视图

Django 还包括以下视图,以允许用户重置他们的密码:

  • PasswordResetView: 允许用户重置密码。它生成一个一次性使用的链接并发送到用户的电子邮件账户

  • PasswordResetDoneView: 告诉用户已向他们发送了一封电子邮件——包括重置密码的链接

  • PasswordResetConfirmView: 允许用户设置新密码

  • PasswordResetCompleteView: 用户成功重置密码后将被重定向到的成功视图

这些视图在构建任何带有用户账户的 Web 应用时可以为您节省大量时间。这些视图使用默认值,这些值可以被覆盖,例如要渲染的模板的位置或视图使用的表单。

您可以在docs.djangoproject.com/en/5.0/topics/auth/default/#all-authentication-views了解更多关于内置认证视图的信息。

登录和注销视图

要了解如何使用 Django 的认证视图,我们将用 Django 的内置等价视图替换我们的自定义登录视图,并集成一个注销视图。

编辑account应用的urls.py文件,并添加以下加粗代码:

**from** **django.contrib.auth** **import** **views** **as** **auth_views**
from django.urls import path
from . import views
urlpatterns = [
**# previous login url**
**#** path('login/', views.user_login, name='login'),
**# login / logout urls**
 **path(****'login/'****, auth_views.LoginView.as_view(), name=****'login'****),**
 **path(****'logout/'****, auth_views.LogoutView.as_view(), name=****'logout'****),**
] 

在前面的代码中,我们已注释掉我们之前创建的user_login视图的 URL 模式。现在我们将使用 Django 认证框架的LoginView视图。我们还添加了一个LogoutView视图的 URL 模式。

account应用的templates/目录内创建一个新的目录,命名为registration。这是 Django 认证视图默认期望您的认证模板所在的路径。

django.contrib.admin模块包括用于管理站点的认证模板,如登录模板。通过在配置项目时将account应用放置在INSTALLED_APPS设置的顶部,我们确保 Django 将使用我们的认证模板而不是任何其他应用中定义的模板。

templates/registration/目录内创建一个新的文件,命名为login.html,并将以下代码添加到其中:

{% extends "base.html" %}
{% block title %}Log-in{% endblock %}
{% block content %}
  <h1>Log-in</h1>
  {% if form.errors %}
    <p>
      Your username and password didn't match.
      Please try again.
    </p>
  {% else %}
    <p>Please, use the following form to log-in:</p>
  {% endif %}
  <div class="login-form">
<form action="{% url 'login' %}" method="post">
      {{ form.as_p }}
      {% csrf_token %}
      <input type="hidden" name="next" value="{{ next }}" />
<p><input type="submit" value="Log-in"></p>
</form>
</div>
{% endblock %} 

这个登录模板与我们之前创建的模板非常相似。Django 默认使用位于django.contrib.auth.formsAuthenticationForm表单。此表单尝试验证用户,如果登录失败,则引发验证错误。我们在模板中使用{% if form.errors %}来检查提供的凭据是否错误。

我们添加了一个隐藏的 HTML <input>元素来提交名为next的变量的值。如果你在请求中传递一个名为next的参数,这个变量就会提供给登录视图,例如,通过访问http://127.0.0.1:8000/account/login/?next=/account/

next参数必须是一个 URL。如果提供了此参数,Django 登录视图将在登录成功后将用户重定向到指定的 URL。

现在,在templates/registration/目录内创建一个名为logged_out.html的模板,并使其看起来像这样:

{% extends "base.html" %}
{% block title %}Logged out{% endblock %}
{% block content %}
  <h1>Logged out</h1>
<p>
    You have been successfully logged out.
    You can <a href="{% url "login" %}">log-in again</a>.
  </p>
{% endblock %} 

这是用户登出后 Django 将显示的模板。

我们为登录和注销视图添加了 URL 模式和模板。现在,用户可以使用 Django 的认证视图登录和注销。

现在,我们将创建一个新的视图来在用户登录账户时显示仪表板。

编辑account应用的views.py文件,并向其中添加以下代码:

from django.contrib.auth.decorators import login_required
@login_required
def dashboard(request):
    return render(
 request,
 'account/dashboard.html',
 {'section': 'dashboard'}
 ) 

我们创建了dashboard视图,并将其应用于认证框架的login_required装饰器。login_required装饰器检查当前用户是否已认证。

如果用户已认证,它将执行装饰过的视图;如果用户未认证,它将重定向用户到登录 URL,并将最初请求的 URL 作为名为nextGET参数。

通过这样做,登录视图将用户重定向到他们成功登录后尝试访问的 URL。记住,我们在登录模板中添加了一个名为next的隐藏<input> HTML 元素来达到这个目的。

我们还定义了一个section变量。我们将使用这个变量来突出显示网站主菜单中的当前部分。

接下来,我们需要为仪表板视图创建一个模板。

templates/account/目录内创建一个新文件,并将其命名为dashboard.html。向其中添加以下代码:

{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
  <h1>Dashboard</h1>
<p>Welcome to your dashboard.</p>
{% endblock %} 

编辑account应用的urls.py文件,并为视图添加以下 URL 模式。新的代码以粗体显示:

urlpatterns = [
    # previous login url
# path('login/', views.user_login, name='login'),
# login / logout urls
    path('login/', auth_views.LoginView.as_view(), name='login'),
    path('logout/', auth_views.LogoutView.as_view(), name='logout'),
 **path(****''****, views.dashboard, name=****'dashboard'****),**
] 

编辑项目的settings.py文件,并向其中添加以下代码:

LOGIN_REDIRECT_URL = 'dashboard'
LOGIN_URL = 'login'
LOGOUT_URL = 'logout' 

我们定义了以下设置:

  • LOGIN_REDIRECT_URL:告诉 Django 在请求中没有next参数时,登录成功后要将用户重定向到哪个 URL

  • LOGIN_URL:重定向用户登录的 URL(例如,使用login_required装饰器的视图)

  • LOGOUT_URL:重定向用户注销的 URL

我们在 URL 模式中使用了之前定义的 URL 的name属性。也可以使用硬编码的 URL 而不是 URL 名称来设置这些参数。

让我们总结一下到目前为止我们所做的工作:

  • 我们将内置的 Django 认证登录和注销视图添加到了项目中。

  • 我们为两个视图创建了自定义模板,并定义了一个简单的仪表板视图,用于用户登录后重定向。

  • 最后,我们为 Django 添加了默认使用这些 URL 的设置。

现在,我们将向基本模板添加一个指向登录 URL 的链接和一个注销按钮。为了做到这一点,我们必须确定当前用户是否已登录,以便为每种情况显示适当的操作。当前用户由认证中间件设置在HttpRequest对象中。你可以通过request.user访问它。即使用户未认证,request对象也包含一个User对象。检查当前用户是否认证的最佳方式是通过访问只读属性is_authenticated

通过添加以下加粗的行来编辑templates/base.html模板:

{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% endblock %}</title>
<link href="{% static "css/base.css" %}" rel="stylesheet">
</head>
<body>
<div id="header">
<span class="logo">Bookmarks</span>
 **{% if request.user.is_authenticated %}**
**<****ul****class****=****"menu"****>**
**<****li** **{%** **if****section** **==** **"dashboard"** **%}****class****=****"selected"****{%** **endif** **%}>**
**<****a****href****=****"{% url "****dashboard****" %}">****My dashboard****</****a****>**
**</****li****>**
**<****li** **{%** **if****section** **==** **"images"** **%}****class****=****"selected"****{%** **endif** **%}>**
**<****a****href****=****"#"****>****Images****</****a****>**
**</****li****>**
**<****li** **{%** **if****section** **==** **"people"** **%}****class****=****"selected"****{%** **endif** **%}>**
**<****a****href****=****"#"****>****People****</****a****>**
**</****li****>**
**</****ul****>**
 **{% endif %}**
**<****span****class****=****"user"****>**
 **{% if request.user.is_authenticated %}**
 **Hello {{ request.user.first_name|default:request.user.username }},**
**<****form****action****=****"{% url "****logout****" %}"** **method****=****"post"****>**
**<****button****type****=****"submit"****>****Logout****</****button****>**
 **{% csrf_token %}**
**</****form****>**
 **{% else %}**
**<****a****href****=****"{% url "****login****" %}">****Log-in****</****a****>**
 **{% endif %}**
**</****span****>**
</div>
<div id="content">
    {% block content %}
    {% endblock %}
  </div>
</body>
</html> 

网站的菜单只对认证用户显示。检查section变量以向当前部分的菜单<li>列表项添加selected类属性。这样做,与当前部分对应的菜单项将通过 CSS 突出显示。如果用户已认证,将显示用户的姓名和一个注销按钮;否则,将显示一个登录链接。如果用户的姓名为空,则使用request.user.first_name|default:request.user.username显示用户名。请注意,对于注销操作,我们使用方法为POST的表单和一个提交表单的按钮。这是因为LogoutView需要POST请求。

在你的浏览器中打开http://127.0.0.1:8000/account/login/。你应该看到登录页面。输入有效的用户名和密码,然后点击登录按钮。你应该看到以下屏幕:

图形用户界面  描述自动生成,置信度中等

图 4.8:仪表板页面

我的仪表板菜单项通过 CSS 突出显示,因为它有一个selected类。由于用户已认证,用户的姓名现在显示在页眉的右侧。点击注销按钮。你应该看到以下页面:

图形用户界面,应用程序  描述自动生成

图 4.9:注销页面

在这个页面上,你可以看到用户已经注销,因此,网站菜单没有显示。页眉右侧现在显示的是登录链接。

如果你看到的是 Django 管理网站的注销页面而不是你自己的注销页面,请检查你项目的INSTALLED_APPS设置,并确保django.contrib.adminaccount应用之后。这两个应用都包含位于相同相对路径的注销模板。Django 模板加载器会遍历INSTALLED_APPS列表中的不同应用,并使用它找到的第一个模板。

修改密码视图

我们需要用户在登录网站后能够更改他们的密码。我们将集成 Django 认证视图来更改密码。

打开 account 应用的 urls.py 文件,并添加以下加粗的 URL 模式:

urlpatterns = [
    # previous login url
# path('login/', views.user_login, name='login'),
# login / logout urls
    path('login/', auth_views.LoginView.as_view(), name='login'),
    path('logout/', auth_views.LogoutView.as_view(), name='logout'),
**# change password urls**
 **path(**
**'password-change/'****,**
 **auth_views.PasswordChangeView.as_view(),**
 **name=****'password_change'**
 **),**
 **path(**
**'password-change/done/'****,**
 **auth_views.PasswordChangeDoneView.as_view(),**
 **name=****'password_change_done'**
 **),**
    path('', views.dashboard, name='dashboard'),
] 

PasswordChangeView 视图将处理更改密码的表单,而 PasswordChangeDoneView 视图将在用户成功更改密码后显示成功消息。让我们为每个视图创建一个模板。

account 应用的 templates/registration/ 目录内添加一个新文件,并将其命名为 password_change_form.html。向其中添加以下代码:

{% extends "base.html" %}
{% block title %}Change your password{% endblock %}
{% block content %}
  <h1>Change your password</h1>
<p>Use the form below to change your password.</p>
<form method="post">
    {{ form.as_p }}
    <p><input type="submit" value="Change"></p>
    {% csrf_token %}
  </form>
{% endblock %} 

password_change_form.html 模板包含了更改密码的表单。

现在,在同一个目录下创建另一个文件,并将其命名为 password_change_done.html。向其中添加以下代码:

{% extends "base.html" %}
{% block title %}Password changed{% endblock %}
{% block content %}
  <h1>Password changed</h1>
<p>Your password has been successfully changed.</p>
{% endblock %} 

password_change_done.html 模板仅包含当用户成功更改密码时显示的成功消息。

在浏览器中打开 http://127.0.0.1:8000/account/password-change/。如果您未登录,浏览器将重定向到 登录 页面。成功认证后,您将看到以下更改密码页面:

图形用户界面,文本,应用程序,电子邮件,描述自动生成

图 4.10:更改密码表单

使用您当前密码和新密码填写表单,然后点击 更改 按钮。您将看到以下成功页面:

图形用户界面,文本,应用程序,描述自动生成

图 4.11:成功更改密码页面

使用新密码注销并重新登录,以验证一切是否按预期工作。

重置密码视图

如果用户忘记密码,他们应该能够恢复账户。我们将实现密码重置功能。这将使用户能够通过接收包含安全链接的密码重置电子邮件来恢复对账户的访问,该链接使用唯一的令牌生成,允许他们创建新密码。

编辑 account 应用的 urls.py 文件,并添加以下加粗的 URL 模式:

urlpatterns = [
    # previous login url
# path('login/', views.user_login, name='login'),
# login / logout urls
    path('login/', auth_views.LoginView.as_view(), name='login'),
    path('logout/', auth_views.LogoutView.as_view(), name='logout'),

    # change password urls
    path(
        'password-change/',
        auth_views.PasswordChangeView.as_view(),
        name='password_change'
 ),
    path(
        'password-change/done/',
        auth_views.PasswordChangeDoneView.as_view(),
        name='password_change_done'
 ),
**# reset password urls**
 **path(**
**'password-reset/'****,**
 **auth_views.PasswordResetView.as_view(),**
 **name=****'password_reset'**
 **),**
 **path(**
**'password-reset/done/'****,**
 **auth_views.PasswordResetDoneView.as_view(),**
 **name=****'password_reset_done'**
 **),**
 **path(**
**'****password-reset/<uidb64>/<token>/'****,**
 **auth_views.PasswordResetConfirmView.as_view(),**
 **name=****'password_reset_confirm'**
 **),**
 **path(****'password-reset/complete/'****,**
 **auth_views.PasswordResetCompleteView.as_view(),**
 **name=****'password_reset_complete'**
 **),**
    path('', views.dashboard, name='dashboard'),
] 

account 应用的 templates/registration/ 目录中添加一个新文件,并将其命名为 password_reset_form.html。向其中添加以下代码:

{% extends "base.html" %}
{% block title %}Reset your password{% endblock %}
{% block content %}
  <h1>Forgotten your password?</h1>
<p>Enter your e-mail address to obtain a new password.</p>
<form method="post">
    {{ form.as_p }}
    <p><input type="submit" value="Send e-mail"></p>
    {% csrf_token %}
  </form>
{% endblock %} 

现在,在同一个目录下创建另一个文件,并将其命名为 password_reset_email.html。向其中添加以下代码:

Someone asked for password reset for email {{ email }}. Follow the link below:
{{ protocol }}://{{ domain }}{% url "password_reset_confirm" uidb64=uid token=token %}
Your username, in case you've forgotten: {{ user.get_username }} 

password_reset_email.html 模板将用于渲染发送给用户以重置密码的电子邮件。它包含一个由视图生成的重置令牌。

在同一个目录下创建另一个文件,并将其命名为 password_reset_done.html。向其中添加以下代码:

{% extends "base.html" %}
{% block title %}Reset your password{% endblock %}
{% block content %}
  <h1>Reset your password</h1>
<p>We've emailed you instructions for setting your password.</p>
<p>If you don't receive an email, please make sure you've entered the address you registered with.</p>
{% endblock %} 

在同一个目录中创建另一个模板,并将其命名为 password_reset_confirm.html。向其中添加以下代码:

{% extends "base.html" %}
{% block title %}Reset your password{% endblock %}
{% block content %}
  <h1>Reset your password</h1>
  {% if validlink %}
    <p>Please enter your new password twice:</p>
<form method="post">
      {{ form.as_p }}
      {% csrf_token %}
      <p><input type="submit" value="Change my password" /></p>
</form>
  {% else %}
    <p>The password reset link was invalid, possibly because it has already been used. Please request a new password reset.</p>
  {% endif %}
{% endblock %} 

在此模板中,我们通过检查 validlink 变量来确认重置密码链接的有效性。PasswordResetConfirmView 视图检查 URL 中提供的令牌的有效性,并将 validlink 变量传递给模板。如果链接有效,则显示用户密码重置表单。用户只有拥有有效的重置密码链接才能设置新密码。

创建另一个模板,并将其命名为 password_reset_complete.html。将以下代码输入其中:

{% extends "base.html" %}
{% block title %}Password reset{% endblock %}
{% block content %}
  <h1>Password set</h1>
<p>Your password has been set. You can <a href="{% url "login" %}">log in now</a></p>
{% endblock %} 

最后,编辑 account 应用程序的 registration/login.html 模板,并添加以下加粗的行:

{% extends "base.html" %}
{% block title %}Log-in{% endblock %}
{% block content %}
  <h1>Log-in</h1>
  {% if form.errors %}
    <p>
      Your username and password didn't match.
      Please try again.
    </p>
  {% else %}
    <p>Please, use the following form to log-in:</p>
  {% endif %}
  <div class="login-form">
<form action="{% url 'login' %}" method="post">
      {{ form.as_p }}
      {% csrf_token %}
      <input type="hidden" name="next" value="{{ next }}" />
<p><input type="submit" value="Log-in"></p>
</form>
**<****p****>**
**<****a****href****=****"{% url "****password_reset****" %}">**
 **Forgotten your password?**
**</****a****>**
**</****p****>**
</div>
{% endblock %} 

现在,在你的浏览器中打开 http://127.0.0.1:8000/account/login/。登录页面现在应该包含一个指向重置密码页面的链接,如下所示:

图形用户界面,文本,应用程序,电子邮件  自动生成的描述

图 4.12:登录页面,包括重置密码页面的链接

点击 忘记密码? 链接。你应该看到以下页面:

图形用户界面,文本,应用程序  自动生成的描述

图 4.13:恢复密码表单

在这一点上,我们需要将一个 简单邮件传输协议SMTP)配置添加到你的项目中的 settings.py 文件,以便 Django 能够发送电子邮件。你已经在 第二章通过高级功能增强你的博客 中学习了如何将电子邮件设置添加到你的项目中。然而,在开发过程中,你可以配置 Django 将电子邮件写入标准输出而不是通过 SMTP 服务器发送。Django 提供了一个电子邮件后端,可以将电子邮件写入控制台。

编辑你的项目中的 settings.py 文件,并向其中添加以下行:

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 

EMAIL_BACKEND 设置指示将用于发送电子邮件的类。

返回浏览器,输入现有用户的电子邮件地址,然后点击 发送电子邮件 按钮。你应该看到以下页面:

图形用户界面,文本,应用程序  自动生成的描述

图 4.14:发送重置密码电子邮件的页面

查看运行开发服务器的 shell 提示符。你会看到生成的电子邮件,如下所示:

Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: Password reset on 127.0.0.1:8000
From: webmaster@localhost
To: test@gmail.com
Date: Mon, 10 Jan 2024 19:05:18 -0000
Message-ID: <162896791878.58862.14771487060402279558@MBP-amele.local>
Someone asked for password reset for email test@gmail.com. Follow the link below:
http://127.0.0.1:8000/account/password-reset/MQ/ardx0u-b4973cfa2c70d652a190e79054bc479a/
Your username, in case you've forgotten: test 

该电子邮件使用你之前创建的 password_reset_email.html 模板进行渲染。重置密码的 URL 包含 Django 动态生成的令牌。

从电子邮件中复制 URL,它应该类似于 http://127.0.0.1:8000/account/password-reset/MQ/ardx0u-b4973cfa2c70d652a190e79054bc479a/,并在浏览器中打开它。你应该看到以下页面:

图形用户界面,文本,应用程序,聊天或短信,电子邮件  自动生成的描述

图 4.15:重置密码表单

设置新密码的页面使用 password_reset_confirm.html 模板。填写新密码并点击 更改我的密码 按钮。

Django 将创建一个新的哈希密码并将其保存在数据库中。你将看到以下成功页面:

图形用户界面,应用程序描述自动生成

图 4.16:密码重置成功的页面

现在,你可以使用新密码重新登录用户账户。

每个用于设置新密码的令牌只能使用一次。如果你再次打开你收到的链接,你会收到一个消息,说明令牌无效。

我们现在已将 Django 认证框架的视图集成到项目中。这些视图适用于大多数情况。然而,如果你需要不同的行为,你可以创建自己的视图。

Django 为认证视图提供了 URL 模式,这些模式与我们刚刚创建的模式等效。我们将用 Django 提供的 URL 模式替换认证 URL 模式。

将你添加到account应用urls.py文件中的认证 URL 模式注释掉,并包含django.contrib.auth.urls,如下所示。新的代码以粗体突出显示:

from django.urls import **include,** path
from django.contrib.auth import views as auth_views
from . import views
urlpatterns = [
    # previous login view
# path('login/', views.user_login, name='login'),
**#** path('login/', auth_views.LoginView.as_view(), name='login'),
**#** path('logout/', auth_views.LogoutView.as_view(), name='logout'),
# change password urls
**#** path(
**#**     'password-change/',
**#**     auth_views.PasswordChangeView.as_view(),
**#**     name='password_change'
**#** ),
**#** path(
**#**     'password-change/done/',
**#**     auth_views.PasswordChangeDoneView.as_view(),
**#**     name='password_change_done'
**#** ),
# reset password urls
**#** path(
**#**     'password-reset/',
**#**     auth_views.PasswordResetView.as_view(),
**#**     name='password_reset'
**#** ),
**#** path(
**#**     'password-reset/done/',
**#**     auth_views.PasswordResetDoneView.as_view(),
**#**     name='password_reset_done'
**#** ),
**#** path(
**#**     'password-reset/<uidb64>/<token>/',
**#**     auth_views.PasswordResetConfirmView.as_view(),
**#**     name='password_reset_confirm'
**#** ),
**#** path(
**#**     'password-reset/complete/',
**#**     auth_views.PasswordResetCompleteView.as_view(),
**#**     name='password_reset_complete'
**#** ),
**path(****''****, include(****'django.contrib.auth.urls'****)),**
    path('', views.dashboard, name='dashboard'),
] 

你可以在github.com/django/django/blob/stable/5.0.x/django/contrib/auth/urls.py中看到包含的认证 URL 模式。

我们现在已将所有必要的认证视图添加到我们的项目中。接下来,我们将实现用户注册。

用户注册和用户资料

网站用户现在可以登录、登出、更改密码和重置密码。然而,我们需要构建一个视图,允许访客创建用户账户。他们应该能够在我们的网站上注册并创建个人资料。一旦注册,用户将能够使用他们的凭证登录我们的网站。

用户注册

让我们创建一个简单的视图,允许在您的网站上注册用户。最初,你必须创建一个表单,让用户输入用户名、他们的真实姓名和密码。

编辑位于account应用目录内的forms.py文件,并添加以下以粗体突出显示的行:

from django import forms
**from** **django.contrib.auth** **import** **get_user_model**
class LoginForm(forms.Form):
    username = forms.CharField()
    password = forms.CharField(widget=forms.PasswordInput)
**class****UserRegistrationForm****(forms.ModelForm):**
 **password = forms.CharField(**
 **label=****'Password'****,**
 **widget=forms.PasswordInput**
 **)**
 **password2 = forms.CharField(**
 **label=****'Repeat password'****,**
 **widget=forms.PasswordInput**
 **)**
**class****Meta****:**
 **model = get_user_model()**
 **fields = [****'username'****,** **'first_name'****,** **'email'****]** 

我们为用户模型创建了一个表单。此表单包括用户模型的usernamefirst_nameemail字段。我们通过使用auth应用提供的get_user_model()函数动态检索用户模型。这检索了用户模型,它可能是一个自定义模型而不是默认的auth User模型,因为 Django 允许你定义自定义用户模型。这些字段将根据其对应模型字段的验证进行验证。例如,如果用户选择了一个已存在的用户名,他们将收到一个验证错误,因为username是一个定义为unique=True的字段。

为了保持代码的通用性,使用get_user_model()方法检索用户模型,并在定义模型与其的关系时使用AUTH_USER_MODEL设置来引用它,而不是直接引用auth用户模型。您可以在docs.djangoproject.com/en/5.0/topics/auth/customizing/#django.contrib.auth.get_user_model了解更多信息。

我们还添加了两个额外的字段——passwordpassword2——供用户设置密码并重复它。让我们添加字段验证来检查两个密码是否相同。

编辑account应用的forms.py文件,并在UserRegistrationForm类中添加以下clean_password2()方法。新的代码已加粗:

class UserRegistrationForm(forms.ModelForm):
    password = forms.CharField(
        label='Password',
        widget=forms.PasswordInput
    )
    password2 = forms.CharField(
        label='Repeat password',
        widget=forms.PasswordInput
    )
    class Meta:
        model = get_user_model()
        fields = ['username', 'first_name', 'email']
**def****clean_password2****(****self****):**
 **cd = self.cleaned_data**
**if** **cd[****'password'****] != cd[****'password2'****]:**
**raise** **forms.ValidationError(****"Passwords don't match."****)**
**return** **cd[****'password2'****]** 

我们定义了一个clean_password2()方法来比较第二个密码与第一个密码,如果密码不匹配则引发验证错误。该方法在调用其is_valid()方法验证表单时执行。您可以为任何表单字段提供一个clean_<fieldname>()方法来清理值或为特定字段引发表单验证错误。表单还包括一个通用的clean()方法来验证整个表单,这对于验证相互依赖的字段非常有用。在这种情况下,我们使用特定字段的clean_password2()验证而不是覆盖表单的clean()方法。这避免了覆盖ModelForm从模型中设置的约束(例如,验证用户名是否唯一)获取的其他特定字段检查。

Django 还提供了一个位于django.contrib.auth.forms中的UserCreationForm表单,与我们创建的非常相似。

编辑account应用的views.py文件,并添加以下加粗的代码:

from django.contrib.auth import authenticate, login
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render
from .forms import LoginForm**, UserRegistrationForm**
# ...
**def****register****(****request****):**
**if** **request.method ==** **'POST'****:**
 **user_form = UserRegistrationForm(request.POST)**
**if** **user_form.is_valid():**
**# Create a new user object but avoid saving it yet**
 **new_user = user_form.save(commit=****False****)**
**# Set the chosen password**
 **new_user.set_password(**
 **user_form.cleaned_data[****'password'****]**
 **)**
**# Save the User object**
 **new_user.save()**
**return** **render(**
**request,**
**'account/register_done.html'****,**
**{****'new_user'****: new_user}**
**)**
**else****:**
 **user_form = UserRegistrationForm()**
**return** **render(**
 **request,**
**'account/register.html'****,**
 **{****'user_form'****: user_form}**
 **)** 

创建用户账户的视图相当简单。出于安全考虑,我们不是保存用户输入的原始密码,而是使用用户模型的set_password()方法。该方法在将密码存储到数据库之前处理密码散列。

Django 不会存储明文密码;它存储的是散列密码。散列是将给定的密钥转换成另一个值的过程。散列函数用于根据数学算法生成一个固定长度的值。通过使用安全的算法散列密码,Django 确保存储在数据库中的用户密码需要大量的计算时间才能破解。

默认情况下,Django 使用PBKDF2散列算法和 SHA256 散列来存储所有密码。然而,Django 不仅支持检查使用PBKDF2散列的现有密码,还支持检查使用其他算法散列的存储密码,例如PBKDF2SHA1argon2bcryptscrypt

PASSWORD_HASHERS设置定义了 Django 项目支持的密码哈希器。以下是默认的PASSWORD_HASHERS列表:

PASSWORD_HASHERS = [
    'django.contrib.auth.hashers.PBKDF2PasswordHasher',
    'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
    'django.contrib.auth.hashers.Argon2PasswordHasher',
    'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
    'django.contrib.auth.hashers.ScryptPasswordHasher',
] 

Django 使用列表中的第一个条目(在本例中为PBKDF2PasswordHasher)来哈希所有密码。其余的哈希器可以被 Django 用来检查现有密码。

scrypt哈希器是在 Django 4.0 中引入的。它更安全,并且比PBKDF2推荐。然而,PBKDF2仍然是默认的哈希器,因为scrypt需要 OpenSSL 1.1+和更多的内存。

您可以在docs.djangoproject.com/en/5.0/topics/auth/passwords/了解更多关于 Django 如何存储密码以及包含的密码哈希器的信息。

现在,编辑account应用的urls.py文件并添加以下加粗的 URL 模式:

urlpatterns = [
    # ...
    path('', include('django.contrib.auth.urls')),
    path('', views.dashboard, name='dashboard'),
    **path(****'register/'****, views.register, name=****'register'****),**
] 

最后,在account应用的templates/account/模板目录中创建一个新的模板,命名为register.html,并使其看起来如下:

{% extends "base.html" %}
{% block title %}Create an account{% endblock %}
{% block content %}
  <h1>Create an account</h1>
<p>Please, sign up using the following form:</p>
<form method="post">
    {{ user_form.as_p }}
    {% csrf_token %}
    <p><input type="submit" value="Create my account"></p>
</form>
{% endblock %} 

在同一目录下创建一个额外的模板文件,并将其命名为register_done.html。向其中添加以下代码:

{% extends "base.html" %}
{% block title %}Welcome{% endblock %}
{% block content %}
  <h1>Welcome {{ new_user.first_name }}!</h1>
<p>
    Your account has been successfully created.
    Now you can <a href="{% url "login" %}">log in</a>.
  </p>
{% endblock %} 

在您的浏览器中打开 http://127.0.0.1:8000/account/register/。您将看到您创建的注册页面:

图片

图 4.17:账户创建表单

填写新用户的详细信息并点击创建我的账户按钮。

如果所有字段都是有效的,用户将被创建,您将看到以下成功消息:

图形用户界面,应用程序,网站  自动生成的描述

图 4.18:账户成功创建页面

点击登录链接并输入您的用户名和密码以验证您是否可以访问您新创建的账户。

让我们在登录模板中添加一个注册链接。编辑registration/login.html模板并找到以下行:

<p>Please, use the following form to log-in:</p> 

用以下行替换它:

<p>
  Please, use the following form to log-in.
  **If you don't have an account** **<****a****href****=****"{% url "****register****" %}"****>****register here****</****a****>****.**
</p> 

在您的浏览器中打开 http://127.0.0.1:8000/account/login/。页面现在应该如下所示:

图形用户界面,文本,应用程序,电子邮件  自动生成的描述

图 4.19:包含注册链接的登录页面

我们现在已将注册页面从登录页面中可访问。

扩展用户模型

虽然 Django 认证框架提供的用户模型对于大多数典型场景来说是足够的,但它确实有一组有限的预定义字段。如果您想捕获与您的应用程序相关的额外细节,您可能需要扩展默认的用户模型。例如,默认用户模型包含first_namelast_name字段,这种结构可能不符合各种国家的命名惯例。此外,您可能还想存储更多的用户详情或构建一个更全面的用户资料。

扩展用户模型的一个简单方法是通过创建一个包含与 Django 用户模型一对一关系的个人资料模型,以及任何额外的字段。一对一关系类似于具有unique=True参数的ForeignKey字段。关系的另一侧是与相关模型的一个隐式一对一关系,而不是多个元素的经理。从关系的每一侧,你可以访问一个相关的单个对象。

编辑你的account应用的models.py文件并添加以下以粗体突出显示的代码:

from django.db import models
**from** **django.conf** **import** **settings**
**class****Profile****(models.Model):**
 **user = models.OneToOneField(**
 **settings.AUTH_USER_MODEL,**
 **on_delete=models.CASCADE**
 **)**
 **date_of_birth = models.DateField(blank=****True****, null=****True****)**
 **photo = models.ImageField(**
 **upload_to=****'users/%Y/%m/%d/'****,**
 **blank=****True**
 **)**
**def****__str__****(****self****):**
**return****f'Profile of** **{self.user.username}****'** 

我们的用户个人资料将包括用户的出生日期和用户的图片。

一对一的字段user将被用来将个人资料与用户关联。我们使用AUTH_USER_MODEL来引用用户模型,而不是直接指向auth.User模型。这使得我们的代码更加通用,因为它可以与自定义定义的用户模型一起操作。通过on_delete=models.CASCADE,我们强制在删除User对象时删除相关的Profile对象。

date_of_birth字段是一个DateField。我们通过blank=True使此字段为可选,并通过null=True允许null值。

photo字段是一个ImageField。我们通过blank=True使此字段为可选。ImageField字段管理图像文件的存储。它验证提供的文件是否为有效的图像,将图像文件存储在由upload_to参数指定的目录中,并将文件的相对路径存储在相关的数据库字段中。默认情况下,ImageField字段在数据库中转换为VARCHAR(100)列。如果值留空,将存储一个空字符串。

安装 Pillow 和托管媒体文件

我们需要安装 Pillow 库来管理图像。Pillow 是 Python 中图像处理的既定标准库。它支持多种图像格式,并提供强大的图像处理功能。Pillow 是 Django 处理带有ImageField的图像所必需的。

通过在 shell 提示符下运行以下命令来安装 Pillow:

python -m pip install Pillow==10.3.0 

编辑项目的settings.py文件并添加以下行:

MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media' 

这将使 Django 能够管理文件上传并托管媒体文件。MEDIA_URL是用于托管用户上传的媒体文件的基准 URL。MEDIA_ROOT是它们所在的本地路径。文件路径和 URL 是动态构建的,通过在它们前面添加项目路径或媒体 URL 来实现可移植性。

现在,编辑bookmarks项目的urls.py主文件并修改代码,如下所示。新的行以粗体突出显示:

**from** **django.conf** **import** **settings**
**from** **django.conf.urls.static** **import** **static**
from django.contrib import admin
from django.urls import path, include
urlpatterns = [
    path('admin/', admin.site.urls),
    path('account/', include('account.urls')),
]
**if** **settings.DEBUG:**
 **urlpatterns += static(**
 **settings.MEDIA_URL,**
 **document_root=settings.MEDIA_ROOT**
 **)** 

我们已经添加了static()辅助函数,在开发期间(即当DEBUG设置设置为True时)通过 Django 开发服务器托管媒体文件。

static()辅助函数适合开发,但不适合生产使用。Django 在服务静态文件方面非常低效。永远不要在生产环境中使用 Django 来服务静态文件。您将在第十七章上线中学习如何在生产环境中服务静态文件。

为个人资料模型创建迁移

让我们为新的Profile模型创建数据库表。打开 shell 并运行以下命令以创建新模型的数据库迁移:

python manage.py makemigrations 

您将得到以下输出:

Migrations for 'account':
  account/migrations/0001_initial.py
    - Create model Profile 

接下来,使用以下命令在 shell 提示符中同步数据库:

python manage.py migrate 

您将看到包括以下行的输出:

Applying account.0001_initial... OK 

编辑account应用的admin.py文件,并在管理网站上通过添加加粗的代码来注册Profile模型:

from django.contrib import admin
**from** **.models** **import** **Profile**
**@admin.register(****Profile****)**
**class****ProfileAdmin****(admin.ModelAdmin):**
 **list_display = [****'user'****,** **'date_of_birth'****,** **'photo'****]**
 **raw_id_fields = [****'user'****]** 

使用以下命令从 shell 提示符运行开发服务器:

python manage.py runserver 

在您的浏览器中打开http://127.0.0.1:8000/admin/。现在,您应该能够在您项目的管理网站上看到Profile模型,如下所示:

图片

图 4.20:管理网站索引页面上的 ACCOUNT 块

点击个人资料行的添加链接。您将看到以下表单以添加新的个人资料:

图片

图 4.21:添加个人资料表单

手动为数据库中现有的每个用户创建一个Profile对象。

接下来,我们将允许用户在网站上编辑他们的个人资料。

编辑account应用的forms.py文件,并添加以下加粗的行:

# ...
**from** **.models** **import** **Profile**
# ...
**class****UserEditForm****(forms.ModelForm):**
**class****Meta****:**
 **model = get_user_model()**
 **fields = [****'first_name'****,** **'last_name'****,** **'email'****]**
**class****ProfileEditForm****(forms.ModelForm):**
**class****Meta****:**
 **model = Profile**
 **fields = [****'date_of_birth'****,** **'photo'****]** 

这些表单如下所示:

  • UserEditForm:这将允许用户编辑他们的名字、姓氏和电子邮件,这些是内置 Django 用户模型的属性。

  • ProfileEditForm:这将允许用户编辑保存在自定义Profile模型中的个人资料数据。用户将能够编辑他们的出生日期并上传个人资料图片。

编辑account应用的views.py文件,并添加以下加粗的行:

# ...
**from** **.models** **import** **Profile**
# ...
def register(request):
    if request.method == 'POST':
        user_form = UserRegistrationForm(request.POST)
        if user_form.is_valid():
            # Create a new user object but avoid saving it yet
            new_user = user_form.save(commit=False)
            # Set the chosen password
            new_user.set_password(
                user_form.cleaned_data['password']
            )
            # Save the User object
            new_user.save()
            **# Create the user profile**
**Profile.objects.create(user=new_user)**
return render(
 request,
 'account/register_done.html',
 {'new_user': new_user}
 )
    else:
        user_form = UserRegistrationForm()
    return render(
        request,
        'account/register.html',
        {'user_form': user_form}
    ) 

当用户在网站上注册时,将自动创建相应的Profile对象,并将其与创建的User对象关联。然而,通过管理网站创建的用户不会自动获得关联的Profile对象。有个人资料和无个人资料的用户(例如,工作人员用户)可以共存。

如果您想强制为所有用户创建个人资料,您可以使用 Django 信号在创建用户时触发Profile对象的创建。您将在第七章跟踪用户行为中学习有关信号的内容,在那里您将在使用 AI 扩展您的项目部分进行练习以实现此功能。

现在,我们将允许用户编辑他们的个人资料。

编辑account应用的views.py文件,并添加以下加粗的代码:

from django.contrib.auth import authenticate, login
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse
from django.shortcuts import render
from .forms import **(**
    LoginForm,
 UserRegistrationForm**,**
**UserEditForm,**
**ProfileEditForm**
**)**
from .models import Profile
# ...
**@login_required**
**def****edit****(****request****):**
**if** **request.method ==** **'POST'****:**
 **user_form = UserEditForm(**
 **instance=request.user,**
 **data=request.POST**
 **)**
 **profile_form = ProfileEditForm(**
 **instance=request.user.profile,**
 **data=request.POST,**
 **files=request.FILES**
 **)**
**if** **user_form.is_valid()** **and** **profile_form.is_valid():**
 **user_form.save()**
 **profile_form.save()**
**else****:**
 **user_form = UserEditForm(instance=request.user)**
 **profile_form = ProfileEditForm(instance=request.user.profile)**
**return** **render(**
 **request,**
**'account/edit.html'****,**
 **{**
**'user_form'****: user_form,**
**'profile_form'****: profile_form**
 **}**
 **)** 

我们添加了新的edit视图,允许用户编辑他们的个人信息。我们为该视图添加了login_required装饰器,因为只有经过身份验证的用户才能编辑他们的个人资料。对于这个视图,我们使用了两个模型表单:UserEditForm用于存储内置用户模型的数据,ProfileEditForm用于存储在自定义Profile模型中的额外个人信息。为了验证提交的数据,我们调用两个表单的is_valid()方法。如果两个表单都包含有效数据,我们通过调用save()方法来保存两个表单,以更新数据库中相应的对象。

将以下 URL 模式添加到account应用的urls.py文件中:

urlpatterns = [
    #...
    path('', include('django.contrib.auth.urls')),
    path('', views.dashboard, name='dashboard'),
    path('register/', views.register, name='register'),
    **path(****'edit/'****, views.edit, name=****'edit'****),**
] 

最后,在templates/account/目录中为这个视图创建一个模板,命名为edit.html。向其中添加以下代码:

{% extends "base.html" %}
{% block title %}Edit your account{% endblock %}
{% block content %}
  <h1>Edit your account</h1>
<p>You can edit your account using the following form:</p>
<form method="post" enctype="multipart/form-data">
    {{ user_form.as_p }}
    {{ profile_form.as_p }}
    {% csrf_token %}
    <p><input type="submit" value="Save changes"></p>
</form>
{% endblock %} 

在前面的代码中,我们已将enctype="multipart/form-data"添加到<form>HTML 元素中,以启用文件上传。我们使用 HTML 表单提交user_formprofile_form表单。

打开 URL http://127.0.0.1:8000/account/register/并注册一个新用户。然后,使用新用户登录并打开 URL http://127.0.0.1:8000/account/edit/。您应该看到以下页面:

图形用户界面,文本,应用程序,电子邮件  自动生成的描述

图 4.22:个人资料编辑表单

您现在可以添加个人资料信息并保存更改。

我们将编辑仪表板模板,以包括编辑个人资料和更改密码页面的链接。

打开templates/account/dashboard.html模板,并添加以下加粗的行:

{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
  <h1>Dashboard</h1>
<p>
    Welcome to your dashboard. **You can** **<****a****href****=****"{% url "****edit****" %}">****edit your profile****</****a****>** **or** **<****a****href****=****"{% url "****password_change****"** **%}">****change your password****</****a****>****.**
</p>
{% endblock %} 

用户现在可以从仪表板访问表单来编辑他们的个人资料。在您的浏览器中打开http://127.0.0.1:8000/account/并测试编辑用户个人资料的链接。仪表板现在应该看起来像这样:

图形用户界面  中度置信度自动生成的描述

图 4.23:仪表板页面内容,包括编辑个人资料和更改密码的链接

使用自定义用户模型

Django 还提供了一种用自定义模型替换用户模型的方法。User类应该继承自 Django 的AbstractUser类,它提供了一个默认用户作为抽象模型的完整实现。您可以在docs.djangoproject.com/en/5.0/topics/auth/customizing/#substituting-a-custom-user-model了解更多关于此方法的信息。

使用自定义用户模型将为您提供更多灵活性,但它也可能导致与直接与 Django 的auth用户模型交互的可插拔应用程序的集成更加困难。

摘要

在本章中,你学习了如何为你的网站构建认证系统。你实现了用户注册、登录、登出、编辑密码和重置密码所需的所有必要视图。你还构建了一个模型来存储自定义用户资料。

在下一章中,你将通过 Django 消息框架提供对用户行为的反馈来提高用户体验。你还将扩展认证方法的范围,使用户能够通过电子邮件地址进行认证,并通过 Google 集成社交认证。你还将学习如何使用 Django Extensions 通过 HTTPS 提供开发服务器,并自定义认证管道以自动创建用户资料。

额外资源

以下资源提供了与本章所涵盖主题相关的额外信息:

加入我们的 Discord!

与其他用户、Django 开发专家以及作者本人一起阅读此书。提问、为其他读者提供解决方案、通过 Ask Me Anything 会议与作者聊天,以及更多。扫描二维码或访问链接加入社区。

packt.link/Django5ByExample

第五章:实现社交认证

在上一章中,您将用户注册和认证集成到您的网站中。您实现了密码更改、重置和恢复功能,并学习了如何为您的用户创建自定义配置文件模型。

在本章中,您将使用 Google 为您的网站添加社交认证。您将使用Python Social Auth for Django通过 OAuth 2.0 实现社交认证,OAuth 2.0 是行业标准的授权协议。您还将修改社交认证管道,以自动为新用户创建用户配置文件。

本章将涵盖以下内容:

  • 使用消息框架

  • 构建自定义认证后端

  • 防止用户使用现有的电子邮件

  • 使用 Python Social Auth 添加社交认证

  • 使用 Django Extensions 通过 HTTPS 运行开发服务器

  • 添加使用 Google 的认证

  • 为使用社交认证注册的用户创建配置文件

功能概述

图 5.1展示了本章将构建的视图、模板和功能:

图片

图 5.1:第五章构建的功能图

在本章中,您将使用 Django 消息框架在edit视图中生成成功和错误消息。您将为用户使用电子邮件地址进行认证创建一个新的认证后端EmailAuthBackend。您将在开发期间使用 Django Extensions 通过 HTTPS 提供服务,并在您的网站上使用 Python Social Auth 实现社交认证。用户认证成功后将被重定向到dashboard视图。您将自定义认证管道,以便在通过社交认证创建新用户时自动创建用户配置文件。

技术要求

本章的源代码可以在github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter05找到。

本章中使用的所有 Python 包都包含在章节源代码中的requirements.txt文件中。您可以根据以下章节中的说明安装每个 Python 包,或者使用python -m pip install -r requirements.txt命令一次性安装所有依赖。

使用消息框架

当用户与平台交互时,有许多情况您可能希望通知他们特定操作的结果,例如在数据库中成功创建对象或成功提交表单。

Django 内置了消息框架,允许您向用户显示一次性通知。这通过提供对用户行为的即时反馈来增强用户体验,使界面更加直观和用户友好。

消息框架位于 django.contrib.messages,当你使用 python manage.py startproject 创建新项目时,它会被包含在 settings.py 文件的默认 INSTALLED_APPS 列表中。设置文件还包含 django.contrib.messages.middleware.MessageMiddleware 中间件,位于 MIDDLEWARE 设置中。

消息框架提供了一个简单的方法来向用户添加消息。默认情况下,消息存储在 cookie 中(如果会话存储失败),它们将在用户的下一个请求中显示和清除。你可以在你的视图中使用消息框架,通过导入 messages 模块并使用简单的快捷方式添加新消息,如下所示:

from django.contrib import messages
messages.error(request, 'Something went wrong') 

你可以使用 add_message() 方法或以下任何快捷方法来创建新消息:

  • success(): 成功消息用于显示操作成功时

  • info(): 提供信息性消息

  • warning(): 这表明失败尚未发生,但它可能即将发生

  • error(): 这表明操作未成功或发生了失败

  • debug(): 这显示了在生产环境中将被删除或忽略的调试消息

让我们向项目中添加消息。消息框架对项目全局有效。我们将使用基本模板来向客户端显示任何可用的消息。这将允许我们在任何页面上通知客户端任何操作的结果。

打开 account 应用程序的 templates/base.html 模板,并添加以下加粗代码:

{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}{% endblock %}</title>
<link href="{% static "css/base.css" %}" rel="stylesheet">
</head>
<body>
<div id="header">
    ...
  </div>
 **{% if messages %}**
**<****ul****class****=****"messages"****>**
 **{% for message in messages %}**
**<****li****class****=****"{{ message.tags }}"****>**
 **{{ message|safe }}**
**<****a****href****=****"#"****class****=****"close"****>****x****</****a****>**
**</****li****>**
 **{% endfor %}**
**</****ul****>**
 **{% endif %}**
<div id="content">
    {% block content %}
    {% endblock %}
  </div>
</body>
</html> 

消息框架包括 django.contrib.messages.context_processors.messages 上下文处理器,它将一个 messages 变量添加到请求上下文中。你可以在项目的 TEMPLATES 设置中的 context_processors 列表中找到它。你可以在模板中使用 messages 变量来向用户显示所有现有的消息。

上下文处理器是一个 Python 函数,它接受请求对象作为参数,并返回一个字典,该字典被添加到请求上下文中。你将在 第八章构建在线商店 中学习如何创建自己的上下文处理器。

让我们修改 edit 视图以使用消息框架。

编辑 account 应用程序的 views.py 文件,并添加以下加粗行:

**from** **django.contrib** **import** **messages**
# ...
@login_required
def edit(request):
    if request.method == 'POST':
        user_form = UserEditForm(
            instance=request.user,
            data=request.POST
        )
        profile_form = ProfileEditForm(
            instance=request.user.profile,
            data=request.POST,
            files=request.FILES
        )
        if user_form.is_valid() and profile_form.is_valid():
            user_form.save()
            profile_form.save()
 **messages.success(**
 **request,**
**'Profile updated successfully'**
 **)**
**else****:**
 **messages.error(request,** **'Error updating your profile'****)**
else:
        user_form = UserEditForm(instance=request.user)
        profile_form = ProfileEditForm(
                                    instance=request.user.profile)
    return render(
        request,
        'account/edit.html',
        {'user_form': user_form, 'profile_form': profile_form}
    ) 

当用户成功更新他们的个人资料时,会生成一条成功消息。如果任何表单包含无效数据,则生成错误消息。

在你的浏览器中打开 http://127.0.0.1:8000/account/edit/ 并编辑用户的个人资料。当个人资料成功更新时,你应该看到以下消息:

带有白色文字的绿色屏幕,描述由低置信度自动生成

图 5.2:成功编辑的个人资料消息

出生日期 字段中输入一个无效的日期并再次提交表单。你应该看到以下消息:

包含图形用户界面的图片 自动生成描述

图 5.3:错误更新配置消息

生成消息以通知用户其操作结果非常直接。您还可以轻松地将消息添加到其他视图中。

您可以在 docs.djangoproject.com/en/5.0/ref/contrib/messages/ 了解更多有关消息框架的信息。

现在我们已经构建了所有与用户认证和资料编辑相关的功能,我们将更深入地探讨自定义认证。我们将学习如何构建自定义后端认证,以便用户可以使用他们的电子邮件地址登录网站。

构建自定义认证后端

Django 允许您对不同的来源进行用户认证,例如内置的 Django 认证系统、外部认证系统如 轻量级目录访问协议 (LDAP) 服务器,甚至是第三方提供商。AUTHENTICATION_BACKENDS 设置包括项目中可用的认证后端列表。Django 允许您指定多个认证后端,以实现灵活的认证方案。AUTHENTICATION_BACKENDS 设置的默认值如下:

['django.contrib.auth.backends.ModelBackend'] 

默认的 ModelBackend 使用 django.contrib.auth 中的 User 模型对数据库中的用户进行认证。这对于大多数 Web 项目来说很合适。然而,您可以创建自定义后端来对其他来源进行用户认证。

您可以在 docs.djangoproject.com/en/5.0/topics/auth/customizing/#other-authentication-sources 了解更多有关自定义认证的信息。

每当使用 django.contrib.auth 中的 authenticate() 函数时,Django 会依次尝试将用户与 AUTHENTICATION_BACKENDS 中定义的每个后端进行认证,直到其中一个成功认证用户。只有当所有后端都未能认证用户时,用户才不会被认证。

Django 提供了一种简单的方式来定义您自己的认证后端。认证后端是一个提供以下两个方法的类:

  • authenticate(): 它接受 request 对象和用户凭证作为参数。如果凭证有效,它必须返回一个与这些凭证匹配的 user 对象,否则返回 Nonerequest 参数是一个 HttpRequest 对象,如果没有提供给 authenticate() 函数,则为 None

  • get_user(): 它接受一个用户 ID 参数,并必须返回一个 user 对象。

创建自定义认证后端就像编写一个实现两种方法的 Python 类一样简单。让我们创建一个认证后端,允许用户使用他们的电子邮件地址而不是用户名在网站上进行认证。

account应用程序目录内创建一个新文件,并将其命名为authentication.py。向其中添加以下代码:

from django.contrib.auth.models import User
class EmailAuthBackend:
    """
    Authenticate using an e-mail address.
    """
def authenticate(self, request, username=None, password=None):
        try:
            user = User.objects.get(email=username)
            if user.check_password(password):
                return user
            return None
except (User.DoesNotExist, User.MultipleObjectsReturned):
            return None
def get_user(self, user_id):
        try:
            return User.objects.get(pk=user_id)
        except User.DoesNotExist:
            return None 

上述代码是一个简单的认证后端。authenticate()方法接收一个request对象和username以及password可选参数。我们可以使用不同的参数,但我们使用usernamepassword来使我们的后端能够立即与认证框架视图一起工作。上述代码的工作原理如下:

  • authenticate(): 检索具有给定电子邮件地址的用户,并使用用户模型的内置check_password()方法检查密码。此方法处理密码散列,将提供的密码与数据库中存储的密码进行比较。捕获两种不同的 QuerySet 异常:DoesNotExistMultipleObjectsReturned。如果找不到具有给定电子邮件地址的用户,将引发DoesNotExist异常。如果找到具有相同电子邮件地址的多个用户,将引发MultipleObjectsReturned异常。我们将在稍后修改注册和编辑视图,以防止用户使用现有的电子邮件地址。

  • get_user(): 您可以通过user_id参数提供的 ID 获取一个用户。Django 使用验证用户的后端在用户会话期间检索User对象。pk主键的缩写,它是数据库中每条记录的唯一标识符。每个 Django 模型都有一个作为其主键的字段。默认情况下,主键是自动生成的 ID 字段。您可以在docs.djangoproject.com/en/5.0/topics/db/models/#automatic-primary-key-fields找到有关自动主键字段的更多信息。

编辑您项目的settings.py文件并添加以下代码:

AUTHENTICATION_BACKENDS = [
    'django.contrib.auth.backends.ModelBackend',
    'account.authentication.EmailAuthBackend',
] 

在上述设置中,我们保留了用于使用用户名和密码进行认证的默认ModelBackend,并包括我们自己的基于电子邮件的认证后端EmailAuthBackend

在您的浏览器中打开http://127.0.0.1:8000/account/login/。请记住,Django 将尝试对每个后端进行用户验证,因此现在您应该能够使用您的用户名或电子邮件账户无缝登录。

用户凭证将使用ModelBackend进行验证,如果没有返回用户,则将使用EmailAuthBackend进行验证。

AUTHENTICATION_BACKENDS设置中列出的后端顺序很重要。如果相同的凭证对多个后端都有效,Django 将使用列表中第一个成功验证这些凭证的后端来验证用户。这意味着一旦找到匹配项,Django 就不会继续检查剩余的后端。

防止用户使用现有的电子邮件地址

认证框架的User模型不允许创建具有相同电子邮件地址的用户。如果有两个或更多用户账户共享相同的电子邮件地址,我们将无法区分哪个用户正在认证。现在用户可以使用电子邮件地址登录,我们必须防止用户使用现有电子邮件地址进行注册。

现在,我们将更改用户注册表单,以防止多个用户使用相同的电子邮件地址进行注册。

编辑account应用的forms.py文件,并将以下加粗的行添加到UserRegistrationForm类中:

class UserRegistrationForm(forms.ModelForm):
    password = forms.CharField(
        label='Password',
        widget=forms.PasswordInput
    )
    password2 = forms.CharField(
        label='Repeat password',
        widget=forms.PasswordInput
    )
    class Meta:
        model = User
        fields = ['username', 'first_name', 'email']
    def clean_password2(self):
        cd = self.cleaned_data
        if cd['password'] != cd['password2']:
            raise forms.ValidationError('Passwords don\'t match.')
        return cd['password2']
**def****clean_email****(****self****):**
 **data = self.cleaned_data[****'email'****]**
**if** **User.objects.****filter****(email=data).exists():**
**raise** **forms.ValidationError(****'Email already in use.'****)**
**return** **data** 

我们已经为email字段添加了验证,防止用户使用现有的电子邮件地址进行注册。我们构建了一个查询集来查找具有相同电子邮件地址的现有用户。我们使用exists()方法检查是否有任何结果。如果查询集包含任何结果,exists()方法返回True,否则返回False

现在,将以下加粗的行添加到UserEditForm类中:

class UserEditForm(forms.ModelForm):
    class Meta:
        model = User
        fields = ['first_name', 'last_name', 'email']
**def****clean_email****(****self****):**
 **data = self.cleaned_data[****'email'****]**
 **qs = User.objects.exclude(**
**id****=self.instance.****id**
 **).****filter****(**
 **email=data**
 **)**
**if** **qs.exists():**
**raise** **forms.ValidationError(****'Email already in use.'****)**
**return** **data** 

在这种情况下,我们为email字段添加了验证,防止用户将他们的现有电子邮件地址更改为另一个用户的现有电子邮件地址。我们从查询集中排除了当前用户。否则,用户的当前电子邮件地址将被视为现有电子邮件地址,表单将无法验证。

将社交认证添加到您的网站

社交认证是一个广泛使用的功能,允许用户使用服务提供商的现有账户通过单点登录SSO)进行认证。认证过程允许用户使用来自社交服务如 Google、Facebook 或 Twitter 的现有账户登录到网站。在本节中,我们将使用 Google 将社交认证添加到网站。

要实现社交认证,我们将使用授权的行业标准协议OAuth 2.0OAuth代表开放授权。OAuth 2.0 是一个旨在允许网站或应用代表用户访问其他 Web 应用托管资源的标准。Google 使用 OAuth 2.0 协议进行认证和授权。

Python Social Auth 是一个 Python 模块,简化了将社交认证添加到您网站的过程。使用此模块,您可以让您的用户使用其他服务的账户登录到您的网站。您可以在github.com/python-social-auth/social-app-django找到此模块的代码。

此模块包含适用于不同 Python 框架的认证后端,包括 Django。

在 shell 中运行以下命令:

python -m pip install social-auth-app-django==5.4.0 

这将安装 Python Social Auth。

然后,将social_django添加到项目settings.py文件中的INSTALLED_APPS设置,如下所示:

INSTALLED_APPS = [
    # ...
**'social_django'****,**
] 

这是默认应用程序,用于将 Python Social Auth 添加到 Django 项目中。现在,运行以下命令以将 Python Social Auth 模型与您的数据库同步:

python -m python manage.py migrate 

您应该看到默认应用程序的迁移已按以下方式应用:

Applying social_django.0001_initial... OK
...
Applying social_django .0015_rename_extra_data_new_usersocialauth_extra_data... OK 

Python Social Auth 包括多个服务的认证后端。您可以在python-social-auth.readthedocs.io/en/latest/backends/index.html#supported-backends找到所有可用后端的列表。

我们将向项目中添加社交认证,允许我们的用户使用 Google 后端进行认证。

首先,我们需要将社交登录 URL 模式添加到项目中。

打开bookmarks项目的urls.py主文件,并按照以下方式包含social_django URL 模式。新行以粗体突出显示:

urlpatterns = [
    path('admin/', admin.site.urls),
    path('account/', include('account.urls')),
    **path(**
**'social-auth/'****,**
 **include(****'social_django.urls'****, namespace=****'social'****)**
 **),**
] 

我们的网络应用程序目前可以通过 localhost IP 地址127.0.0.1或使用localhost主机名访问。Google 允许在成功认证后将用户重定向到localhost,但其他社交服务期望 URL 重定向使用域名。在这个项目中,我们将通过在本地机器下使用域名来模拟真实环境。

定位您机器的hosts文件。如果您使用 Linux 或 macOS,hosts文件位于/etc/hosts。如果您使用 Windows,hosts文件位于C:\Windows\System32\Drivers\etc\hosts

编辑您机器的hosts文件,并添加以下行到其中:

127.0.0.1 mysite.com 

这将告诉您的计算机将mysite.com主机名指向您的机器。

让我们验证主机名关联是否成功。从 shell 提示符运行以下命令来启动开发服务器:

python manage.py runserver 

在浏览器中打开http://mysite.com:8000/account/login/。您将看到以下错误:

图片

图 5.4:无效的主机头消息

Django 使用ALLOWED_HOSTS设置控制可以服务应用程序的主机。这是一个安全措施,用于防止 HTTP 主机头攻击。Django 只会允许列表中包含的主机来服务应用程序。

您可以在docs.djangoproject.com/en/5.0/ref/settings/#allowed-hosts了解更多关于ALLOWED_HOSTS设置的信息。

编辑项目的settings.py文件,并按以下方式修改ALLOWED_HOSTS设置。新代码以粗体突出显示:

ALLOWED_HOSTS = [**'mysite.com'****,** **'localhost'****,** **'127.0.0.1'**] 

除了mysite.com主机外,我们还明确包含了localhost127.0.0.1。这允许通过localhost127.0.0.1访问网站,这是当DEBUGTrueALLOWED_HOSTS为空时 Django 的默认行为。

再次在浏览器中打开http://mysite.com:8000/account/login/。现在,您应该看到网站的登录页面而不是错误。

通过 HTTPS 运行开发服务器

接下来,我们将通过 HTTPS 运行开发服务器以模拟一个真实环境,在这个环境中与浏览器交换的内容是安全的。这有助于我们在 第六章在您的网站上共享内容 中安全地提供我们的网站并在任何安全网站上加载我们的图像书签工具。传输层安全性TLS)协议是通过安全连接提供网站的标准。TLS 的前身是安全套接字层SSL)。

虽然 SSL 现在已弃用,但你将在多个库和在线文档中找到对 TLS 和 SSL 这两个术语的引用。Django 开发服务器无法通过 HTTPS 提供服务,因为这不是它的预期用途。为了测试通过 HTTPS 提供网站的社会认证功能,我们将使用 Django Extensions 的 RunServerPlus 扩展。此包包含一系列有用的 Django 工具。请注意,你永远不应该使用开发服务器在生产环境中运行你的网站。

使用以下命令安装 Django 扩展:

python -m pip install django-extensions==3.2.3 

你需要安装 Werkzeug,它包含 Django Extensions 的 RunServerPlus 扩展所需的调试层。使用以下命令安装 Werkzeug:

python -m pip install werkzeug==3.0.2 

最后,使用以下命令安装 pyOpenSSL,这是使用 RunServerPlus 的 SSL/TLS 功能所必需的:

python -m pip install pyOpenSSL==24.1.0 

编辑你的项目的 settings.py 文件,并将 Django Extensions 添加到 INSTALLED_APPS 设置中,如下所示:

INSTALLED_APPS = [
    # ...
**'django_extensions'****,**
] 

现在,使用 Django Extensions 提供的 runserver_plus 管理命令来运行开发服务器,如下所示:

python manage.py runserver_plus --cert-file cert.crt 

我们已经为 runserver_plus 命令提供了 SSL/TLS 证书的文件名。Django Extensions 将自动生成密钥和证书。

在你的浏览器中打开 https://mysite.com:8000/account/login/。现在,你正在通过 HTTPS 访问你的网站。注意我们现在使用 https:// 而不是 http://

你的浏览器将显示一个安全警告,因为你正在使用一个由自己生成的证书而不是由认证机构CA)信任的证书。

如果你正在使用 Google Chrome,你将看到以下屏幕:

图 5.5:Google Chrome 中的安全错误

在这种情况下,点击 高级,然后点击 继续到 mysite.com(不安全)

如果你正在使用 Safari,你将看到以下屏幕:

图形用户界面,文本,应用程序,电子邮件  自动生成的描述

图 5.6:Safari 中的安全错误

在这种情况下,点击 显示详细信息,然后点击 访问此网站

如果你正在使用 Microsoft Edge,你将看到以下屏幕:

图形用户界面,文本,应用程序,电子邮件  自动生成的描述

图 5.7:Microsoft Edge 中的安全错误

在这种情况下,点击 高级,然后点击 继续到 mysite.com(不安全)

如果您使用的是其他浏览器,请访问浏览器显示的详细信息,并接受自签名证书,以便浏览器信任该证书。

你会看到 URL 以 https:// 开头,在某些情况下,一个表示连接安全的锁形图标。一些浏览器可能会显示一个损坏的锁形图标,因为你正在使用自签名证书而不是受信任的证书。这不会影响我们的测试:

图 5.8:带有安全连接图标的 URL

Django Extensions 包含了许多其他有趣的工具和功能。您可以在 https://django-extensions.readthedocs.io/en/latest/ 上找到有关此包的更多信息。

您现在可以在开发期间通过 HTTPS 提供您的网站服务。

使用 Google 进行身份验证

Google 提供了使用 OAuth 2.0 的社交身份验证,允许用户使用 Google 账户登录。您可以在 developers.google.com/identity/protocols/OAuth2 上了解有关 Google OAuth2 实现的更多信息。

要实现使用 Google 的身份验证,请将以下加粗的行添加到项目 settings.py 文件中的 AUTHENTICATION_BACKENDS 设置中:

AUTHENTICATION_BACKENDS = [
    'django.contrib.auth.backends.ModelBackend',
    'account.authentication.EmailAuthBackend',
**'social_core.backends.google.GoogleOAuth2'****,**
] 

首先,您需要在 Google 开发者控制台中创建一个 API 密钥。在您的浏览器中打开 console.cloud.google.com/projectcreate。您将看到以下屏幕:

图 5.9:Google 项目创建表单

项目名称 下,输入 Bookmarks 并点击 创建 按钮。

当新项目准备就绪时,请确保在顶部导航栏中选择项目,如下所示:

图 5.10:Google 开发者控制台顶部导航栏

项目创建完成后,在 APIs & Services 下方点击 Credentials

图 5.11:Google API 和服务菜单

您将看到以下屏幕:

图 5.12:Google API 创建 API 凭据

然后,点击 创建凭据 并点击 OAuth 客户端 ID

Google 会要求您首先配置同意屏幕,如下所示:

图 5.13:配置 OAuth 同意屏幕的警告

我们将配置将向用户显示的页面,以便他们同意使用 Google 账户访问您的网站。点击 配置同意屏幕 按钮。您将被重定向到以下屏幕:

图形用户界面,文本,应用程序,电子邮件  自动生成的描述

图 5.14:在 Google OAuth 同意屏幕设置中的用户类型选择

用户类型 选择为 外部 并点击 创建 按钮。您将看到以下屏幕:

图形用户界面,文本,应用程序,电子邮件  自动生成的描述

图 5.15:Google OAuth 同意屏幕设置

应用名称下,输入Bookmarks并选择您的邮箱作为用户支持邮箱

授权域名下,按照如下方式输入mysite.com

文本  使用低置信度自动生成的描述

图 5.16:Google OAuth 授权域名

开发者联系信息下输入您的电子邮件,然后点击保存并继续

在第 2 步,作用域,不要更改任何内容,然后点击保存并继续

在第 3 步,测试用户,将您的 Google 用户添加到测试用户中,然后按照如下方式点击保存并继续

图形用户界面,文本,应用程序,电子邮件  自动生成的描述

图 5.17:Google OAuth 测试用户

您将看到您的同意屏幕配置摘要。点击返回仪表板

在左侧菜单中,点击凭证,然后再次点击创建凭证,接着点击OAuth 客户端 ID

作为下一步,输入以下信息:

  • 应用类型:选择Web 应用

  • 名称:输入Bookmarks

  • 授权 JavaScript 源:添加https://mysite.com:8000

  • 授权重定向 URI:添加https://mysite.com:8000/social-auth/complete/google-oauth2/

表单应如下所示:

图形用户界面,文本,应用程序,电子邮件  自动生成的描述

图 5.18:Google OAuth 客户端 ID 创建表单

点击创建按钮。您将获得客户端 ID客户端密钥

图 5.19:Google OAuth – 客户端 ID 和客户端密钥

在您项目的根目录内创建一个新文件,命名为.env.env文件将包含环境变量的键值对。将 OAuth2 凭证添加到新文件中,如下所示:

GOOGLE_OAUTH2_KEY=xxxx
GOOGLE_OAUTH2_SECRET=xxxx 

xxxx分别替换为 OAuth2 密钥和密钥。

为了便于将配置与代码分离,我们将使用python-decouple。您已经在第二章增强您的博客并添加社交功能中使用了这个库。

通过运行以下命令使用pip安装python-decouple

python -m pip install python-decouple==3.8 

编辑您项目的settings.py文件,并向其中添加以下代码:

**from** **decouple** **import** **config**
# ...
**SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = config(****'GOOGLE_OAUTH2_KEY'****)**
**SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = config(****'GOOGLE_OAUTH2_SECRET'****)** 

SOCIAL_AUTH_GOOGLE_OAUTH2_KEYSOCIAL_AUTH_GOOGLE_OAUTH2_SECRET设置是从.env文件中定义的环境变量加载的。

编辑account应用的registration/login.html模板,并在content块的底部添加以下加粗的代码:

{% block content %}
  ...
**<****div****class****=****"social"****>**
**<****ul****>**
**<****li****class****=****"google"****>**
**<****a****href****=****"{% url "****social:begin****"** **"****google-oauth2****" %}">**
 **Sign in with Google**
**</****a****>**
**</****li****>**
**</****ul****>**
**</****div****>**
{% endblock %} 

使用 Django Extensions 提供的runserver_plus管理命令来运行开发服务器,如下所示:

python manage.py runserver_plus --cert-file cert.crt 

在您的浏览器中打开https://mysite.com:8000/account/login/。登录页面现在应如下所示:

图 5.20:包含 Google 身份验证按钮的登录页面

点击使用 Google 登录按钮。您将看到以下屏幕:

图 5.21:谷歌应用程序授权屏幕

点击您的谷歌账户以授权应用程序。您将被登录并重定向到您网站的仪表板页面。请记住,您已在LOGIN_REDIRECT_URL设置中设置了此 URL。如您所见,将社交认证添加到您的网站相当简单。

您现在已将社交认证添加到您的项目,并使用 Google 实现了社交认证。您可以使用 Python Social Auth 轻松实现其他在线服务的社交认证。在下一节中,我们将讨论使用社交认证注册时创建用户配置文件的问题。

为使用社交认证注册的用户创建配置文件

当用户使用社交认证进行身份验证时,如果该社交配置文件没有关联现有用户,则会创建一个新的User对象。Python Social Auth 使用一个由一系列函数组成的管道,这些函数在身份验证流程中以特定顺序执行。这些函数负责检索任何用户详情,在数据库中创建一个社交配置文件,并将其与现有用户关联或创建一个新的用户。

目前,当通过社交认证创建新用户时,不会创建新的Profile对象。我们将向管道添加一个新步骤,以便在创建新用户时自动在数据库中创建一个Profile对象。

将以下SOCIAL_AUTH_PIPELINE设置添加到您的项目的settings.py文件中:

SOCIAL_AUTH_PIPELINE = [
    'social_core.pipeline.social_auth.social_details',
    'social_core.pipeline.social_auth.social_uid',
    'social_core.pipeline.social_auth.auth_allowed',
    'social_core.pipeline.social_auth.social_user',
    'social_core.pipeline.user.get_username',
    'social_core.pipeline.user.create_user',
    'social_core.pipeline.social_auth.associate_user',
    'social_core.pipeline.social_auth.load_extra_data',
    'social_core.pipeline.user.user_details',
] 

这是 Python Social Auth 使用的默认身份验证管道。它由执行不同任务的多个函数组成。您可以在python-social-auth.readthedocs.io/en/latest/pipeline.html找到有关默认身份验证管道的更多详细信息。

让我们构建一个函数,在创建新用户时在数据库中创建一个Profile对象。然后我们将把这个函数添加到社交认证管道中。

编辑account/authentication.py文件,并向其中添加以下代码:

from account.models import Profile
def create_profile(backend, user, *args, **kwargs):
    """
    Create user profile for social authentication
    """
    Profile.objects.get_or_create(user=user) 

create_profile函数接受两个必需参数:

  • backend:用于用户认证的社交认证后端。请记住,您已将社交认证后端添加到项目的AUTHENTICATION_BACKENDS设置中。

  • user:新或现有已验证用户的User实例。

您可以在python-social-auth.readthedocs.io/en/latest/pipeline.html#extending-the-pipeline检查传递给管道函数的不同参数。

create_profile函数中,我们检查是否存在user对象,并使用get_or_create()方法查找给定用户的Profile对象,并在必要时创建一个。

现在,我们需要将新函数添加到认证流程中。将以下加粗的行添加到你的 settings.py 文件中的 SOCIAL_AUTH_PIPELINE 设置:

SOCIAL_AUTH_PIPELINE = [
    'social_core.pipeline.social_auth.social_details',
    'social_core.pipeline.social_auth.social_uid',
    'social_core.pipeline.social_auth.auth_allowed',
    'social_core.pipeline.social_auth.social_user',
    'social_core.pipeline.user.get_username',
    'social_core.pipeline.user.create_user',
**'account.authentication.create_profile'****,**
'social_core.pipeline.social_auth.associate_user',
    'social_core.pipeline.social_auth.load_extra_data',
    'social_core.pipeline.user.user_details',
] 

我们在 social_core.pipeline.create_user 之后添加了 create_profile 函数。在此阶段,有一个 User 实例可用。用户可以是现有用户或在此管道步骤中创建的新用户。create_profile 函数使用 User 实例查找相关的 Profile 对象,并在必要时创建一个新的。

在管理站点 https://mysite.com:8000/admin/auth/user/ 中访问用户列表。删除任何通过社交认证创建的用户。

然后,打开 https://mysite.com:8000/account/login/ 并为已删除的用户执行社交认证。将创建一个新用户,并且现在还会创建一个 Profile 对象。访问 https://mysite.com:8000/admin/account/profile/ 以验证是否为新用户创建了一个资料。

我们已成功添加了自动创建用户资料的功能,用于社交认证。

Python Social Auth 还为断开连接流程提供了管道机制。你可以在 python-social-auth.readthedocs.io/en/latest/pipeline.html#disconnection-pipeline 找到更多详细信息。

摘要

在本章中,你通过创建基于电子邮件的认证后端和添加与 Google 的社交认证,显著提高了你的社交网站认证能力。你还通过使用 Django 消息框架为他们的操作提供反馈来改善了用户体验。最后,你自定义了认证流程以自动为新用户创建用户资料。

在下一章中,你将创建一个图像书签系统。你将学习多对多关系和自定义表单的行为。你将学习如何生成图像缩略图以及如何使用 JavaScript 和 Django 构建 AJAX 功能。

其他资源

以下资源提供了与本章涵盖主题相关的额外信息:

第六章:在您的网站上分享内容

在上一章中,您使用 Django 消息框架向您的网站添加了成功和错误消息。您还创建了一个电子邮件认证后端,并使用 Google 添加了社交认证到您的网站。您学习了如何使用 Django Extensions 在本地机器上以 HTTPS 运行开发服务器。您还自定义了社交认证管道,以自动为新用户创建用户资料。

在本章中,您将学习如何创建一个 JavaScript 书签工具,以便在您的网站上分享来自其他网站的内容,并且您将在项目中使用 JavaScript 和 Django 实现异步浏览器请求。

本章将涵盖以下主题:

  • 创建多对多关系

  • 自定义表单的行为

  • 在 Django 中使用 JavaScript

  • 构建 JavaScript 书签工具

  • 使用easy-thumbnails生成图片缩略图

  • 使用 JavaScript 和 Django 实现异步 HTTP 请求

  • 构建无限滚动分页

在本章中,您将创建一个图片收藏系统。您将创建具有多对多关系的模型,并自定义表单的行为。您将学习如何生成图片缩略图,以及如何使用 JavaScript 和 Django 构建异步浏览器功能。

功能概述

图 6.1展示了本章将要构建的视图、模板和功能:

图 6.1:第六章构建的功能图

在本章中,您将实现一个“收藏它”按钮,允许用户从任何网站收藏图片。您将使用 JavaScript 在网站顶部显示一个图片选择器,让用户选择要收藏的图片。您将实现image_create视图和表单,从图片的原始来源检索图片并将其存储在您的网站上。您将构建image_detail视图来显示单个图片,并使用easy-thumbnails包自动生成图片缩略图。您还将实现image_like视图,允许用户对图片进行点赞/取消点赞。此视图将处理使用 JavaScript 执行的异步 HTTP 请求,并以 JSON 格式返回响应。最后,您将创建image_list视图来显示所有收藏的图片,并使用 JavaScript 和 Django 分页实现无限滚动。

本章的源代码可以在github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter06找到。

本章中使用的所有 Python 包都包含在章节源代码中的requirements.txt文件中。您可以在以下部分按照说明安装每个 Python 包,或者使用python -m pip install -r requirements.txt命令一次性安装所有依赖。

创建一个图片收藏网站

现在,我们将学习如何允许用户将他们在其他网站上找到的图片书签并分享到我们的网站上。为了构建这个功能,我们需要以下元素:

  • 一个数据模型来存储图片和相关信息。

  • 一个表单和一个视图来处理图片上传。

  • 可以在任何网站上执行的 JavaScript 书签代码。此代码将在页面中查找图片,并允许用户选择他们想要书签的图片。

首先,在您的bookmarks项目目录内创建一个新的应用程序,通过在 shell 提示符中运行以下命令:

django-admin startapp images 

将新应用程序添加到项目settings.py文件中的INSTALLED_APPS设置中,如下所示:

INSTALLED_APPS = [
    # ...
**'images.apps.ImagesConfig'****,**
] 

我们已经在项目中激活了images应用程序。

构建图片模型

编辑images应用程序的models.py文件,并向其中添加以下代码:

from django.conf import settings
from django.db import models
class Image(models.Model):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        related_name='images_created',
        on_delete=models.CASCADE
    )
    title = models.CharField(max_length=200)
    slug = models.SlugField(max_length=200, blank=True)
    url = models.URLField(max_length=2000)
    image = models.ImageField(upload_to='images/%Y/%m/%d/')
    description = models.TextField(blank=True)
    created = models.DateTimeField(auto_now_add=True)
    class Meta:
        indexes = [
            models.Index(fields=['-created']),
        ]
        ordering = ['-created']
    def __str__(self):
        return self.title 

这是我们将在平台上存储图片所使用的模型。让我们看看这个模型的字段:

  • user: 这表示书签此图片的User对象。这是一个外键字段,因为它指定了一个多对一的关系:一个用户可以发布多个图片,但每张图片都由单个用户发布。我们为on_delete参数使用了CASCADE,以便在删除用户时删除相关图片。

  • title: 图片的标题。

  • slug: 一个仅包含字母、数字、下划线或连字符的短标签,用于构建美观且 SEO 友好的 URL。

  • url: 此图片的原始 URL。我们使用max_length来定义最大长度为2000个字符。

  • image: 图片文件。

  • description: 图片的可选描述。

  • created: 表示对象在数据库中创建的日期和时间的日期和时间。我们添加了auto_now_add来自动设置对象创建时的当前日期和时间。

在模型的Meta类中,我们为created字段定义了一个降序数据库索引。我们还添加了ordering属性来告诉 Django 默认按created字段排序结果。我们通过在字段名前使用连字符来表示降序,例如-created,这样新的图片将首先显示。

数据库索引可以提高查询性能。考虑为那些您经常使用filter()exclude()order_by()查询的字段创建索引。ForeignKey字段或具有unique=True的字段意味着创建索引。您可以在docs.djangoproject.com/en/5.0/ref/models/options/#django.db.models.Options.indexes了解更多关于数据库索引的信息。

我们将覆盖Image模型的save()方法,以便根据title字段的值自动生成slug字段。以下代码中,新行以粗体突出显示。导入slugify()函数并添加到Image模型的save()方法中,如下所示:

**from** **django.utils.text** **import** **slugify**
class Image(models.Model):
    # ...
**def****save****(****self, *args, **kwargs****):**
**if****not** **self.slug:**
 **self.slug = slugify(self.title)**
**super****().save(*args, **kwargs)** 

当一个Image对象被保存时,如果slug字段没有值,则会使用slugify()函数自动从图像的title字段生成一个 slug。然后对象被保存。通过从标题自动生成 slug,用户在分享我们网站上的图像时无需提供 slug。

创建多对多关系

接下来,我们将向Image模型添加另一个字段以存储喜欢图像的用户。在这种情况下,我们需要一个多对多关系,因为一个用户可能喜欢多个图像,每个图像也可能被多个用户喜欢。

将以下字段添加到Image模型中:

users_like = models.ManyToManyField(
    settings.AUTH_USER_MODEL,
    related_name='images_liked',
    blank=True
) 

当我们定义一个ManyToManyField字段时,Django 会使用两个模型的键创建一个中间连接表。图 6.2显示了将为这种关系创建的数据库表:

图片

图 6.2:多对多关系的中间数据库表

Django 创建了一个名为images_image_users_like的中间表,它引用了images_image表(Image模型)和auth_user表(User模型)。ManyToManyField字段可以在两个相关模型中的任何一个中定义。

ForeignKey字段一样,ManyToManyFieldrelated_name属性允许您从相关对象命名回此关系。ManyToManyField字段提供了一个多对多管理器,允许您检索相关对象,例如image.users_like.all(),或从user对象中获取它们,例如user.images_liked.all()

您可以在docs.djangoproject.com/en/5.0/topics/db/examples/many_to_many/了解更多关于多对多关系的信息。

打开 shell 提示符并运行以下命令以创建初始迁移:

python manage.py makemigrations images 

输出应类似于以下内容:

Migrations for 'images':
  images/migrations/0001_initial.py
    - Create model Image
    - Create index images_imag_created_d57897_idx on field(s) -created of model image 

现在运行以下命令以应用您的迁移:

python manage.py migrate images 

您将得到包含以下行的输出:

Applying images.0001_initial... OK 

Image模型现在已同步到数据库。

在管理站点注册图像模型

编辑images应用的admin.py文件,并将Image模型注册到管理站点,如下所示:

from django.contrib import admin
**from** **.models** **import** **Image**
**@admin.register(****Image****)**
**class****ImageAdmin****(admin.ModelAdmin):**
 **list_display = [****'title'****,** **'slug'****,** **'image'****,** **'created'****]**
 **list_filter = [****'created'****]** 

使用以下命令启动开发服务器:

python manage.py runserver_plus --cert-file cert.crt 

在浏览器中打开https://127.0.0.1:8000/admin/,您将看到管理站点中的Image模型,如下所示:

图片

图 6.3:Django 管理站点索引页面上的图像块

您已完成了存储图像的模型。现在您将学习如何实现一个表单,通过 URL 检索图像并使用Image模型存储它们。

发布来自其他网站的内容

我们将允许用户从外部网站书签图片并在我们的网站上分享。用户将提供图片的 URL、标题和可选描述。我们将创建一个表单和一个视图来下载图片,并在数据库中创建一个新的Image对象。

让我们先构建一个表单来提交新的图片。

images应用程序目录内创建一个新的forms.py文件,并将以下代码添加到其中:

from django import forms
from .models import Image
class ImageCreateForm(forms.ModelForm):
    class Meta:
        model = Image
        fields = ['title', 'url', 'description']
        widgets = {
            'url': forms.HiddenInput,
        } 

我们已从Image模型定义了一个ModelForm表单,仅包括titleurldescription字段。用户不会直接在表单中输入图片 URL。相反,我们将为他们提供一个 JavaScript 工具,从外部网站选择图片,表单将接收图片的 URL 作为参数。我们已覆盖了url字段的默认小部件,以使用HiddenInput小部件。此小部件渲染为具有type="hidden"属性的 HTML input元素。我们使用此小部件是因为我们不希望此字段对用户可见。

清理表单字段

为了验证提供的图片 URL 是否有效,我们将检查文件名是否以.jpg.jpeg.png扩展名结尾,以允许仅共享 JPEG 和 PNG 文件。在上一章中,我们使用了clean_<fieldname>()约定来实现字段验证。当我们在表单实例上调用is_valid()时,如果字段存在,该方法将针对每个字段执行。在clean方法中,您可以更改字段的值或为字段引发任何验证错误。

images应用程序的forms.py文件中,向ImageCreateForm类添加以下方法:

def clean_url(self):
    url = self.cleaned_data['url']
    valid_extensions = ['jpg', 'jpeg', 'png']
    extension = url.rsplit('.', 1)[1].lower()
    if extension not in valid_extensions:
        raise forms.ValidationError(
            'The given URL does not match valid image extensions.'
 )
    return url 

在前面的代码中,我们定义了一个clean_url()方法来清理url字段。代码如下:

  1. 通过访问表单实例的cleaned_data字典来检索url字段的值。

  2. 将 URL 分割以检查文件是否有有效的扩展名。如果扩展名无效,将引发ValidationError,并且表单实例不会被验证。

除了验证给定的 URL 之外,我们还需要下载图片文件并将其保存。例如,我们可以使用处理表单的视图来下载图片文件。相反,让我们通过覆盖模型表单的save()方法来采取更通用的方法,在表单保存时执行此任务。

安装 Requests 库

当用户将图片书签时,我们需要通过其 URL 下载图片文件。我们将为此目的使用 Requests Python 库。Requests 是 Python 中最流行的 HTTP 库。它抽象了处理 HTTP 请求的复杂性,并为消费 HTTP 服务提供了一个非常简单的接口。您可以在requests.readthedocs.io/en/master/找到 Requests 库的文档。

打开 shell,使用以下命令安装 Requests 库:

python -m pip install requests==2.31.0 

我们现在将覆盖ImageCreateFormsave()方法,并使用 Requests 库通过 URL 检索图像。

覆盖 ModelForm 的 save()方法

如您所知,ModelForm提供了一个save()方法,用于将当前模型实例保存到数据库并返回该对象。此方法接收一个布尔commit参数,允许您指定对象是否必须持久化到数据库。如果commitFalse,则save()方法将返回一个模型实例,但不会将其保存到数据库中。我们将覆盖表单的save()方法,以通过给定的 URL 检索图像文件并将其保存到文件系统中。

forms.py文件的顶部添加以下导入:

import requests
from django.core.files.base import ContentFile
from django.utils.text import slugify 

然后,向ImageCreateForm表单添加以下save()方法:

def save(self, force_insert=False, force_update=False, commit=True):
    image = super().save(commit=False)
    image_url = self.cleaned_data['url']
    name = slugify(image.title)
    extension = image_url.rsplit('.', 1)[1].lower()
    image_name = f'{name}.{extension}'
# download image from the given URL
    response = requests.get(image_url)
    image.image.save(
        image_name,
        ContentFile(response.content),
        save=False
 )
    if commit:
        image.save()
    return image 

我们已经覆盖了save()方法,保留了ModelForm所需的参数。前面的代码可以这样解释:

  1. 通过调用表单的save()方法并传递commit=False来创建一个新的image实例。

  2. 从表单的cleaned_data字典中检索图像的 URL。

  3. 通过将图像标题的 slug 与图像的原始文件扩展名组合来生成图像名称。

  4. 使用 Requests Python 库通过发送使用图像 URL 的 HTTP GET请求来下载图像。响应存储在response对象中。

  5. 调用image字段的save()方法,传递一个使用下载的文件内容实例化的ContentFile对象。这样,文件就被保存到项目的媒体目录中。传递save=False参数以防止对象被保存到数据库中。

  6. 为了保持与模型表单原始save()方法相同的行为,只有当commit参数为True时,表单才会保存到数据库。

我们需要一个视图来创建表单的实例并处理其提交。

编辑images应用的views.py文件,并向其中添加以下代码。新代码以粗体显示:

**from** **django.contrib** **import** **messages**
**from** **django.contrib.auth.decorators** **import** **login_required**
from django.shortcuts import **redirect,** render
**from** **.forms** **import** **ImageCreateForm**
**@login_required**
**def****image_create****(****request****):**
**if** **request.method ==** **'POST'****:**
**# form is sent**
 **form = ImageCreateForm(data=request.POST)**
**if** **form.is_valid():**
**# form data is valid**
 **cd = form.cleaned_data**
 **new_image = form.save(commit=****False****)**
**# assign current user to the item**
 **new_image.user = request.user**
 **new_image.save()**
 **messages.success(request,** **'Image added successfully'****)**
**# redirect to new created item detail view**
**return** **redirect(new_image.get_absolute_url())**
**else****:**
**# build form with data provided by the bookmarklet via GET**
 **form = ImageCreateForm(data=request.GET)**
**return** **render(**
 **request,**
**'images/image/create.html'****,**
 **{****'section'****:** **'images'****,** **'form'****: form}**
 **)** 

在前面的代码中,我们创建了一个视图来在网站上存储图像。我们向image_create视图添加了login_required装饰器,以防止未经认证的用户访问。这个视图的工作方式如下:

  1. 为了创建表单的实例,必须通过GET HTTP 请求提供初始数据。这些数据将包括来自外部网站的图像的urltitle属性。这两个参数将由我们稍后创建的 JavaScript 书签工具在GET请求中设置。目前,我们可以假设这些数据将在请求中可用。

  2. 当表单通过POST HTTP 请求提交时,它通过form.is_valid()进行验证。如果表单数据有效,则通过form.save(commit=False)保存表单来创建一个新的image实例。由于commit=False,新实例不会被保存到数据库中。

  3. 使用new_image.user = request.user将当前执行请求的用户与新的image实例关联起来。这样我们就能知道谁上传了每张图片。

  4. Image对象被保存到数据库中。

  5. 最后,使用 Django 消息框架创建一个成功消息,并将用户重定向到新图像的规范 URL。我们尚未实现Image模型的get_absolute_url()方法;我们将在稍后完成。

images应用程序内创建一个新的urls.py文件并向其中添加以下代码:

from django.urls import path
from . import views
app_name = 'images'
urlpatterns = [
    path('create/', views.image_create, name='create'),
] 

编辑bookmarks项目的主体urls.py文件以包含images应用程序的模式,如下所示。新的代码以粗体显示:

urlpatterns = [
    path('admin/', admin.site.urls),
    path('account/', include('account.urls')),
    path(
        'social-auth/',
        include('social_django.urls', namespace='social')
    ),
 **path(****'images/'****, include(****'images.urls'****, namespace=****'images'****)),**
] 

最后,我们需要创建一个模板来渲染表单。在images应用程序目录内创建以下目录结构:

templates/
  images/
    image/
      create.html 

编辑新的create.html模板并向其中添加以下代码:

{% extends "base.html" %}
{% block title %}Bookmark an image{% endblock %}
{% block content %}
  <h1>Bookmark an image</h1>
<img src="img/{{ request.GET.url }}" class="image-preview">
<form method="post">
    {{ form.as_p }}
    {% csrf_token %}
    <input type="submit" value="Bookmark it!">
</form>
{% endblock %} 

在 shell 提示符中使用以下命令运行开发服务器:

python manage.py runserver_plus --cert-file cert.crt 

在你的浏览器中打开https://127.0.0.1:8000/images/create/?title=...&url=...,包括titleurl GET 参数,在后者中提供一个现有的 JPEG 图像 URL。例如,你可以使用以下 URL:https://127.0.0.1:8000/images/create/?title=%20Django%20and%20Duke&url=https://upload.wikimedia.org/wikipedia/commons/8/85/Django_Reinhardt_and_Duke_Ellington_%28Gottlieb%29.jpg

你将看到带有图像预览的表单,如下所示:

图 6.4:书签图像的页面

添加描述并点击书签它按钮。一个新的Image对象将被保存在你的数据库中。然而,你将得到一个错误,表明Image模型没有get_absolute_url()方法,如下所示:

图 6.5:显示Image对象没有get_absolute_url属性的错误

目前不必担心这个错误;我们将在稍后实现Image模型中的get_absolute_url方法。

在你的浏览器中打开https://127.0.0.1:8000/admin/images/image/并验证新的Image对象是否已保存,如下所示:

图 6.6:显示创建的Image对象的网站管理器图像列表页面

使用 JavaScript 构建书签小工具

书签小工具是一种存储在网页浏览器中的书签,其中包含用于扩展浏览器功能的 JavaScript 代码。当你点击浏览器书签栏或收藏夹中的书签时,JavaScript 代码将在浏览器中显示的网站上执行。这对于构建与其他网站交互的工具非常有用。

一些在线服务,如 Pinterest,实现了他们自己的书签小工具,让用户可以从其他网站在他们的平台上分享内容。Pinterest 书签小工具作为浏览器扩展实现,可在help.pinterest.com/en/article/save-pins-with-the-pinterest-browser-button找到。Pinterest 保存扩展允许用户通过在浏览器中单击一次即可将图片或网站保存到他们的 Pinterest 账户。

图 6.7:Pinterest 保存扩展

让我们以类似的方式为您的网站创建一个书签小工具。为此,我们将使用 JavaScript。

这是用户如何将书签小工具添加到他们的浏览器并使用它的方法:

  1. 用户将来自您网站的链接拖动到浏览器书签栏。链接在其href属性中包含 JavaScript 代码。此代码将被存储在书签中。

  2. 用户导航到任何网站并点击书签栏或收藏夹中的书签。书签的 JavaScript 代码将被执行。

由于 JavaScript 代码将被存储为书签,因此用户将其添加到书签栏后,我们将无法更新它。这是一个重要的缺点,您可以通过实现启动脚本来解决。用户将启动脚本保存为书签,启动脚本将从 URL 加载实际的 JavaScript 书签小工具。通过这样做,您将能够随时更新书签小工具的代码。这是我们构建书签小工具将采取的方法。让我们开始吧!

images/templates/下创建一个新的模板,并将其命名为bookmarklet_launcher.js。这将是一个启动脚本。将以下 JavaScript 代码添加到新文件中:

(function(){
  if(!window.bookmarklet) {
    bookmarklet_js = document.body.appendChild(document.createElement('script'));
    bookmarklet_js.src = '//127.0.0.1:8000/static/js/bookmarklet.js?r='+Math.floor(Math.random()*9999999999999999);
    window.bookmarklet = true;
  }
  else {
    bookmarkletLaunch();
  }
})(); 

上述脚本通过使用if(!window.bookmarklet)检查bookmarklet窗口变量的值来检查书签小工具是否已被加载:

  • 如果window.bookmarklet未定义或没有真值(在布尔上下文中被视为true),将通过在浏览器中加载的 HTML 文档的主体中添加一个<script>元素来加载一个 JavaScript 文件。使用src属性加载bookmarklet.js脚本的 URL,该 URL 使用Math.random()*9999999999999999生成的随机 16 位整数参数。使用随机数,我们防止浏览器从浏览器缓存中加载文件。如果书签小工具 JavaScript 已被先前加载,不同的参数值将迫使浏览器再次从源 URL 加载脚本。这样,我们确保书签小工具始终运行最新的 JavaScript 代码。

  • 如果window.bookmarklet已定义并且具有真值,则执行bookmarkletLaunch()函数。我们将在bookmarklet.js脚本中将bookmarkletLaunch()定义为全局函数。

通过检查 bookmarklet 窗口变量,我们防止用户反复点击书签时书签 JavaScript 代码被加载多次。

你创建了书签启动器代码。实际的书签代码将位于 bookmarklet.js 静态文件中。使用启动器代码允许你在任何时间更新书签代码,而无需要求用户更改他们之前添加到浏览器中的书签。

让我们向仪表板页面添加书签启动器,以便用户可以将其添加到浏览器书签栏中。

编辑 account/dashboard.html 模板,使其看起来如下。新行以粗体突出显示:

{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
  <h1>Dashboard</h1>
 **{% with total_images_created=request.user.images_created.count %}**
**<****p****>****Welcome to your dashboard. You have bookmarked {{ total_images_created }} image{{ total_images_created|pluralize }}.****</****p****>**
 **{% endwith %}**
**<****p****>****Drag the following button to your bookmarks toolbar to bookmark images from other websites →** **<****a****href****=****"****javascript:{% include "****bookmarklet_launcher.js****" %}"** **class****=****"button"****>****Bookmark it****</****a****></****p****>**
**<****p****>****You can also** **<****a****href****=****"{% url "****edit****" %}">****edit your profile****</****a****>** **or** **<****a****href****=****"{% url "****password_change****" %}">****change your password****</****a****>****.****</****p****>**
{% endblock %} 

确保不要将模板标签拆分到多行;Django 不支持多行标签。

仪表板现在显示用户书签的总图像数。我们添加了一个 {% with %} 模板标签来创建一个变量,该变量包含当前用户书签的总图像数。我们包含了一个带有 href 属性的链接,该属性包含书签启动器脚本。此 JavaScript 代码是从 bookmarklet_launcher.js 模板加载的。

在你的浏览器中打开 https://127.0.0.1:8000/account/。你应该看到以下页面:

图 6.8:仪表板页面,包括书签的总图像数和书签按钮

现在在 images 应用程序目录内创建以下目录和文件:

static/
  js/
    bookmarklet.js 

你将在与本章代码一起提供的 images 应用程序目录中找到 static/css/ 目录。将 css/ 目录复制到你的代码的 static/ 目录中。你可以在 github.com/PacktPublishing/Django-5-by-Example/tree/main/Chapter06/bookmarks/images/static 找到目录的内容。

css/bookmarklet.css 文件提供了 JavaScript 书签的样式。现在 static/ 目录应包含以下文件结构:

 css/
    bookmarklet.css
  js/
    bookmarklet.js 

编辑 bookmarklet.js 静态文件,并向其中添加以下 JavaScript 代码:

const siteUrl = '//127.0.0.1:8000/';
const styleUrl = siteUrl + 'static/css/bookmarklet.css';
const minWidth = 250;
const minHeight = 250; 

你已声明了四个不同的常量,这些常量将被书签使用。这些常量是:

  • siteUrlstaticUrl:网站的基准 URL 和静态文件的基准 URL。

  • minWidthminHeight:书签将从网站收集的图像的最小宽度和高度(以像素为单位)。书签将识别至少有 250px 宽度和 250px 高度的图像。

编辑 bookmarklet.js 静态文件,并向其中添加以下以粗体突出显示的代码:

const siteUrl = '//127.0.0.1:8000/';
const styleUrl = siteUrl + 'static/css/bookmarklet.css';
const minWidth = 250;
const minHeight = 250;
**// load CSS**
**var head = document.getElementsByTagName(****'****head'****)[****0****];**
**var link = document.createElement(****'link'****);**
**link.rel =** **'stylesheet'****;**
**link.****type** **=** **'text/css'****;**
**link.href = styleUrl +** **'?r='** **+ Math.floor(Math.random()*****9999999999999999****);**
**head.appendChild(link);** 

本节加载书签脚本的 CSS 样式表。我们使用 JavaScript 来操作文档对象模型DOM)。DOM 代表内存中的 HTML 文档,并在加载网页时由浏览器创建。DOM 被构建为一个对象树,这些对象构成了 HTML 文档的结构和内容。

之前的代码生成一个与以下 JavaScript 代码等效的对象,并将其附加到 HTML 页面的<head>元素中:

<link rel="stylesheet" type="text/css" href= "//127.0.0.1:8000/static/css/bookmarklet.css?r=1234567890123456"> 

让我们回顾一下这是如何完成的:

  1. 使用document.getElementsByTagName()检索网站的<head>元素。此函数检索页面给定标签的所有 HTML 元素。通过使用[0],我们访问找到的第一个实例。我们访问第一个元素,因为所有 HTML 文档都应该有一个单独的<head>元素。

  2. 使用document.createElement('link')创建一个<link>元素。

  3. <link>元素的reltype属性被设置。这相当于 HTML <link rel="stylesheet" type="text/css">

  4. <link>元素的href属性被设置为bookmarklet.css样式表的 URL。使用 16 位随机数作为 URL 参数,以防止浏览器从缓存中加载文件。

  5. 使用head.appendChild(link)将新的<link>元素添加到 HTML 页面的<head>元素中。

现在我们将创建一个 HTML 元素,在网站中显示一个在书签脚本执行时使用的<div>容器。这个 HTML 容器将用于显示网站上找到的所有图片,并让用户选择他们想要分享的图片。它将使用在bookmarklet.css样式表中定义的 CSS 样式。

编辑bookmarklet.js静态文件,并添加以下加粗的代码:

const siteUrl = '//127.0.0.1:8000/';
const styleUrl = siteUrl + 'static/css/bookmarklet.css';
const minWidth = 250;
const minHeight = 250;
// load CSS
var head = document.getElementsByTagName('head')[0];
var link = document.createElement('link');
link.rel = 'stylesheet';
link.type = 'text/css';
link.href = styleUrl + '?r=' + Math.floor(Math.random()*9999999999999999);
head.appendChild(link);
**// load HTML**
**var body = document.getElementsByTagName(****'body'****)[****0****];**
**boxHtml = `**
 **<div** **id****=****"bookmarklet"****>**
 **<a href=****"#"****id****=****"close"****>&times;</a>**
 **<h1>Select an image to bookmark:</h1>**
 **<div** **class****=****"images"****></div>**
 **</div>`;**
**body.innerHTML += boxHtml;** 

通过这段代码,检索 DOM 的<body>元素,并通过修改其属性innerHTML向其中添加新的 HTML。在页面主体中添加一个新的<div>元素。该<div>容器包含以下元素:

  • 一个使用<a href="#" id="close">&times;</a>定义的关闭容器链接。

  • 使用<h1>Select an image to bookmark:</h1>定义的标题。

  • 一个使用<div class="images"></div>定义的<div>元素,用于列出网站上找到的图片。此容器最初为空,并将填充网站上找到的图片。

包括之前加载的 CSS 样式在内的 HTML 容器将看起来像图 6.9

图片

图 6.9:图片选择容器

现在让我们实现一个启动书签脚本的函数。编辑bookmarklet.js静态文件,并在底部添加以下代码:

function bookmarkletLaunch() {
  bookmarklet = document.getElementById('bookmarklet');
  var imagesFound = bookmarklet.querySelector('.images');
  // clear images found
  imagesFound.innerHTML = '';
  // display bookmarklet
  bookmarklet.style.display = 'block';
  // close event
  bookmarklet.querySelector('#close')
             .addEventListener('click', function(){
               bookmarklet.style.display = 'none'
             });
}
// launch the bookmkarklet
bookmarkletLaunch(); 

这是bookmarkletLaunch()函数。在定义此函数之前,已加载书签脚本的 CSS,并将 HTML 容器添加到页面的 DOM 中。bookmarkletLaunch()函数的工作方式如下:

  1. 通过使用document.getElementById()获取具有 ID bookmarklet的 DOM 元素来检索书签脚本的主体容器。

  2. 使用 bookmarklet 元素来检索具有类 images 的子元素。querySelector() 方法允许您使用 CSS 选择器检索 DOM 元素。选择器允许您找到应用了一组 CSS 规则的 DOM 元素。您可以在 developer.mozilla.org/en-US/docs/Web/CSS/CSS_Selectors 找到 CSS 选择器的列表,您可以在 developer.mozilla.org/en-US/docs/Web/API/Document_object_model/Locating_DOM_elements_using_selectors 上阅读有关如何使用选择器定位 DOM 元素的相关信息。

  3. 通过将 innerHTML 属性设置为空字符串来清除 images 容器,并通过将 display CSS 属性设置为 block 来显示书签工具。

  4. 使用 #close 选择器来查找具有 ID close 的 DOM 元素。使用 addEventListener() 方法将 click 事件附加到该元素上。当用户点击该元素时,通过将 display 属性设置为 none 来隐藏书签工具的主要容器。

  5. bookmarkletLaunch() 函数在其定义之后执行。

在加载了书签工具的 CSS 样式和 HTML 容器之后,您需要在当前网站的 DOM 中找到图像元素。具有最小所需尺寸的图像必须添加到书签工具的 HTML 容器中。编辑 bookmarklet.js 静态文件,并将以下加粗的代码添加到 bookmarklet() 函数的底部:

function bookmarkletLaunch() {
  bookmarklet = document.getElementById('bookmarklet');
  var imagesFound = bookmarklet.querySelector('.images');
  // clear images found
  imagesFound.innerHTML = '';
  // display bookmarklet
  bookmarklet.style.display = 'block';
  // close event
  bookmarklet.querySelector('#close')
             .addEventListener('click', function(){
               bookmarklet.style.display = 'none'
             });
**// find images in the DOM with the minimum dimensions**
 **images =** **document****.****querySelectorAll****(****'img[src$=".jpg"], img[src$=".jpeg"], img[src$=".png"]'****);**
 **images.****forEach****(****image** **=>** **{**
**if****(image.****naturalWidth** **>= minWidth**
 **&& image.****naturalHeight** **>= minHeight)**
 **{**
**var** **imageFound =** **document****.****createElement****(****'img'****);**
 **imageFound.****src** **= image.****src****;**
 **imagesFound.****append****(imageFound);**
 **}**
 **})**
**}**
**// launch the bookmkarklet**
**bookmarkletLaunch****();** 

上述代码使用 img[src$=".jpg"]img[src$=".jpeg"]img[src$=".png"] 选择器来查找所有 <img> DOM 元素,其 src 属性分别以 .jpg.jpeg.png 结尾。使用这些选择器与 document.querySelectorAll() 结合,您可以在网站上找到所有显示的 JPEG 和 PNG 格式的图像。使用 forEach() 方法遍历结果。由于我们不认为小图像相关,因此会过滤掉它们。只有大于 minWidthminHeight 变量指定的尺寸的图像才用于结果。对于每个找到的图像,创建一个新的 <img> 元素,其中 src 源 URL 属性从原始图像复制并添加到 imagesFound 容器中。

由于安全原因,您的浏览器将阻止您在通过 HTTPS 提供的网站上使用 HTTP 运行书签工具。这就是我们为什么继续使用 RunServerPlus 来通过自动生成的 TLS/SSL 证书运行开发服务器的原因。记住,您在 第五章,实现社交认证 中学习了如何通过 HTTPS 运行开发服务器。

在生产环境中,需要一个有效的 TLS/SSL 证书。当您拥有域名时,您可以申请一个受信任的认证机构CA)为其颁发 TLS/SSL 证书,以便浏览器可以验证其身份。如果您想为真实域名获取一个受信任的证书,您可以使用Let’s Encrypt服务。Let’s Encrypt是一个非营利性 CA,它简化了免费获取和更新受信任的 TLS/SSL 证书。您可以在letsencrypt.org找到更多信息。

从壳式提示符中运行以下命令来启动开发服务器:

python manage.py runserver_plus --cert-file cert.crt 

在您的浏览器中打开https://127.0.0.1:8000/account/。使用现有用户登录,然后按照以下步骤点击并拖动BOOKMARK IT按钮到浏览器的书签栏:

图片

图 6.10:将BOOKMARK IT按钮添加到书签栏

在您的浏览器中打开您选择的网站,并点击书签栏中的Bookmark it快捷书签。您会看到网站上出现一个新的白色覆盖层,显示所有尺寸大于 250×250 像素的 JPEG 和 PNG 图像。

图 6.11 展示了在amazon.com/上运行的快捷书签:

图片

图 6.11:在 amazon.com 上加载的快捷书签

如果 HTML 容器没有出现,请检查RunServer壳式控制台日志。如果您看到 MIME 类型错误,那么很可能是您的 MIME 映射文件不正确或需要更新。您可以通过在settings.py文件中添加以下行来为 JavaScript 和 CSS 文件应用正确的映射:

if DEBUG:
    import mimetypes
    mimetypes.add_type('application/javascript', '.js', True)
    mimetypes.add_type('text/css', '.css', True) 

HTML 容器包括可以保存的图像。我们现在将实现用户点击所需图像以保存它的功能。

编辑js/bookmarklet.js静态文件,并在bookmarklet()函数底部添加以下代码:

function bookmarkletLaunch() {
  bookmarklet = document.getElementById('bookmarklet');
  var imagesFound = bookmarklet.querySelector('.images');
  // clear images found
  imagesFound.innerHTML = '';
  // display bookmarklet
  bookmarklet.style.display = 'block';
  // close event
  bookmarklet.querySelector('#close')
             .addEventListener('click', function(){
               bookmarklet.style.display = 'none'
             });
  // find images in the DOM with the minimum dimensions
  images = document.querySelectorAll('img[src$=".jpg"], img[src$=".jpeg"], img[src$=".png"]');
  images.forEach(image => {
    if(image.naturalWidth >= minWidth
       && image.naturalHeight >= minHeight)
    {
      var imageFound = document.createElement('img');
      imageFound.src = image.src;
      imagesFound.append(imageFound);
    }
  })
**// select image event**
 **imagesFound.****querySelectorAll****(****'img'****).****forEach****(****image** **=>** **{**
 **image.****addEventListener****(****'click'****,** **function****(****event****){**
 **imageSelected = event.****target****;**
 **bookmarklet.****style****.****display** **=** **'none'****;**
**window****.****open****(siteUrl +** **'images/create/?url='**
 **+** **encodeURIComponent****(imageSelected.****src****)**
 **+** **'&title='**
 **+** **encodeURIComponent****(****document****.****title****),**
**'_blank'****);**
 **})**
 **})**
}
// launch the bookmkarklet
bookmarkletLaunch(); 

上述代码的工作原理如下:

  1. click()事件附加到imagesFound容器内的每个图像元素上。

  2. 当用户点击任何图像时,点击的图像元素被存储在变量imageSelected中。

  3. 然后通过将display属性设置为none来隐藏快捷书签。

  4. 会打开一个新的浏览器窗口,其中包含用于在网站上保存新图像的 URL。网站<title>元素的内容通过title GET参数传递到 URL 中,而选定的图像 URL 通过url参数传递。

使用您的浏览器打开一个新的 URL,例如,commons.wikimedia.org/,如下所示:

图片

图 6.12:维基媒体共享资源网站

图 6.126.15图像:以色列北部的胡拉谷中的一群鹤(Grus grus),作者 Tomere(许可:Creative Commons Attribution-Share Alike 4.0 国际:https://creativecommons.org/licenses/by-sa/4.0/deed.en)

点击收藏它书签工具以显示图片选择覆盖层。您将看到如下所示的图片选择覆盖层:

图 6.13:在外部网站上加载的书签工具

如果您点击图片,您将被重定向到图片创建页面,传递网站的标题和所选图片的 URL 作为GET参数。页面将如下所示:

图 6.14:收藏图片的表单

恭喜!这是您的第一个 JavaScript 书签工具,它已完全集成到您的 Django 项目中。接下来,我们将为图片创建详细视图并实现图片的规范 URL。

创建图片的详细视图

现在,让我们创建一个简单的详细视图来显示网站上已收藏的图片。打开images应用的views.py文件,并向其中添加以下代码:

from django.shortcuts import get_object_or_404
from .models import Image
def image_detail(request, id, slug):
    image = get_object_or_404(Image, id=id, slug=slug)
    return render(
 request,
 'images/image/detail.html',
 {'section': 'images', 'image': image}
    ) 

这是一个简单的视图来显示图片。编辑images应用的urls.py文件,并添加以下加粗的 URL 模式:

urlpatterns = [
    path('create/', views.image_create, name='create'),
 **path(**
**'detail/<int:id>/<slug:slug>/'****,**
 **views.image_detail,**
 **name=****'detail'**
**),**
] 

编辑images应用的models.py文件,并将get_absolute_url()方法添加到Image模型中,如下所示:

**from** **django.urls** **import** **reverse**
class Image(models.Model):
    # ...
**def****get_absolute_url****(****self****):**
**return** **reverse(****'images:detail'****, args=[self.****id****, self.slug])** 

记住,为对象提供规范 URL 的常见模式是在模型中定义get_absolute_url()方法。

最后,在/templates/images/image/模板目录内为images应用创建一个模板,命名为detail.html。向其中添加以下代码:

{% extends "base.html" %}
{% block title %}{{ image.title }}{% endblock %}
{% block content %}
  <h1>{{ image.title }}</h1>
<img src="img/{{ image.image.url }}" class="image-detail">
  {% with total_likes=image.users_like.count %}
    <div class="image-info">
<div>
<span class="count">
          {{ total_likes }} like{{ total_likes|pluralize }}
        </span>
</div>
      {{ image.description|linebreaks }}
    </div>
<div class="image-likes">
      {% for user in image.users_like.all %}
        <div>
          {% if user.profile.photo %}
            <img src="img/{{ user.profile.photo.url }}">
          {% endif %}
          <p>{{ user.first_name }}</p>
</div>
      {% empty %}
        Nobody likes this image yet.
      {% endfor %}
    </div>
  {% endwith %}
{% endblock %} 

这是显示已收藏图片详细视图的模板。我们使用了{% with %}标签来创建total_likes变量,该变量包含一个查询集的结果,该查询集统计了所有用户的点赞数。通过这样做,我们避免了两次评估同一个查询集(第一次用于显示点赞总数,第二次用于使用pluralize模板过滤器)。我们还包含了图片描述,并添加了一个{% for %}循环来遍历image.users_like.all,以显示所有喜欢这张图片的用户。

当您需要在模板中重复查询时,请使用{% with %}模板标签来防止额外的数据库查询。

现在,在您的浏览器中打开一个外部 URL,并使用书签工具收藏一张新图片。发布图片后,您将被重定向到图片详细页面。页面将包含以下成功消息:

图 6.15:图片书签的图片详细页面

太好了!您完成了书签工具的功能。接下来,您将学习如何为图片创建缩略图。

使用 easy-thumbnails 创建图片缩略图

我们在详细页面上显示原始图像,但不同图像的尺寸可能差异很大。某些图像的文件大小可能非常大,加载它们可能需要很长时间。以统一方式显示优化图像的最佳方法是生成缩略图。缩略图是较大图像的小型图像表示。缩略图在浏览器中加载更快,并且是统一非常不同尺寸图像的绝佳方式。我们将使用名为easy-thumbnails的 Django 应用程序为用户书签的图像生成缩略图。

打开终端,使用以下命令安装easy-thumbnails

python -m pip install easy-thumbnails==2.8.5 

编辑bookmarks项目的settings.py文件,并将easy_thumbnails添加到INSTALLED_APPS设置中,如下所示:

INSTALLED_APPS = [
    # ...
**'****easy_thumbnails'****,**
] 

然后,运行以下命令以将应用程序与数据库同步:

python manage.py migrate 

您将看到包括以下行的输出:

Applying easy_thumbnails.0001_initial... OK
Applying easy_thumbnails.0002_thumbnaildimensions... OK 

easy-thumbnails应用程序提供了多种定义图像缩略图的方式。该应用程序提供了一个{% thumbnail %}模板标签,用于在模板中生成缩略图,以及一个自定义的ImageField,如果您想在模型中定义缩略图。让我们使用模板标签方法。

编辑images/image/detail.html模板并考虑以下行:

<img src="img/{{ image.image.url }}" class="image-detail"> 

以下行应替换前面的行:

**{%** **load** **thumbnail %}**
**<****a****href****=****"****{{ image.image.url }}****"****>**
<img src="img/**{%** **thumbnail** **image.image 300x0 %}**" class="image-detail">
**</****a****>** 

我们定义了一个宽度为300像素且高度灵活的缩略图,通过使用值0来保持宽高比。用户第一次加载此页面时,将创建一个缩略图图像。缩略图存储在原始文件相同的目录中。位置由MEDIA_ROOT设置和Image模型的image字段的upload_to属性定义。生成的缩略图将在以下请求中提供。

从 shell 提示符运行以下命令来启动开发服务器:

python manage.py runserver_plus --cert-file cert.crt 

访问现有图像的详细页面。缩略图将被生成并在网站上显示。右键单击图像,在新浏览器标签页中打开,如下所示:

图片

图 6.16:在新浏览器标签页中打开图像

在浏览器中检查生成的图像的 URL。它应该如下所示:

图片

图 6.17:生成的图像的 URL

原始文件名后面跟着创建缩略图所使用的设置的其他详细信息。对于 JPEG 图像,您将看到一个类似filename.jpg.300x0_q85.jpg的文件名,其中300x0表示生成缩略图时使用的尺寸参数,而85是库生成缩略图时使用的默认 JPEG 质量值。

您可以使用不同的质量值,使用quality参数。要设置最高的 JPEG 质量,您可以使用值100,如下所示:{% thumbnail image.image 300x0 quality=100 %}。更高的质量将意味着更大的文件大小。

easy-thumbnails 应用程序提供了几个选项来自定义您的缩略图,包括裁剪算法和可以应用的不同效果。如果您在生成缩略图时遇到任何问题,可以将 THUMBNAIL_DEBUG = True 添加到 settings.py 文件中以获取调试信息。您可以在 easy-thumbnails.readthedocs.io/ 阅读有关 easy-thumbnails 的完整文档。

使用 JavaScript 添加异步操作

我们打算在图片详情页添加一个 like 按钮,让用户点击它来喜欢一张图片。当用户点击 like 按钮时,我们将使用 JavaScript 向网页服务器发送 HTTP 请求。这将执行 like 操作而不会重新加载整个页面。为了实现这个功能,我们将实现一个允许用户喜欢/不喜欢图片的视图。

JavaScript 的 Fetch API 是从网页浏览器向网页服务器发送异步 HTTP 请求的内置方式。通过使用 Fetch API,您可以在不刷新整个页面的情况下发送和检索来自网页服务器的数据。Fetch API 作为浏览器内置的 XMLHttpRequest (XHR) 对象的现代替代品推出,该对象用于在不重新加载页面的情况下发送 HTTP 请求。用于在不重新加载页面的情况下异步发送和检索来自网页服务器数据的网页开发技术也被称为 AJAX,代表 Asynchronous JavaScript and XML。AJAX 这个名字具有误导性,因为 AJAX 请求不仅可以交换 XML 格式的数据,还可以交换 JSON、HTML 和纯文本等格式的数据。您可能会在互联网上找到对 Fetch API 和 AJAX 的混淆性引用。

您可以在 developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch 找到有关 Fetch API 的信息。

我们将首先实现执行 likeunlike 操作的视图,然后我们将向相关的模板添加 JavaScript 代码以执行异步 HTTP 请求。

编辑 images 应用程序的 views.py 文件,并向其中添加以下代码:

from django.http import JsonResponse
from django.views.decorators.http import require_POST
@login_required
@require_POST
def image_like(request):
    image_id = request.POST.get('id')
    action = request.POST.get('action')
    if image_id and action:
        try:
            image = Image.objects.get(id=image_id)
            if action == 'like':
                image.users_like.add(request.user)
            else:
                image.users_like.remove(request.user)
            return JsonResponse({'status': 'ok'})
        except Image.DoesNotExist:
            pass
return JsonResponse({'status': 'error'}) 

我们为新的视图使用了两个装饰器。login_required 装饰器阻止未登录的用户访问此视图。require_POST 装饰器如果 HTTP 请求不是通过 POST 执行,则返回一个 HttpResponseNotAllowed 对象(状态码 405)。这样,您只为这个视图允许 POST 请求。

Django 还提供了一个 require_GET 装饰器,仅允许 GET 请求,以及一个 require_http_methods 装饰器,您可以将允许的方法列表作为参数传递。

此视图期望以下 POST 参数:

  • image_id:用户执行操作时 Image 对象的 ID

  • action:用户想要执行的操作,应该是一个值为 likeunlike 的字符串

我们使用了 Django 为 Image 模型的 users_like 多对多字段提供的管理器,以便使用 add()remove() 方法添加或删除关系中的对象。如果调用 add() 方法时传递的对象已经在相关对象集中存在,则不会重复。如果调用 remove() 方法时传递的对象不在相关对象集中,则不会发生任何操作。多对多管理器的另一个有用方法是 clear(),它从相关对象集中删除所有对象。

为了生成视图响应,我们使用了 Django 提供的 JsonResponse 类,它返回一个带有 application/json 内容类型的 HTTP 响应,将给定的对象转换为 JSON 输出。

编辑 images 应用程序的 urls.py 文件,并添加以下加粗的 URL 模式:

urlpatterns = [
    path('create/', views.image_create, name='create'),
    path(
        'detail/<int:id>/<slug:slug>/',
        views.image_detail,
        name='detail'
 ),
 **path(****'like/'****, views.image_like, name=****'like'****),**
**]** 

在 DOM 上加载 JavaScript

我们需要在图像详情模板中添加 JavaScript 代码。为了在我们的模板中使用 JavaScript,我们首先在项目的 base.html 模板中添加一个基本包装器。

编辑 account 应用程序的 base.html 模板,并在关闭 </body> HTML 标签之前包含以下加粗的代码:

<!DOCTYPE html>
<html>
<head>
 ...
</head>
<body>
  ...
**<****script****>**
**document****.****addEventListener****(****'DOMContentLoaded'****,** **(****event****) =>** **{**
**// DOM loaded**
 **{% block domready %}**
 **{% endblock %}**
 **})**
**</****script****>**
</body>
</html> 

我们添加了一个 <script> 标签来包含 JavaScript 代码。document.addEventListener() 方法用于定义当给定事件触发时将被调用的函数。我们传递事件名称 DOMContentLoaded,它在初始 HTML 文档完全加载并且 DOM 层次结构已完全构建时触发。通过使用此事件,我们确保在交互任何 HTML 元素和操作 DOM 之前,DOM 已完全构建。函数内的代码仅在 DOM 准备就绪后执行。

在文档就绪处理程序内部,我们包含了一个名为 domready 的 Django 模板块。任何扩展 base.html 模板的模板都可以使用此块来包含在 DOM 准备就绪时执行的特定 JavaScript 代码。

不要被 JavaScript 代码和 Django 模板标签搞混。Django 模板语言在服务器端渲染以生成 HTML 文档,而 JavaScript 在客户端的浏览器中执行。在某些情况下,使用 Django 动态生成 JavaScript 代码是有用的,以便使用 QuerySets 或服务器端计算的结果在 JavaScript 中定义变量。

本章的示例在 Django 模板中包含 JavaScript 代码。向模板添加 JavaScript 代码的首选方法是加载 .js 文件,这些文件作为静态文件提供,特别是如果你使用的是大型脚本。

JavaScript 中 HTTP 请求的跨站请求伪造

第二章使用高级功能增强你的博客中,你学习了跨站请求伪造CSRF)。当 CSRF 保护激活时,Django 会在所有POST请求中查找 CSRF 令牌。当你提交表单时,你可以使用{% csrf_token %}模板标签将令牌与表单一起发送。在 JavaScript 中执行的 HTTP 请求必须在每个POST请求中传递 CSRF 令牌。

Django 允许你在 HTTP 请求中设置一个自定义的X-CSRFToken头,其值为 CSRF 令牌。

要在从 JavaScript 发起的 HTTP 请求中包含令牌,我们需要从csrftokencookie 中检索 CSRF 令牌,该 cookie 由 Django 在 CSRF 保护激活时设置。为了处理 cookie,我们将使用 JavaScript Cookie。JavaScript Cookie 是一个用于处理 cookie 的轻量级 JavaScript API。您可以在github.com/js-cookie/js-cookie了解更多信息。

编辑account应用的base.html模板,并在<body>元素的底部添加以下加粗代码:

<!DOCTYPE html>
<html>
<head>
 ...
</head>
<body>
  ...
 **<script src=****"//cdn.jsdelivr.net/npm/js-cookie@3.0.5/dist/js.cookie.min.js"****></script>**
  <script>
 **const csrftoken = Cookies.get(****'csrftoken'****);**
    document.addEventListener('DOMContentLoaded', (event) => {
      // DOM loaded
      {% block domready %}
      {% endblock %}
    })
  </script>
</body>
</html> 

我们已经实现了以下功能:

  1. JavaScript Cookie 插件是从公共内容分发网络CDN)加载的。

  2. 使用Cookies.get()检索csrftokencookie 的值,并将其存储在 JavaScript 常量csrftoken中。

我们必须在所有使用不安全 HTTP 方法(如POSTPUT)的 JavaScript fetch 请求中包含 CSRF 令牌。在发送 HTTP POST请求时,我们将在自定义 HTTP 头X-CSRFToken中包含csrftoken常量。

你可以在docs.djangoproject.com/en/5.0/ref/csrf/#ajax找到有关 Django CSRF 保护和 AJAX 的更多信息。

接下来,我们将实现用户喜欢/取消喜欢图像的 HTML 和 JavaScript 代码。

使用 JavaScript 执行 HTTP 请求

编辑images/image/detail.html模板,并添加以下加粗代码:

{% extends "base.html" %}
{% block title %}{{ image.title }}{% endblock %}
{% block content %}
  <h1>{{ image.title }}</h1>
  {% load thumbnail %}
  <a href="{{ image.image.url }}">
<img src="img/{% thumbnail image.image 300x0 %}" class="image-detail">
</a>
  {% with total_likes=image.users_like.count **users_like=image.users_like.all** %}
    <div class="image-info">
<div>
<span class="count">
**<****span****class****=****"total"****>****{{ total_likes }}****</****span****>**
        like{{ total_likes|pluralize }}
        </span>
**<****a****href****=****"****#"****data-id****=****"{{ image.id }}"****data-action****=****"{% if request.user in users_like %}un{% endif %}like"**
**class****=****"like button"****>**
 **{% if request.user not in users_like %}**
 **Like**
 **{% else %}**
 **Unlike**
 **{% endif %}**
**</****a****>**
</div>
      {{ image.description|linebreaks }}
    </div>
<div class="image-likes">
      {% for user in users_like %}
        <div>
          {% if user.profile.photo %}
            <img src="img/{{ user.profile.photo.url }}">
          {% endif %}
          <p>{{ user.first_name }}</p>
</div>
      {% empty %}
        Nobody likes this image yet.
      {% endfor %}
    </div>
  {% endwith %}
{% endblock %} 

在前面的代码中,我们向{% with %}模板标签添加了另一个变量来存储image.users_like.all查询的结果,以防止查询多次对数据库执行。该变量用于检查当前用户是否在这个列表中,使用{% if request.user in users_like %}{% if request.user not in users_like %}。然后,使用相同的变量遍历喜欢该图像的用户,使用{% for user in users_like %}

我们已将喜欢该图像的用户总数添加到该页面,并为用户添加了喜欢/取消喜欢图像的链接。相关的对象集users_like用于检查request.user是否包含在相关对象集中,根据用户与该图像的当前关系显示喜欢取消喜欢文本。我们已向<a>HTML 链接元素添加以下属性:

  • data-id:显示的图像的 ID。

  • data-action:用户点击链接时要执行的操作。这可以是 likeunlike

任何以 data- 开头的名称的 HTML 元素属性都是数据属性。数据属性用于存储应用程序的定制数据。

我们将在 HTTP 请求中将 data-iddata-action 属性的值发送到 image_like 视图。当用户点击 like/unlike 链接时,我们将在浏览器中执行以下操作:

  1. image_like 视图发送 HTTP POST 请求,并将图像 idaction 参数传递给它。

  2. 如果 HTTP 请求成功,更新 <a> HTML 元素的 data-action 属性以使用相反的操作(like / unlike),并相应地修改其显示文本。

  3. 更新页面上显示的点赞总数。

images/image/detail.html 模板的底部添加以下 domready 块:

{% block domready %}
  const url = '{% url "images:like" %}';
  var options = {
    method: 'POST',
    headers: {'X-CSRFToken': csrftoken},
    mode: 'same-origin'
  }
  document.querySelector('a.like')
          .addEventListener('click', function(e){
    e.preventDefault();
    var likeButton = this;
  });
{% endblock %} 

上述代码的工作原理如下:

  1. 使用 {% url %} 模板标签构建 images:like URL。生成的 URL 存储在 url JavaScript 常量中。

  2. 创建了一个 options 对象,其中包含将传递给 Fetch API 的 HTTP 请求的选项。这些是:

    • method:要使用的 HTTP 方法。在这种情况下,它是 POST

    • headers:要包含在请求中的附加 HTTP 头。我们包含具有 csrftoken 常量值的 X-CSRFToken 头。

    • mode:HTTP 请求的模式。我们使用 same-origin 来表示请求是针对同一源的。您可以在 developer.mozilla.org/en-US/docs/Web/API/Request/mode 找到有关模式的更多信息。

  3. 使用 a.like 选择器通过 document.querySelector() 查找所有具有 like 类的 HTML 文档 <a> 元素。

  4. 为使用选择器指定的元素定义了 click 事件的事件监听器。每次用户点击 like/unlike 链接时,都会执行此函数。

  5. 在处理函数内部,使用 e.preventDefault() 来避免 <a> 元素的默认行为。这将阻止链接元素的默认行为,停止事件传播,并防止链接跟随 URL。

  6. likeButton 变量用于存储对 this 的引用,即触发事件的元素。

现在我们需要使用 Fetch API 发送 HTTP 请求。编辑 images/image/detail.html 模板的 domready 块,并添加以下加粗的代码:

{% block domready %}
  const url = '{% url "images:like" %}';
  var options = {
    method: 'POST',
    headers: {'X-CSRFToken': csrftoken},
    mode: 'same-origin'
  }
  document.querySelector('a.like')
          .addEventListener('click', function(e){
    e.preventDefault();
    var likeButton = this;
 **// add request body**
 **var formData = new FormData();**
 **formData.append(****'id'****, likeButton.dataset.****id****);**
 **formData.append(****'action'****, likeButton.dataset.action);**
 **options[****'body'****] = formData;**
 **// send HTTP request**
 **fetch(url, options)**
 **.then(response => response.json())**
 **.then(data => {**
**if** **(data[****'****status'****] ===** **'ok'****)**
 **{**
 **}**
 **})**
  });
{% endblock %} 

新代码的工作原理如下:

  1. 创建一个 FormData 对象来构建一组表示表单字段及其值的键/值对。该对象存储在 formData 变量中。

  2. image_like Django 视图所期望的 idaction 参数被添加到 formData 对象中。这些参数的值是从点击的 likeButton 元素中检索的。使用 dataset.iddataset.action 访问 data-iddata-action 属性。

  3. 在用于 HTTP 请求的 options 对象中添加了一个新的 body 键,其值是 formData 对象。

  4. 通过调用 fetch() 函数使用 Fetch API。将之前定义的 url 变量作为请求的 URL 传递,并将 options 对象作为请求的选项传递。

  5. fetch() 函数返回一个解析为 Response 对象的承诺,Response 对象是 HTTP 响应的表示。使用 .then() 方法定义承诺的处理程序。为了提取 JSON 主体内容,我们使用 response.json()。你可以在 developer.mozilla.org/en-US/docs/Web/API/Response 上了解更多关于 Response 对象的信息。

  6. .then() 方法再次被用来定义一个处理程序,用于处理提取到 JSON 的数据。在这个处理程序中,使用接收到的数据的 status 属性来检查其值是否为 ok

你添加了发送 HTTP 请求和处理响应的功能。在请求成功后,你需要将按钮及其相关操作更改为相反的:从 点赞取消点赞,或从 取消点赞点赞。通过这样做,用户可以撤销他们的操作。

编辑 images/image/detail.html 模板的 domready 块,并添加以下加粗的代码:

{% block domready %}
  var url = '{% url "images:like" %}';
  var options = {
    method: 'POST',
    headers: {'X-CSRFToken': csrftoken},
    mode: 'same-origin'
  }
  document.querySelector('a.like')
          .addEventListener('click', function(e){
    e.preventDefault();
    var likeButton = this;
    // add request body
    var formData = new FormData();
    formData.append('id', likeButton.dataset.id);
    formData.append('action', likeButton.dataset.action);
    options['body'] = formData;
    // send HTTP request
    fetch(url, options)
    .then(response => response.json())
    .then(data => {
      if (data['status'] === 'ok')
      {
 **var previousAction = likeButton.dataset.action;**
 **// toggle button text** **and** **data-action**
 **var action = previousAction ===** **'like'** **?** **'unlike'** **:** **'like'****;**
 **likeButton.dataset.action = action;**
 **likeButton.innerHTML = action;**
 **// update like count**
 **var likeCount = document.querySelector(****'span.count .total'****);**
 **var totalLikes = parseInt(likeCount.innerHTML);**
 **likeCount.innerHTML = previousAction ===** **'like'** **? totalLikes +** **1** **: totalLikes -** **1****;**
      }
    })
  });
{% endblock %} 

上述代码的工作原理如下:

  1. 从链接的 data-action 属性中检索按钮的先前操作,并将其存储在 previousAction 变量中。

  2. 切换链接的 data-action 属性和链接文本。这允许用户撤销他们的操作。

  3. 通过使用选择器 span.count.total 从 DOM 中检索总点赞数,并使用 parseInt() 将其解析为整数。根据执行的操作(点赞取消点赞)增加或减少总点赞数。

在你的浏览器中打开你上传的图片的详细页面。你应该能够看到以下初始点赞数和 点赞 按钮,如下所示:

图 6.18:图片详细模板中的点赞数和 点赞 按钮

点击 点赞 按钮。你会注意到总点赞数增加一个,按钮文本变为 取消点赞,如下所示:

图 6.19:点击 点赞 按钮后的点赞数和按钮

如果你点击 取消点赞 按钮,将执行操作,然后按钮的文本变回 点赞,总计数相应地更改。

在编写 JavaScript 时,尤其是在执行 AJAX 请求时,建议使用一个用于调试 JavaScript 和 HTTP 请求的工具。大多数现代浏览器都包含用于调试 JavaScript 的开发者工具。通常,您可以在网站上任何地方右键单击以打开上下文菜单,然后点击检查检查元素来访问您浏览器的网页开发者工具。

在下一节中,您将学习如何使用 JavaScript 和 Django 的异步 HTTP 请求来实现无限滚动分页。

为图像列表添加无限滚动分页

接下来,我们需要列出网站上所有已标记的图片。我们将使用 JavaScript 请求来构建无限滚动功能。无限滚动是通过在用户滚动到页面底部时自动加载下一组结果来实现的。

让我们实现一个图像列表视图,该视图将处理标准浏览器请求和来自 JavaScript 的请求。当用户最初加载图像列表页面时,我们将显示第一页的图像。当他们滚动到页面底部时,我们将使用 JavaScript 检索下一页的项目并将其附加到主页的底部。

相同的视图将处理标准和 AJAX 无限滚动分页。编辑images应用的views.py文件,并添加以下加粗的代码:

**from** **django.core.paginator** **import** **EmptyPage, PageNotAnInteger, Paginator**
**from** **django.http** **import** **HttpResponse**
# ...
**@login_required**
**def****image_list****(****request****):**
 **images = Image.objects.****all****()**
 **paginator = Paginator(images,** **8****)**
 **page = request.GET.get(****'page'****)**
 **images_only = request.GET.get(****'images_only'****)**
**try****:**
 **images = paginator.page(page)**
**except** **PageNotAnInteger:**
**# If page is not an integer deliver the first page**
 **images = paginator.page(****1****)**
**except** **EmptyPage:**
**if** **images_only:**
**# If AJAX request and page out of range**
**# return an empty page**
**return** **HttpResponse(****''****)**
**# If page out of range return last page of results**
 **images = paginator.page(paginator.num_pages)**
**if** **images_only:**
**return** **render(**
**request,**
**'images/image/list_images.html'****,**
 **{****'section'****:** **'images'****,** **'****images'****: images}**
 **)**
**return** **render(**
**request,**
**'images/image/list.html'****,**
 **{****'section'****:** **'images'****,** **'images'****: images}**
 **)** 

在这个视图中,创建了一个 QuerySet 来从数据库中检索所有图片。然后,创建了一个Paginator对象来对结果进行分页,每页检索八张图片。通过检索page HTTP GET参数来获取请求的页面编号。通过检索images_only HTTP GET参数来了解是否需要渲染整个页面或仅渲染新图片。

当浏览器请求时,我们将渲染整个页面。然而,对于 Fetch API 请求,我们只会渲染包含新图片的 HTML,因为我们将会将它们附加到现有的 HTML 页面上。

如果请求的页面超出了范围,将会触发EmptyPage异常。如果这种情况发生,并且只需要渲染图片,将返回一个空的HttpResponse。这将允许您在到达最后一页时在客户端停止 AJAX 分页。结果使用两个不同的模板进行渲染:

  • 对于包含images_only参数的 JavaScript HTTP 请求,将渲染list_images.html模板。此模板将仅包含请求页面的图片。

  • 对于浏览器请求,将渲染list.html模板。此模板将扩展base.html模板以显示整个页面,并将包含list_images.html模板以包含图像列表。

编辑images应用的urls.py文件,并添加以下加粗的 URL 模式:

urlpatterns = [
    path('create/', views.image_create, name='create'),
    path(
        'detail/<int:id>/<slug:slug>/',
        views.image_detail,
        name='detail'
 ),
    path('like/', views.image_like, name='like'),
 **path(****''****, views.image_list, name=****'list'****),**
] 

最后,您需要创建这里提到的模板。在 images/image/ 模板目录内,创建一个新的模板并命名为 list_images.html。向其中添加以下代码:

{% load thumbnail %}
{% for image in images %}
  <div class="image">
<a href="{{ image.get_absolute_url }}">
      {% thumbnail image.image 300x300 crop="smart" as im %}
      <a href="{{ image.get_absolute_url }}">
<img src="img/{{ im.url }}">
</a>
</a>
<div class="info">
<a href="{{ image.get_absolute_url }}" class="title">
        {{ image.title }}
      </a>
</div>
</div>
{% endfor %} 

上述模板显示了图片列表。您将使用它来返回 AJAX 请求的结果。在此代码中,您遍历图片并为每张图片生成一个正方形缩略图。您将缩略图的大小标准化为 300x300 像素。您还使用了 smart 裁剪选项。此选项表示图像必须通过从边缘移除具有最少熵的部分,逐步裁剪到请求的大小。

在同一目录下创建另一个模板,并将其命名为 images/image/list.html。向其中添加以下代码:

{% extends "base.html" %}
{% block title %}Images bookmarked{% endblock %}
{% block content %}
  <h1>Images bookmarked</h1>
<div id="image-list">
    {% include "images/image/list_images.html" %}
  </div>
{% endblock %} 

列表模板扩展了 base.html 模板。为了避免代码重复,您包括 images/image/list_images.html 模板以显示图片。images/image/list.html 模板将包含在页面滚动到页面底部时加载额外页面的 JavaScript 代码。

编辑 images/image/list.html 模板,并添加以下加粗的代码:

{% extends "base.html" %}
{% block title %}Images bookmarked{% endblock %}
{% block content %}
  <h1>Images bookmarked</h1>
<div id="image-list">
    {% include "images/image/list_images.html" %}
  </div>
{% endblock %}
**{% block domready %}**
 **var page = 1;**
 **var emptyPage = false;**
 **var blockRequest = false;**
**window.addEventListener('scroll', function(e) {**
 **var margin = document.body.clientHeight - window.innerHeight - 200;**
 **if(window.pageYOffset > margin && !emptyPage && !blockRequest) {**
 **blockRequest = true;**
 **page += 1;**
 **fetch('?images_only=1&page=' + page)**
 **.then(response => response.text())**
 **.then(html => {**
 **if (html === '') {**
 **emptyPage = true;**
 **}**
 **else {**
 **var imageList = document.getElementById('image-list');**
 **imageList.insertAdjacentHTML('beforeEnd', html);**
 **blockRequest = false;**
 **}**
 **})**
 **}**
 **});**
 **// Launch scroll event**
 **const scrollEvent = new Event('scroll');**
 **window.dispatchEvent(scrollEvent);**
**{% endblock %}** 

上述代码提供了无限滚动功能。您需要在 base.html 模板中定义的 domready 块中包含 JavaScript 代码。代码如下:

  1. 您定义以下变量:

    • page:存储当前页码。

    • empty_page:允许您知道用户是否在最后一页,并检索空页面。一旦您收到空页面,您将停止发送额外的 HTTP 请求,因为您将假设没有更多结果。

    • block_request:在 HTTP 请求进行时阻止您发送额外的请求。

  2. 您使用 window.addEventListener() 捕获 scroll 事件并为它定义一个处理函数。

  3. 您计算 margin 变量以获取总文档高度与窗口内部高度之间的差值,因为那是用户可以滚动的剩余内容的高度。您从结果中减去 200 的值,以便当用户距离页面底部小于 200 像素时加载下一页。

  4. 在发送 HTTP 请求之前,您需要检查以下内容:

    • window.pageYOffset 的值高于计算出的边距。

    • 用户没有到达结果页的最后一页(emptyPage 必须为 false)。

    • 没有其他正在进行的 HTTP 请求(blockRequest 必须为 false)。

  5. 如果满足上述条件,您将 blockRequest 设置为 true 以防止 scroll 事件触发额外的 HTTP 请求,并将 page 计数器增加 1 以获取下一页。

  6. 您使用 fetch() 发送 HTTP GET 请求,设置 URL 参数 image_only=1 以获取图片的 HTML 而不是整个 HTML 页面,以及 page 用于请求的页面编号。

  7. 使用 response.text() 从 HTTP 响应中提取正文内容,并相应地处理返回的 HTML:

    • 如果响应没有内容:你到达了结果的末尾,没有更多的页面可以加载。你将emptyPage设置为true以防止额外的 HTTP 请求。

    • 如果响应包含数据:你将数据追加到具有image-list ID 的 HTML 元素中。页面内容垂直扩展,当用户接近页面底部时追加结果。通过将blockRequest设置为false来移除对额外 HTTP 请求的锁定。

  8. 在事件监听器下方,当页面加载时,你模拟一个初始的scroll事件。你通过创建一个新的Event对象来创建事件,然后使用window.dispatchEvent()来触发它。这样做可以确保如果初始内容适合窗口且没有滚动,事件将被触发。

在你的浏览器中打开https://127.0.0.1:8000/images/。你会看到你迄今为止已书签的图片列表。它应该看起来像这样:

图片

图 6.20:具有无限滚动分页的图片列表页面

图 6.19 图片归属:

滚动到页面底部以加载额外的页面。确保你已经使用书签工具书签了超过八张图片,因为这是你每页显示的图片数量。

你可以使用浏览器开发者工具来跟踪 AJAX 请求。通常,你可以在网站上任何地方右键点击以打开上下文菜单,然后点击InspectInspect Element来访问浏览器的网络开发者工具。寻找网络请求的面板。

重新加载页面并滚动到页面底部以加载新页面。你会看到第一页的请求和额外的页面 AJAX 请求,如图 6.21所示:

图片

图 6.21:浏览器开发者工具中注册的 HTTP 请求

在你运行 Django 的 shell 中,你也会看到请求,如下所示:

[04/Jan/2024 08:14:20] "GET /images/ HTTP/1.1" 200
[04/Jan/2024 08:14:25] "GET /images/?images_only=1&page=2 HTTP/1.1" 200
[04/Jan/2024 08:14:26] "GET /images/?images_only=1&page=3 HTTP/1.1" 200
[04/Jan/2024 08:14:26] "GET /images/?images_only=1&page=4 HTTP/1.1" 200 

最后,编辑account应用的base.html模板,并添加粗体显示的images项目的 URL:

<ul class="menu">
  ...
  <li {% if section == "images" %}class="selected"{% endif %}>
<a href=**"{% url "****images:list****" %}"**>Images</a>
</li>
  ...
</ul> 

现在,你可以从主菜单访问图片列表。

摘要

在本章中,你创建了具有多对多关系的模型,并学习了如何自定义表单的行为。你构建了一个 JavaScript 书签工具来分享来自其他网站的图片到你的网站上。本章还涵盖了使用easy-thumbnails应用程序创建图片缩略图的方法。

最后,你使用 JavaScript Fetch API 实现了 AJAX 视图,并添加了无限滚动分页到图片列表视图。

在下一章中,你将学习如何构建关注系统和活动流。你将使用泛型关系、信号和反规范化。你还将学习如何使用 Django 和 Redis 来统计图片查看次数并生成图片排名。

其他资源

以下资源提供了与本章所涵盖主题相关的额外信息:

第七章:跟踪用户行为

在上一章中,你构建了一个 JavaScript 书签工具来分享你平台上其他网站的内容。你还在项目中实现了 JavaScript 的异步操作并创建了一个无限滚动。

在本章中,你将学习如何构建一个关注系统并创建用户活动流。你还将了解 Django 信号的工作原理,并将 Redis 的快速 I/O 存储集成到你的项目中以存储项目视图。

本章将涵盖以下内容:

  • 构建关注系统

  • 使用中间模型创建多对多关系

  • 创建活动流应用程序

  • 向模型添加通用关系

  • 优化相关对象的查询集

  • 使用信号进行去规范化计数

  • 使用 Django 调试工具栏获取相关调试信息

  • 使用 Redis 统计图片查看次数

  • 使用 Redis 创建最受欢迎图片的排名

功能概述

图 7.1展示了本章将要构建的视图、模板和功能表示:

图片

图 7.1:第七章构建的功能图

在本章中,你将构建user_list视图以列出所有用户和user_detail视图以显示单个用户资料。你将使用user_follow视图通过 JavaScript 实现关注系统,并存储用户关注。你将创建一个存储用户行为的系统,并实现创建账户、关注用户、创建图片和喜欢图片的操作。你将使用这个系统在dashboard视图中显示最新的行为流。你还将使用 Redis 在每次加载image_detail视图时存储一个视图,并创建image_ranking视图以显示最受欢迎图片的排名。

本章的源代码可以在github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter07找到。

本章中使用的所有 Python 包都包含在章节源代码中的requirements.txt文件中。你可以按照以下章节中的说明安装每个 Python 包,或者你可以使用命令python -m pip install -r requirements.txt一次性安装所有需求。

构建关注系统

让我们在项目中构建一个关注系统。这意味着你的用户将能够相互关注并跟踪其他用户在平台上分享的内容。用户之间的关系是多对多关系;这意味着一个用户可以关注多个用户,反过来,他们也可以被多个用户关注。

使用中间模型创建多对多关系

在前面的章节中,你通过向相关模型之一添加ManyToManyField并让 Django 创建关系数据库表来创建多对多关系。这在大多数情况下是合适的,但有时你可能需要为关系创建一个中间模型。当你想在关系上存储额外的信息时,例如关系创建的日期或描述关系性质的字段,创建中间模型是必要的。

让我们创建一个中间模型来建立用户之间的关系。使用中间模型的原因有两个:

  • 你正在使用 Django 提供的User模型,并且想要避免修改它

  • 你想要存储关系创建的时间

编辑account应用的models.py文件,并向其中添加以下代码:

class Contact(models.Model):
    user_from = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        related_name='rel_from_set',
        on_delete=models.CASCADE
    )
    user_to = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        related_name='rel_to_set',
        on_delete=models.CASCADE
    )
    created = models.DateTimeField(auto_now_add=True)
    class Meta:
        indexes = [
            models.Index(fields=['-created']),
        ]
        ordering = ['-created']
    def __str__(self):
        return f'{self.user_from} follows {self.user_to}' 

上述代码展示了你将用于用户关系的Contact模型。它包含以下字段:

  • user_from: 创建关系的用户的ForeignKey

  • user_to: 被关注的用户的ForeignKey

  • created: 一个带有auto_now_add=TrueDateTimeField字段,用于存储关系创建的时间

ForeignKey字段上会自动创建数据库索引。在模型的Meta类中,我们为created字段定义了一个降序数据库索引。我们还添加了ordering属性来告诉 Django 默认按created字段排序结果。我们通过在字段名前使用连字符来表示降序,例如-created

图 7.2展示了中间Contact模型及其对应的数据库表:

图片

图 7.2:中间Contact模型及其数据库表

使用 ORM,你可以创建一个用户user1关注另一个用户user2的关系,如下所示:

user1 = User.objects.get(id=1)
user2 = User.objects.get(id=2)
Contact.objects.create(user_from=user1, user_to=user2) 

相关管理器rel_from_setrel_to_set将返回Contact模型的 QuerySet。为了从User模型访问关系的另一端,最好在User中包含一个ManyToManyField,如下所示:

following = models.ManyToManyField(
    'self',
    through=Contact,
    related_name='followers',
    symmetrical=False
) 

在前面的示例中,你通过在ManyToManyField中添加through=Contact来告诉 Django 使用你的自定义中间模型来处理关系。这是一个从User模型到自身的多对多关系;你在ManyToManyField中引用'self'来创建与同一模型的关系。

当你在多对多关系中需要额外的字段时,为关系的每一侧创建一个带有ForeignKey的自定义模型。你可以使用中间模型来管理关系,或者你可以在相关模型之一中添加一个ManyToManyField字段,并通过将其包含在through参数中来告诉 Django 使用你的中间模型。

如果 User 模型是应用程序的一部分,你可以将前面的字段添加到模型中。然而,你不能直接修改 User 类,因为它属于 django.contrib.auth 应用。让我们采取稍微不同的方法,通过动态地将此字段添加到 User 模型中。

编辑 account 应用的 models.py 文件,并添加以下加粗的行:

**from** **django.contrib.auth** **import** **get_user_model**
# ...
**# Add the following field to User dynamically**
**user_model = get_user_model()**
**user_model.add_to_class(**
**'following'****,**
 **models.ManyToManyField(**
**'self'****,**
 **through=Contact,**
 **related_name=****'followers'****,**
 **symmetrical=****False**
 **)**
**)** 

在前面的代码中,你使用 Django 提供的通用函数 get_user_model() 获取用户模型。你使用 Django 模型的 add_to_class() 方法来猴子补丁 User 模型。

请注意,使用 add_to_class() 不是向模型中添加字段的推荐方式。然而,你可以利用它在这个案例中的使用,避免创建自定义用户模型,同时保留 Django 内置 User 模型的所有优点。

你还简化了使用 Django ORM 通过 user.followers.all()user.following.all() 获取相关对象的方式。你使用中间的 Contact 模型,避免了涉及额外数据库连接的复杂查询,就像你在自定义 Profile 模型中定义关系时那样。这个多对多关系的表将使用 Contact 模型创建。因此,动态添加的 ManyToManyField 不会对 Django User 模型产生任何数据库更改。

请记住,在大多数情况下,最好是向之前创建的 Profile 模型中添加字段,而不是对 User 模型进行猴子补丁。理想情况下,你不应该修改现有的 Django User 模型。Django 允许你使用自定义用户模型。如果你想使用自定义用户模型,请查看docs.djangoproject.com/en/5.0/topics/auth/customizing/#specifying-a-custom-user-model上的文档。

当你开始一个新的项目时,强烈建议你创建一个自定义用户模型,即使默认的 User 模型对你来说已经足够。这是因为你获得了自定义模型的选择权。

注意,该关系包括 symmetrical=False。当你在一个模型中定义一个与自身建立关系的 ManyToManyField 时,Django 会强制该关系是对称的。在这种情况下,你设置 symmetrical=False 来定义一个非对称关系(如果我理解正确,这并不意味着你自动跟随我)。

当你使用中间模型进行多对多关系时,一些相关管理器的功能被禁用,例如 add()create()remove()。你需要创建或删除中间模型的实例。

运行以下命令以生成 account 应用程序的初始迁移:

python manage.py makemigrations account 

你将获得如下所示的输出:

Migrations for 'account':
  account/migrations/0002_contact.py
    - Create model Contact 

现在,运行以下命令以将应用程序与数据库同步:

python manage.py migrate account 

你应该会看到一个包含以下行的输出:

Applying account.0002_contact... OK 

Contact 模型现在已同步到数据库,你可以创建用户之间的关系。然而,你的网站还没有提供浏览用户或查看特定用户资料的方法。让我们为 User 模型构建列表和详细视图。

创建用户资料的列表和详细视图

打开 account 应用程序的 views.py 文件,并添加以下以粗体显示的代码:

from django.contrib.auth import authenticate, **get_user_model,** login
from django.shortcuts import **get_object_or_404,** render
# ...
**User = get_user_model()**
**@login_required**
**def****user_list****(****request****):**
 **users = User.objects.****filter****(is_active=****True****)**
**return** **render(**
**request,**
**'account/user/list.html'****,**
**{****'section'****:** **'people'****,** **'users'****: users}**
 **)**
**@login_required**
**def****user_detail****(****request, username****):**
 **user = get_object_or_404(User, username=username, is_active=****True****)**
**return** **render(**
 **request,**
**'account/user/detail.html'****,**
 **{****'section'****:** **'people'****,** **'user'****: user}**
 **)** 

这些是针对 User 对象的简单列表和详细视图。我们通过使用 get_user_model() 函数动态检索 User 模型。user_list 视图获取所有活跃用户。Django 的 User 模型包含一个 is_active 标志,用于指定用户账户是否被视为活跃。你可以通过 is_active=True 过滤查询,以返回仅活跃用户。此视图返回所有结果,但你可以通过添加分页来改进它,就像你在 image_list 视图中做的那样。

user_detail 视图使用 get_object_or_404() 快捷方式检索具有给定用户名的活跃用户。如果找不到具有给定用户名的活跃用户,视图将返回 HTTP 404 响应。

编辑 account 应用程序的 urls.py 文件,并为每个视图添加一个 URL 模式,如下所示。新的代码以粗体显示:

urlpatterns = [
    # ...
    path('', include('django.contrib.auth.urls')),
    path('', views.dashboard, name='dashboard'),
    path('register/', views.register, name='register'),
    path('edit/', views.edit, name='edit'),
 **path(****'users/'****, views.user_list, name=****'user_list'****),**
 **path(****'users/<username>/'****, views.user_detail, name=****'user_detail'****),**
] 

你将使用 user_detail URL 模式来生成用户的规范 URL。你已经在模型中定义了一个 get_absolute_url() 方法,用于返回每个对象的规范 URL。另一种指定模型 URL 的方法是在项目中添加 ABSOLUTE_URL_OVERRIDES 设置。

通过在 user_detail URL 模式中使用用户名而不是用户 ID,你提高了可用性和安全性。与顺序 ID 不同,用户名通过隐藏你的数据结构来阻止枚举攻击。这使得攻击者更难预测 URL 并制定攻击向量。

编辑你项目的 settings.py 文件,并添加以下以粗体显示的代码:

**from** **django.urls** **import** **reverse_lazy**
# ...
**ABSOLUTE_URL_OVERRIDES = {**
**'auth.user'****:** **lambda** **u: reverse_lazy(****'user_detail'****, args=[u.username])**
**}** 

Django 会动态地将 get_absolute_url() 方法添加到任何出现在 ABSOLUTE_URL_OVERRIDES 设置中的模型。此方法返回设置中指定的给定模型的对应 URL。在上面的代码部分中,你为 auth.user 对象生成了给定用户的 user_detail URL。现在,你可以在 User 实例上使用 get_absolute_url() 来检索其对应的 URL。

使用以下命令打开 Python 命令行界面:

python manage.py shell 

然后运行以下代码进行测试:

>>> from django.contrib.auth.models import User
>>> user = User.objects.latest('id')
>>> str(user.get_absolute_url())
'/account/users/ellington/' 

返回的 URL 符合预期的格式,/account/users/<username>/

你需要为刚刚创建的视图创建模板。将以下目录和文件添加到 account 应用程序的 templates/account/ 目录中:

/user/
    detail.html
    list.html 

编辑 account/user/list.html 模板,并向其中添加以下代码:

{% extends "base.html" %}
{% load thumbnail %}
{% block title %}People{% endblock %}
{% block content %}
  <h1>People</h1>
<div id="people-list">
    {% for user in users %}
      <div class="user">
<a href="{{ user.get_absolute_url }}">
<img src="img/{% thumbnail user.profile.photo 180x180 %}">
</a>
<div class="info">
<a href="{{ user.get_absolute_url }}" class="title">
            {{ user.get_full_name }}
          </a>
</div>
</div>
    {% endfor %}
  </div>
{% endblock %} 

上述模板允许你列出网站上所有活跃用户。你遍历给定的用户,并使用来自easy-thumbnails{% thumbnail %}模板标签生成个人头像缩略图。

注意,用户需要有一个个人头像。为了给没有个人头像的用户使用默认头像,你可以在代码中添加一个if/else语句来检查用户是否有个人照片,例如{% if user.profile.photo %} {# photo thumbnail #} {% else %} {# default image #} {% endif %}

打开你的项目的base.html模板,并在以下菜单项的href属性中包含user_list URL。新的代码已加粗:

<ul class="menu">
  ...
  <li {% if section == "people" %}class="selected"{% endif %}>
<a href="**{% url "****user_list****" %}"**>People</a>
</li>
</ul> 

使用以下命令启动开发服务器:

python manage.py runserver 

在你的浏览器中打开http://127.0.0.1:8000/account/users/。你应该会看到一个类似以下用户列表:

图形用户界面,文本,网站  自动生成的描述

图 7.3:带有个人头像缩略图的用户列表页面

记住,如果你在生成缩略图时遇到任何困难,你可以在settings.py文件中添加THUMBNAIL_DEBUG = True来在 shell 中获得调试信息。

编辑account应用的account/user/detail.html模板,并向其中添加以下代码:

{% extends "base.html" %}
{% load thumbnail %}
{% block title %}{{ user.get_full_name }}{% endblock %}
{% block content %}
  <h1>{{ user.get_full_name }}</h1>
<div class="profile-info">
<img src="img/{% thumbnail user.profile.photo 180x180 %}" class="user-detail">
</div>
  {% with total_followers=user.followers.count %}
    <span class="count">
<span class="total">{{ total_followers }}</span>
      follower{{ total_followers|pluralize }}
    </span>
<a href="#" data-id="{{ user.id }}" data-action="{% if request.user in user.followers.all %}un{% endif %}follow" class="follow button">
      {% if request.user not in user.followers.all %}
        Follow
      {% else %}
        Unfollow
      {% endif %}
    </a>
<div id="image-list" class="image-container">
      {% include "images/image/list_images.html" with images=user.images_created.all %}
    </div>
  {% endwith %}
{% endblock %} 

确保不要将模板标签拆分成多行;Django 不支持多行标签。

detail模板中,显示用户个人资料,并使用{% thumbnail %}模板标签来显示个人头像。同时展示关注者总数,并提供一个关注或取消关注用户的链接。此链接将用于关注/取消关注特定用户。<a> HTML 元素的data-iddata-action属性包含用户 ID 和当链接元素被点击时执行的初始操作——followunfollow。初始操作(followunfollow)取决于请求页面的用户是否已经是该用户的关注者。用户标记的图片通过包含images/image/list_images.html模板来显示。

再次打开你的浏览器,点击一个标记了一些图片的用户。用户页面将如下所示:

图形用户界面,文本,应用程序,网站  自动生成的描述

图 7.4:用户详情页面

上述图片是 ataelw 创作的* Chick Corea*,采用 Creative Commons Attribution 2.0 通用许可:creativecommons.org/licenses/by/2.0/

使用 JavaScript 添加用户关注/取消关注操作

让我们添加关注/取消关注用户的功能。我们将创建一个新的视图来关注/取消关注用户,并使用 JavaScript 实现异步 HTTP 请求以执行关注/取消关注操作。

编辑account应用的views.py文件,并添加以下加粗代码:

from django.http import HttpResponse**, JsonResponse**
**from** **django.views.decorators.http** **import** **require_POST**
from .models import **Contact,** Profile
# ...
**@require_POST**
**@login_required**
**def****user_follow****(****request****):**
 **user_id = request.POST.get(****'id'****)**
 **action = request.POST.get(****'action'****)**
**if** **user_id** **and** **action:**
**try****:**
 **user = User.objects.get(****id****=user_id)**
**if** **action ==** **'follow'****:**
 **Contact.objects.get_or_create(**
 **user_from=request.user,**
 **user_to=user**
 **)**
**else****:**
 **Contact.objects.****filter****(**
 **user_from=request.user,**
 **user_to=user**
 **).delete()**
**return** **JsonResponse({****'status'****:****'ok'****})**
**except** **User.DoesNotExist:**
**return** **JsonResponse({****'status'****:****'error'****})**
**return** **JsonResponse({****'status'****:****'error'****})** 

user_follow 视图与你在 第六章 中创建的 image_like 视图非常相似,在您的网站上分享内容。由于你正在使用用于用户多对多关系的自定义中间模型,因此 ManyToManyField 的自动管理器的默认 add()remove() 方法不可用。相反,使用中间 Contact 模型来创建或删除用户关系。

编辑 account 应用程序的 urls.py 文件,并添加以下以粗体显示的 URL 模式:

urlpatterns = [
    path('', include('django.contrib.auth.urls')),
    path('', views.dashboard, name='dashboard'),
    path('register/', views.register, name='register'),
    path('edit/', views.edit, name='edit'),
    path('users/', views.user_list, name='user_list'),
    **path(****'****users/follow/'****, views.user_follow, name=****'user_follow'****),**
    path('users/<username>/', views.user_detail, name='user_detail'),
] 

确保将前面的模式放在 user_detail URL 模式之前。否则,任何对 /users/follow/ 的请求都将匹配 user_detail 模式的正则表达式,并且将执行该视图。记住,在每次 HTTP 请求中,Django 都会按照出现的顺序将请求的 URL 与每个模式进行比较,并在第一个匹配项处停止。

编辑 account 应用程序的 user/detail.html 模板,并向其中添加以下代码:

{% block domready %}
  const url = '{% url "user_follow" %}';
  var options = {
    method: 'POST',
    headers: {'X-CSRFToken': csrftoken},
    mode: 'same-origin'
  }
  document.querySelector('a.follow')
          .addEventListener('click', function(e){
    e.preventDefault();
    var followButton = this;
    // add request body
var formData = new FormData();
    formData.append('id', followButton.dataset.id);
    formData.append('action', followButton.dataset.action);
    options['body'] = formData;
    // send HTTP request
fetch(url, options)
    .then(response => response.json())
    .then(data => {
      if (data['status'] === 'ok')
      {
        var previousAction = followButton.dataset.action;
        // toggle button text and data-action
var action = previousAction === 'follow' ? 'unfollow' : 'follow';
        followButton.dataset.action = action;
        followButton.innerHTML = action;
        // update follower count
var followerCount = document.querySelector('span.count .total');
        var totalFollowers = parseInt(followerCount.innerHTML);
        followerCount.innerHTML = previousAction === 'follow' ? totalFollowers + 1 : totalFollowers - 1;
      }
    })
  });
{% endblock %} 

前面的模板块包含执行关注或取消关注特定用户的异步 HTTP 请求的 JavaScript 代码,以及切换关注/取消关注链接。

使用 Fetch API 执行 AJAX 请求,并根据其前一个值设置 data-action 属性和 HTML <a> 元素的文本。当操作完成后,页面上显示的关注者总数也会更新。

打开现有用户的用户详情页面,并点击 关注 链接来测试你刚刚构建的功能。你会在以下图像的左侧看到,关注者数量已经增加:

图片

图 7.5:关注者数量和关注/取消关注按钮

关注系统现在已经完成,用户可以相互关注。接下来,我们将构建一个活动流,为每个用户创建基于他们关注的人的相关内容。

创建活动流应用程序

许多社交网站向用户展示活动流,以便他们可以跟踪其他用户在平台上的行为。活动流是用户或一组用户最近执行的活动列表。例如,Facebook 的新闻源就是一个活动流。示例操作可以是 用户 X 收藏了图片 Y用户 X 现在正在关注用户 Y

你将构建一个活动流应用程序,以便每个用户都可以看到他们关注的用户的最近互动。为此,你需要一个模型来保存用户在网站上执行的操作,以及一个简单的方法来向流中添加操作。

在你的项目中使用以下命令创建一个名为 actions 的新应用程序:

python manage.py startapp actions 

将新应用程序添加到项目中的 settings.py 文件中的 INSTALLED_APPS 以激活该应用程序。新行以粗体显示:

INSTALLED_APPS = [
    # ...
**'actions.apps.ActionsConfig'****,**
] 

编辑 actions 应用程序的 models.py 文件,并向其中添加以下代码:

**from** **django.conf** **import** **settings**
from django.db import models
**class****Action****(models.Model):**
 **user = models.ForeignKey(**
 **settings.AUTH_USER_MODEL,**
 **related_name=****'actions'****,**
 **on_delete=models.CASCADE**
 **)**
 **verb = models.CharField(max_length=****255****)**
 **created = models.DateTimeField(auto_now_add=****True****)**
**class****Meta****:**
 **indexes = [**
 **models.Index(fields=[****'-created'****]),**
 **]**
 **ordering = [****'-created'****]** 

上述代码显示了将用于存储用户活动的Action模型。该模型的字段如下:

  • user: 执行该操作的用户;这是一个指向AUTH_USER_MODELForeignKey,默认情况下是 Django 的User模型。

  • verb: 描述用户所执行操作的动词。

  • created: 该操作创建的日期和时间。我们使用auto_now_add=True来自动将此字段设置为对象在数据库中首次保存时的当前日期和时间。

在模型的Meta类中,我们为created字段定义了一个降序数据库索引。我们还添加了ordering属性,告诉 Django 默认按created字段降序排序结果。

使用这个基本模型,您只能存储诸如“用户 X 做了某事”之类的操作。您需要一个额外的ForeignKey字段来保存涉及target对象的操作,例如“用户 X 收藏了图像 Y”或“用户 X 现在正在关注用户 Y”。如您所知,一个普通的ForeignKey只能指向一个模型。相反,您将需要一个方法,使操作的target对象成为现有模型的一个实例。这正是 Django 的contenttypes框架将帮助您完成的。

使用contenttypes框架

Django 在django.contrib.contenttypes位置包含一个contenttypes框架。此应用程序可以跟踪您项目中安装的所有模型,并提供一个通用接口与您的模型交互。

当您使用startproject命令创建新项目时,django.contrib.contenttypes应用程序默认包含在INSTALLED_APPS设置中。它被其他contrib包使用,例如认证框架和管理应用程序。

contenttypes应用程序包含一个ContentType模型。该模型的实例代表您应用程序的实际模型,并且当您的项目中新安装模型时,会自动创建新的ContentType实例。ContentType模型具有以下字段:

  • app_label: 这表示模型所属的应用程序名称。这自动从模型Meta选项的app_label属性中获取。例如,您的Image模型属于images应用程序。

  • model: 模型类名称。

  • name: 这是一个属性,表示模型的可读名称,自动从模型Meta选项的verbose_name属性中生成。

让我们看看您如何与ContentType对象交互。使用以下命令打开 shell:

python manage.py shell 

您可以通过使用app_labelmodel属性进行查询来获取与特定模型对应的ContentType对象,如下所示:

>>> from django.contrib.contenttypes.models import ContentType
>>> image_type = ContentType.objects.get(app_label='images', model='image')
>>> image_type
<ContentType: images | image> 

您还可以通过调用其model_class()方法从ContentType对象中检索模型类:

>>> image_type.model_class()
<class 'images.models.Image'> 

获取特定模型类的ContentType对象也很常见,如下所示:

>>> from images.models import Image
>>> ContentType.objects.get_for_model(Image)
<ContentType: images | image> 

这些只是使用contenttypes的一些示例。Django 提供了更多与之交互的方式。您可以在docs.djangoproject.com/en/5.0/ref/contrib/contenttypes/找到contenttypes框架的官方文档。

将泛型关系添加到您的模型中

在泛型关系中,ContentType对象扮演着指向用于关系的模型的角色。您需要在模型中设置泛型关系时使用三个字段:

  • 一个指向ContentTypeForeignKey字段:这将告诉您关系的模型是什么

  • 存储相关对象主键的字段:这通常将是一个PositiveIntegerField,以匹配 Django 的自动主键字段

  • 使用前两个字段定义和管理泛型关系的字段:contenttypes框架为此目的提供了一个GenericForeignKey字段

编辑actions应用程序的models.py文件,并向其中添加以下加粗的代码:

from django.conf import settings
**from** **django.contrib.contenttypes.fields** **import** **GenericForeignKey**
**from** **django.contrib.contenttypes.models** **import** **ContentType**
from django.db import models
class Action(models.Model):
    user = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        related_name='actions',
        on_delete=models.CASCADE
    )
    verb = models.CharField(max_length=255)
    created = models.DateTimeField(auto_now_add=True)
 **target_ct = models.ForeignKey(**
 **ContentType,**
 **blank=****True****,**
 **null=****True****,**
 **related_name=****'target_obj'****,**
 **on_delete=models.CASCADE**
 **)**
 **target_id = models.PositiveIntegerField(null=****True****, blank=****True****)**
 **target = GenericForeignKey(****'target_ct'****,** **'target_id'****)**
class Meta:
        indexes = [
            models.Index(fields=['-created']),
 **models.Index(fields=[****'target_ct'****,** **'target_id'****]),**
        ]
        ordering = ['-created'] 

我们已向Action模型添加了以下字段:

  • target_ct: 指向ContentType模型的ForeignKey字段

  • target_id: 存储相关对象主键的PositiveIntegerField

  • target: 一个基于前两个字段组合的指向相关对象的GenericForeignKey字段

我们还添加了一个包含target_cttarget_id字段的多个字段索引。

Django 不会在数据库中创建GenericForeignKey字段。唯一映射到数据库字段的是target_cttarget_id字段。这两个字段都有blank=Truenull=True属性,这样在保存Action对象时不需要target对象。

您可以通过使用泛型关系而不是外键来使您的应用程序更加灵活。泛型关系允许您以非排他方式关联模型,使单个模型能够关联到多个其他模型。

图 7.6显示了Action模型,包括与contenttypes Django contrib 包的ContentType模型的关联:

图 7.6

图 7.6:Action 模型和 ContentType 模型

运行以下命令为此应用程序创建初始迁移:

python manage.py makemigrations actions 

您应该看到以下输出:

Migrations for 'actions':
  actions/migrations/0001_initial.py
    - Create model Action
    - Create index actions_act_created_64f10d_idx on field(s) -created of model action
    - Create index actions_act_target__f20513_idx on field(s) target_ct, target_id of model action 

然后,运行下一个命令以将应用程序与数据库同步:

python manage.py migrate 

命令的输出应指示新迁移已应用,如下所示:

Applying actions.0001_initial... OK 

让我们将Action模型添加到管理网站。编辑actions应用程序的admin.py文件,并向其中添加以下代码:

from django.contrib import admin
**from** **.models** **import** **Action**
**@admin.register(****Action****)**
**class****ActionAdmin****(admin.ModelAdmin):**
 **list_display = [****'user'****,** **'verb'****,** **'target'****,** **'created'****]**
 **list_filter = [****'created'****]**
 **search_fields = [****'verb'****]** 

您已在管理网站上注册了Action模型。

使用以下命令启动开发服务器:

python manage.py runserver 

在您的浏览器中打开http://127.0.0.1:8000/admin/actions/action/add/。您应该看到创建新Action对象的页面,如下所示:

图 7.6

图 7.7:Django 管理网站上的添加操作页面

如您在前面的屏幕截图中所注意到的,只有映射到实际数据库字段的target_cttarget_id字段被显示。GenericForeignKey字段在表单中不出现。target_ct字段允许您选择您 Django 项目中注册的任何模型。您可以使用target_ct字段中的limit_choices_to属性将内容类型限制为有限的一组模型;limit_choices_to属性允许您将ForeignKey字段的内容限制为特定的值集。

actions应用目录内创建一个新文件,并将其命名为utils.py。您需要定义一个快捷函数,这将允许您以简单的方式创建新的Action对象。编辑新的utils.py文件,并向其中添加以下代码:

from django.contrib.contenttypes.models import ContentType
from .models import Action
def create_action(user, verb, target=None):
    action = Action(user=user, verb=verb, target=target)
    action.save() 

create_action()函数允许您创建可选包含target对象的操作。您可以在代码的任何位置使用此函数作为快捷方式将新操作添加到活动流中。

避免在活动流中重复操作

有时,您的用户可能会多次点击喜欢不喜欢按钮,或者在短时间内多次执行相同的操作。这很容易导致存储和显示重复操作。为了避免这种情况,让我们改进create_action()函数以跳过明显的重复操作。

按照以下方式编辑actions应用的utils.py文件:

**import** **datetime**
from django.contrib.contenttypes.models import ContentType
**from** **django.utils** **import** **timezone**
from .models import Action
def create_action(user, verb, target=None):
**# check for any similar action made in the last minute**
 **now = timezone.now()**
 **last_minute = now - datetime.timedelta(seconds=****60****)**
 **similar_actions = Action.objects.****filter****(**
 **user_id=user.****id****,**
 **verb= verb,**
 **created__gte=last_minute**
 **)**
**if** **target:**
 **target_ct = ContentType.objects.get_for_model(target)**
 **similar_actions = similar_actions.****filter****(**
 **target_ct=target_ct,**
 **target_id=target.****id**
**)**
**if****not** **similar_actions:**
**# no existing actions found**
        action = Action(user=user, verb=verb, target=target)
        action.save()
**return****True**
**return****False** 

您已将create_action()函数更改为避免保存重复操作并返回一个布尔值来告诉您操作是否已保存。这就是您避免重复的方法:

  1. 首先,您使用 Django 提供的timezone.now()方法获取当前时间。此方法与datetime.datetime.now()执行相同,但返回一个时区感知对象。Django 提供了一个名为USE_TZ的设置来启用或禁用时区支持。使用startproject命令创建的默认settings.py文件包括USE_TZ=True

  2. 您使用last_minute变量来存储一分钟前的日期时间,并检索用户从那时起执行的任何相同操作。

  3. 如果在过去一分钟内不存在相同的操作,您将创建一个Action对象。如果创建了Action对象,则返回True,否则返回False

将用户操作添加到活动流

是时候向您的视图添加一些操作来为您的用户构建活动流了。您将为以下每个交互存储一个操作:

  • 用户收藏图片

  • 用户喜欢图片

  • 用户创建账户

  • 用户开始关注另一个用户

编辑images应用的views.py文件并添加以下导入:

from actions.utils import create_action 

image_create视图中,在保存图片后添加create_action(),如下所示。新行以粗体突出显示:

@login_required
def image_create(request):
    if request.method == 'POST':
        # form is sent
        form = ImageCreateForm(data=request.POST)
        if form.is_valid():
            # form data is valid
            cd = form.cleaned_data
            new_image = form.save(commit=False)
            # assign current user to the item
            new_image.user = request.user
            new_image.save()
 **create_action(request.user,** **'bookmarked image'****, new_image)**
            messages.success(request, 'Image added successfully')
            # redirect to new created image detail view
return redirect(new_image.get_absolute_url())
    else:
        # build form with data provided by the bookmarklet via GET
        form = ImageCreateForm(data=request.GET)
    return render(
 request,
 'images/image/create.html',
 {'section': 'images', 'form': form}
    ) 

image_like视图中,在将用户添加到users_like关系后添加create_action(),如下所示。新行以粗体突出显示:

@login_required
@require_POST
def image_like(request):
    image_id = request.POST.get('id')
    action = request.POST.get('action')
    if image_id and action:
        try:
            image = Image.objects.get(id=image_id)
            if action == 'like':
                image.users_like.add(request.user)
 **create_action(request.user,** **'likes'****, image)**
else:
                image.users_like.remove(request.user)
            return JsonResponse({'status':'ok'})
        except Image.DoesNotExist:
            pass
return JsonResponse({'status':'error'}) 

现在,编辑 account 应用程序的 views.py 文件并添加以下导入:

from actions.utils import create_action 

register 视图中,在创建 Profile 对象后添加 create_action(),如下所示。新行以粗体突出显示:

def register(request):
    if request.method == 'POST':
        user_form = UserRegistrationForm(request.POST)
        if user_form.is_valid():
            # Create a new user object but avoid saving it yet
            new_user = user_form.save(commit=False)
            # Set the chosen password
            new_user.set_password(
                user_form.cleaned_data['password']
            )
            # Save the User object
            new_user.save()
            # Create the user profile
            Profile.objects.create(user=new_user)
 **create_action(new_user,** **'has created an account'****)**
return render(
 request,
 'account/register_done.html',
 {'new_user': new_user}
 )
    else:
        user_form = UserRegistrationForm()
    return render(
 request,
 'account/register.html',
 {'user_form': user_form}
 ) 

user_follow 视图中,添加 create_action(),如下所示。新行以粗体突出显示:

@require_POST
@login_required
def user_follow(request):
    user_id = request.POST.get('id')
    action = request.POST.get('action')
    if user_id and action:
        try:
            user = User.objects.get(id=user_id)
            if action == 'follow':
                Contact.objects.get_or_create(
                    user_from=request.user,
                    user_to=user
                )
 **create_action(request.user,** **'****is following'****, user)**
else:
                Contact.objects.filter(
                    user_from=request.user,
                    user_to=user
                ).delete()
            return JsonResponse({'status':'ok'})
        except User.DoesNotExist:
            return JsonResponse({'status':'error'})
    return JsonResponse({'status':'error'}) 

如前述代码所示,由于 Action 模型和辅助函数,将新操作保存到活动流中非常简单。

显示活动流

最后,您需要一种方式来显示每个用户的活动流。您将在用户的仪表板上包含活动流。编辑 account 应用程序的 views.py 文件。导入 Action 模型并修改 dashboard 视图,如下所示。新代码以粗体突出显示:

**from** **actions.models** **import** **Action**
# ...
@login_required
def dashboard(request):
    # Display all actions by default
 **actions = Action.objects.exclude(user=request.user)**
 **following_ids = request.user.following.values_list(**
**'id'****, flat=****True**
**)**
**if** **following_ids:**
**# If user is following others, retrieve only their actions**
 **actions = actions.****filter****(user_id__in=following_ids)**
 **actions = actions[:****10****]**
return render(
 request,
 'account/dashboard.html',
 {'section': 'dashboard'**,** **'actions'****: actions}**
    ) 

在前面的视图中,您从数据库中检索所有操作,但不包括当前用户执行的操作。默认情况下,您检索平台上所有用户执行的最新操作。

如果用户正在关注其他用户,您将查询限制为仅检索他们关注的用户执行的操作。最后,您将结果限制为返回的前 10 个操作。您不使用 order_by() 在 QuerySet 中,因为您依赖于在 Action 模型的 Meta 选项中提供的默认排序。最近的操作将首先显示,因为您在 Action 模型中设置了 ordering = ['-created']

优化涉及相关对象的 QuerySets

每次检索 Action 对象时,您通常会访问其相关的 User 对象和用户的 Profile 对象。Django ORM 提供了一种简单的方法来同时检索相关对象,从而避免对数据库进行额外的查询。

Django 提供了一个名为 select_related() 的 QuerySet 方法,允许您检索一对多关系中的相关对象。这相当于一个单一、更复杂的 QuerySet,但在访问相关对象时可以避免额外的查询。

select_related 方法用于 ForeignKeyOneToOne 字段。它通过执行 SQL JOIN 并在 SELECT 语句中包含相关对象的字段来实现。

要利用 select_related(),请编辑账户应用程序 views.py 文件中前面的代码行,添加 select_related,包括您将使用的字段,如下所示。编辑 account 应用程序的 views.py 文件。新代码以粗体突出显示:

@login_required
def dashboard(request):
    # Display all actions by default
    actions = Action.objects.exclude(user=request.user)
    following_ids = request.user.following.values_list(
        'id', flat=True
 )
    if following_ids:
        # If user is following others, retrieve only their actions
        actions = actions.filter(user_id__in=following_ids)
    actions = actions**.select_related(**
**'user'****,** **'user__profile'**
**)**[:10]
    return render(
 request,
 'account/dashboard.html',
 {'section': 'dashboard', 'actions': actions}
    ) 

您使用 user__profile 在单个 SQL 查询中连接 Profile 表。如果您不对 select_related() 传递任何参数,它将检索所有 ForeignKey 关系的对象。始终将 select_related() 限制在之后将访问的关系上。

谨慎使用 select_related() 可以显著提高执行时间。

select_related()可以帮助你提高检索一对多关系中的相关对象的性能。然而,select_related()不适用于多对多或多对一关系(ManyToMany或反向ForeignKey字段)。Django 提供了一个不同的查询集方法prefetch_related,它除了支持select_related()的关系外,还适用于多对多和多对一关系。prefetch_related()方法为每个关系执行单独的查找,并使用 Python 连接结果。此方法还支持GenericRelationGenericForeignKey的预取。

编辑account应用程序的views.py文件,并为targetGenericForeignKey字段添加prefetch_related()以完成你的查询,如下所示。新的代码以粗体显示:

@login_required
def dashboard(request):
    # Display all actions by default
    actions = Action.objects.exclude(user=request.user)
    following_ids = request.user.following.values_list(
        'id', flat=True
 )
    if following_ids:
        # If user is following others, retrieve only their actions
        actions = actions.filter(user_id__in=following_ids)
    actions = actions.select_related(
        'user', 'user__profile'
 ) **.prefetch_related(****'target'****)**[:10]
    return render(
 request,
 'account/dashboard.html',
 {'section': 'dashboard', 'actions': actions}
    ) 

此查询现在已优化以检索用户动作,包括相关对象。

创建动作模板

现在让我们创建一个模板来显示特定的Action对象。在actions应用程序目录内创建一个新的目录,并将其命名为templates。向其中添加以下文件结构:

actions/
    action/
        detail.html 

编辑actions/action/detail.html模板文件,并添加以下行:

{% load thumbnail %}
{% with user=action.user profile=action.user.profile %}
<div class="action">
<div class="images">
    {% if profile.photo %}
      {% thumbnail user.profile.photo "80x80" crop="100%" as im %}
      <a href="{{ user.get_absolute_url }}">
<img src="img/{{ im.url }}" alt="{{ user.get_full_name }}"
 class="item-img">
</a>
    {% endif %}
    {% if action.target %}
      {% with target=action.target %}
        {% if target.image %}
          {% thumbnail target.image "80x80" crop="100%" as im %}
          <a href="{{ target.get_absolute_url }}">
<img src="img/{{ im.url }}" class="item-img">
</a>
        {% endif %}
      {% endwith %}
    {% endif %}
  </div>
<div class="info">
<p>
<span class="date">{{ action.created|timesince }} ago</span>
<br />
<a href="{{ user.get_absolute_url }}">
        {{ user.first_name }}
      </a>
      {{ action.verb }}
      {% if action.target %}
        {% with target=action.target %}
          <a href="{{ target.get_absolute_url }}">{{ target }}</a>
        {% endwith %}
      {% endif %}
    </p>
</div>
</div>
{% endwith %} 

这是用来显示Action对象的模板。首先,你使用{% with %}模板标签来检索执行动作的用户和相关的Profile对象。然后,如果Action对象有一个相关的target对象,就显示target对象的图片。最后,显示执行动作的用户、动词以及如果有,target对象的链接。

编辑account/dashboard.html模板的account应用程序,并将以下以粗体显示的代码追加到content块的底部:

{% extends "base.html" %}
{% block title %}Dashboard{% endblock %}
{% block content %}
  ...
**<****h2****>****What's happening****</****h2****>**
**<****div****id****=****"action-list"****>**
 **{% for action in actions %}**
 **{% include "actions/action/detail.html" %}**
 **{% endfor %}**
**</****div****>**
{% endblock %} 

在你的浏览器中打开http://127.0.0.1:8000/account/。以现有用户身份登录并执行几个动作,以便它们被存储在数据库中。然后,使用另一个用户登录,关注前面的用户,并查看仪表板页面上的生成的动作流。

它应该看起来像以下这样:

图形用户界面,文本,应用程序,聊天或文本消息  自动生成的描述

图 7.8:当前用户的动态流

图 7.8 图片归属:*

特斯拉的感应电机 由 Ctac 提供(许可 – Creative Commons Attribution Share-Alike 3.0 Unported: creativecommons.org/licenses/by-sa/3.0/)

图灵机模型 Davey 2012 由 Rocky Acosta 提供(许可 – Creative Commons Attribution 3.0 Unported: creativecommons.org/licenses/by/3.0/)

你刚刚为你的用户创建了一个完整的活动流,并且可以轻松地向其中添加新的用户操作。你还可以通过实现用于 image_list 视图的相同 AJAX 分页器来向活动流添加无限滚动功能。接下来,你将学习如何使用 Django 信号来反规范化操作计数。

使用信号进行反规范化计数

有一些情况下,你可能想要对你的数据进行反规范化。反规范化是以一种使数据冗余的方式,从而优化读取性能。例如,你可能会将相关数据复制到对象中,以避免在检索相关数据时对数据库进行昂贵的读取查询。你必须小心处理反规范化,并且只有在真正需要时才开始使用它。你将发现反规范化的最大问题是难以保持反规范化数据更新。

让我们看看如何通过反规范化计数来改进查询的示例。你将对 Image 模型的数据进行反规范化,并使用 Django 信号来保持数据更新。

与信号一起工作

Django 内置了一个信号调度器,允许接收器函数在特定动作发生时得到通知。当需要你的代码在每次其他动作发生时执行某些操作时,信号非常有用。信号允许你解耦逻辑:你可以捕获某个动作,无论触发该动作的应用或代码是什么,都可以实现当该动作发生时执行的逻辑。例如,你可以构建一个信号接收器函数,每次 User 对象被保存时都会执行。你也可以创建自己的信号,以便在事件发生时通知其他人。

Django 在 django.db.models.signals 中提供了几个针对模型的信号。以下是一些信号:

  • pre_savepost_save 在调用模型的 save() 方法之前或之后发送

  • pre_deletepost_delete 在调用模型或 QuerySet 的 delete() 方法之前或之后发送

  • 当模型上的 ManyToManyField 发生变化时,会发送 m2m_changed

这些只是 Django 提供的信号的一个子集。你可以在 docs.djangoproject.com/en/5.0/ref/signals/ 找到所有内置信号的列表。

假设你想要按受欢迎程度检索图像。你可以使用 Django 聚合函数来检索按用户点赞数排序的图像。记住,你在 第三章扩展你的博客应用 中使用了 Django 聚合函数。以下代码示例将根据点赞数检索图像:

from django.db.models import Count
from images.models import Image
images_by_popularity = Image.objects.annotate(
    total_likes=Count('users_like')
).order_by('-total_likes') 

然而,按总点赞数对图像进行排序在性能上比按存储总计数的字段进行排序更昂贵。你可以在 Image 模型中添加一个字段来反规范化总点赞数,以提升涉及此字段的查询性能。问题是如何保持此字段更新。

编辑 images 应用程序的 models.py 文件,并将以下 total_likes 字段添加到 Image 模型中。新的代码以粗体显示:

class Image(models.Model):
    # ...
 **total_likes = models.PositiveIntegerField(default=****0****)**
class Meta:
        indexes = [
            models.Index(fields=['-created']),
 **models.Index(fields=[****'-total_likes'****]),**
        ]
        ordering = ['-created'] 

total_likes 字段将允许你存储喜欢每个图像的用户总数。当你想通过它们过滤或排序查询集时,反规范化计数是有用的。我们为 total_likes 字段添加了一个降序的数据库索引,因为我们计划按总喜欢数降序检索图像。

在对字段进行反规范化之前,你必须考虑几种提高性能的方法。在开始反规范化你的数据之前,考虑数据库索引、查询优化和缓存。

运行以下命令以创建向数据库表添加新字段的迁移:

python manage.py makemigrations images 

你应该看到以下输出:

Migrations for 'images':
  images/migrations/0002_image_total_likes_and_more.py
    - Add field total_likes to image
    - Create index images_imag_total_l_0bcd7e_idx on field(s) -total_likes of model image 

然后,运行以下命令以应用迁移:

python manage.py migrate images 

输出应包括以下行:

Applying images.0002_image_total_likes_and_more... OK 

你需要将一个接收器函数附加到 m2m_changed 信号。

images 应用程序目录内创建一个新文件,并命名为 signals.py。向其中添加以下代码:

from django.db.models.signals import m2m_changed
from django.dispatch import receiver
from .models import Image
@receiver(m2m_changed, sender=Image.users_like.through)
def users_like_changed(sender, instance, **kwargs):
    instance.total_likes = instance.users_like.count()
    instance.save() 

首先,使用 receiver() 装饰器将 users_like_changed 函数注册为一个接收器函数,并将其附加到 m2m_changed 信号上。然后,将函数连接到 Image.users_like.through,这样只有在 m2m_changed 信号由这个发送者触发时,函数才会被调用。注册接收器函数的另一种方法是使用 Signal 对象的 connect() 方法。

Django 信号是同步和阻塞的。不要将信号与异步任务混淆。然而,你可以将两者结合起来,当你的代码被信号通知时启动异步任务。你将在 第八章构建在线商店 中学习如何使用 Celery 创建异步任务。

你必须将你的接收器函数连接到一个信号,以便在信号发送时被调用。注册你的信号推荐的方法是导入它们到你的应用程序配置类的 ready() 方法中。Django 提供了一个应用程序注册器,允许你配置和检查你的应用程序。

应用程序配置类

Django 允许你为你的应用程序指定配置类。当你使用 startapp 命令创建一个应用程序时,Django 会将一个 apps.py 文件添加到应用程序目录中,包括一个继承自 AppConfig 类的基本应用程序配置。

应用程序配置类允许你存储元数据和应用程序的配置,并提供应用程序的检查。你可以在 docs.djangoproject.com/en/5.0/ref/applications/ 找到更多关于应用程序配置的信息。

为了注册你的信号接收器函数,当你使用receiver()装饰器时,你只需要在应用配置类的ready()方法中导入应用中的signals模块。该方法在应用注册表完全填充后立即调用。任何其他的应用初始化也应该包含在这个方法中。

编辑images应用的apps.py文件,并添加以下加粗的代码:

from django.apps import AppConfig
class ImagesConfig(AppConfig):
    default_auto_field = 'django.db.models.BigAutoField'
    name = 'images'
**def****ready****(****self****):**
**# import signal handlers**
**import** **images.signals** 

你在这个应用的ready()方法中导入信号,以便在images应用加载时导入。

使用以下命令运行开发服务器:

python manage.py runserver 

打开你的浏览器查看图片详情页面,并点击点赞按钮。

前往管理站点,导航到编辑图片的 URL,例如http://127.0.0.1:8000/admin/images/image/1/change/,并查看total_likes属性。你应该看到total_likes属性已更新为喜欢该图片的用户总数,如下所示:

图形用户界面  自动生成的描述

图 7.9:管理站点上的图片编辑页面,包括总点赞数的反规范化

现在,你可以使用total_likes属性按受欢迎程度排序图片或显示该值,避免使用复杂的查询来计算它。

考虑以下查询以按点赞数降序获取图片:

from django.db.models import Count
images_by_popularity = Image.objects.annotate(
    likes=Count('users_like')
).order_by('-likes') 

之前的查询现在可以写成以下形式:

images_by_popularity = Image.objects.order_by('-total_likes') 

这得益于对图片总点赞数的反规范化,从而产生了更经济的 SQL 查询。你也已经学会了如何使用 Django 信号。

使用信号时要谨慎,因为它们会使控制流难以理解。在许多情况下,如果你知道哪些接收器需要被通知,你可以避免使用信号。

你需要为其余的Image对象设置初始计数,以匹配数据库的当前状态。

使用以下命令打开 shell:

python manage.py shell 

在 shell 中执行以下代码:

>>> from images.models import Image
>>> for image in Image.objects.all():
...    image.total_likes = image.users_like.count()
...    image.save() 

你已经手动更新了数据库中现有图片的点赞数。从现在起,users_like_changed信号接收器函数将处理在多对多相关对象更改时更新total_likes字段。

接下来,你将学习如何使用 Django 调试工具栏来获取有关请求的相关调试信息,包括执行时间、执行的 SQL 查询、渲染的模板、注册的信号等等。

使用 Django 调试工具栏

到目前为止,你已经开始熟悉 Django 的调试页面了。在前面的章节中,你已经多次看到了独特的黄色和灰色 Django 调试页面。

例如,在第二章使用高级功能增强您的博客,在处理分页错误部分,调试页面显示了在实现对象分页时未处理的异常的相关信息。

Django 调试页面提供了有用的调试信息。然而,有一个 Django 应用程序包含更详细的调试信息,在开发过程中非常有帮助。

Django Debug Toolbar 是一个外部 Django 应用程序,允许你查看当前请求/响应周期相关的调试信息。这些信息被分为多个面板,显示不同的信息,包括请求/响应数据、使用的 Python 包版本、执行时间、设置、头部、SQL 查询、使用的模板、缓存、信号和日志。

你可以在django-debug-toolbar.readthedocs.io/找到 Django Debug Toolbar 的文档。

安装 Django Debug Toolbar

使用以下命令通过pip安装django-debug-toolbar

python -m pip install django-debug-toolbar==4.3.0 

编辑你项目的settings.py文件,并将debug_toolbar添加到INSTALLED_APPS设置中,如下所示。新行加粗:

INSTALLED_APPS = [
    # ...
**'debug_toolbar'****,**
# ...
] 

在同一文件中,将以下加粗的行添加到MIDDLEWARE设置中:

MIDDLEWARE = [
**'debug_toolbar.middleware.DebugToolbarMiddleware'****,**
'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 Debug Toolbar 主要作为中间件实现。MIDDLEWARE的顺序很重要。DebugToolbarMiddleware必须放在任何其他中间件之前,除了那些编码响应内容的中间件,例如GZipMiddleware,如果存在,则应该放在最前面。

settings.py文件的末尾添加以下行:

INTERNAL_IPS = [
    '127.0.0.1',
] 

Django Debug Toolbar 只有在你的 IP 地址与INTERNAL_IPS设置中的条目匹配时才会显示。为了防止在生产环境中显示调试信息,Django Debug Toolbar 会检查DEBUG设置是否为True

编辑你项目的urls.py主文件,并将以下加粗的 URL 模式添加到urlpatterns中:

urlpatterns = [
    path('admin/', admin.site.urls),
    path('account/', include('account.urls')),
    path(
        'social-auth/',
        include('social_django.urls', namespace='social')
    ),
    path('images/', include('images.urls', namespace='images')),
 **path(****'__debug__/'****, include(****'debug_toolbar.urls'****)),**
] 

Django Debug Toolbar 现在已安装到你的项目中。让我们试试它!

使用以下命令运行开发服务器:

python manage.py runserver 

使用你的浏览器打开http://127.0.0.1:8000/images/。你现在应该看到一个可折叠的侧边栏在右侧。它应该看起来如下:

图 7.10:Django Debug Toolbar 侧边栏

图 7.10 图片归属:

Al Jarreau Düsseldorf 1981 由 Eddi Laumanns 即 RX-Guru 创作(许可 – Creative Commons Attribution 3.0 Unported: creativecommons.org/licenses/by/3.0/)

Al Jarreau 由 Kingkongphoto 和 www.celebrity-photos.com 创作(许可 – Creative Commons Attribution-ShareAlike 2.0 Generic: creativecommons.org/licenses/by-sa/2.0/)

如果调试工具栏没有出现,检查 RunServer shell 控制台日志。如果你看到一个 MIME 类型错误,这很可能是你的 MIME 映射文件不正确或需要更新。

您可以通过在settings.py文件中添加以下行来应用 JavaScript 和 CSS 文件的正确映射:

if DEBUG:
    import mimetypes
    mimetypes.add_type('application/javascript', '.js', True)
    mimetypes.add_type('text/css', '.css', True) 

Django 调试工具栏面板

Django 调试工具栏包含多个面板,用于组织请求/响应周期的调试信息。侧边栏包含每个面板的链接,您可以使用任何面板的复选框来激活或停用它。更改将应用于下一个请求。当我们对特定面板不感兴趣,但计算对请求的负载太大时,这很有用。

在侧边栏菜单中点击时间。您将看到以下面板:

图片

图 7.11:时间面板 – Django 调试工具栏

时间面板包括请求/响应周期不同阶段的计时器。它还显示了 CPU、经过的时间和上下文切换次数。如果您使用的是 Windows,您将无法看到时间面板。在 Windows 中,只有总时间可用,并在工具栏中显示。

在侧边栏菜单中点击SQL。您将看到以下面板:

图片

图 7.12:SQL 面板 – Django 调试工具栏

在这里,您可以查看已执行的不同 SQL 查询。这些信息可以帮助您识别不必要的查询、可重用的重复查询或可优化的长时间运行查询。根据您的发现,您可以在视图中改进查询集,如果需要,在模型字段上创建新的索引,或在需要时缓存信息。在本章中,您学习了如何使用select_related()prefetch_related()优化涉及关系的查询。您将在第十四章“渲染和缓存内容”中学习如何缓存数据。

在侧边栏菜单中点击模板。您将看到以下面板:

图片

图 7.13:模板面板 – Django 调试工具栏

此面板显示了渲染内容时使用的不同模板、模板路径以及使用的上下文。您还可以看到使用的不同上下文处理器。您将在第八章“构建在线商店”中了解上下文处理器。

在侧边栏菜单中点击信号。您将看到以下面板:

图片

图 7.14:信号面板 – Django 调试工具栏

在此面板中,您可以查看在您的项目中注册的所有信号以及附加到每个信号的接收器函数。例如,您可以找到之前创建的users_like_changed接收器函数,它附加到m2m_changed信号。其他信号和接收器是不同 Django 应用程序的一部分。

我们已经审查了 Django 调试工具栏附带的一些面板。除了内置面板外,您还可以找到额外的第三方面板,您可以从django-debug-toolbar.readthedocs.io/en/latest/panels.html#third-party-panels下载并使用。

Django 调试工具栏命令

除了请求/响应调试面板之外,Django Debug Toolbar 还提供了一个管理命令来调试 ORM 调用的 SQL。管理命令 debugsqlshell 复制 Django shell 命令,但它输出使用 Django ORM 执行的查询的 SQL 语句。

使用以下命令打开 shell:

python manage.py debugsqlshell 

执行以下代码:

>>> from images.models import Image
>>> Image.objects.get(id=1) 

您将看到以下输出:

SELECT "images_image"."id",
       "images_image"."user_id",
       "images_image"."title",
       "images_image"."slug",
       "images_image"."url",
       "images_image"."image",
       "images_image"."description",
       "images_image"."created",
       "images_image"."total_likes"
FROM "images_image"
WHERE "images_image"."id" = 1
LIMIT 21 [0.44ms]
<Image: Django and Duke> 

您可以使用此命令在将它们添加到视图之前测试 ORM 查询。您可以检查每个 ORM 调用的结果 SQL 语句和执行时间。

在下一节中,您将学习如何使用 Redis 来统计图片浏览量,Redis 是一个内存数据库,它提供了低延迟和高吞吐量的数据访问。

使用 Redis 统计图片浏览量

Redis 是一个高级的键/值数据库,允许您保存不同类型的数据。它还具有极快的 I/O 操作。Redis 将所有内容存储在内存中,但可以通过定期将数据集转储到磁盘或通过将每个命令添加到日志中来持久化数据。与其它键/值存储相比,Redis 非常灵活:它提供了一套强大的命令,并支持多种数据结构,如字符串、散列、列表、集合、有序集合,甚至 bitmapHyperLogLog 方法。

虽然 SQL 最适合于模式定义的持久数据存储,但 Redis 在处理快速变化的数据、易失性存储或需要快速缓存时提供了许多优势。让我们看看 Redis 如何用于在您的项目中构建新的功能。

您可以在 Redis 的主页 redis.io/ 上找到更多关于 Redis 的信息。

Redis 提供了一个 Docker 镜像,使得使用标准配置部署 Redis 服务器变得非常容易。

安装 Redis

要安装 Redis Docker 镜像,请确保您的机器上已安装 Docker。您在 第三章扩展您的博客应用程序 中学习了如何安装 Docker。

从 shell 中运行以下命令:

docker pull redis:7.2.4 

这将下载 Redis Docker 镜像到您的本地机器。您可以在 hub.docker.com/_/redis 找到有关官方 Redis Docker 镜像的信息。您可以在 redis.io/download/ 找到其他安装 Redis 的替代方法。

在 shell 中执行以下命令以启动 Redis Docker 容器:

docker run -it --rm --name redis -p 6379:6379 redis:7.2.4 

使用此命令,我们在 Docker 容器中运行 Redis。-it 选项告诉 Docker 直接进入容器进行交互式输入。--rm 选项告诉 Docker 在容器退出时自动清理容器并删除文件系统。--name 选项用于给容器分配一个名称。-p 选项用于将 Redis 运行的 6379 端口发布到同一主机的接口端口。6379 是 Redis 的默认端口。

您应该看到以下行结束的输出:

# Server initialized
* Ready to accept connections 

保持 Redis 服务器在端口6379上运行,并打开另一个 shell。使用以下命令启动 Redis 客户端:

docker exec -it redis sh 

您将看到一行带有井号:

# 

使用以下命令启动 Redis 客户端:

# redis-cli 

您将看到 Redis 客户端 shell 提示符,如下所示:

127.0.0.1:6379> 

Redis 客户端允许您直接从 shell 中执行 Redis 命令。让我们尝试一些命令。在 Redis shell 中输入SET命令以在键中存储一个值:

127.0.0.1:6379> SET name "Peter"
OK 

前面的命令在 Redis 数据库中创建了一个具有字符串值"Peter"name键。OK输出表示键已成功保存。

接下来,使用GET命令检索值,如下所示:

127.0.0.1:6379> GET name
"Peter" 

您还可以使用EXISTS命令检查键是否存在。如果给定的键存在,则该命令返回1,否则返回0

127.0.0.1:6379> EXISTS name
(integer) 1 

您可以使用EXPIRE命令设置一个键的过期时间,该命令允许您以秒为单位设置生存时间。另一个选项是使用EXPIREAT命令,它期望一个 Unix 时间戳。键过期对于将 Redis 用作缓存或存储易失性数据很有用:

127.0.0.1:6379> GET name
"Peter"
127.0.0.1:6379> EXPIRE name 2
(integer) 1 

等待超过两秒钟,然后再次尝试获取相同的键:

127.0.0.1:6379> GET name
(nil) 

(nil)响应是一个空响应,表示没有找到任何键。您还可以使用DEL命令删除任何键,如下所示:

127.0.0.1:6379> SET total 1
OK
127.0.0.1:6379> DEL total
(integer) 1
127.0.0.1:6379> GET total
(nil) 

这些只是基本的关键操作命令。您可以在redis.io/commands/找到所有 Redis 命令,在redis.io/docs/manual/data-types/找到所有 Redis 数据类型。

使用 Python 与 Redis 结合使用

您将需要 Redis 的 Python 绑定。使用以下命令通过pip安装redis-py

python -m pip install redis==5.0.4 

您可以在redis-py.readthedocs.io/找到redis-py的文档。

redis-py包与 Redis 交互,提供了一个遵循 Redis 命令语法的 Python 接口。使用以下命令打开 Python shell:

python manage.py shell 

执行以下代码:

>>> import redis
>>> r = redis.Redis(host='localhost', port=6379, db=0) 

前面的代码与 Redis 数据库建立了连接。在 Redis 中,数据库通过整数索引而不是数据库名称来标识。默认情况下,客户端连接到数据库0。可用的 Redis 数据库数量设置为16,但您可以在redis.conf配置文件中更改此设置。

接下来,使用 Python shell 设置一个键:

>>> r.set('foo', 'bar')
True 

命令返回True,表示键已成功创建。现在您可以使用get()命令检索键:

>>> r.get('foo')
b'bar' 

如您从前面的代码中注意到的,Redis 的方法遵循 Redis 命令语法。

让我们将 Redis 集成到您的项目中。编辑bookmarks项目的settings.py文件,并向其中添加以下设置:

REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 0 

这些是您将用于项目的 Redis 服务器和数据库的设置。

在 Redis 中存储图像视图

让我们找到一种方法来存储图像被查看的总次数。如果你使用 Django ORM 实现此功能,每次显示图像时都会涉及一个 SQL UPDATE查询。

如果你使用 Redis,你只需在内存中增加一个计数器的值,这将带来更好的性能和更低的开销。

编辑images应用的views.py文件,并在现有的import语句之后添加以下代码:

import redis
from django.conf import settings
# connect to redis
r = redis.Redis(
    host=settings.REDIS_HOST,
    port=settings.REDIS_PORT,
    db=settings.REDIS_DB
) 

使用前面的代码,你建立了 Redis 连接,以便在视图中使用它。编辑images应用的views.py文件,并修改image_detail视图,如下所示。新代码加粗显示:

def image_detail(request, id, slug):
    image = get_object_or_404(Image, id=id, slug=slug)
    # increment total image views by 1
 **total_views = r.incr(****f'image:****{image.****id****}****:views'****)**
return render(
        request,
        'images/image/detail.html',
        {
            'section': 'images',
            'image': image**,**
**'total_views'****: total_views**
        }
    ) 

在此视图中,你使用incr命令,该命令将给定键的值增加1。如果键不存在,incr命令将创建它。incr()方法返回执行操作后的键的最终值。你将值存储在total_views变量中,并将其传递到模板上下文中。你使用类似于object-type:id:field(例如,image:33:id)的表示法构建 Redis 键。

Redis 键的命名约定是使用冒号作为命名空间键的分隔符。这样做后,键名特别冗长,相关的键在其名称中共享部分相同的模式。

编辑images应用的images/image/detail.html模板,并添加以下加粗显示的代码:

...
<div class="image-info">
  <div>
    <span class="count">
      <span class="total">{{ total_likes }}</span>
      like{{ total_likes|pluralize }}
    </span>
 **<span** **class****=****"count"****>**
 **{{ total_views }} view{{ total_views|pluralize }}**
 **</span>**
    <a href="#" data-id="{{ image.id }}" data-action="{% if request.user in users_like %}un{% endif %}like"
class="like button">
      {% if request.user not in users_like %}
        Like
      {% else %}
        Unlike
      {% endif %}
    </a>
  </div>
  {{ image.description|linebreaks }}
</div>
... 

使用以下命令运行开发服务器:

python manage.py runserver 

在你的浏览器中打开一个图像详情页面,并多次刷新它。你会看到每次处理查看时,显示的总查看次数都会增加1。看看以下示例:

图形用户界面、文本、应用程序、聊天或文本消息  自动生成的描述

图 7.15:图像详情页面,包括点赞和查看次数

太好了!你已经成功将 Redis 集成到你的项目中,用于计算图像查看次数。在下一节中,你将学习如何使用 Redis 构建查看次数最多的图像的排名。

在 Redis 中存储排名

我们现在将使用 Redis 创建一个更复杂的东西。我们将使用 Redis 来存储平台上查看次数最多的图像的排名。我们将使用 Redis 有序集来完成此操作。有序集是一个非重复的字符串集合,其中每个成员都与一个分数相关联。项目按其分数排序。

编辑images应用的views.py文件,并在image_detail视图中添加以下加粗显示的代码:

def image_detail(request, id, slug):
    image = get_object_or_404(Image, id=id, slug=slug)
    # increment total image views by 1
    total_views = r.incr(f'image:{image.id}:views')
**# increment image ranking by 1**
 **r.zincrby(****'image_ranking'****,** **1****, image.****id****)**
return render(
        request,
        'images/image/detail.html',
        {
            'section': 'images',
            'image': image,
            'total_views': total_views
        }
    ) 

你使用zincrby()命令将图像查看次数存储在带有image:ranking键的有序集中。你将存储图像id和相关的分数1,这将添加到有序集中该元素的累计分数。这将允许你跟踪所有图像查看次数的全局情况,并按查看次数的总量对有序集进行排序。

现在,创建一个新的视图来显示最常查看的图像排名。将以下代码添加到 images 应用程序的 views.py 文件中:

@login_required
def image_ranking(request):
    # get image ranking dictionary
    image_ranking = r.zrange(
        'image_ranking', 0, -1,
        desc=True
 )[:10]
    image_ranking_ids = [int(id) for id in image_ranking]
    # get most viewed images
    most_viewed = list(
        Image.objects.filter(
            id__in=image_ranking_ids
        )
    )
    most_viewed.sort(key=lambda x: image_ranking_ids.index(x.id))
    return render(
        request,
        'images/image/ranking.html',
        {'section': 'images', 'most_viewed': most_viewed}
    ) 

image_ranking 视图的工作方式如下:

  1. 你使用 zrange() 命令来获取有序集合中的元素。此命令期望一个自定义范围,根据最低和最高分数。通过使用 0 作为最低分数和 -1 作为最高分数,你告诉 Redis 返回有序集合中的所有元素。你还指定 desc=True 以按降序分数检索元素。最后,使用 [:10] 切片结果以获取分数最高的前 10 个元素。

  2. 你构建一个返回的图像 ID 列表,并将其存储在 image_ranking_ids 变量中,作为整数列表。你检索这些 ID 的 Image 对象,并使用 list() 函数强制执行查询。强制执行 QuerySet 的查询很重要,因为你会对其使用 sort() 方法(在此点,你需要一个对象列表而不是 QuerySet)。

  3. 你通过图像排名中图像出现的索引对 Image 对象进行排序。现在你可以在模板中使用 most_viewed 列表来显示最常查看的 10 张图像。

images 应用程序的 images/image/ 模板目录中创建一个新的 ranking.html 模板,并将其中的以下代码添加到其中:

{% extends "base.html" %}
{% block title %}Images ranking{% endblock %}
{% block content %}
  <h1>Images ranking</h1>
<ol>
    {% for image in most_viewed %}
      <li>
<a href="{{ image.get_absolute_url }}">
          {{ image.title }}
        </a>
</li>
    {% endfor %}
  </ol>
{% endblock %} 

模板非常简单。你遍历 most_viewed 列表中的 Image 对象,并显示它们的名称,包括指向图像详情页面的链接。

最后,你需要为新的视图创建一个 URL 模式。编辑 images 应用程序的 urls.py 文件,并添加以下加粗的 URL 模式:

urlpatterns = [
    path('create/', views.image_create, name='create'),
    path('detail/<int:id>/<slug:slug>/',
         views.image_detail, name='detail'),
    path('like/', views.image_like, name='like'),
    path('', views.image_list, name='list'),
 **path(****'ranking/'****, views.image_ranking, name=****'ranking'****),**
] 

运行开发服务器,在您的网页浏览器中访问您的网站,并对不同图像多次加载图像详情页面。然后,从浏览器访问 http://127.0.0.1:8000/images/ranking/。您应该能够看到一个图像排名,如下所示:

图形用户界面,表格  描述自动生成

图 7.16:使用从 Redis 获取的数据构建的排名页面

太棒了!你刚刚使用 Redis 创建了一个排名。

下一步使用 Redis

Redis 不是一个 SQL 数据库的替代品,但它确实提供了适合某些任务的快速内存存储。将其添加到你的堆栈中,并在真正需要时使用它。以下是一些 Redis 可能有用的场景:

  • 计数:如你所见,使用 Redis 管理计数器非常容易。你可以使用 incr()incrby() 来计数。

  • 存储最新项:你可以使用 lpush()rpush() 在列表的开始/结束处添加项。使用 lpop()/rpop() 移除并返回第一个/最后一个元素。你可以使用 ltrim() 来修剪列表的长度,以保持其长度。

  • 队列:除了 pushpop 命令外,Redis 还提供了阻塞队列命令。

  • 缓存:使用 expire()expireat() 可以让您将 Redis 用作缓存。您还可以找到适用于 Django 的第三方 Redis 缓存后端。

  • 发布/订阅:Redis 提供了订阅/取消订阅和向频道发送消息的命令。

  • 排名和排行榜:Redis 的带分数的有序集合使得创建排行榜变得非常容易。

  • 实时跟踪:Redis 的快速 I/O 使其非常适合实时场景。

摘要

在本章中,您使用多对多关系和一个中间模型构建了一个关注系统。您还使用通用关系创建了一个活动流,并优化了 QuerySets 以检索相关对象。然后本章向您介绍了 Django 信号,并创建了一个信号接收器函数以去规范化相关对象计数。我们介绍了应用程序配置类,您使用它们来加载您的信号处理程序。您还向您的项目中添加了 Django Debug Toolbar。您还学习了如何在 Django 项目中安装和配置 Redis。最后,您在项目中使用 Redis 存储项目视图,并使用 Redis 构建了一个图像排名。

在下一章中,您将学习如何构建在线商店。您将创建产品目录并使用会话构建购物车。您将学习如何创建自定义上下文处理器。您还将使用 Celery 和 RabbitMQ 管理客户订单并发送异步通知。

使用 AI 扩展您的项目

在本节中,您将面临一个扩展您项目的任务,并附有 ChatGPT 的示例提示以协助您。要参与 ChatGPT,请访问 chat.openai.com/。如果您是第一次与 ChatGPT 互动,您可以回顾第三章,“扩展您的博客应用”中的“使用 AI 扩展您的项目”部分。

在这个项目示例中,您学习了如何使用 Django 信号并成功实现了信号接收器,以便在点赞计数发生变化时更新图像的总点赞数。现在,让我们利用 ChatGPT 探索实现一个信号接收器的实现,该接收器在创建 User 对象时自动生成相关的 Profile 对象。您可以使用提供的提示 github.com/PacktPublishing/Django-5-by-example/blob/main/Chapter07/prompts/task.md

在成功实现信号接收器后,您可以移除之前在 account 应用程序的 register 视图中和社交认证管道中包含的手动创建个人资料步骤。现在,接收器函数已附加到 User 模型的 post_save 信号,新用户将自动创建个人资料。

如果你在理解书中某个特定概念或主题时遇到困难,请向 ChatGPT 提供额外的示例或以不同的方式解释该概念。这种个性化的方法可以加强你的学习,并确保你掌握复杂主题。

其他资源

以下资源提供了与本章涵盖主题相关的额外信息:

第八章:构建在线商店

在上一章中,你创建了一个关注系统并构建了用户活动流。你还学习了 Django 信号的工作原理,并将 Redis 集成到你的项目中以统计图片浏览量。

在本章中,你将启动一个新的 Django 项目,该项目包含一个功能齐全的在线商店。本章和接下来的两章将向你展示如何构建电子商务平台的基本功能。你的在线商店将使客户能够浏览产品,将它们添加到购物车,应用折扣代码,完成结账流程,使用信用卡支付,并获得发票。你还将实现一个推荐引擎向你的客户推荐产品,并使用国际化将你的网站提供多种语言。

在本章中,你将学习如何:

  • 创建产品目录

  • 使用 Django 会话构建购物车

  • 创建自定义模板上下文处理器

  • 管理客户订单

  • 在你的项目中配置 Celery 使用 RabbitMQ 作为消息代理

  • 使用 Celery 向客户发送异步通知

  • 使用 Flower 监控 Celery

功能概述

图 8.1展示了本章将要构建的视图、模板和主要功能:

图 8.1

图 8.1:第八章中构建的功能图

在本章中,你将实现product_list视图以列出所有产品,以及product_detail视图以显示单个产品。你将在product_list视图中使用category_slug参数允许按类别过滤产品。你将使用会话实现购物车,并构建cart_detail视图以显示购物车项目。你将创建cart_add视图以向购物车添加产品并更新数量,以及cart_remove视图以从购物车中删除产品。你将实现cart模板上下文处理器以在网站页眉上显示购物车项目数量和总成本。你还将创建order_create视图以放置订单,并使用 Celery 实现order_created异步任务,在客户下单时向他们发送电子邮件确认。本章将为你提供在应用程序中实现用户会话的知识,并展示如何处理异步任务。这两者都是非常常见的用例,你可以将它们应用到几乎任何项目中。

本章的源代码可以在github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter08找到。

本章中使用的所有 Python 模块都包含在本章源代码中的requirements.txt文件中。你可以按照以下说明安装每个 Python 模块,或者你可以使用命令python -m pip install -r requirements.txt一次性安装所有需求。

创建在线商店项目

让我们从一个新的 Django 项目开始,构建一个在线商店。您的用户将能够浏览产品目录并将产品添加到购物车。最后,他们可以结账并下单。本章将涵盖以下在线商店的功能:

  • 创建产品目录模型,将它们添加到管理站点,并构建基本视图以显示目录

  • 使用 Django 会话构建购物车系统,允许用户在浏览网站时保留所选产品

  • 创建表单和功能,以便在网站上放置订单

  • 当用户下单时,发送异步电子邮件确认给用户

打开 shell 并使用以下命令在env/目录内为该项目创建一个新的虚拟环境:

python -m venv env/myshop 

如果您使用 Linux 或 macOS,请运行以下命令以激活您的虚拟环境:

source env/myshop/bin/activate 

如果您使用 Windows,请使用以下命令代替:

.\env\myshop\Scripts\activate 

壳提示将显示您的活动虚拟环境,如下所示:

(myshop)laptop:~ zenx$ 

使用以下命令在虚拟环境中安装 Django:

python -m pip install Django~=5.0.4 

通过打开 shell 并运行以下命令来启动一个名为myshop的新项目,其中包含一个名为shop的应用程序:

django-admin startproject myshop 

初始项目结构已创建。使用以下命令进入您的项目目录并创建一个名为shop的新应用程序:

cd myshop/
django-admin startapp shop 

编辑settings.py并在INSTALLED_APPS列表中添加以下加粗的行:

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
**'shop.apps.ShopConfig'****,**
] 

您的应用程序现在已为此项目激活。让我们定义产品目录的模型。

创建产品目录模型

您的商店目录将由组织成不同类别的产品组成。每个产品将有一个名称、可选的描述、可选的图片、价格和其可用性。

编辑您刚刚创建的shop应用程序的models.py文件,并添加以下代码:

from django.db import models
**class****Category****(models.Model):**
 **name = models.CharField(max_length=****200****)**
 **slug = models.SlugField(max_length=****200****, unique=****True****)**
**class****Meta****:**
 **ordering = [****'name'****]**
 **indexes = [**
 **models.Index(fields=[****'name'****]),**
 **]**
 **verbose_name =** **'category'**
 **verbose_name_plural =** **'categories'**
**def****__str__****(****self****):**
**return** **self.name**
**class****Product****(models.Model):**
 **category = models.ForeignKey(**
 **Category,**
 **related_name=****'products'****,**
 **on_delete=models.CASCADE**
 **)**
 **name = models.CharField(max_length=****200****)**
 **slug = models.SlugField(max_length=****200****)**
 **image = models.ImageField(**
 **upload_to=****'products/%Y/%m/%d'****,**
 **blank=****True**
 **)**
 **description = models.TextField(blank=****True****)**
 **price = models.DecimalField(max_digits=****10****, decimal_places=****2****)**
 **available = models.BooleanField(default=****True****)**
 **created = models.DateTimeField(auto_now_add=****True****)**
 **updated = models.DateTimeField(auto_now=****True****)**
**class****Meta****:**
 **ordering = [****'name'****]**
 **indexes = [**
 **models.Index(fields=[****'id'****,** **'slug'****]),**
 **models.Index(fields=[****'name'****]),**
 **models.Index(fields=[****'-created'****]),**
 **]**
**def****__str__****(****self****):**
**return** **self.name** 

这些是CategoryProduct模型。Category模型由一个name字段和一个唯一的slug字段组成(unique意味着创建一个索引)。在Category模型的Meta类中,我们为name字段定义了一个索引。

Product模型字段如下:

  • category:到Category模型的ForeignKey。这是一个一对一的关系:一个产品属于一个类别,一个类别包含多个产品。

  • name:产品的名称。

  • slug:用于构建美观 URL 的此产品的 slug。

  • image:可选的产品图片。

  • description:产品的可选描述。

  • price:此字段使用 Python 的decimal.Decimal类型来存储一个固定精度的十进制数。最大数字数(包括小数点)是通过max_digits属性设置的,小数位数是通过decimal_places属性设置的。

  • available:一个布尔值,表示产品是否可用。它将用于在目录中启用/禁用产品。

  • created:此字段存储对象的创建时间。

  • updated:此字段存储对象最后更新的时间。

对于 price 字段,我们使用 DecimalField 而不是 FloatField 来避免四舍五入问题。

总是使用 DecimalField 来存储货币金额。FloatField 在内部使用 Python 的 float 类型,而 DecimalField 使用 Python 的 Decimal 类型。通过使用 Decimal 类型,你可以避免 float 四舍五入的问题。

Product 模型的 Meta 类中,我们为 idslug 字段定义了一个多字段索引。这两个字段一起索引以提高使用这两个字段的查询性能。

我们计划通过 idslug 两个字段查询产品。我们为 name 字段添加了一个索引,并为 created 字段添加了一个索引。我们在字段名前使用了一个连字符来定义降序索引。

图 8.2 展示了你创建的两个数据模型:

图描述自动生成

图 8.2:产品目录的模型

图 8.2 中,你可以看到数据模型的不同字段以及 CategoryProduct 模型之间的一对多关系。

这些模型将导致 图 8.3 中显示的以下数据库表:

包含图的图片描述自动生成

图 8.3:产品目录模型的数据表

两个表之间的一对多关系是通过 shop_product 表中的 category_id 字段定义的,该字段用于存储每个 Product 对象相关联的 Category 的 ID。

让我们为 shop 应用程序创建初始的数据库迁移。由于你将在模型中处理图像,你需要安装 Pillow 库。记住,在 第四章构建社交网站 中,你学习了如何安装 Pillow 库来管理图像。打开 shell 并使用以下命令安装 Pillow

python -m pip install Pillow==10.3.0 

现在运行下一个命令来为你的项目创建初始迁移:

python manage.py makemigrations 

你将看到以下输出:

Migrations for 'shop':
  shop/migrations/0001_initial.py
    - Create model Category
    - Create model Product 

运行下一个命令来同步数据库:

python manage.py migrate 

你将看到包含以下行的输出:

Applying shop.0001_initial... OK 

数据库现在与你的模型同步了。

在管理站点注册目录模型

让我们添加你的模型到管理站点,这样你可以轻松地管理类别和产品。编辑 shop 应用的 admin.py 文件,并向其中添加以下代码:

from django.contrib import admin
**from** **.models** **import** **Category, Product**
**@admin.register(****Category****)**
**class****CategoryAdmin****(admin.ModelAdmin):**
 **list_display = [****'name'****,** **'slug'****]**
 **prepopulated_fields = {****'****slug'****: (****'name'****,)}**
**@admin.register(****Product****)**
**class****ProductAdmin****(admin.ModelAdmin):**
 **list_display = [**
**'name'****,**
**'slug'****,**
**'price'****,**
**'available'****,**
**'****created'****,**
**'updated'**
 **]**
 **list_filter = [****'available'****,** **'created'****,** **'updated'****]**
 **list_editable = [****'price'****,** **'available'****]**
 **prepopulated_fields = {****'slug'****: (****'name'****,)}** 

记住,你使用 prepopulated_fields 属性来指定值自动使用其他字段的值来设置的字段。正如你之前看到的,这对于生成 slugs 很方便。

您在ProductAdmin类中使用list_editable属性来设置可以从管理网站的列表显示页面编辑的字段。这将允许您一次性编辑多行。list_editable中的任何字段也必须在list_display属性中列出,因为只有显示的字段可以编辑。

现在使用以下命令为您网站创建一个超级用户:

python manage.py createsuperuser 

输入所需的用户名、电子邮件和密码。使用以下命令运行开发服务器:

python manage.py runserver 

在您的浏览器中打开http://127.0.0.1:8000/admin/shop/product/add/并使用您刚刚创建的用户登录。使用管理界面添加一个新的类别和产品。添加产品表单应如下所示:

图片

图 8.4:产品创建表单

点击保存按钮。此时,管理页面的产品更改列表页面将看起来如下:

图片

图 8.5:产品更改列表页面

构建目录视图

为了显示产品目录,您需要创建一个视图来列出所有产品或根据给定的类别过滤产品。编辑shop应用的views.py文件,并添加以下加粗代码:

from django.shortcuts import **get_object_or_404,** render
**from** **.models** **import** **Category, Product**
**def****product_list****(****request, category_slug=****None****):**
 **category =** **None**
 **categories = Category.objects.****all****()**
 **products = Product.objects.****filter****(available=****True****)**
**if** **category_slug:**
 **category = get_object_or_404(Category,** **slug=category_slug)**
 **products = products.****filter****(category=category)**
**return** **render(**
**request,**
**'shop/product/list.html'****,**
**{**
**'category'****: category,**
**'categories'****: categories,**
**'****products'****: products**
**}**
 **)** 

在前面的代码中,您使用available=True过滤QuerySet以仅检索可用的产品。您使用可选的category_slug参数以可选方式根据给定的类别过滤产品。

您还需要一个视图来检索和显示单个产品。将以下视图添加到views.py文件中:

def product_detail(request, id, slug):
    product = get_object_or_404(
        Product, id=id, slug=slug, available=True
 )
    return render(
 request,
 shop/product/detail.html',
 {'product': product}
    ) 

product_detail视图期望idslug参数以便检索Product实例。由于它是一个唯一属性,您可以通过 ID 直接获取此实例。然而,您在 URL 中包含 slug 以构建对产品友好的 SEO URL。

在构建产品列表和详细视图之后,您必须为它们定义 URL 模式。在shop应用目录内创建一个新文件,并将其命名为urls.py。向其中添加以下代码:

from django.urls import path
from . import views
app_name = 'shop'
urlpatterns = [
    path('', views.product_list, name='product_list'),
    path(
        '<slug:category_slug>/',
        views.product_list,
        name='product_list_by_category'
    ),
    path(
        '<int:id>/<slug:slug>/',
        views.product_detail,
        name='product_detail'
    ),
] 

这些是您产品目录的 URL 模式。您为product_list视图定义了两个不同的 URL 模式:一个名为product_list的模式,它调用不带任何参数的product_list视图,以及一个名为product_list_by_category的模式,它为视图提供一个category_slug参数以根据给定的类别过滤产品。您还添加了一个product_detail视图的模式,该模式将idslug参数传递给视图以检索特定产品。

编辑myshop项目的urls.py文件,使其看起来如下所示:

from django.contrib import admin
from django.urls import **include,** path
urlpatterns = [
    path('admin/', admin.site.urls),
    **path(****''****, include(****'shop.urls'****, namespace=****'shop'****)),**
] 

在项目的 URL 主模式中,您在名为shop的自定义命名空间下包含shop应用的 URL。

接下来,编辑shop应用的models.py文件,导入reverse()函数,并为CategoryProduct模型添加一个get_absolute_url()方法,如下所示。新的代码已加粗:

from django.db import models
**from** **django.urls** **import** **reverse**
class Category(models.Model):
    # ...
**def****get_absolute_url****(****self****):**
**return** **reverse(**
**'shop:product_list_by_category'****, args=[self.slug]**
 **)**
class Product(models.Model):
    # ...
**def****get_absolute_url****(****self****):**
**return** **reverse(****'shop:product_detail'****, args=[self.****id****, self.slug])** 

正如您所知,get_absolute_url() 是检索给定对象 URL 的约定。在这里,您使用在 urls.py 文件中刚刚定义的 URL 模式。

创建目录模板

现在您需要在产品列表和详情视图创建模板。在 shop 应用程序目录内创建以下目录和文件结构:

templates/
    shop/
        base.html
        product/
            list.html
            detail.html 

您需要定义一个基本模板,然后在产品列表和详情模板中扩展它。编辑 shop/base.html 模板,并向其中添加以下代码:

{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>{% block title %}My shop{% endblock %}</title>
<link href="{% static "css/base.css" %}" rel="stylesheet">
</head>
<body>
<div id="header">
<a href="/" class="logo">My shop</a>
</div>
<div id="subheader">
<div class="cart">
        Your cart is empty.
      </div>
</div>
<div id="content">
      {% block content %}
      {% endblock %}
    </div>
</body>
</html> 

这是您将用于商店的基本模板。为了包含模板使用的 CSS 样式和图像,您需要复制本章附带的静态文件,这些文件位于 shop 应用程序的 static/ 目录中。将它们复制到项目中的同一位置。您可以在 github.com/PacktPublishing/Django-5-by-Example/tree/main/Chapter08/myshop/shop/static 找到目录的内容。

编辑 shop/product/list.html 模板,并向其中添加以下代码:

{% extends "shop/base.html" %}
{% load static %}
{% block title %}
  {% if category %}{{ category.name }}{% else %}Products{% endif %}
{% endblock %}
{% block content %}
  <div id="sidebar">
<h3>Categories</h3>
<ul>
<li {% if not category %}class="selected"{% endif %}>
<a href="{% url "shop:product_list" %}">All</a>
</li>
      {% for c in categories %}
        <li {% if category.slug == c.slug %}class="selected"
        {% endif %}>
<a href="{{ c.get_absolute_url }}">{{ c.name }}</a>
</li>
      {% endfor %}
    </ul>
</div>
<div id="main" class="product-list">
<h1>{% if category %}{{ category.name }}{% else %}Products
    {% endif %}</h1>
    {% for product in products %}
      <div class="item">
<a href="{{ product.get_absolute_url }}">
<img src="img/{% if product.image %}{{ product.image.url }}{% else %}{% static "img/no_image.png" %}{% endif %}">
</a>
<a href="{{ product.get_absolute_url }}">{{ product.name }}</a>
<br>
        ${{ product.price }}
      </div>
    {% endfor %}
  </div>
{% endblock %} 

确保没有模板标签被拆分到多行。

这是产品列表模板。它扩展了 shop/base.html 模板,并使用 categories 上下文变量在侧边栏中显示所有类别,以及 products 来显示当前页面的产品。相同的模板用于列出所有可用的产品和按类别过滤的产品。由于 Product 模型的 image 字段可能为空,您需要为没有图像的产品提供一个默认图像。该图像位于您的静态文件目录中,相对路径为 img/no_image.png

由于您正在使用 ImageField 存储产品图像,您需要开发服务器来提供上传的图像文件。

编辑 myshopsettings.py 文件,并添加以下设置:

MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media' 

MEDIA_URL 是为用户上传的媒体文件提供服务的基准 URL。MEDIA_ROOT 是这些文件所在的本地路径,您通过动态地将 BASE_DIR 变量前置来构建它。

为了让 Django 使用开发服务器提供上传的媒体文件,编辑 myshop 的主 urls.py 文件,并添加以下加粗代码:

**from** **django.conf** **import** **settings**
**from** **django.conf.urls.static** **import** **static**
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('shop.urls', namespace='shop')),
]
**if** **settings.DEBUG:**
 **urlpatterns += static(**
 **settings.MEDIA_URL, document_root=settings.MEDIA_ROOT**
 **)** 

请记住,您只在开发期间以这种方式提供静态文件。在生产环境中,您永远不应该使用 Django 提供静态文件;Django 开发服务器以低效的方式提供静态文件。第十七章,“上线”,将教您如何在生产环境中提供静态文件。

使用以下命令运行开发服务器:

python manage.py runserver 

使用管理站点添加一些产品到您的商店,并在浏览器中打开 http://127.0.0.1:8000/。您将看到产品列表页面,其外观将类似于以下:

图形用户界面,网站,自动生成的描述

图 8.6:产品列表页面

本章图片的版权:

  • 绿茶:由 Jia Ye 在 Unsplash 上的照片

  • 红茶:由 Manki Kim 在 Unsplash 上的照片

  • 茶粉:由 Phuong Nguyen 在 Unsplash 上的照片

如果您使用管理站点创建产品而没有为其上传图片,将显示默认的no_image.png图片:

图 8.7:显示默认图片的产品列表,这些产品没有图片

编辑shop/product/detail.html模板,并向其中添加以下代码:

{% extends "shop/base.html" %}
{% load static %}
{% block title %}
  {{ product.name }}
{% endblock %}
{% block content %}
  <div class="product-detail">
<img src="{% if product.image %}{{ product.image.url }}{% else %}
    {% static "img/no_image.png" %}{% endif %}">
<h1>{{ product.name }}</h1>
<h2>
<a href="{{ product.category.get_absolute_url }}">
        {{ product.category }}
      </a>
</h2>
<p class="price">${{ product.price }}</p>
    {{ product.description|linebreaks }}
  </div>
{% endblock %} 

在前面的代码中,您在相关类别对象上调用get_absolute_url()方法来显示属于同一类别的可用产品。

现在,在您的浏览器中打开http://127.0.0.1:8000/,并点击任何产品以查看产品详情页面。它看起来如下所示:

图形用户界面,文本,应用程序,自动生成的描述

图 8.8:产品详情页面

您现在已创建了一个基本的产品目录。接下来,您将实现一个购物车,允许用户在浏览在线商店时添加任何产品。

构建购物车

在构建产品目录之后,下一步是创建购物车,以便用户可以选择他们想要购买的产品。购物车允许用户选择产品并设置他们想要订购的数量,然后在浏览网站期间暂时存储此信息,直到他们最终下订单。购物车必须保持在会话中,以便在用户访问期间保持购物车项目。

您将使用 Django 的会话框架来持久化购物车。购物车将保持在会话中,直到完成或用户结账购物车。您还需要为购物车及其项目构建额外的 Django 模型。

使用 Django 会话

Django 提供了一个支持匿名和用户会话的会话框架。会话框架允许您为每个访客存储任意数据。会话数据存储在服务器端,除非您使用基于 cookie 的会话引擎,否则 cookie 包含会话 ID。会话中间件管理 cookie 的发送和接收。默认会话引擎将会话数据存储在数据库中,但您可以选择其他会话引擎。

要使用会话,您必须确保项目的MIDDLEWARE设置包含django.contrib.sessions.middleware.SessionMiddleware。此中间件管理会话。当您使用startproject命令创建新项目时,它默认添加到MIDDLEWARE设置中。

会话中间件使当前会话在request对象中可用。您可以使用request.session访问当前会话,将其视为 Python 字典来存储和检索会话数据。默认情况下,session字典接受任何可以序列化为 JSON 的 Python 对象。您可以这样在会话中设置变量:

request.session['foo'] = 'bar' 

您可以按照以下方式检索会话键:

request.session.get('foo') 

您可以按照以下方式删除之前存储在会话中的键:

del request.session['foo'] 

当用户登录到网站时,他们的匿名会话会丢失,并为认证用户创建一个新的会话。如果您在匿名会话中存储了在用户登录后需要保留的项目,您必须将旧会话数据复制到新会话中。您可以通过在 Django 认证系统的login()函数登录用户之前检索会话数据,并在之后将其存储在会话中来实现这一点。

会话设置

您可以使用几个设置来配置项目的会话。其中最重要的是SESSION_ENGINE。此设置允许您设置会话存储的位置。默认情况下,Django 使用django.contrib.sessions应用程序的Session模型在数据库中存储会话。

Django 为存储会话数据提供了以下选项:

  • 数据库会话:会话数据存储在数据库中。这是默认的会话引擎。

  • 基于文件的会话:会话数据存储在文件系统中。

  • 缓存会话:会话数据存储在缓存后端中。您可以使用CACHES设置指定缓存后端。在缓存系统中存储会话数据提供了最佳性能。

  • 缓存数据库会话:会话数据存储在写入缓存和数据库中。如果数据不在缓存中,则只使用数据库进行读取。

  • 基于 cookie 的会话:会话数据存储在发送到浏览器的 cookie 中。

为了更好的性能,请使用基于缓存的会话引擎。Django 默认支持 Memcached,您可以在 Redis 和其他缓存系统中找到第三方缓存后端。

您可以使用特定的设置来自定义会话。以下是一些重要的与会话相关的设置:

  • SESSION_COOKIE_AGE:会话 cookie 的持续时间(以秒为单位)。默认值为1209600(两周)。

  • SESSION_COOKIE_DOMAIN:用于会话 cookie 的域名。将其设置为mydomain.com以启用跨域 cookie,或使用None以使用标准域名 cookie。

  • SESSION_COOKIE_HTTPONLY:是否在会话 cookie 上使用HttpOnly标志。如果设置为True,客户端 JavaScript 将无法访问会话 cookie。默认值为True,以提高对用户会话劫持的安全性。

  • SESSION_COOKIE_SECURE:一个布尔值,表示只有在连接是 HTTPS 连接时才发送 cookie。默认值为False

  • SESSION_EXPIRE_AT_BROWSER_CLOSE:一个布尔值,表示会话必须在浏览器关闭时过期。默认值为False

  • SESSION_SAVE_EVERY_REQUEST:一个布尔值,如果为True,则在每个请求上都将会话保存到数据库中。每次保存时,会话过期时间也会更新。默认值为False

您可以在docs.djangoproject.com/en/5.0/ref/settings/#sessions中查看所有会话设置及其默认值。

会话过期

您可以选择使用浏览器长度会话或使用SESSION_EXPIRE_AT_BROWSER_CLOSE设置来使用持久会话。默认情况下,此设置为False,强制会话持续时间等于SESSION_COOKIE_AGE设置中存储的值。如果您将SESSION_EXPIRE_AT_BROWSER_CLOSE设置为True,则当用户关闭浏览器时,会话将过期,并且SESSION_COOKIE_AGE设置将没有任何效果。

您可以使用request.sessionset_expiry()方法来覆盖当前会话的持续时间。

在会话中存储购物车

您需要创建一个简单的结构,可以序列化为 JSON,以便在会话中存储购物车项目。购物车必须包含以下数据,对于其中包含的每个项目:

  • Product实例的 ID

  • 为产品选择数量

  • 产品的单价

由于产品价格可能不同,让我们在将产品添加到购物车时,将产品的价格与其本身一起存储。这样做,无论产品价格之后是否更改,用户添加到购物车时都使用产品的当前价格。这意味着当客户端将项目添加到购物车时,该项目的价格在会话中保持不变,直到结账完成或会话结束。

接下来,您必须构建创建购物车并将其与会话关联的功能。这必须按照以下方式工作:

  • 当需要购物车时,您需要检查是否设置了自定义会话密钥。如果会话中没有设置购物车,您将创建一个新的购物车并将其保存在购物车会话密钥中。

  • 对于后续请求,您将执行相同的检查,并从购物车会话密钥中获取购物车项目。您从会话中检索购物车项目及其相关的Product对象从数据库中。

编辑您项目的settings.py文件,并添加以下设置:

CART_SESSION_ID = 'cart' 

这是您将要用来在用户会话中存储购物车的密钥。由于 Django 会话是按访客管理的,因此您可以为所有会话使用相同的购物车会话密钥。

让我们创建一个用于管理购物车的应用程序。打开终端,在项目目录中创建一个新的应用程序,运行以下命令:

python manage.py startapp cart 

然后,编辑您项目的settings.py文件,并将新应用程序添加到INSTALLED_APPS设置中,以下行以粗体突出显示:

INSTALLED_APPS = [
    # ...
**'****cart.apps.CartConfig'****,**
'shop.apps.ShopConfig',
] 

cart应用程序目录内创建一个新文件,并将其命名为cart.py。将以下代码添加到其中:

from decimal import Decimal
from django.conf import settings
from shop.models import Product
class Cart:
    def __init__(self, request):
        """
        Initialize the cart.
        """
        self.session = request.session
        cart = self.session.get(settings.CART_SESSION_ID)
        if not cart:
            # save an empty cart in the session
            cart = self.session[settings.CART_SESSION_ID] = {}
        self.cart = cart 

这是一个允许您管理购物车的Cart类。您需要将购物车初始化为一个request对象。您使用self.session = request.session来存储当前会话,以便其他Cart类的方法可以访问它。

首先,您尝试使用self.session.get(settings.CART_SESSION_ID)从当前会话中获取购物车。如果没有购物车存在于会话中,您通过在会话中设置空字典来创建一个空购物车。

您将使用产品 ID 作为键来构建您的cart字典,对于每个产品键,一个包含数量和价格的字典将作为值。通过这种方式,您可以保证产品不会添加到购物车中超过一次。这样,您也可以简化检索购物车项目。

让我们创建一个方法来向购物车添加产品或更新它们的数量。将以下add()save()方法添加到Cart类中:

class Cart:
    # ...
**def****add****(****self, product, quantity=****1****, override_quantity=****False****):**
**"""**
 **Add a product to the cart or update its quantity.**
 **"""**
 **product_id =** **str****(product.****id****)**
**if** **product_id** **not****in** **self.cart:**
 **self.cart[product_id] = {**
**'quantity'****:** **0****,**
**'price'****:** **str****(product.price)**
 **}**
**if** **override_quantity:**
 **self.cart[product_id][****'quantity'****] = quantity**
**else****:**
 **self.cart[product_id][****'quantity'****] += quantity**
 **self.save()**
**def****save****(****self****):**
**# mark the session as "modified" to make sure it gets saved**
 **self.session.modified =** **True** 

add()方法接受以下参数作为输入:

  • product:要添加或更新到购物车中的product实例。

  • quantity:一个可选的整数,表示产品数量。默认为1

  • override_quantity:一个布尔值,表示是否需要用给定的数量覆盖数量(True)或是否需要将新数量添加到现有数量(False)。

您使用产品 ID 作为购物车内容字典中的键。您将产品 ID 转换为字符串,因为 Django 使用 JSON 序列化会话数据,而 JSON 只允许字符串键名。产品 ID 是键,您持久化的值是一个包含产品数量和价格的字典。将产品的价格从十进制转换为字符串以进行序列化。最后,您调用save()方法来保存会话中的购物车。

save()方法使用session.modified = True标记会话为已修改。这告诉 Django 会话已更改,需要保存。

您还需要一个从购物车中删除产品的方法。将以下方法添加到Cart类中:

class Cart:
    # ...
**def****remove****(****self, product****):**
**"""**
 **Remove a product from the cart.**
 **"""**
 **product_id =** **str****(product.****id****)**
**if** **product_id** **in** **self.cart:**
**del** **self.cart[product_id]**
 **self.save()** 

remove()方法从cart字典中删除指定的产品,并调用save()方法来更新会话中的购物车。

您将不得不遍历购物车中的项目并访问相关的Product实例。为此,您可以在类中定义一个__iter__()方法。将以下方法添加到Cart类中:

class Cart:
    # ...
**def****__iter__****(****self****):**
**"""**
 **Iterate over the items in the cart and get the products**
 **from the database.**
 **"""**
 **product_ids = self.cart.keys()**
**# get the product objects and add them to the cart**
 **products = Product.objects.****filter****(id__in=product_ids)**
 **cart = self.cart.copy()**
**for** **product** **in** **products:**
 **cart[****str****(product.****id****)][****'product'****] = product**
**for** **item** **in** **cart.values():**
 **item[****'price'****] = Decimal(item[****'price'****])**
 **item[****'total_price'****] = item[****'price'****] * item[****'quantity'****]**
**yield** **item** 

__iter__()方法中,您检索购物车中存在的Product实例以将它们包含在购物车商品中。您将当前购物车复制到cart变量中,并将Product实例添加到其中。最后,您遍历购物车商品,将每个商品的价格转换回十进制,并为每个商品添加一个total_price属性。此__iter__()方法将允许您轻松地在视图和模板中遍历购物车中的商品。

您还需要一种方法来返回购物车中商品的总数。当len()函数在一个对象上执行时,Python 会调用其__len__()方法来获取其长度。接下来,您将定义一个自定义的__len__()方法来返回存储在购物车中的商品总数。

将以下__len__()方法添加到Cart类中:

class Cart:
    # ...
**def****__len__****(****self****):**
**"""**
 **Count all items in the cart.**
 **"""**
**return****sum****(item[****'quantity'****]** **for** **item** **in** **self.cart.values())** 

您返回购物车中所有商品数量的总和。

添加以下方法以计算购物车中商品的总成本:

class Cart:
    # ...
**def****get_total_price****(****self****):**
**return****sum****(**
**Decimal(item[****'price'****]) * item[****'quantity'****]**
**for** **item** **in** **self.cart.values()**
 **)** 

最后,添加一个清除购物车会话的方法:

class Cart:
    # ...
**def****clear****(****self****):**
**# remove cart from session**
**del** **self.session[settings.CART_SESSION_ID]**
 **self.save()** 

您的Cart类现在已准备好管理购物车。

创建购物车视图

现在您有一个Cart类来管理购物车,您需要创建添加、更新或从其中删除商品的视图。您需要创建以下视图:

  • 一个视图,用于添加或更新购物车中的商品,可以处理当前和新数量

  • 一个用于从购物车中删除商品的视图

  • 一个用于显示购物车商品和总计的视图

添加商品到购物车

要添加商品到购物车,您需要一个允许用户选择数量的表单。在cart应用目录内创建一个forms.py文件,并将以下代码添加到其中:

from django import forms
PRODUCT_QUANTITY_CHOICES = [(i, str(i)) for i in range(1, 21)]
class CartAddProductForm(forms.Form):
    quantity = forms.TypedChoiceField(
        choices=PRODUCT_QUANTITY_CHOICES,
        coerce=int
    )
    override = forms.BooleanField(
        required=False,
        initial=False,
        widget=forms.HiddenInput
    ) 

您将使用此表单添加产品到购物车。您的CartAddProductForm类包含以下两个字段:

  • 数量: 这允许用户在 1 到 20 之间选择数量。您使用带有coerce=intTypedChoiceField字段将输入转换为整数。

  • 覆盖: 这允许您指示是否需要将数量添加到购物车中此产品的任何现有数量(False)或是否需要用给定的数量覆盖现有数量(True)。您使用HiddenInput小部件为此字段,因为您不希望将其显示给用户。

让我们创建一个用于添加商品的视图。编辑cart应用的views.py文件,并添加以下加粗代码:

from django.shortcuts import **get_object_or_404, redirect,** render
**from** **django.views.decorators.http** **import** **require_POST**
**from** **shop.models** **import** **Product**
**from** **.cart** **import** **Cart**
**from** **.forms** **import** **CartAddProductForm**
**@require_POST**
**def****cart_add****(****request, product_id****):**
 **cart = Cart(request)**
 **product = get_object_or_404(Product,** **id****=product_id)**
 **form = CartAddProductForm(request.POST)**
**if** **form.is_valid():**
 **cd = form.cleaned_data**
 **cart.add(**
 **product=product,**
 **quantity=cd[****'quantity'****],**
 **override_quantity=cd[****'override'****]**
 **)**
**return** **redirect(****'cart:cart_detail'****)** 

这是用于添加产品到购物车或更新现有产品数量的视图。您使用require_POST装饰器仅允许POST请求。视图接收产品 ID 作为参数。您使用给定的 ID 检索Product实例并验证CartAddProductForm。如果表单有效,您将添加或更新购物车中的产品。视图将重定向到cart_detail URL,该 URL 将显示购物车的商品。您将很快创建cart_detail视图。

您还需要一个视图来从购物车中删除项目。将以下代码添加到cart应用程序的views.py文件中:

@require_POST
def cart_remove(request, product_id):
    cart = Cart(request)
    product = get_object_or_404(Product, id=product_id)
    cart.remove(product)
    return redirect('cart:cart_detail') 

cart_remove视图接收产品 ID 作为参数。您使用require_POST装饰器只允许POST请求。您使用给定的 ID 检索Product实例并将其从购物车中删除。然后,您将用户重定向到cart_detail URL。

最后,您需要一个视图来显示购物车及其项目。将以下视图添加到cart应用程序的views.py文件中:

def cart_detail(request):
    cart = Cart(request)
    return render(request, 'cart/detail.html', {'cart': cart}) 

cart_detail视图获取当前购物车以显示它。

您已创建了添加项目到购物车、更新数量、从购物车中删除项目和显示购物车内容等的视图。让我们为这些视图添加 URL 模式。在cart应用程序目录内创建一个新文件,并将其命名为urls.py。向其中添加以下 URL 模式:

from django.urls import path
from . import views
app_name = 'cart'
urlpatterns = [
    path('', views.cart_detail, name='cart_detail'),
    path('add/<int:product_id>/', views.cart_add, name='cart_add'),
    path(
        'remove/<int:product_id>/',
        views.cart_remove,
        name='cart_remove'
 ),
] 

编辑myshop项目的主体urls.py文件,并添加以下突出显示的 URL 模式以包含购物车 URL:

urlpatterns = [
    path('admin/', admin.site.urls),
 **path(****'cart/'****, include(****'cart.urls'****, namespace=****'cart'****)),**
    path('', include('shop.urls', namespace='shop')),
] 

确保在shop.urls模式之前包含此 URL 模式,因为它比后者更严格。

构建一个模板来显示购物车

cart_addcart_remove视图不渲染任何模板,但您需要为cart_detail视图创建一个模板来显示购物车项目和总计。

cart应用程序目录内创建以下文件结构:

templates/
    cart/
        detail.html 

编辑cart/detail.html模板,并向其中添加以下代码:

{% extends "shop/base.html" %}
{% load static %}
{% block title %}
  Your shopping cart
{% endblock %}
{% block content %}
  <h1>Your shopping cart</h1>
<table class="cart">
<thead>
<tr>
<th>Image</th>
<th>Product</th>
<th>Quantity</th>
<th>Remove</th>
<th>Unit price</th>
<th>Price</th>
</tr>
</thead>
<tbody>
      {% for item in cart %}
        {% with product=item.product %}
          <tr>
<td>
<a href="{{ product.get_absolute_url }}">
<img src="{% if product.image %}{{ product.image.url }}
                {% else %}{% static "img/no_image.png" %}{% endif %}">
</a>
</td>
<td>{{ product.name }}</td>
<td>{{ item.quantity }}</td>
<td>
<form action="{% url "cart:cart_remove" product.id %}" method="post">
<input type="submit" value="Remove">
                {% csrf_token %}
              </form>
</td>
<td class="num">${{ item.price }}</td>
<td class="num">${{ item.total_price }}</td>
</tr>
        {% endwith %}
      {% endfor %}
      <tr class="total">
<td>Total</td>
<td colspan="4"></td>
<td class="num">${{ cart.get_total_price }}</td>
</tr>
</tbody>
</table>
<p class="text-right">
<a href="{% url "shop:product_list" %}" class="button
    light">Continue shopping</a>
<a href="#" class="button">Checkout</a>
</p>
{% endblock %} 

确保没有模板标签被拆分到多行中。

这是用于显示购物车内容的模板。它包含一个表格,其中存储了当前购物车中的项目。您允许用户通过提交到cart_add视图的表单来更改所选产品的数量。您还允许用户通过为每个项目提供一个删除按钮来从购物车中删除项目。最后,您使用一个具有指向包含产品 ID 的cart_remove URL 的action属性的 HTML 表单。

添加产品到购物车

现在您需要在产品详情页添加一个添加到购物车按钮。编辑shop应用程序的views.py文件,并将CartAddProductForm添加到product_detail视图中,如下所示:

**from** **cart.forms** **import** **CartAddProductForm**
# ...
def product_detail(request, id, slug):
    product = get_object_or_404(
        Product, id=id, slug=slug, available=True
 )
 **cart_product_form = CartAddProductForm()**
return render(
        request,
        'shop/product/detail.html',
        {'product': product**,** **'cart_product_form'****: cart_product_form**}
    ) 

编辑shop/product/detail.html模板,并将以下表单添加到产品价格处,如下所示。新行以粗体突出显示:

...
<p class="price">${{ product.price }}</p>
**<****form****action****=****"{% url "****cart:cart_add****"** **product.id** **%}"** **method****=****"post"****>**
 **{{ cart_product_form }}**
 **{% csrf_token %}**
**<****input****type****=****"submit"****value****=****"Add to cart"****>**
**</****form****>**
{{ product.description|linebreaks }}
... 

使用以下命令运行开发服务器:

python manage.py runserver 

现在在您的浏览器中打开http://127.0.0.1:8000/并导航到产品详情页。它将包含一个表单,在将产品添加到购物车之前选择数量。页面看起来如下所示:

图形用户界面,应用程序描述自动生成

图 8.9:产品详情页,包括添加到购物车按钮

选择数量并点击加入购物车按钮。表单通过POST提交到cart_add视图。视图将产品添加到会话中的购物车,包括其当前价格和所选数量。然后,它将用户重定向到购物车详情页面,其外观将类似于图 8.10

图形用户界面,时间线描述自动生成,中等置信度

图 8.10:购物车详情页面

更新购物车中的产品数量

当用户看到购物车时,他们可能在下单前想要更改产品数量。您将允许用户从购物车详情页面更改数量。

编辑cart应用程序的views.py文件,并将以下加粗行添加到cart_detail视图中:

def cart_detail(request):
    cart = Cart(request)
**for** **item** **in** **cart:**
 **item[****'update_quantity_form'****] = CartAddProductForm(**
 **initial={****'quantity'****: item[****'quantity'****],** **'override'****:** **True****}**
 **)**
return render(request, 'cart/detail.html', {'cart': cart}) 

您为购物车中的每个项目创建一个CartAddProductForm实例,以便更改产品数量。您使用当前项目数量初始化表单,并将override字段设置为True,这样当您将表单提交给cart_add视图时,当前数量将被新数量替换。

现在编辑cart/detail.html模板中的cart应用程序,并找到以下行:

<td>{{ item.quantity }}</td> 

将前面的行替换为以下代码:

<td>
**<****form****action****=****"{% url "****cart:cart_add****"** **product.id** **%}"** **method****=****"post"****>**
 **{{ item.update_quantity_form.quantity }}**
 **{{ item.update_quantity_form.override }}**
**<****input****type****=****"submit"****value****=****"Update"****>**
 **{% csrf_token %}**
**</****form****>**
</td> 

使用以下命令运行开发服务器:

python manage.py runserver 

在您的浏览器中打开http://127.0.0.1:8000/cart/

您将看到一个用于编辑每个购物车项目数量的表单,如下所示:

图形用户界面,网站描述自动生成

图 8.11:购物车详情页面,包括更新产品数量的表单

更改项目数量并点击更新按钮以测试新功能。您也可以通过点击删除按钮从购物车中移除项目。

为当前购物车创建上下文处理器

您可能已经注意到,即使在购物车包含项目时,网站标题中也会显示消息您的购物车为空。您应该显示购物车中的项目总数和总成本。由于这需要在所有页面上显示,您需要构建一个上下文处理器,以便将当前购物车包含在请求上下文中,无论处理请求的视图是什么。

上下文处理器

上下文处理器是一个 Python 函数,它接受request对象作为参数,并返回一个字典,该字典被添加到请求上下文中。当您需要将某些内容全局提供给所有模板时,上下文处理器非常有用。

默认情况下,当您使用startproject命令创建新项目时,您的项目在TEMPLATES设置中的context_processors选项中包含以下模板上下文处理器:

  • django.template.context_processors.debug:此操作在上下文中设置布尔debugsql_queries变量,代表在请求中执行的 SQL 查询列表。

  • django.template.context_processors.request: 这将在上下文中设置request变量。

  • django.contrib.auth.context_processors.auth: 这将在请求中设置user变量。

  • django.contrib.messages.context_processors.messages: 这将在上下文中设置一个messages变量,包含使用消息框架生成的所有消息。

Django 还启用了django.template.context_processors.csrf以避免跨站请求伪造CSRF)攻击。此上下文处理器在设置中不存在,但它始终启用,出于安全原因无法关闭。

您可以在docs.djangoproject.com/en/5.0/ref/templates/api/#built-in-template-context-processors查看所有内置的上下文处理器的列表。

在请求上下文中设置购物车

让我们创建一个上下文处理器来设置请求上下文中的当前购物车。有了它,您将能够在任何模板中访问购物车。

cart应用程序目录内创建一个新文件,并将其命名为context_processors.py。上下文处理器可以存在于代码的任何位置,但在这里创建它们将有助于保持代码的整洁。将以下代码添加到文件中:

from .cart import Cart
def cart(request):
    return {'cart': Cart(request)} 

在您的上下文处理器中,您使用request对象实例化购物车,并将其作为名为cart的变量提供给模板。

编辑您项目的settings.py文件,并将cart.context_processors.cart添加到TEMPLATES设置中的context_processors选项中,如下所示。新行以粗体突出显示:

TEMPLATES = [
    {
        'BACKEND': 'django.template.backends.django.DjangoTemplates',
        'DIRS': [],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
**'cart.context_processors.cart'****,**
            ],
        },
    },
] 

每次使用 Django 的RequestContext渲染模板时,都会执行cart上下文处理器。cart变量将设置在模板的上下文中。您可以在docs.djangoproject.com/en/5.0/ref/templates/api/#django.template.RequestContext中了解更多关于RequestContext的信息。

上下文处理器在所有使用RequestContext的请求中执行。如果您不需要在所有模板中使用功能,尤其是如果它涉及数据库查询,您可能想创建一个自定义模板标签而不是上下文处理器。

接下来,编辑shop/base.html模板,这是shop应用程序的一部分,并找到以下行:

<div class="cart">
  Your cart is empty.
</div> 

将前面的行替换为以下代码:

<div class="cart">
 **{% with total_items=cart|length %}**
 **{% if total_items > 0 %}**
 **Your cart:**
**<****a****href****=****"{% url "****cart:cart_detail****" %}">**
 **{{ total_items }} item{{ total_items|pluralize }},**
 **${{ cart.get_total_price }}**
**</****a****>**
 **{% else %}**
 **Your cart is empty.**
 **{% endif %}**
 **{% endwith %}**
</div> 

使用以下命令重新启动开发服务器:

python manage.py runserver 

在您的浏览器中打开http://127.0.0.1:8000/并添加一些产品到购物车。

在网站页眉中,您现在可以看到购物车中的项目总数和总金额,如下所示:

图形用户界面,网站描述自动生成

图 8.12:显示购物车中当前项目的网站页眉

恭喜!您已完成了购物车功能。这是您的在线商店项目中的一个重要里程碑。接下来,您将创建注册客户订单的功能,这是任何电子商务平台的基础元素之一。

注册客户订单

当购物车结账时,您需要在数据库中保存一个订单。订单将包含有关客户和他们购买的产品信息。

使用以下命令创建用于管理客户订单的新应用:

python manage.py startapp orders 

编辑您项目的settings.py文件,并将新应用添加到INSTALLED_APPS设置中,如下所示:

INSTALLED_APPS = [
    # ...
'cart.apps.CartConfig',
**'orders.apps.OrdersConfig'****,**
'shop.apps.ShopConfig',
] 

您已激活orders应用。

创建订单模型

您需要一个模型来存储订单详情,以及第二个模型来存储购买的项目,包括它们的单价和数量。编辑orders应用的models.py文件,并向其中添加以下代码:

from django.db import models
class Order(models.Model):
    first_name = models.CharField(max_length=50)
    last_name = models.CharField(max_length=50)
    email = models.EmailField()
    address = models.CharField(max_length=250)
    postal_code = models.CharField(max_length=20)
    city = models.CharField(max_length=100)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)
    paid = models.BooleanField(default=False)
    class Meta:
        ordering = ['-created']
        indexes = [
            models.Index(fields=['-created']),
        ]
    def __str__(self):
        return f'Order {self.id}'
def get_total_cost(self):
        return sum(item.get_cost() for item in self.items.all())
class OrderItem(models.Model):
    order = models.ForeignKey(
        Order,
        related_name='items',
        on_delete=models.CASCADE
)
    product = models.ForeignKey(
        'shop.Product',
        related_name='order_items',
        on_delete=models.CASCADE
    )
    price = models.DecimalField(
        max_digits=10,
        decimal_places=2
 )
    quantity = models.PositiveIntegerField(default=1)
    def __str__(self):
        return str(self.id)
    def get_cost(self):
        return self.price * self.quantity 

订单模型包含几个字段以存储客户信息,以及一个默认为Falsepaid布尔字段。稍后,您将使用此字段来区分已付款和未付款的订单。我们还定义了一个get_total_cost()方法来获取此订单中购买项目的总成本。

OrderItem模型允许您存储每个项目的产品、数量和支付的金额。我们定义了一个get_cost()方法,通过将项目价格乘以数量来返回项目的成本。在product字段中,我们使用字符串'shop.Product',格式为app.Model,这是指向相关模型的另一种方式,也是一种避免循环导入的好方法。

运行以下命令为orders应用创建初始迁移:

python manage.py makemigrations 

您将看到类似以下输出的内容:

Migrations for 'orders':
  orders/migrations/0001_initial.py
    - Create model Order
    - Create model OrderItem 

运行以下命令以应用新的迁移:

python manage.py migrate 

您将看到以下输出:

Applying orders.0001_initial... OK 

您的订单模型现在已同步到数据库中。

将订单模型包含在管理站点中

让我们将订单模型添加到管理站点。编辑orders应用的admin.py文件,并添加以下加粗代码:

from django.contrib import admin
**from** **.models** **import** **Order, OrderItem**
**class****OrderItemInline****(admin.TabularInline):**
 **model = OrderItem**
 **raw_id_fields = [****'product'****]**
**@admin.register(****Order****)**
**class****OrderAdmin****(admin.ModelAdmin):**
 **list_display = [**
**'id'****,**
**'first_name'****,**
**'last_name'****,**
**'email'****,**
**'address'****,**
**'postal_code'****,**
**'city'****,**
**'paid'****,**
**'created'****,**
**'updated'**
**]**
 **list_filter = [****'paid'****,** **'created'****,** **'updated'****]**
 **inlines = [OrderItemInline]** 

您使用ModelInline类为OrderItem模型,将其作为内联包含在OrderAdmin类中。内联允许您将模型包含在其相关模型相同的编辑页面上。

使用以下命令运行开发服务器:

python manage.py runserver 

在您的浏览器中打开http://127.0.0.1:8000/admin/orders/order/add/。您将看到以下页面:

图 8.13:包含 OrderItemInline 的添加订单表单

创建客户订单

您将使用创建的订单模型来持久化购物车中包含的项目,当用户最终下订单时。创建新订单的步骤如下:

  1. 向用户展示一个订单表单以填写他们的数据。

  2. 使用输入的数据创建一个新的Order实例,并为购物车中的每个项目创建一个相关的OrderItem实例。

  3. 清空购物车的所有内容并将用户重定向到成功页面。

首先,您需要一个表单来输入订单详情。在orders应用目录内创建一个新文件,命名为forms.py。向其中添加以下代码:

from django import forms
from .models import Order
class OrderCreateForm(forms.ModelForm):
    class Meta:
        model = Order
        fields = [
            'first_name',
            'last_name',
            'email',
            'address',
            'postal_code',
            'city'
 ] 

这是您将要用来创建新Order对象的表单。现在您需要一个视图来处理表单并创建新订单。编辑orders应用的views.py文件并添加以下加粗的代码:

**from** **cart.cart** **import** **Cart**
from django.shortcuts import render
**from** **.forms** **import** **OrderCreateForm**
**from** **.models** **import** **OrderItem**
**def****order_create****(****request****):**
 **cart = Cart(request)**
**if** **request.method ==** **'POST'****:**
 **form = OrderCreateForm(request.POST)**
**if** **form.is_valid():**
 **order = form.save()**
**for** **item** **in** **cart:**
 **OrderItem.objects.create(**
 **order=order,**
 **product=item[****'product'****],**
 **price=item[****'price'****],**
 **quantity=item[****'quantity'****]**
 **)**
**# clear the cart**
 **cart.clear()**
**return** **render(**
 **request,** **'orders/order/created.html'****, {****'order'****: order}**
 **)**
**else****:**
 **form = OrderCreateForm()**
**return** **render(**
 **request,**
**'orders/order/create.html'****,**
 **{****'cart'****: cart,** **'form'****: form}**
 **)** 

order_create视图中,您使用cart = Cart(request)从会话中获取当前购物车。根据请求方法,您执行以下任务:

  • GET 请求:实例化OrderCreateForm表单并渲染orders/order/create.html模板。

  • POST 请求:验证请求中发送的数据。如果数据有效,您将使用order = form.save()在数据库中创建一个新的订单。您遍历购物车项目并为每个项目创建一个OrderItem。最后,您清除购物车的所有内容并渲染模板orders/order/created.html

orders应用目录内创建一个新文件,命名为urls.py。向其中添加以下代码:

from django.urls import path
from . import views
app_name = 'orders'
urlpatterns = [
    path('create/', views.order_create, name='order_create'),
] 

这是order_create视图的 URL 模式。

编辑myshopurls.py文件并包含以下模式。请记住将其放在shop.urls模式之前,如下所示。新行已加粗:

urlpatterns = [
    path('admin/', admin.site.urls),
    path('cart/', include('cart.urls', namespace='cart')),
 **path(****'orders/'****, include(****'orders.urls'****, namespace=****'orders'****)),**
    path('', include('shop.urls', namespace='shop')),
] 

编辑cart/detail.html模板的cart应用并找到以下行:

<a href="#" class="button">Checkout</a> 

order_create URL 添加到href HTML 属性中,如下所示:

<a href="**{% url "****orders:order_create****" %}**" class="button">
  Checkout
</a> 

用户现在可以从购物车详情页面导航到订单表单。

您还需要定义创建订单的模板。在orders应用目录内创建以下文件结构:

templates/
    orders/
        order/
            create.html
            created.html 

编辑orders/order/create.html模板并添加以下代码:

{% extends "shop/base.html" %}
{% block title %}
  Checkout
{% endblock %}
{% block content %}
  <h1>Checkout</h1>
<div class="order-info">
<h3>Your order</h3>
<ul>
      {% for item in cart %}
        <li>
          {{ item.quantity }}x {{ item.product.name }}
          <span>${{ item.total_price }}</span>
</li>
      {% endfor %}
    </ul>
<p>Total: ${{ cart.get_total_price }}</p>
</div>
<form method="post" class="order-form">
    {{ form.as_p }}
    <p><input type="submit" value="Place order"></p>
    {% csrf_token %}
  </form>
{% endblock %} 

此模板显示购物车项目,包括总计和下订单的表单。

编辑orders/order/created.html模板并添加以下代码:

{% extends "shop/base.html" %}
{% block title %}
  Thank you
{% endblock %}
{% block content %}
  <h1>Thank you</h1>
<p>Your order has been successfully completed. Your order number is
  <strong>{{ order.id }}</strong>.</p>
{% endblock %} 

这是订单成功创建时渲染的模板。

启动 Web 开发服务器以加载新文件。在浏览器中打开http://127.0.0.1:8000/,向购物车添加几个产品,然后继续到结账页面。您将看到以下表单:

图形用户界面,应用程序描述自动生成

图 8.14:订单创建页面,包括图表结账表单和订单详情

使用有效数据填写表单并点击下订单按钮。订单将被创建,您将看到如下成功页面:

包含文本的图片,描述自动生成

图 8.15:显示订单号的订单创建模板

订单已注册,购物车已清空。

您可能已经注意到,当订单完成时,在页眉中会显示消息您的购物车为空。这是因为购物车已被清空。我们可以轻松避免在模板上下文中包含order对象的视图显示此消息。

编辑shop应用的shop/base.html模板,并替换以下加粗的行:

...
<div class="cart">
  {% with total_items=cart|length %}
    {% if total_items > 0 %}
      Your cart:
      <a href="{% url "cart:cart_detail" %}">
        {{ total_items }} item{{ total_items|pluralize }},
        ${{ cart.get_total_price }}
      </a>
 **{% elif not order %}**
      Your cart is empty.
    {% endif %}
  {% endwith %}
</div>
... 

当创建订单时,将不再显示消息您的购物车为空

现在打开管理站点http://127.0.0.1:8000/admin/orders/order/。您会看到订单已成功创建,如下所示:

图片

图 8.16:管理站点中的订单变更列表部分,包括创建的订单

您已经实现了订单系统。现在您将学习如何创建异步任务,在用户下单时向用户发送确认邮件。

创建异步任务

当收到 HTTP 请求时,您需要尽可能快地返回响应给用户。记住,在第七章跟踪用户行为中,您使用了 Django 调试工具栏来检查请求/响应周期的不同阶段的时间和 SQL 查询的执行时间。

在请求/响应周期中执行的所有任务都会累计到总响应时间。长时间运行的任务可能会严重减慢服务器响应。我们如何在完成耗时任务的同时,仍然快速返回响应给用户?我们可以通过异步执行来实现。

与异步任务一起工作

我们可以通过在后台执行某些任务来从请求/响应周期中卸载工作。例如,一个视频分享平台允许用户上传视频,但需要很长时间来转码上传的视频。当用户上传视频时,网站可能会返回一个响应,告知他们转码将很快开始,并异步开始转码视频。另一个例子是向用户发送电子邮件。如果您的网站从一个视图中发送电子邮件通知,简单邮件传输协议SMTP)连接可能会失败或减慢响应。通过异步发送电子邮件,您可以避免阻塞代码执行。

异步执行对于数据密集型、资源密集型、耗时或可能需要重试策略的过程特别相关。

工作者、消息队列和消息代理

当您的 Web 服务器处理请求并返回响应时,您需要一个名为worker的第二个基于任务的服务器来处理异步任务。一个或多个工作者可以在后台运行并执行任务。这些工作者可以访问数据库、处理文件、发送电子邮件等。工作者甚至可以排队未来的任务,同时保持主 Web 服务器空闲以处理 HTTP 请求。

为了告诉工作者执行哪些任务,我们需要发送消息。我们通过向消息队列添加消息与代理进行通信,这基本上是一个先进先出FIFO)的数据结构。当代理可用时,它从队列中取出第一条消息并开始执行相应的任务。完成后,代理从队列中取出下一条消息并执行相应的任务。当消息队列为空时,代理处于空闲状态。当使用多个代理时,每个代理在可用时按顺序取第一条可用的消息。队列确保每个代理一次只获取一个任务,并且没有任务被多个工作者处理。

图 8.17 展示了消息队列的工作方式:

形状描述自动生成,置信度中等

图 8.17:使用消息队列和工作者进行异步执行

生产者向队列发送消息,工作者(们)基于“先到先得”的原则消费消息;添加到消息队列中的第一条消息是工作者(们)首先处理的消息。

为了管理消息队列,我们需要一个消息代理。消息代理用于将消息转换为正式的消息协议,并为多个接收者管理消息队列。它提供可靠的存储和保证的消息传递。消息代理允许我们创建消息队列、路由消息、在工作者之间分配消息等。

要在项目中实现异步任务,你将使用 Celery 来管理任务队列,并将 RabbitMQ 作为 Celery 使用的消息代理。这两项技术将在下一节中介绍。

使用 Django 与 Celery 和 RabbitMQ

Celery 是一个分布式任务队列,可以处理大量消息。我们将使用 Celery 在 Django 应用程序中定义异步任务,作为 Python 函数。我们将运行 Celery 工作者,他们将监听消息代理以获取要处理的新消息。

使用 Celery,你不仅可以轻松创建异步任务并让它们尽快由工作者执行,还可以安排它们在特定时间运行。你可以在docs.celeryq.dev/en/stable/index.html找到 Celery 的文档。

Celery 通过消息进行通信,并需要一个消息代理在客户端和工作者之间进行调解。对于 Celery 的消息代理有几种选择,包括 Redis 这样的键/值存储,或者 RabbitMQ 这样的实际消息代理。

RabbitMQ 是最广泛部署的消息代理。它支持多种消息协议,例如高级消息队列协议AMQP),并且是 Celery 推荐的消息工作者。RabbitMQ 轻量级,易于部署,可以配置以实现可伸缩性和高可用性。

图 8.18展示了我们将如何使用 Django、Celery 和 RabbitMQ 来执行异步任务:

包含图表的图片  自动生成的描述

图 8.18:使用 Django、RabbitMQ 和 Celery 的异步任务架构

安装 Celery

让我们安装 Celery 并将其集成到项目中。使用以下命令通过pip安装 Celery:

python -m pip install celery==5.4.0 

您可以在docs.celeryq.dev/en/stable/getting-started/introduction.html找到 Celery 的简介。

安装 RabbitMQ

RabbitMQ 社区提供了一个 Docker 镜像,使得使用标准配置部署 RabbitMQ 服务器变得非常简单。记住,您在第三章扩展您的博客应用中学习了如何安装 Docker。

在您的机器上安装 Docker 后,您可以通过在 shell 中运行以下命令轻松拉取 RabbitMQ Docker 镜像:

docker pull rabbitmq:3.13.1-management 

这将下载 RabbitMQ Docker 镜像到您的本地机器。您可以在hub.docker.com/_/rabbitmq找到有关官方 RabbitMQ Docker 镜像的信息。

如果您想在您的机器上本地安装 RabbitMQ 而不是使用 Docker,您可以在www.rabbitmq.com/download.html找到不同操作系统的详细安装指南。

在 shell 中执行以下命令以使用 Docker 启动 RabbitMQ 服务器:

docker run -it --rm --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3.13.1-management 

使用此命令,我们告诉 RabbitMQ 在端口5672上运行,并且我们在端口15672上运行其基于 Web 的管理用户界面。

您将看到包含以下行的输出:

Starting broker...
...
completed with 4 plugins.
Server startup complete; 4 plugins started. 

RabbitMQ 正在端口5672上运行,并准备好接收消息。

访问 RabbitMQ 的管理界面

在浏览器中打开http://127.0.0.1:15672/。您将看到 RabbitMQ 管理 UI 的登录屏幕。它看起来像这样:

图形用户界面,文本,应用程序,电子邮件  自动生成的描述

图 8.19:RabbitMQ 管理 UI 登录屏幕

将用户名和密码都输入为guest,然后点击登录。您将看到以下屏幕:

图 8.20:RabbitMQ 管理 UI 仪表板

这是 RabbitMQ 的默认管理员用户。在此屏幕上,您可以监控 RabbitMQ 的当前活动。您可以看到有一个节点正在运行,没有注册连接或队列。

如果您在生产环境中使用 RabbitMQ,您需要创建一个新的管理员用户并删除默认的guest用户。您可以在管理 UI 的管理员部分完成此操作。

现在我们将向项目中添加 Celery。然后,我们将运行 Celery 并测试与 RabbitMQ 的连接。

将 Celery 添加到您的项目中

你必须为 Celery 实例提供配置。在 myshopsettings.py 文件旁边创建一个新文件,并将其命名为 celery.py。此文件将包含你项目的 Celery 配置。向其中添加以下代码:

import os
from celery import Celery
# set the default Django settings module for the 'celery' program.
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myshop.settings')
app = Celery('myshop')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks() 

在此代码中,你需要执行以下操作:

  • 你为 Celery 命令行程序设置了 DJANGO_SETTINGS_MODULE 变量。

  • 使用 app = Celery('myshop') 创建应用程序的一个实例。

  • 你可以使用 config_from_object() 方法从你的项目设置中加载任何自定义配置。namespace 属性指定了 Celery 相关设置在 settings.py 文件中的前缀。通过设置 CELERY 命名空间,所有 Celery 设置都需要在它们的名称中包含 CELERY_ 前缀(例如,CELERY_BROKER_URL)。

  • 最后,你告诉 Celery 自动发现应用程序中的异步任务。Celery 将在添加到 INSTALLED_APPS 的每个应用程序目录中查找 tasks.py 文件,以便加载其中定义的异步任务。

你需要在项目的 __init__.py 文件中导入 celery 模块,以确保在 Django 启动时加载它。

编辑 myshop/__init__.py 文件,并向其中添加以下代码:

# import celery
from .celery import app as celery_app
__all__ = ['celery_app'] 

你已经将 Celery 添加到 Django 项目中,现在你可以开始使用它了。

运行 Celery 工作进程

Celery 工作进程是一个处理诸如发送/接收队列消息、注册任务、终止挂起任务、跟踪状态等功能的过程。工作进程实例可以从任意数量的消息队列中消费。

在另一个 shell 中,从你的项目目录启动一个 Celery 工作进程,使用以下命令:

celery -A myshop worker -l info 

Celery 工作进程现在正在运行并准备处理任务。让我们检查 Celery 和 RabbitMQ 之间是否存在连接。

在你的浏览器中打开 http://127.0.0.1:15672/ 以访问 RabbitMQ 管理界面。你现在将看到 排队消息 下的一个图表和 消息速率 下的另一个图表,如图 8.21 所示:

图片

图 8.21:显示连接和队列的 RabbitMQ 管理仪表板

显然,目前没有排队消息,因为我们还没有向消息队列发送任何消息。消息速率下的图表应该每五秒更新一次;你可以在屏幕右上角看到刷新率。这次,连接队列应该显示一个大于零的数字。

现在我们可以开始编写异步任务了。

CELERY_ALWAYS_EAGER 设置允许你以同步方式在本地执行任务,而不是将它们发送到队列。这对于运行单元测试或在本地环境中执行应用程序而不运行 Celery 非常有用。

向你的应用程序添加异步任务

让我们在在线商店中每次下单时都向用户发送确认邮件。我们将通过 Python 函数实现发送邮件,并将其作为 Celery 任务注册。然后,我们将将其添加到order_create视图以异步执行任务。

当执行order_create视图时,Celery 会将消息发送到由 RabbitMQ 管理的消息队列,然后一个 Celery 代理将执行我们用 Python 函数定义的异步任务。

Celery 方便任务发现的习惯做法是在应用目录中的tasks模块中定义您的应用异步任务。

orders应用内部创建一个新文件,并将其命名为tasks.py。这是 Celery 寻找异步任务的地方。向其中添加以下代码:

from celery import shared_task
from django.core.mail import send_mail
from .models import Order
@shared_task
def order_created(order_id):
    """
    Task to send an e-mail notification when an order is
    successfully created.
    """
    order = Order.objects.get(id=order_id)
    subject = f'Order nr. {order.id}'
    message = (
        f'Dear {order.first_name},\n\n'
f'You have successfully placed an order.'
f'Your order ID is {order.id}.'
    )
    mail_sent = send_mail(
        subject, message, 'admin@myshop.com', [order.email]
    )
    return mail_sent 

我们通过使用@shared_task装饰器定义了order_created任务。如您所见,Celery 任务只是一个带有@shared_task装饰器的 Python 函数。order_created任务函数接收一个order_id参数。始终建议只向任务函数传递 ID,并在任务执行时从数据库检索对象。这样做可以避免访问过时信息,因为数据库中的数据可能在任务排队期间已更改。我们使用了 Django 提供的send_mail()函数向下单用户发送电子邮件通知。

您在第二章中学习了如何配置 Django 以使用您的 SMTP 服务器,即增强您的博客高级功能。如果您不想设置电子邮件设置,您可以通过向settings.py文件添加以下设置来告诉 Django 将电子邮件写入控制台:

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 

不仅对于耗时过程,对于执行时间不那么多但可能遇到连接失败或需要重试策略的其他过程,也应使用异步任务。

现在您需要将任务添加到order_create视图中。编辑orders应用的views.py文件,导入任务,并在清空购物车后调用order_created异步任务,如下所示:

# ...
**from** **.tasks** **import** **order_created**
def order_create(request):
    # ...
if request.method == 'POST':
        # ...
if form.is_valid():
            # ...
            cart.clear()
**# launch asynchronous task**
 **order_created.delay(order.****id****)**
# ... 

您通过调用任务的delay()方法来异步执行它。任务将被添加到消息队列,并由 Celery 工作进程尽快执行。

确保 RabbitMQ 正在运行。然后,停止 Celery 工作进程,并使用以下命令重新启动:

celery -A myshop worker -l info 

Celery 工作进程已注册了任务。在另一个 shell 中,从项目目录使用以下命令启动开发服务器:

python manage.py runserver 

在您的浏览器中打开http://127.0.0.1:8000/,将一些产品添加到您的购物车,并完成订单。在您启动 Celery 工作进程的 shell 中,您将看到类似以下输出的内容:

[2024-01-02 20:25:19,569: INFO/MainProcess] Task orders.tasks.order_created[a94dc22e-372b-4339-bff7-52bc83161c5c] received
...
[2024-01-02 20:25:19,605: INFO/ForkPoolWorker-8] Task orders.tasks.order_created[a94dc22e-372b-4339-bff7-52bc83161c5c] succeeded in 0.015824042027816176s: 1 

order_created任务已执行,并已发送订单的电子邮件通知。如果您使用的是console.EmailBackend电子邮件后端,则不会发送电子邮件,但您应该在控制台输出中看到电子邮件的渲染文本。

使用 Flower 监控 Celery

除了 RabbitMQ 管理 UI 之外,您还可以使用其他工具来监控使用 Celery 执行的后台异步任务。Flower 是一个用于监控 Celery 的有用基于 Web 的工具。您可以在flower.readthedocs.io/找到 Flower 的文档。

使用以下命令安装 Flower:

python -m pip install flower==2.0.1 

安装完成后,您可以通过在项目目录的新 shell 中运行以下命令来启动 Flower:

celery -A myshop flower 

在浏览器中打开http://localhost:5555/。您将能够看到活动的 Celery 工作进程和异步任务统计信息。屏幕应如下所示:

图 8.22:Flower 仪表板

您将看到一个名为celery@且状态为在线的活动工作进程。

点击工作进程的名称,然后点击队列标签。您将看到以下屏幕:

图 8.23:Flower – Worker Celery 任务队列

在这里,您可以查看名为celery的活动队列。这是连接到消息代理的活动队列消费者。

点击任务标签。您将看到以下屏幕:

图 8.24:Flower – Worker Celery 任务

在这里,您可以查看已处理的任务以及它们被执行次数。您应该看到order_created任务及其被执行的总次数。这个数字可能会根据您放置的订单数量而变化。

在浏览器中打开http://localhost:8000/。将一些商品添加到购物车,然后完成结账流程。

在浏览器中打开http://localhost:5555/。Flower 已将任务注册为已处理。现在您应该在已处理下看到1,在成功下也看到1

图 8.25:Flower – Celery 工作进程

任务下,您可以查看 Celery 注册的每个任务的详细信息:

图 8.26:Flower – Celery 任务

Flower 绝不应该在没有安全措施的情况下公开部署到生产环境中。让我们给 Flower 实例添加身份验证。使用Ctrl + C停止 Flower,然后通过执行以下命令以--basic-auth选项重新启动它:

celery -A myshop flower --basic-auth=user:pwd 

userpwd替换为您想要的用户名和密码。在浏览器中打开http://localhost:5555/。浏览器现在将提示您输入凭据,如图 8.27 所示:

图 8.27:访问 Flower 所需的基本身份验证

Flower 提供其他认证选项,例如 Google、GitHub 或 Okta OAuth。您可以在 flower.readthedocs.io/en/latest/auth.html 了解更多关于 Flower 的认证方法。

摘要

在本章中,您创建了一个基本的电子商务应用程序。您制作了产品目录,并使用会话构建了购物车。您实现了一个自定义上下文处理器,以便将购物车提供给所有模板,并创建了一个订单提交表单。您还学习了如何使用 Celery 和 RabbitMQ 实现异步任务。完成本章后,您现在理解了使用 Django 构建电子商务平台的基础元素,包括管理产品、处理订单和处理异步任务。您现在也能够开发能够高效处理用户交易并无缝扩展以处理复杂后台操作的项目。

在下一章中,您将了解如何将支付网关集成到您的商店中,向管理站点添加自定义操作,以 CSV 格式导出数据,以及动态生成 PDF 文件。

其他资源

以下资源提供了与本章所涵盖主题相关的额外信息:

加入我们的 Discord 社群!

与其他用户、Django 开发专家以及作者本人一起阅读这本书。提问、为其他读者提供解决方案、通过 Ask Me Anything 会话与作者聊天,还有更多。扫描二维码或访问链接加入社区。

packt.link/Django5ByExample

第九章:管理支付和订单

在上一章中,您创建了一个基本的在线商店,包括产品目录和购物车。您学习了如何使用 Django 会话并构建自定义上下文处理器。您还学习了如何使用 Celery 和 RabbitMQ 启动异步任务。

在本章中,您将学习如何将支付网关集成到您的网站中,以便用户可以通过信用卡支付并管理订单支付。您还将扩展管理站点以添加不同的功能。

在本章中,您将:

  • 将 Stripe 支付网关集成到您的项目中

  • 使用 Stripe 处理信用卡支付

  • 处理支付通知并将订单标记为已支付

  • 将订单导出为 CSV 文件

  • 为管理站点创建自定义视图

  • 动态生成 PDF 发票

功能概述

图 9.1 展示了本章将构建的视图、模板和功能表示:

图 9.1:第九章构建的功能图

在本章中,您将创建一个新的 payment 应用程序,在该应用程序中,您将实现 payment_process 视图以启动结账会话并使用 Stripe 支付订单。您将构建 payment_completed 视图以在支付成功后重定向用户,以及 payment_canceled 视图以在支付取消时重定向用户。您将实现 export_to_csv 管理操作以在管理站点中以 CSV 格式导出订单。您还将构建管理视图 admin_order_detail 以显示订单详情和 admin_order_pdf 视图以动态生成 PDF 发票。您将实现 stripe_webhook webhook 以接收来自 Stripe 的异步支付通知,并且您将实现 payment_completed 异步任务以在订单支付时向客户发送发票。

本章的源代码可以在 github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter09 找到。

本章中使用的所有 Python 包都包含在章节源代码中的 requirements.txt 文件中。您可以根据以下部分中的说明安装每个 Python 包,或者您可以使用命令 python -m pip install -r requirements.txt 一次性安装所有依赖项。

集成支付网关

支付网关是一种由商家使用的在线处理客户支付的技术。使用支付网关,您可以管理客户的订单并将支付处理委托给可靠、安全的第三方。通过使用受信任的支付网关,您无需担心在自己的系统中处理信用卡的技术、安全和监管复杂性。

有多个支付网关提供商可供选择。我们将集成 Stripe,这是一个非常流行的支付网关,被 Shopify、Uber、Twitch 和 GitHub 等在线服务以及其他服务使用。

Stripe 提供了一个 应用程序编程接口 (API),允许您使用多种支付方式(如信用卡、Google Pay 和 Apple Pay)处理在线支付。您可以在 www.stripe.com/ 上了解更多关于 Stripe 的信息。

Stripe 提供与支付处理相关的不同产品。它可以管理一次性支付、订阅服务的定期支付、平台和市场的多方支付等。

Stripe 提供不同的集成方法,从 Stripe 托管的支付表单到完全可定制的结账流程。我们将集成 Stripe Checkout 产品,它由一个优化转换的支付页面组成。用户将能够轻松地使用信用卡或其他支付方式支付他们订购的商品。我们将从 Stripe 收到支付通知。您可以在 stripe.com/docs/payments/checkout 上查看 Stripe Checkout 文档。

通过利用 Stripe Checkout 处理支付,您依赖于一个既安全又符合 支付卡行业 (PCI) 要求的解决方案。您将能够从 Google Pay、Apple Pay、Afterpay、Alipay、SEPA 直接借记、Bacs 直接借记、BECS 直接借记、iDEAL、Sofort、GrabPay、FPX 以及其他支付方式中收集款项。

创建 Stripe 账户

您需要一个 Stripe 账户才能将支付网关集成到您的网站上。让我们创建一个账户来测试 Stripe API。在您的浏览器中打开 dashboard.stripe.com/register

您将看到一个如下所示的形式:

图 9.2:Stripe 注册表单

使用您自己的数据填写表格,并点击 创建账户。您将收到来自 Stripe 的电子邮件,其中包含一个用于验证电子邮件地址的链接。电子邮件将如下所示:

图 9.3:验证电子邮件地址的验证邮件

打开您的收件箱中的电子邮件并点击 验证电子邮件

您将被重定向到 Stripe 控制台屏幕,其外观如下:

图 9.4:验证电子邮件地址后的 Stripe 控制台

在屏幕右上角,您可以看到 测试模式 已激活。Stripe 为您提供了一个测试环境和生产环境。如果您是商人或自由职业者,您可以添加您的业务详情以激活账户并获取处理真实支付的权利。然而,这并不是通过 Stripe 实现和测试支付所必需的,因为我们将在测试环境中工作。

您需要添加一个账户名称来处理支付。在您的浏览器中打开 dashboard.stripe.com/settings/account

您将看到以下屏幕:

图 9.5:Stripe 账户设置

账户名称下输入您选择的名称,然后点击保存。返回 Stripe 仪表板。您将在页眉中看到您的账户名称:

图 9.6:包含账户名称的 Stripe 仪表板页眉

我们将继续通过安装 Stripe Python SDK 并将 Stripe 添加到我们的 Django 项目中。

安装 Stripe Python 库

Stripe 提供了一个 Python 库,简化了处理其 API 的过程。我们将使用stripe库将支付网关集成到项目中。

您可以在github.com/stripe/stripe-python找到 Stripe Python 库的源代码。

使用以下命令从 shell 中安装stripe库:

python -m pip install stripe==9.3.0 

将 Stripe 添加到您的项目中

在浏览器中打开dashboard.stripe.com/test/apikeys。您也可以通过点击 Stripe 仪表板上的开发者然后点击API 密钥来访问此页面。您将看到以下屏幕:

图 9.7:Stripe 测试 API 密钥屏幕

Stripe 为两个不同的环境提供了密钥对,即测试和生产环境。每个环境都有一个发布密钥密钥。测试模式的发布密钥前缀为pk_test_,实时模式的发布密钥前缀为pk_live_。测试模式的密钥前缀为sk_test_,实时模式的密钥前缀为sk_live_

您需要这些信息来验证对 Stripe API 的请求。您应该始终保密您的私钥并安全存储。发布密钥可用于客户端代码,如 JavaScript 脚本。您可以在stripe.com/docs/keys上了解更多关于 Stripe API 密钥的信息。

为了便于将配置与代码分离,我们将使用python-decouple。您已经在第二章增强您的博客并添加社交功能中使用了这个库。

在您的项目根目录内创建一个新文件,并将其命名为.env.env文件将包含环境变量的键值对。将 Stripe 凭证添加到新文件中,如下所示:

STRIPE_PUBLISHABLE_KEY=pk_test_XXXX
STRIPE_SECRET_KEY=sk_test_XXXX 

STRIPE_PUBLISHABLE_KEYSTRIPE_SECRET_KEY值替换为 Stripe 提供的测试发布密钥密钥值。

如果您使用git仓库存储代码,请确保将.env包含在您的仓库.gitignore文件中。这样做可以确保凭证不被包含在仓库中。

通过运行以下命令使用pip安装python-decouple

python -m pip install python-decouple==3.8 

编辑您的项目settings.py文件,并向其中添加以下代码:

**from** **decouple** **import** **config**
# ...
**STRIPE_PUBLISHABLE_KEY = config(****'****STRIPE_PUBLISHABLE_KEY'****)**
**STRIPE_SECRET_KEY = config(****'STRIPE_SECRET_KEY'****)**
**STRIPE_API_VERSION =** **'2024-04-10'** 

您将使用 Stripe API 版本2024-04-10。您可以在stripe.com/docs/upgrades#2024-04-10上查看此 API 版本的发布说明。

您正在使用项目的测试环境密钥。一旦您上线并验证您的 Stripe 账户,您将获得生产环境的密钥。在第十七章上线中,您将学习如何配置多个环境的设置。

让我们将支付网关集成到结账流程中。您可以在stripe.com/docs/api?lang=python找到 Stripe 的 Python 文档。

构建支付流程

结账流程将按以下方式工作:

  1. 将商品添加到购物车。

  2. 检查购物车。

  3. 输入信用卡详情并支付。

我们将创建一个新的应用程序来管理支付。使用以下命令在您的项目中创建一个新的应用程序:

python manage.py startapp payment 

编辑项目的settings.py文件并将新应用程序添加到INSTALLED_APPS设置中,如下所示。新行以粗体突出显示:

INSTALLED_APPS = [
    # ...
    'cart.apps.CartConfig',
    'orders.apps.OrdersConfig',
**'payment.apps.PaymentConfig'****,**
'shop.apps.ShopConfig',
] 

payment应用程序现在已在项目中激活。

目前,用户可以下订单但不能支付。在客户下单后,我们需要将他们重定向到支付流程。

编辑orders应用程序的views.py文件并包含以下导入:

from django.shortcuts import **redirect,** render 

在同一文件中,找到以下order_create视图的行:

# launch asynchronous task
order_created.delay(order.id)
return render(
  request, 'orders/order/created.html', {'order': order}
) 

将它们替换为以下代码:

# launch asynchronous task
order_created.delay(order.id)
**# set the order in the session**
**request.session[****'order_id'****] = order.****id**
**# redirect for payment**
**return** **redirect(****'payment:process'****)** 

编辑后的视图应如下所示:

from django.shortcuts import **redirect,** render
# ...
def order_create(request):
    cart = Cart(request)
    if request.method == 'POST':
        form = OrderCreateForm(request.POST)
        if form.is_valid():
            order = form.save()
            for item in cart:
                OrderItem.objects.create(
                    order=order,
                    product=item['product'],
                    price=item['price'],
                    quantity=item['quantity']
                )
            # clear the cart
            cart.clear()
            # launch asynchronous task
            order_created.delay(order.id)
**# set the order in the session**
 **request.session[****'order_id'****] = order.****id**
**# redirect for payment**
**return** **redirect(****'payment:process'****)**
else:
        form = OrderCreateForm()
    return render(
        request,
        'orders/order/create.html',
        {'cart': cart, 'form': form}
    ) 

在放置新订单时,不是渲染模板orders/order/created.html,而是将订单 ID 存储在用户会话中,并将用户重定向到payment:process URL。我们将在稍后实现此 URL。请记住,Celery 必须运行,以便order_created任务可以排队并执行。

让我们将支付网关集成。

集成 Stripe 结账

Stripe 结账集成包括由 Stripe 托管的结账页面,允许用户输入支付详情,通常是一张信用卡,然后它收集支付。如果支付成功,Stripe 将客户端重定向到成功页面。如果客户端取消支付,它将客户端重定向到取消页面。

我们将实现三个视图:

  • payment_process:创建 Stripe 结账会话并将客户端重定向到由 Stripe 托管的支付表单。结账会话是客户端重定向到支付表单时看到的程序表示,包括产品、数量、货币和要收取的金额。

  • payment_completed:显示成功支付的提示信息。如果支付成功,用户将被重定向到此视图。

  • payment_canceled:显示取消支付的提示信息。如果支付被取消,用户将被重定向到此视图。

图 9.8显示了结账支付流程:

图形用户界面,应用程序描述自动生成

图 9.8:结账支付流程

完整的结账流程将按以下方式工作:

  1. 创建订单后,用户将被重定向到payment_process视图。用户将看到订单摘要和继续付款的按钮。

  2. 当用户继续付款时,将创建一个 Stripe 结账会话。结账会话包括用户将要购买的项目列表、成功付款后重定向用户的 URL 以及付款取消时重定向用户的 URL。

  3. 视图将用户重定向到由 Stripe 托管的结账页面。此页面包括付款表单。客户端输入他们的信用卡详情并提交表单。

  4. Stripe 处理付款并将客户端重定向到payment_completed视图。如果客户端未完成付款,Stripe 将客户端重定向到payment_canceled视图。

让我们开始构建付款视图。编辑payment应用的views.py文件,并向其中添加以下代码:

from decimal import Decimal
import stripe
from django.conf import settings
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from orders.models import Order
# create the Stripe instance
stripe.api_key = settings.STRIPE_SECRET_KEY
stripe.api_version = settings.STRIPE_API_VERSION
def payment_process(request):
    order_id = request.session.get('order_id')
    order = get_object_or_404(Order, id=order_id)
    if request.method == 'POST':
        success_url = request.build_absolute_uri(
            reverse('payment:completed')
        )
        cancel_url = request.build_absolute_uri(
            reverse('payment:canceled')
        )
        # Stripe checkout session data
        session_data = {
            'mode': 'payment',
            'client_reference_id': order.id,
            'success_url': success_url,
            'cancel_url': cancel_url,
            'line_items': []
        }
        # create Stripe checkout session
        session = stripe.checkout.Session.create(**session_data)
        # redirect to Stripe payment form
return redirect(session.url, code=303)
    else:
        return render(request, 'payment/process.html', locals()) 

在前面的代码中,导入了stripe模块,并使用STRIPE_SECRET_KEY设置的值设置 Stripe API 密钥。要使用的 API 版本也使用STRIPE_API_VERSION设置的值设置。

payment_process视图执行以下任务:

  1. 使用order_id会话键从数据库检索当前的Order对象,该键之前由order_create视图存储在会话中。

  2. 对于给定的 ID 检索Order对象。通过使用快捷函数get_object_or_404(),如果没有找到具有给定 ID 的订单,将引发Http404(页面未找到)异常。

  3. 如果视图通过GET请求加载,则渲染并返回模板payment/process.html。此模板将包括订单摘要和继续付款的按钮,这将生成一个发送到视图的POST请求。

  4. 或者,如果视图通过POST请求加载,则使用以下参数通过stripe.checkout.Session.create()创建带有POST请求的 Stripe 结账会话:

    • mode: 结账会话的模式。我们使用payment进行一次性付款。您可以在stripe.com/docs/api/checkout/sessions/object#checkout_session_object-mode查看此参数接受的不同值。

    • client_reference_id: 这是此付款的唯一参考。我们将使用它来对冲 Stripe 结账会话与我们的订单。通过传递订单 ID,我们将 Stripe 付款与系统中的订单链接起来,并能够从 Stripe 接收付款通知以标记订单为已支付。

    • success_url: 如果支付成功,Stripe 将重定向用户到的 URL。我们使用request.build_absolute_uri()从 URL 路径生成绝对 URI。您可以在docs.djangoproject.com/en/5.0/ref/request-response/#django.http.HttpRequest.build_absolute_uri查看此方法的文档。

    • cancel_url: 如果支付被取消,Stripe 将重定向用户到的 URL。

    • line_items: 这是一个空列表。我们将接下来用要购买的商品订单填充它。

  5. 创建结账会话后,返回 HTTP 重定向状态码303以将用户重定向到 Stripe。建议在执行 HTTP POST操作后,将 Web 应用程序重定向到新的 URI 时使用状态码303

您可以在stripe.com/docs/api/checkout/sessions/create查看创建 Stripe session对象的所有参数。

让我们用订单商品填充line_items列表以创建结账会话。每个项目将包含项目的名称、要收取的金额、使用的货币和购买的数量。

将以下加粗的代码添加到payment_process视图中:

def payment_process(request):
    order_id = request.session.get('order_id')
    order = get_object_or_404(Order, id=order_id)
    if request.method == 'POST':
        success_url = request.build_absolute_uri(
            reverse('payment:completed')
        )
        cancel_url = request.build_absolute_uri(
            reverse('payment:canceled')
        )
        # Stripe checkout session data
        session_data = {
            'mode': 'payment',
            'success_url': success_url,
            'cancel_url': cancel_url,
            'line_items': []
        }
**# add order items to the Stripe checkout session**
**for** **item** **in** **order.items.****all****():**
 **session_data[****'line_items'****].append(**
 **{**
**'price_data'****: {**
**'unit_amount'****:** **int****(item.price * Decimal(****'100'****)),**
**'currency'****:** **'usd'****,**
**'product_data'****: {**
**'name'****: item.product.name,**
 **},**
 **},**
**'quantity'****: item.quantity,**
 **}**
 **)**
# create Stripe checkout session
        session = stripe.checkout.Session.create(**session_data)
        # redirect to Stripe payment form
return redirect(session.url, code=303)
    else:
        return render(request, 'payment/process.html', locals()) 

我们为每个项目使用以下信息:

  • price_data: 与价格相关的信息:

    • unit_amount: 支付要收取的金额(以分计)。这是一个正整数,表示以最小货币单位(无小数位)收取的金额。例如,要收取 10.00 美元,这将表示1000(即 1,000 分)。项目价格item.price乘以Decimal('100')以获得分值,然后将其转换为整数。

    • currency: 在三个字母的 ISO 格式中使用的货币。我们使用usd表示美元。您可以在stripe.com/docs/currencies查看支持的货币列表。

  • product_data: 与产品相关的信息:

    • name: 产品的名称
  • quantity: 购买单位的数量

payment_process视图现在已准备就绪。让我们为支付成功和取消页面创建简单的视图。

将以下代码添加到payment应用程序的views.py文件中:

def payment_completed(request):
    return render(request, 'payment/completed.html')
def payment_canceled(request):
    return render(request, 'payment/canceled.html') 

payment应用程序目录内创建一个新文件,并将其命名为urls.py。向其中添加以下代码:

from django.urls import path
from . import views
app_name = 'payment'
urlpatterns = [
    path('process/', views.payment_process, name='process'),
    path('completed/', views.payment_completed, name='completed'),
    path('canceled/', views.payment_canceled, name='canceled'),
] 

这些是支付工作流程的 URL。我们包含了以下 URL 模式:

  • process: 显示订单摘要给用户的视图,创建 Stripe 结账会话,并将用户重定向到由 Stripe 托管的支付表单

  • completed: 如果支付成功,Stripe 将重定向用户到的视图

  • canceled: 如果支付被取消,Stripe 将重定向用户到的视图

编辑myshop项目的主体urls.py文件,并包含payment应用程序的 URL 模式,如下所示:

urlpatterns = [
    path('admin/', admin.site.urls),
    path('cart/', include('cart.urls', namespace='cart')),
    path('orders/', include('orders.urls', namespace='orders')),
 **path(****'payment/'****, include(****'payment.urls'****, namespace=****'payment'****)),**
    path('', include('shop.urls', namespace='shop')),
] 

我们在shop.urls模式之前放置了新的路径,以避免与shop.urls中定义的模式意外匹配。请记住,Django 按顺序遍历每个 URL 模式,并在找到第一个与请求 URL 匹配的模式时停止。

让我们为每个视图构建一个模板。在payment应用程序目录内创建以下文件结构:

templates/
    payment/
        process.html
        completed.html
        canceled.html 

编辑payment/process.html模板,并向其中添加以下代码:

{% extends "shop/base.html" %}
{% load static %}
{% block title %}Pay your order{% endblock %}
{% block content %}
  <h1>Order summary</h1>
<table class="cart">
<thead>
<tr>
<th>Image</th>
<th>Product</th>
<th>Price</th>
<th>Quantity</th>
<th>Total</th>
</tr>
</thead>
<tbody>
      {% for item in order.items.all %}
        <tr class="row{% cycle "1" "2" %}">
<td>
<img src="{% if item.product.image %}{{ item.product.image.url }}
            {% else %}{% static "img/no_image.png" %}{% endif %}">
</td>
<td>{{ item.product.name }}</td>
<td class="num">${{ item.price }}</td>
<td class="num">{{ item.quantity }}</td>
<td class="num">${{ item.get_cost }}</td>
</tr>
      {% endfor %}
      <tr class="total">
<td colspan="4">Total</td>
<td class="num">${{ order.get_total_cost }}</td>
</tr>
</tbody>
</table>
<form action="{% url "payment:process" %}" method="post">
<input type="submit" value="Pay now">
    {% csrf_token %}
  </form>
{% endblock %} 

这是向用户显示订单摘要并允许客户端进行支付的模板。它包括一个表单和一个立即支付按钮,可以通过POST提交。当表单提交时,payment_process视图将创建 Stripe 结账会话,并将用户重定向到 Stripe 托管的支付表单。

编辑payment/completed.html模板,并向其中添加以下代码:

{% extends "shop/base.html" %}
{% block title %}Payment successful{% endblock %}
{% block content %}
  <h1>Your payment was successful</h1>
<p>Your payment has been processed successfully.</p>
{% endblock %} 

这是用户在成功支付后被重定向到的页面模板。

编辑payment/canceled.html模板,并向其中添加以下代码:

{% extends "shop/base.html" %}
{% block title %}Payment canceled{% endblock %}
{% block content %}
  <h1>Your payment has not been processed</h1>
<p>There was a problem processing your payment.</p>
{% endblock %} 

这是当支付被取消时用户被重定向到的页面模板。

我们已经实现了处理支付所需的所有视图,包括它们的 URL 模式和模板。现在是时候尝试结账流程了。

测试结账流程

在 shell 中执行以下命令以使用 Docker 启动 RabbitMQ 服务器:

docker run -it --rm --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3.13.1-management 

这将在端口5672上运行 RabbitMQ,并在端口15672上运行基于 Web 的管理界面。

在另一个 shell 中,从你的项目目录使用以下命令启动 Celery 工作进程:

celery -A myshop worker -l info 

在另一个 shell 中,使用以下命令从你的项目目录启动开发服务器:

python manage.py runserver 

在你的浏览器中打开http://127.0.0.1:8000/,添加一些产品到购物车,并填写结账表单。点击下单按钮。订单将被持久化到数据库中,订单 ID 将被保存在当前会话中,你将被重定向到支付流程页面。

支付流程页面将如下所示:

包含图形用户界面的图片 描述自动生成

图 9.9:包含订单摘要的支付流程页面

本章中的图片:

  • 绿茶:由 Jia Ye 在 Unsplash 上的照片

  • 红茶:由 Manki Kim 在 Unsplash 上的照片

在这个页面上,你可以看到一个订单摘要和一个立即支付按钮。点击立即支付payment_process视图将创建 Stripe 结账会话,并将你重定向到 Stripe 托管支付表单。

你将看到以下页面:

图片

图 9.10:Stripe 结账支付流程

使用测试信用卡

Stripe 提供了来自不同发卡机构和国家的不同测试信用卡,这允许你模拟支付以测试所有可能的场景(成功支付、拒绝支付等)。以下表格显示了你可以测试的不同场景的一些卡片:

结果 测试信用卡 CVC 到期日期
成功支付 4242 4242 4242 4242 任意 3 位数字 任意未来日期
支付失败 4000 0000 0000 0002 任意 3 位数字 任意未来日期
需要 3D 安全认证 4000 0025 0000 3155 任意 3 位数字 任意未来日期

您可以在 stripe.com/docs/testing 找到用于测试的完整信用卡列表。

我们将使用测试卡 4242 4242 4242 4242,这是一张返回成功购买的 Visa 卡。我们将使用 CVC 123 和任何未来的到期日期,例如 12/29。按照以下方式在支付表单中输入信用卡详情:

图 9.11:带有有效测试信用卡详情的支付表单

点击 支付 按钮。按钮文本将变为 处理中…,如图 9.12 所示:

图 9.12:正在处理的支付表单

几秒钟后,您将看到按钮变为绿色,如图 9.13 所示:

图 9.13:支付成功后的支付表单

然后,Stripe 将您的浏览器重定向到您在创建结账会话时提供的支付完成 URL。您将看到以下页面:

图形用户界面,文本,应用程序  自动生成的描述

图 9.14:成功支付页面

检查 Stripe 控制台中的支付信息

访问 Stripe 控制台 dashboard.stripe.com/test/payments。在 支付 选项下,您将能够看到支付信息,如图 9.15 所示:

图 9.15:Stripe 控制台中状态为成功的支付对象

支付状态为 成功。支付描述包括以 pi_ 开头的 支付意图 ID。当结账会话被确认时,Stripe 会创建与该会话关联的支付意图。支付意图用于从用户那里收集支付。Stripe 记录所有尝试的支付作为支付意图。每个支付意图都有一个唯一的 ID,并封装了交易详情,例如支持的支付方式、要收集的金额和期望的货币。点击交易以访问支付详情。

您将看到以下屏幕:

图 9.16:Stripe 交易的支付详情

在这里,您可以查看支付信息和支付时间线,包括支付变更。在 结账摘要 选项下,您可以找到购买的行项目,包括名称、数量、单价和金额。

支付详情 选项下,您可以查看已支付金额和 Stripe 处理支付的费用详情。

在此部分下,您将找到一个 支付方式 部分,包括支付方式的详细信息以及 Stripe 执行的信用卡检查,如图 9.17 所示:

图 9.17:Stripe 交易中使用的支付方式

在本节下,您将找到另一个名为事件和日志的节,如图 9.18 所示:

图 9.18:Stripe 交易的日志和事件

本节包含与交易相关的所有活动,包括对 Stripe API 的请求。您可以通过点击任何请求来查看对 Stripe API 的 HTTP 请求和 JSON 格式的响应。

让我们按时间顺序回顾活动事件,从下到上:

  1. 首先,通过向 Stripe API 端点/v1/checkout/sessions发送POST请求创建一个新的结账会话。在payment_process视图中使用的 Stripe SDK 方法stripe.checkout.Session.create()构建并发送请求到 Stripe API,处理响应以返回一个session对象。

  2. 用户被重定向到结账页面,在该页面他们提交支付表单。Stripe 结账页面发送一个确认结账会话的请求。

  3. 创建了一个新的支付意向。

  4. 创建了一个与支付意向相关的费用。

  5. 支付意向现在已完成,并成功支付。

  6. 结账会话已完成。

恭喜!您已成功将 Stripe Checkout 集成到您的项目中。接下来,您将学习如何从 Stripe 接收支付通知以及如何在您的商店订单中引用 Stripe 支付。

使用 webhooks 接收支付通知

Stripe 可以通过使用 webhooks 将实时事件推送到我们的应用程序。webhook,也称为回调,可以被视为一个事件驱动的 API,而不是请求驱动的 API。我们不必频繁轮询 Stripe API 以了解何时完成新的支付,Stripe 可以向我们的应用程序的 URL 发送 HTTP 请求,以实时通知我们成功的支付。这些事件的通知将是异步的,当事件发生时,无论我们是否同步调用 Stripe API。

我们将构建一个 webhook 端点以接收 Stripe 事件。该 webhook 将包含一个视图,该视图将接收一个 JSON 有效负载,其中包含事件信息以进行处理。我们将使用事件信息在结账会话成功完成后标记订单为已支付。

创建 webhook 端点

您可以将 webhook 端点 URL 添加到您的 Stripe 账户以接收事件。由于我们正在使用 webhooks,我们没有可以通过公共 URL 访问的托管网站,我们将使用 Stripe 命令行界面CLI)来监听事件并将它们转发到我们的本地环境。

在您的浏览器中打开dashboard.stripe.com/test/webhooks。您将看到以下屏幕:

图形用户界面,文本,应用程序,聊天或文本消息  自动生成的描述

图 9.19:Stripe webhooks 默认屏幕

在这里,您可以查看 Stripe 如何异步通知您的集成的架构。每当发生事件时,您将实时收到 Stripe 通知。Stripe 发送不同类型的事件,如结账会话创建、支付意图创建、支付意图更新或结账会话完成。您可以在 stripe.com/docs/api/events/types 找到 Stripe 发送的所有事件类型的列表。

点击 在本地环境中测试。您将看到以下屏幕:

图形用户界面,文本,应用程序  自动生成的描述

图 9.20:Stripe webhook 设置屏幕

此屏幕显示了从您的本地环境监听 Stripe 事件的步骤。它还包括一个示例 Python webhook 端点。仅复制 endpoint_secret 值。

编辑您项目的 .env 文件,并向其中添加以下加粗的环境变量:

STRIPE_PUBLISHABLE_KEY=pk_test_XXXX
STRIPE_SECRET_KEY=sk_test_XXXX
**STRIPE_WEBHOOK_SECRET=whsec_XXXX** 

STRIPE_WEBHOOK_SECRET 值替换为 Stripe 提供的 endpoint_secret 值。

编辑 myshop 项目的 settings.py 文件,并向其中添加以下设置:

# ...
STRIPE_PUBLISHABLE_KEY = config('STRIPE_PUBLISHABLE_KEY')
STRIPE_SECERT_KEY = config('STRIPE_SECRET_KEY')
STRIPE_API_VERSION = '2024-04-10'
**STRIPE_WEBHOOK_SECRET = config(****'STRIPE_WEBHOOK_SECRET'****)** 

要构建 webhook 端点,我们将创建一个视图来接收包含事件详细信息的 JSON 负载。我们将检查事件详细信息以确定何时完成结账会话,并将相关订单标记为已支付。

Stripe 通过在每个事件中包含一个 Stripe-Signature 标头来对其发送到您的端点的 webhook 事件进行签名,每个事件都有一个签名。通过检查 Stripe 签名,您可以验证事件是由 Stripe 发送的,而不是由第三方发送的。如果您不检查签名,攻击者可能会故意向您的 webhook 发送伪造的事件。Stripe SDK 提供了一种验证签名的方法。我们将使用它来创建一个验证签名的 webhook。

payment/ 应用程序目录添加一个新文件,并将其命名为 webhooks.py。将以下代码添加到新的 webhooks.py 文件中:

import stripe
from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from orders.models import Order
@csrf_exempt
def stripe_webhook(request):
    payload = request.body
    sig_header = request.META['HTTP_STRIPE_SIGNATURE']
    event = None
try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
        )
    except ValueError as e:
        # Invalid payload
return HttpResponse(status=400)
    except stripe.error.SignatureVerificationError as e:
        # Invalid signature
return HttpResponse(status=400)
    return HttpResponse(status=200) 

@csrf_exempt 装饰器用于防止 Django 对所有默认的 POST 请求执行 跨站请求伪造CSRF)验证。我们使用 stripe 库的 stripe.Webhook.construct_event() 方法来验证事件的签名标头。如果事件的负载或签名无效,我们返回 HTTP 400 Bad Request 响应。否则,我们返回 HTTP 200 OK 响应。

这是验证签名并从 JSON 负载中构建事件的必要基本功能。现在,我们可以实现 webhook 端点的操作。

将以下加粗的代码添加到 stripe_webhook 视图中:

@csrf_exempt
def stripe_webhook(request):
    payload = request.body
    sig_header = request.META['HTTP_STRIPE_SIGNATURE']
    event = None
try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
        )
    except ValueError as e:
        # Invalid payload
return HttpResponse(status=400)
    except stripe.error.SignatureVerificationError as e:
        # Invalid signature
return HttpResponse(status=400)
**if** **event.****type** **==** **'checkout.session.completed'****:**
 **session = event.data.****object**
**if** **(**
 **session.mode ==** **'payment'**
**and** **session.payment_status ==** **'paid'**
 **):**
**try****:**
 **order = Order.objects.get(**
**id****=session.client_reference_id**
 **)**
**except** **Order.DoesNotExist:**
**return** **HttpResponse(status=****404****)**
**# mark order as paid**
 **order.paid =** **True**
 **order.save()**
return HttpResponse(status=200) 

在新代码中,我们检查接收到的事件是否为 checkout.session.completed。此事件表示结账会话已成功完成。如果我们收到此事件,我们将检索 session 对象并检查会话 mode 是否为 payment,因为这是单次付款的预期模式。

然后,我们获取我们在创建结账会话时使用的client_reference_id属性,并使用 Django ORM 检索具有给定idOrder对象。如果订单不存在,我们抛出 HTTP 404异常。否则,我们将订单标记为已支付,通过order.paid = True,并将订单保存在数据库中。

编辑payment应用程序的urls.py文件,并添加以下加粗代码:

from django.urls import path
from . import views**, webhooks**
app_name = 'payment'
urlpatterns = [
    path('process/', views.payment_process, name='process'),
    path('completed/', views.payment_completed, name='completed'),
    path('canceled/', views.payment_canceled, name='canceled'),
 **path(****'webhook/'****, webhooks.stripe_webhook, name=****'stripe-webhook'****),**
] 

我们已导入webhooks模块,并添加了 Stripe webhook 的 URL 模式。

测试 webhook 通知

要测试 webhooks,您需要安装 Stripe CLI。Stripe CLI 是一个开发者工具,允许您直接从您的 shell 测试和管理与 Stripe 的集成。您可以在stripe.com/docs/stripe-cli#install找到安装说明。

如果您使用 macOS 或 Linux,可以使用以下命令使用 Homebrew 安装 Stripe CLI:

brew install stripe/stripe-cli/stripe 

如果您使用 Windows,或者您使用没有 Homebrew 的 macOS 或 Linux,可以从github.com/stripe/stripe-cli/releases/latest下载最新的 macOS、Linux 或 Windows Stripe CLI 版本,并解压文件。如果您使用 Windows,运行解压后的.exe文件。

安装 Stripe CLI 后,从 shell 运行以下命令:

stripe login 

您将看到以下输出:

Your pairing code is: xxxx-yyyy-zzzz-oooo This pairing code verifies your authentication with Stripe.Press Enter to open the browser or visit https://dashboard.stripe.com/stripecli/confirm_auth?t=.... 

按下Enter或打开浏览器中的 URL。您将看到以下屏幕:

图形用户界面、文本、应用程序 描述自动生成

图 9.21:Stripe CLI 配对屏幕

确认 Stripe CLI 中的配对代码与网站上显示的代码匹配,然后点击允许访问。您将看到以下消息:

图形用户界面、应用程序、团队 描述自动生成

图 9.22:Stripe CLI 配对确认

现在,从您的 shell 运行以下命令:

stripe listen --forward-to 127.0.0.1:8000/payment/webhook/ 

我们使用此命令告诉 Stripe 监听事件并将它们转发到我们的 localhost。我们使用 Django 开发服务器运行的端口8000,以及与我们的 webhook URL 模式匹配的路径/payment/webhook/

您将看到以下输出:

Getting ready... > Ready! You are using Stripe API Version [2024-04-10]. Your webhook signing secret is xxxxxxxxxxxxxxxxxxx (^C to quit) 

在这里,您可以看到 webhook 密钥。检查 webhook 签名密钥是否与项目settings.py文件中的STRIPE_WEBHOOK_SECRET设置匹配。

在您的浏览器中打开dashboard.stripe.com/test/webhooks。您将看到以下屏幕:

图 9.23:Stripe Webhooks 页面

本地监听器下,您将看到我们创建的本地监听器。

在生产环境中,不需要 Stripe CLI。相反,您需要使用托管应用程序的 URL 添加一个托管 webhook 端点。

在您的浏览器中打开http://127.0.0.1:8000/,向购物车添加一些产品,并完成结账流程。

检查您运行 Stripe CLI 的 shell:

2024-01-03 18:06:13   --> **payment_intent.created** [evt_...]
2024-01-03 18:06:13  <--  [200] POST http://127.0.0.1:8000/payment/webhook/ [evt_...]
2024-01-03 18:06:13   --> **payment_intent.succeeded** [evt_...]
2024-01-03 18:06:13  <--  [200] POST http://127.0.0.1:8000/payment/webhook/ [evt_...]
2024-01-03 18:06:13   --> **charge.succeeded** [evt_...]
2024-01-03 18:06:13  <--  [200] POST http://127.0.0.1:8000/payment/webhook/ [evt_...]
2024-01-03 18:06:14   --> **checkout.session.completed** [evt_...]
2024-01-03 18:06:14  <--  [200] POST http://127.0.0.1:8000/payment/webhook/ [evt_...] 

您可以看到 Stripe 已发送到本地 webhook 端点的不同事件。事件的顺序可能与上面不同。Stripe 不保证按事件生成顺序交付事件。让我们回顾一下事件:

  • payment_intent.created:支付意向已创建。

  • payment_intent.succeeded:支付意向成功。

  • charge.succeeded:与支付意向关联的扣款成功。

  • checkout.session.completed:结账会话已完成。这是我们用来标记订单已付款的事件。

stripe_webhook webhook 对所有由 Stripe 发送的请求返回 HTTP 200 OK响应。然而,我们只处理checkout.session.completed事件来标记与支付相关的订单为已付款。

接下来,在浏览器中打开http://127.0.0.1:8000/admin/orders/order/。现在订单应标记为已付款:

图片

图 9.24:在管理网站订单列表中标记为已付款的订单

现在,订单会自动通过 Stripe 支付通知标记为已付款。接下来,您将学习如何在您的商店订单中引用 Stripe 支付。

在订单中引用 Stripe 支付

每一笔 Stripe 支付都有一个唯一的标识符。我们可以使用支付 ID 将每个订单与其对应的 Stripe 支付关联起来。我们将在orders应用的Order模型中添加一个新字段,以便我们可以通过其 ID 引用相关的支付。这将允许我们将每个订单与相关的 Stripe 交易链接起来。

编辑orders应用的models.py文件,并在Order模型中添加以下字段。新字段以粗体显示:

class Order(models.Model):
    # ...
 **stripe_id = models.CharField(max_length=****250****, blank=****True****)** 

让我们将此字段与数据库同步。使用以下命令为项目生成数据库迁移:

python manage.py makemigrations 

您将看到以下输出:

Migrations for 'orders':
  orders/migrations/0002_order_stripe_id.py
    - Add field stripe_id to order 

使用以下命令将迁移应用到数据库:

python manage.py migrate 

您将看到以下行结束的输出:

Applying orders.0002_order_stripe_id... OK 

模型更改现在已与数据库同步。现在,您将能够为每个订单存储 Stripe 支付 ID。

在支付应用的webhooks.py文件中编辑stripe_webhook函数,并添加以下以粗体显示的行:

# ...
@csrf_exempt
def stripe_webhook(request):
    # ...
if event.type == 'checkout.session.completed':
        session = event.data.object
if (
            session.mode == 'payment'
and session.payment_status == 'paid'
        ):
            try:
                order = Order.objects.get(
                    id=session.client_reference_id
                )
            except Order.DoesNotExist:
                return HttpResponse(status=404)
            # mark order as paid
            order.paid = True
**# store Stripe payment ID**
 **order.stripe_id = session.payment_intent**
            order.save()
    return HttpResponse(status=200) 

通过这个更改,当收到完成结账会话的 webhook 通知时,支付意向 ID 将存储在Order对象的stripe_id字段中。

在您的浏览器中打开http://127.0.0.1:8000/,向购物车添加一些产品,并完成结账流程。然后,在浏览器中访问http://127.0.0.1:8000/admin/orders/order/并点击最新的订单 ID 进行编辑。stripe_id字段应包含支付意向 ID,如图 9.25 所示:

图片

图 9.25:包含支付意向 ID 的 Stripe id 字段

太好了!我们已经成功在订单中引用了 Stripe 支付。现在,我们可以在管理网站的订单列表中添加 Stripe 支付 ID。我们还可以为每个支付 ID 添加一个链接,以便在 Stripe 仪表板中查看支付详情。

编辑orders应用的models.py文件,并添加以下粗体显示的代码:

**from** **django.conf** **import** **settings**
from django.db import models
class Order(models.Model):
    # ...
class Meta:
        # ...
def __str__(self):
        return f'Order {self.id}'
def get_total_cost(self):
        return sum(item.get_cost() for item in self.items.all())
**def****get_stripe_url****(****self****):**
**if****not** **self.stripe_id:**
**# no payment associated**
**return****''**
**if****'_test_'****in** **settings.STRIPE_SECRET_KEY:**
**# Stripe path for test payments**
 **path =** **'/test/'**
**else****:**
**# Stripe path for real payments**
 **path =** **'****/'**
**return****f'https://dashboard.stripe.com****{path}****payments/****{self.stripe_id}****'** 

我们已经将新的get_stripe_url()方法添加到Order模型中。此方法用于返回与订单关联的 Stripe 仪表板的 URL。如果Order对象的stripe_id字段中没有存储支付 ID,则返回空字符串。否则,返回 Stripe 仪表板中支付的 URL。我们检查STRIPE_SECRET_KEY设置中是否包含字符串_test_,以区分生产环境和测试环境。生产环境中的支付遵循模式https://dashboard.stripe.com/payments/{id},而测试支付遵循模式https://dashboard.stripe.com/payments/test/{id}

让我们在管理网站的列表显示页面上为每个Order对象添加一个链接。

编辑orders应用的admin.py文件,并添加以下粗体显示的代码:

# ...
**from** **django.utils.safestring** **import** **mark_safe**
**def****order_payment****(****obj****):**
 **url = obj.get_stripe_url()**
**if** **obj.stripe_id:**
 **html =** **f'<a href="****{url}****" target="_blank">****{obj.stripe_id}****</a>'**
**return** **mark_safe(html)**
**return****''**
**order_payment.short_description =** **'Stripe payment'**
@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    list_display = [
        'id',
        'first_name',
        'last_name',
        'email',
        'address',
        'postal_code',
        'city',
        'paid',
 **order_payment,**
'created',
        'updated'
    ]
    # ... 

order_stripe_payment()函数接受一个Order对象作为参数,并返回一个包含 Stripe 支付 URL 的 HTML 链接。Django 默认会转义 HTML 输出。我们使用mark_safe函数来避免自动转义。

避免在来自用户的输入上使用mark_safe,以避免跨站脚本攻击XSS)。XSS 允许攻击者向其他用户查看的网页内容中注入客户端脚本。

在你的浏览器中打开http://127.0.0.1:8000/admin/orders/order/。你会看到一个名为STRIPE PAYMENT的新列。你可以看到最新订单的相关 Stripe 支付 ID。如果你点击支付 ID,你将被带到 Stripe 中的支付 URL,在那里你可以找到额外的支付详情。

图 9.26:管理网站中 Order 对象的 Stripe 支付 ID

现在,当收到支付通知时,你将自动在订单中存储 Stripe 支付 ID。你已经成功将 Stripe 集成到你的项目中。

上线

一旦你测试了你的集成,你可以申请一个生产 Stripe 账户。当你准备好进入生产环境时,记得在settings.py文件中将测试 Stripe 凭据替换为实时凭据。你还需要在你的托管网站上添加一个 webhook 端点,而不是使用 Stripe CLI。dashboard.stripe.com/webhooks。第十七章,上线,将教你如何为多个环境配置项目设置。

将订单导出到 CSV 文件

有时,您可能希望将模型中包含的信息导出到文件中,以便您可以将其导入到另一个系统中。最广泛使用的导出/导入数据格式之一是逗号分隔值CSV)格式。CSV 文件是一个由多个记录组成的纯文本文件。通常每行有一个记录,并且有一些分隔符字符,通常是字面意义上的逗号,用于分隔记录字段。我们将自定义管理网站以能够导出订单到 CSV 文件。

向管理网站添加自定义操作。

Django 提供了广泛的选择来自定义管理网站。您将修改对象列表视图以包括自定义管理操作。您可以通过实现自定义管理操作来允许工作人员用户在更改列表视图中一次性应用操作。

管理操作的工作方式如下:用户通过复选框从管理对象列表页面选择对象,选择要对所有选中项执行的操作,然后执行操作。图 9.27显示了操作在管理网站上的位置:

图形用户界面、文本、应用程序、聊天或文本消息  自动生成的描述

图 9.27:Django 管理操作的下拉菜单

您可以通过编写一个接收以下参数的常规函数来创建自定义操作:

  • 当前显示的ModelAdmin

  • 当前请求对象作为HttpRequest实例。

  • 用户选择的对象查询集。

当从管理网站触发操作时,将执行此函数。

您将创建一个自定义管理操作,以将订单列表下载为 CSV 文件。

编辑orders应用的admin.py文件,并在OrderAdmin类之前添加以下代码:

import csv
import datetime
from django.http import HttpResponse
def export_to_csv(modeladmin, request, queryset):
    opts = modeladmin.model._meta
    content_disposition = (
        f'attachment; filename={opts.verbose_name}.csv'
 )
    response = HttpResponse(content_type='text/csv')
    response['Content-Disposition'] = content_disposition
    writer = csv.writer(response)
    fields = [
        field
        for field in opts.get_fields()
        if not field.many_to_many and not field.one_to_many
    ]
    # Write a first row with header information
    writer.writerow([field.verbose_name for field in fields])
    # Write data rows
for obj in queryset:
        data_row = []
        for field in fields:
            value = getattr(obj, field.name)
            if isinstance(value, datetime.datetime):
                value = value.strftime('%d/%m/%Y')
            data_row.append(value)
        writer.writerow(data_row)
    return response
export_to_csv.short_description = 'Export to CSV' 

在此代码中,您执行以下任务:

  1. 您创建一个HttpResponse实例,指定text/csv内容类型,以告诉浏览器响应必须被处理为 CSV 文件。您还添加一个Content-Disposition头,以指示 HTTP 响应包含一个附加文件。

  2. 您创建一个将写入response对象的 CSV writer对象。

  3. 您使用模型的_meta选项的get_fields()方法动态获取model字段。您排除了多对多和一对多关系。

  4. 您写入一个包含字段名称的标题行。

  5. 您遍历给定的 QuerySet,并为 QuerySet 返回的每个对象写入一行。您会注意格式化datetime对象,因为 CSV 的输出值必须是字符串。

  6. 您可以通过在函数上设置short_description属性来在管理网站的“操作”下拉元素中自定义操作的显示名称。

您已创建一个通用的管理操作,可以添加到任何ModelAdmin类中。

最后,将新的export_to_csv管理操作添加到OrderAdmin类中,如下所示。新的代码加粗显示:

@admin.register(Order)
class OrderAdmin(admin.ModelAdmin):
    # ...
 **actions = [export_to_csv]** 

使用以下命令启动开发服务器:

python manage.py runserver 

在你的浏览器中打开http://127.0.0.1:8000/admin/orders/order/。生成的管理操作应该看起来像这样:

图片

图 9.28:使用自定义导出到 CSV 管理操作

选择一些订单,从选择框中选择导出到 CSV操作,然后点击Go按钮。你的浏览器将下载名为order.csv的生成 CSV 文件。使用文本编辑器打开下载的文件。你应该看到以下格式的内容,包括标题行和每个所选Order对象的行:

ID,first name,last name,email,address,postal code,city,created,updated,paid,stripe id
4,Antonio,Melé,email@domain.com,20 W 34th St,10001,New York,03/01/2024,03/01/2024,True,pi_3ORvzkGNwIe5nm8S1wVd7l7i
... 

如你所见,创建管理操作相当简单。你可以在docs.djangoproject.com/en/5.0/howto/outputting-csv/了解更多关于使用 Django 生成 CSV 文件的信息。

如果你想要向你的管理站点添加更高级的导入/导出功能,你可以使用第三方应用django-import-export。你可以在django-import-export.readthedocs.io/en/latest/找到它的文档。

我们实现的示例对于小型到中型数据集效果良好。鉴于导出发生在 HTTP 请求中,如果服务器在导出过程完成之前关闭连接,非常大的数据集可能会导致服务器超时。为了避免这种情况,你可以使用 Celery 异步生成导出,使用django-import-export-celery应用。该项目可在github.com/auto-mat/django-import-export-celery找到。

接下来,你将通过创建自定义管理视图进一步自定义管理站点。

通过自定义视图扩展管理站点

有时候,你可能想要自定义管理站点,超出通过配置ModelAdmin、创建管理操作和覆盖管理模板所能实现的范围。你可能想要实现现有管理视图或模板中不可用的附加功能。如果是这种情况,你需要创建一个自定义管理视图。使用自定义视图,你可以构建任何你想要的功能;你只需确保只有工作人员用户可以访问你的视图,并且通过使你的模板扩展管理模板来保持管理的外观和感觉。

让我们创建一个自定义视图来显示关于订单的信息。编辑orders应用的views.py文件,并添加以下加粗的代码:

**from** **django.contrib.admin.views.decorators** **import** **staff_member_required**
from django.shortcuts import **get_object_or_404,** redirect, render
from cart.cart import Cart
from .forms import OrderCreateForm
from .models import Order, OrderItem
from .tasks import order_created
def order_create(request):
    # ...
**@staff_member_required**
**def****admin_order_detail****(****request, order_id****):**
 **order = get_object_or_404(Order,** **id****=order_id)**
**return** **render(**
 **request,** **'admin/orders/order/detail.html'****, {****'order'****: order}**
 **)** 

staff_member_required装饰器检查请求页面的用户is_activeis_staff字段是否都设置为True。在这个视图中,你获取具有给定 ID 的Order对象并渲染一个模板来显示订单。

接下来,编辑orders应用的urls.py文件,并添加以下突出显示的 URL 模式:

urlpatterns = [
    path('create/', views.order_create, name='order_create'),
 **path(**
**'admin/order/<int:order_id>/'****,**
 **views.admin_order_detail,**
 **name=****'admin_order_detail'**
**),**
] 

orders应用的templates/目录内创建以下文件结构:

admin/
    orders/
        order/
            detail.html 

编辑detail.html模板,并向其中添加以下内容:

{% extends "admin/base_site.html" %}
{% block title %}
  Order {{ order.id }} {{ block.super }}
{% endblock %}
{% block breadcrumbs %}
  <div class="breadcrumbs">
<a href="{% url "admin:index" %}">Home</a> &rsaquo;
<a href="{% url "admin:orders_order_changelist" %}">Orders</a>
&rsaquo;
<a href="{% url "admin:orders_order_change" order.id %}">Order {{ order.id }}</a>
&rsaquo; Detail
  </div>
{% endblock %}
{% block content %}
<div class="module">
<h1>Order {{ order.id }}</h1>
<ul class="object-tools">
<li>
<a href="#" onclick="window.print();">
        Print order
      </a>
</li>
</ul>
<table>
<tr>
<th>Created</th>
<td>{{ order.created }}</td>
</tr>
<tr>
<th>Customer</th>
<td>{{ order.first_name }} {{ order.last_name }}</td>
</tr>
<tr>
<th>E-mail</th>
<td><a href="mailto:{{ order.email }}">{{ order.email }}</a></td>
</tr>
<tr>
<th>Address</th>
<td>
      {{ order.address }},
      {{ order.postal_code }} {{ order.city }}
    </td>
</tr>
<tr>
<th>Total amount</th>
<td>${{ order.get_total_cost }}</td>
</tr>
<tr>
<th>Status</th>
<td>{% if order.paid %}Paid{% else %}Pending payment{% endif %}</td>
</tr>
<tr>
<th>Stripe payment</th>
<td>
        {% if order.stripe_id %}
          <a href="{{ order.get_stripe_url }}" target="_blank">
            {{ order.stripe_id }}
          </a>
        {% endif %}
      </td>
</tr>
</table>
</div>
<div class="module">
<h2>Items bought</h2>
<table style="width:100%">
<thead>
<tr>
<th>Product</th>
<th>Price</th>
<th>Quantity</th>
<th>Total</th>
</tr>
</thead>
<tbody>
      {% for item in order.items.all %}
        <tr class="row{% cycle "1" "2" %}">
<td>{{ item.product.name }}</td>
<td class="num">${{ item.price }}</td>
<td class="num">{{ item.quantity }}</td>
<td class="num">${{ item.get_cost }}</td>
</tr>
      {% endfor %}
      <tr class="total">
<td colspan="3">Total</td>
<td class="num">${{ order.get_total_cost }}</td>
</tr>
</tbody>
</table>
</div>
{% endblock %} 

确保没有模板标签被拆分到多行中。

这是用于在管理网站上显示订单详情的模板。该模板扩展了 Django 管理网站的admin/base_site.html模板,其中包含主要的 HTML 结构和 CSS 样式。你使用父模板中定义的块来包含你自己的内容。你显示关于订单和购买项目的信息。

当你想扩展管理模板时,你需要了解其结构并识别现有块。你可以在github.com/django/django/tree/5.0/django/contrib/admin/templates/admin找到所有管理模板。

如果需要,你也可以覆盖管理模板。为此,将一个模板复制到你的templates/目录中,保持相同的相对路径和文件名。Django 的管理网站将使用你的自定义模板而不是默认模板。

最后,让我们在管理网站列表显示页面的每个Order对象上添加一个链接。编辑orders应用的admin.py文件,并在OrderAdmin类之上添加以下代码:

from django.urls import reverse
def order_detail(obj):
    url = reverse('orders:admin_order_detail', args=[obj.id])
    return mark_safe(f'<a href="{url}">View</a>') 

这是一个接受Order对象作为参数的函数,并返回admin_order_detail URL 的 HTML 链接。Django 默认会转义 HTML 输出。你必须使用mark_safe函数来避免自动转义。

然后,编辑OrderAdmin类以显示链接,如下所示。新的代码以粗体突出显示:

class OrderAdmin(admin.ModelAdmin):
    list_display = [
        'id',
        'first_name',
        'last_name',
        'email',
        'address',
        'postal_code',
        'city',
        'paid',
        order_payment,
        'created',
        'updated',
 **order_detail,**
    ]
    # ... 

使用以下命令启动开发服务器:

python manage.py runserver 

在你的浏览器中打开http://127.0.0.1:8000/admin/orders/order/。每一行都包含一个视图链接,如下所示:

图片

图 9.29:每个订单行中包含的视图链接

点击任何订单的视图链接以加载自定义订单详情页面。你应该看到如下页面:

图片

图片

现在你已经创建了产品详情页面,你将学习如何动态生成 PDF 格式的订单发票。

动态生成 PDF 发票

现在你已经有一个完整的结账和支付系统,你可以为每个订单生成 PDF 发票。有几个 Python 库可以生成 PDF 文件。一个流行的使用 Python 代码生成 PDF 的库是 ReportLab。你可以在 docs.djangoproject.com/en/5.0/howto/outputting-pdf/ 找到有关如何使用 ReportLab 输出 PDF 文件的信息。

在大多数情况下,你将不得不向你的 PDF 文件添加自定义样式和格式。你会发现渲染 HTML 模板并将其转换为 PDF 文件,同时将 Python 从表示层中移开,会更加方便。你将遵循这种方法,并使用一个模块来使用 Django 生成 PDF 文件。你将使用 WeasyPrint,这是一个可以从 HTML 模板生成 PDF 文件的 Python 库。

安装 WeasyPrint

首先,从 doc.courtbouillon.org/weasyprint/stable/first_steps.html 安装适用于你的操作系统的 WeasyPrint 依赖项。然后,使用以下命令通过 pip 安装 WeasyPrint:

python -m pip install WeasyPrint==61.2 

创建 PDF 模板

你需要一个 HTML 文档作为 WeasyPrint 的输入。你将创建一个 HTML 模板,使用 Django 进行渲染,并将其传递给 WeasyPrint 以生成 PDF 文件。

orders 应用的 templates/orders/order/ 目录中创建一个新的模板文件,并将其命名为 pdf.html。向其中添加以下代码:

<html>
<body>
<h1>My Shop</h1>
<p>
    Invoice no. {{ order.id }}<br>
<span class="secondary">
      {{ order.created|date:"M d, Y" }}
    </span>
</p>
<h3>Bill to</h3>
<p>
    {{ order.first_name }} {{ order.last_name }}<br>
    {{ order.email }}<br>
    {{ order.address }}<br>
    {{ order.postal_code }}, {{ order.city }}
  </p>
<h3>Items bought</h3>
<table>
<thead>
<tr>
<th>Product</th>
<th>Price</th>
<th>Quantity</th>
<th>Cost</th>
</tr>
</thead>
<tbody>
      {% for item in order.items.all %}
        <tr class="row{% cycle "1" "2" %}">
<td>{{ item.product.name }}</td>
<td class="num">${{ item.price }}</td>
<td class="num">{{ item.quantity }}</td>
<td class="num">${{ item.get_cost }}</td>
</tr>
      {% endfor %}
      <tr class="total">
<td colspan="3">Total</td>
<td class="num">${{ order.get_total_cost }}</td>
</tr>
</tbody>
</table>
<span class="{% if order.paid %}paid{% else %}pending{% endif %}">
    {% if order.paid %}Paid{% else %}Pending payment{% endif %}
  </span>
</body>
</html> 

这是 PDF 发票的模板。在这个模板中,你显示所有订单详情和一个包含产品的 HTML <table> 元素。你还包括一个消息来显示订单是否已支付。

渲染 PDF 文件

你将创建一个视图来使用管理站点生成现有订单的 PDF 发票。编辑 orders 应用程序目录内的 views.py 文件,并向其中添加以下代码:

import weasyprint
from django.contrib.staticfiles import finders
from django.http import HttpResponse
from django.template.loader import render_to_string
@staff_member_required
def admin_order_pdf(request, order_id):
    order = get_object_or_404(Order, id=order_id)
    html = render_to_string('orders/order/pdf.html', {'order': order})
    response = HttpResponse(content_type='application/pdf')
    response['Content-Disposition'] = f'filename=order_{order.id}.pdf'
    weasyprint.HTML(string=html).write_pdf(
        response,
        stylesheets=[weasyprint.CSS(finders.find('css/pdf.css'))]
    )
    return response 

这是生成订单 PDF 发票的视图。你使用 staff_member_required 装饰器确保只有工作人员用户可以访问此视图。

你获取具有给定 ID 的 Order 对象,并使用 Django 提供的 render_to_string() 函数渲染 orders/order/pdf.html。渲染后的 HTML 保存到 html 变量中。

然后,你生成一个新的 HttpResponse 对象,指定 application/pdf 内容类型,并包含 Content-Disposition 头来指定文件名。你使用 WeasyPrint 从渲染的 HTML 代码生成 PDF 文件,并将文件写入 HttpResponse 对象。

你使用静态文件 css/pdf.css 向生成的 PDF 文件添加 CSS 样式。为了定位文件,你使用 staticfiles 模块的 finders() 函数。最后,你返回生成的响应。

如果你缺少 CSS 样式,请记住将位于 shop 应用程序 static/ 目录中的静态文件复制到你的项目相同的位置。

您可以在github.com/PacktPublishing/Django-5-by-Example/tree/main/Chapter09/myshop/shop/static找到目录内容。

由于您需要使用STATIC_ROOT设置,您必须将其添加到您的项目中。这是静态文件所在的项目路径。编辑myshop项目的settings.py文件,并添加以下设置:

STATIC_ROOT = BASE_DIR / 'static' 

然后,运行以下命令:

python manage.py collectstatic 

您应该看到以下结尾的输出:

131 static files copied to 'code/myshop/static'. 

collectstatic命令会将所有静态文件从您的应用复制到STATIC_ROOT设置中定义的目录。这允许每个应用通过包含它们的static/目录来提供自己的静态文件。您还可以在STATICFILES_DIRS设置中提供额外的静态文件源。当执行collectstatic时,所有在STATICFILES_DIRS列表中指定的目录也将被复制到STATIC_ROOT目录。每次您再次执行collectstatic时,都会询问您是否要覆盖现有的静态文件。

编辑orders应用目录内的urls.py文件,并添加以下加粗的 URL 模式:

urlpatterns = [
    # ...
 **path(****'admin/order/<int:order_id>/pdf/'****,**
 **views.admin_order_pdf,**
 **name=****'admin_order_pdf'**
**),**
] 

现在,您可以编辑Order模型的行政列表显示页面,为每个结果添加一个指向 PDF 文件的链接。编辑orders应用内的admin.py文件,并在OrderAdmin类上方添加以下代码:

def order_pdf(obj):
    url = reverse('orders:admin_order_pdf', args=[obj.id])
    return mark_safe(f'<a href="{url}">PDF</a>')
order_pdf.short_description = 'Invoice' 

如果您为您的可调用对象指定了short_description属性,Django 将使用它作为列的名称。

order_pdf添加到OrderAdmin类的list_display属性中,如下所示:

class OrderAdmin(admin.ModelAdmin):
    list_display = [
        'id',
        'first_name',
        'last_name',
        'email',
        'address',
        'postal_code',
        'city',
        'paid',
        order_payment,
        'created',
        'updated',
        order_detail,
 **order_pdf,**
    ] 

确保开发服务器正在运行。在您的浏览器中打开http://127.0.0.1:8000/admin/orders/order/。现在,每一行都应该包括一个PDF链接,如下所示:

图 9.31:包含在每个订单行中的 PDF 链接

点击任何订单的PDF链接。您应该看到一个生成的 PDF 文件,如下所示(对于尚未付款的订单):

图 9.32:未付款订单的 PDF 发票

对于已付款订单,您将看到以下 PDF 文件:

图 9.33:已付款订单的 PDF 发票

通过电子邮件发送 PDF 文件

当支付成功时,您将向您的客户发送包含生成的 PDF 发票的自动电子邮件。您将创建一个异步任务来执行此操作。

payment应用目录内创建一个新文件,命名为tasks.py。向其中添加以下代码:

from io import BytesIO
import weasyprint
from celery import shared_task
from django.contrib.staticfiles import finders
from django.core.mail import EmailMessage
from django.template.loader import render_to_string
from orders.models import Order
@shared_task
def payment_completed(order_id):
    """
    Task to send an e-mail notification when an order is
    successfully paid.
    """
    order = Order.objects.get(id=order_id)
    # create invoice e-mail
    subject = f'My Shop - Invoice no. {order.id}'
    message = (
        'Please, find attached the invoice for your recent purchase.'
    )
    email = EmailMessage(
        subject, message, 'admin@myshop.com', [order.email]
    )
    # generate PDF
    html = render_to_string('orders/order/pdf.html', {'order': order})
    out = BytesIO()
    stylesheets=[weasyprint.CSS(finders.find('css/pdf.css'))]
    weasyprint.HTML(string=html).write_pdf(out, stylesheets=stylesheets)
    # attach PDF file
    email.attach(
        f'order_{order.id}.pdf', out.getvalue(), 'application/pdf'
 )
    # send e-mail
    email.send() 

你通过使用 @shared_task 装饰器来定义 payment_completed 任务。在这个任务中,你使用 Django 提供的 EmailMessage 类创建一个 email 对象。然后,你在 html 变量中渲染模板。从渲染的模板生成 PDF 文件并将其输出到 BytesIO 实例,这是一个内存中的字节缓冲区。然后,使用 attach() 方法将生成的 PDF 文件附加到 EmailMessage 对象上,包括 out 缓冲区的内容。最后,发送电子邮件。

记得在项目的 settings.py 文件中设置你的 简单邮件传输协议 (SMTP) 设置以发送电子邮件。你可以参考 第二章通过高级功能增强你的博客,以查看 SMTP 配置的工作示例。如果你不想设置电子邮件设置,你可以通过在 settings.py 文件中添加以下设置来告诉 Django 将电子邮件写入控制台:

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 

让我们将 payment_completed 任务添加到处理支付完成事件的 webhook 端点。

编辑 payment 应用程序的 webhooks.py 文件,并修改它使其看起来像这样:

import stripe
from django.conf import settings
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from orders.models import Order
**from** **.tasks** **import** **payment_completed**
@csrf_exempt
def stripe_webhook(request):
    payload = request.body
    sig_header = request.META['HTTP_STRIPE_SIGNATURE']
    event = None
try:
        event = stripe.Webhook.construct_event(
            payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
        )
    except ValueError as e:
        # Invalid payload
return HttpResponse(status=400)
    except stripe.error.SignatureVerificationError as e:
        # Invalid signature
return HttpResponse(status=400)
    if event.type == 'checkout.session.completed':
        session = event.data.object
if (
            session.mode == 'payment'
and session.payment_status == 'paid'
        ):
            try:
                order = Order.objects.get(
                    id=session.client_reference_id
                )
            except Order.DoesNotExist:
                return HttpResponse(status=404)
            # mark order as paid
            order.paid = True
# store Stripe payment ID
            order.stripe_id = session.payment_intent
            order.save()
            **# launch asynchronous task**
 **payment_completed.delay(order.****id****)**
return HttpResponse(status=200) 

通过调用其 delay() 方法,将 payment_completed 任务排队。该任务将被添加到队列中,并由 Celery 工作器尽快异步执行。

现在,你可以完成一个新的结账过程,以便在您的电子邮件中接收 PDF 发票。如果您正在使用 console.EmailBackend 作为您的电子邮件后端,在您运行 Celery 的 shell 中,您将能够看到以下输出:

MIME-Version: 1.0
Subject: My Shop - Invoice no. 7
From: admin@myshop.com
To: email@domain.com
Date: Wed, 3 Jan 2024 20:15:24 -0000
Message-ID: <164841212458.94972.10344068999595916799@amele-mbp.home>
--===============8908668108717577350==
Content-Type: text/plain; charset="utf-8"
MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Please, find attached the invoice for your recent purchase.
--===============8908668108717577350==
Content-Type: application/pdf
MIME-Version: 1.0
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="order_7.pdf"
JVBERi0xLjcKJfCflqQKMSAwIG9iago8PAovVHlwZSA... 

此输出显示电子邮件包含附件。你已经学会了如何将文件附加到电子邮件并程序化地发送它们。

恭喜!你已经完成了 Stripe 集成,并为你的商店添加了有价值的功能。

摘要

在本章中,你将 Stripe 支付网关集成到你的项目中,并创建了一个 webhook 端点以接收支付通知。你构建了一个自定义管理操作来导出订单到 CSV 文件。你还使用自定义视图和模板自定义了 Django 管理站点。最后,你学习了如何使用 WeasyPrint 生成 PDF 文件并将它们附加到电子邮件中。

下一章将教你如何使用 Django 会话创建优惠券系统,并且你将使用 Redis 构建一个产品推荐引擎。

其他资源

以下资源提供了与本章涵盖的主题相关的额外信息:

第十章:扩展你的店铺

在上一章中,你学习了如何将支付网关集成到你的店铺中。你还学习了如何生成 CSV 和 PDF 文件。

在本章中,你将为你的店铺添加一个优惠券系统并创建一个产品推荐引擎。

本章将涵盖以下内容:

  • 创建优惠券系统

  • 将优惠券应用于购物车

  • 将优惠券应用于订单

  • 为 Stripe 结账创建优惠券

  • 存储通常一起购买的产品

  • 使用 Redis 构建产品推荐引擎

功能概述

图 10.1 展示了本章将要构建的视图、模板和功能:

图片

图 10.1:第十章构建的功能图

在本章中,你将构建一个新的 coupons 应用程序,并创建 coupon_apply 视图以将折扣优惠券应用于购物车会话。你将把应用的折扣添加到 cart 应用程序的 cart_detail 视图模板中。当使用 orders 应用的 order_create 视图创建订单时,你将把优惠券保存到创建的订单中。然后,当你创建 payment 应用的 payment_process 视图中的 Stripe 会话时,你将在将用户重定向到 Stripe 完成支付之前,将优惠券添加到 Stripe 结账会话中。你将把应用的折扣添加到 order 应用的管理员视图 admin_order_detailadmin_order_pdf 的模板中。除了优惠券系统,你还将实现一个推荐系统。当 stripe_webhook 视图接收到 checkout.session.completed Stripe 事件时,你将把一起购买的产品保存到 Redis 中。你将通过从 Redis 中检索经常一起购买的项目,将产品推荐添加到 product_detailcart_detail 视图中。

本章的源代码可以在 github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter10 找到。

本章中使用的所有 Python 包都包含在章节源代码的 requirements.txt 文件中。你可以按照以下部分的说明安装每个 Python 包,或者你可以使用命令 python -m pip install -r requirements.txt 一次性安装所有依赖。

创建优惠券系统

许多在线商店会给顾客发放优惠券,顾客可以用这些优惠券在购买时享受折扣。在线优惠券通常由一个给用户的代码组成,并在特定时间段内有效。

你将为你店铺创建一个优惠券系统。你的优惠券将在特定时间段内对顾客有效。优惠券在可兑换次数上没有限制,并将应用于购物车总价值。

为了实现这个功能,您需要创建一个模型来存储优惠券代码、有效时间范围和要应用的折扣。

使用以下命令在 myshop 项目中创建一个新的应用程序:

python manage.py startapp coupons 

编辑 myshopsettings.py 文件,并将应用程序添加到 INSTALLED_APPS 设置中,如下所示:

INSTALLED_APPS = [
    # ...
**'coupons.apps.CouponsConfig'****,**
] 

新的应用程序现在已在您的 Django 项目中生效。

构建优惠券模型

让我们从创建 Coupon 模型开始。编辑 coupons 应用程序的 models.py 文件,并向其中添加以下代码:

from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
class Coupon(models.Model):
    code = models.CharField(max_length=50, unique=True)
    valid_from = models.DateTimeField()
    valid_to = models.DateTimeField()
    discount = models.IntegerField(
        validators=[MinValueValidator(0), MaxValueValidator(100)],
        help_text='Percentage value (0 to 100)'
 )
    active = models.BooleanField()
    def __str__(self):
        return self.code 

这是您将要用来存储优惠券的模型。Coupon 模型包含以下字段:

  • code:用户必须输入以将优惠券应用到他们的购买中的代码。

  • valid_from:表示优惠券何时生效的日期时间值。

  • valid_to:表示优惠券何时失效的日期时间值。

  • discount:要应用的折扣率(这是一个百分比,因此其值从 0100)。您可以使用验证器来限制该字段的最低和最高接受值。

  • active:一个布尔值,表示优惠券是否激活。

运行以下命令为 coupons 应用程序生成初始迁移:

python manage.py makemigrations 

输出应包括以下行:

Migrations for 'coupons':
  coupons/migrations/0001_initial.py
    - Create model Coupon 

然后,执行以下命令以应用迁移:

python manage.py migrate 

您应该看到包含以下行的输出:

Applying coupons.0001_initial... OK 

迁移现在已应用到数据库。让我们将 Coupon 模型添加到管理站点。编辑 coupons 应用程序的 admin.py 文件,并向其中添加以下代码:

from django.contrib import admin
from .models import Coupon
@admin.register(Coupon)
class CouponAdmin(admin.ModelAdmin):
    list_display = [
        'code',
        'valid_from',
        'valid_to',
        'discount',
        'active'
 ]
    list_filter = ['active', 'valid_from', 'valid_to']
    search_fields = ['code'] 

Coupon 模型现在已在管理站点注册。请确保您的本地服务器正在以下命令下运行:

python manage.py runserver 

在您的浏览器中打开 http://127.0.0.1:8000/admin/coupons/coupon/add/

您应该看到以下表单:

图 10.2:Django 管理站点上的添加优惠券表单

填写表单以创建一个适用于当前日期的新优惠券。确保您勾选了激活复选框,并点击保存按钮。图 10.3展示了创建优惠券的示例:

图 10.3:带有示例数据的添加优惠券表单

创建优惠券后,管理站点上的优惠券更改列表页面将类似于图 10.4

图 10.4:Django 管理站点上的优惠券更改列表页面

接下来,我们将实现将优惠券应用到购物车的功能。

将优惠券应用到购物车

您可以存储新的优惠券并对现有优惠券进行查询。现在您需要一种让客户能够将优惠券应用到他们的购买中的方法。应用优惠券的功能如下:

  1. 用户将产品添加到购物车。

  2. 用户可以在购物车详情页面上显示的表单中输入优惠券代码。

  3. 当用户输入优惠券代码并提交表单时,您将寻找具有给定代码且当前有效的现有优惠券。您必须检查优惠券代码是否与用户输入的一致,active 属性是否为 True,以及当前日期时间是否在 valid_fromvalid_to 值之间。

  4. 如果找到优惠券,您将其保存到用户的会话中,并显示包含折扣的购物车以及更新的总金额。

  5. 当用户下单时,您将优惠券保存到指定的订单中。

coupons 应用程序目录内创建一个新文件,命名为 forms.py。向其中添加以下代码:

from django import forms
class CouponApplyForm(forms.Form):
    code = forms.CharField() 

这是用户输入优惠券代码将使用的表单。编辑 coupons 应用程序内的 views.py 文件,并向其中添加以下代码:

from django.shortcuts import redirect
from django.utils import timezone
from django.views.decorators.http import require_POST
from .forms import CouponApplyForm
from .models import Coupon
@require_POST
def coupon_apply(request):
    now = timezone.now()
    form = CouponApplyForm(request.POST)
    if form.is_valid():
        code = form.cleaned_data['code']
        try:
            coupon = Coupon.objects.get(
                code__iexact=code,
                valid_from__lte=now,
                valid_to__gte=now,
                active=True
            )
            request.session['coupon_id'] = coupon.id
except Coupon.DoesNotExist:
            request.session['coupon_id'] = None
return redirect('cart:cart_detail') 

coupon_apply 视图验证优惠券并将其存储在用户的会话中。您将 require_POST 装饰器应用到这个视图中以限制它只接受 POST 请求。在视图中,您执行以下任务:

  1. 使用提交的数据实例化 CouponApplyForm 表单,并检查表单是否有效。

  2. 如果表单有效,您从表单的 cleaned_data 字典中获取用户输入的 code。您尝试检索具有给定代码的 Coupon 对象。您使用 iexact 字段查找来执行不区分大小写的精确匹配。优惠券必须当前处于活动状态(active=True)且对当前日期时间有效。您使用 Django 的 timezone.now() 函数来获取当前时区的日期时间,并通过执行 lte(小于等于)和 gte(大于等于)字段查找来分别与 valid_fromvalid_to 字段进行比较。

  3. 您将优惠券 ID 存储在用户的会话中。

  4. 您将用户重定向到 cart_detail URL 以显示应用了优惠券的购物车。

您需要一个 coupon_apply 视图的 URL 模式。在 coupons 应用程序目录内创建一个新文件,命名为 urls.py。向其中添加以下代码:

from django.urls import path
from . import views
app_name = 'coupons'
urlpatterns = [
    path('apply/', views.coupon_apply, name='apply'),
] 

然后,编辑 myshop 项目的主体 urls.py 并包含以下加粗的 coupons URL 模式:

urlpatterns = [
    path('admin/', admin.site.urls),
    path('cart/', include('cart.urls', namespace='cart')),
    path('orders/', include('orders.urls', namespace='orders')),
    path('payment/', include('payment.urls', namespace='payment')),
 **path(****'coupons/'****, include(****'coupons.urls'****, namespace=****'coupons'****)),**
    path('', include('shop.urls', namespace='shop')),
] 

记得在 shop.urls 模式之前放置此模式。

现在,编辑 cart 应用程序的 cart.py 文件。包含以下导入:

from coupons.models import Coupon 

将以下加粗代码添加到 Cart 类的 __init__() 方法末尾以从当前会话初始化优惠券:

class Cart:
    def __init__(self, request):
        """
        Initialize the cart.
        """
        self.session = request.session
        cart = self.session.get(settings.CART_SESSION_ID)
        if not cart:
            # save an empty cart in the session
            cart = self.session[settings.CART_SESSION_ID] = {}
        self.cart = cart
**# store current applied coupon**
 **self.coupon_id = self.session.get(****'coupon_id'****)** 

在此代码中,您尝试从当前会话中获取 coupon_id 会话键并将其值存储在 Cart 对象中。向 Cart 对象添加以下加粗的方法:

class Cart:
    # ...
 **@property**
**def****coupon****(****self****):**
**if** **self.coupon_id:**
**try****:**
**return** **Coupon.objects.get(****id****=self.coupon_id)**
**except** **Coupon.DoesNotExist:**
**pass**
**return****None**
**def****get_discount****(****self****):**
**if** **self.coupon:**
**return** **(**
 **self.coupon.discount / Decimal(****100****)**
 **) * self.get_total_price()**
**return** **Decimal(****0****)**
**def****get_total_price_after_discount****(****self****):**
**return** **self.get_total_price() - self.get_discount()** 

这些方法如下:

  • coupon(): 您将此方法定义为 property。如果购物车包含 coupon_id 属性,则返回具有给定 ID 的 Coupon 对象。

  • get_discount(): 如果购物车包含优惠券,您将检索其折扣率并返回从购物车总金额中扣除的金额。

  • get_total_price_after_discount(): 您返回扣除get_discount()方法返回的金额后的购物车总金额。

Cart类现在已准备好处理当前会话中应用的优惠券并应用相应的折扣。

让我们将优惠券系统包含到购物车详情视图中。编辑cart应用的views.py文件,并在文件顶部添加以下导入:

from coupons.forms import CouponApplyForm 

在下面,编辑cart_detail视图并按照以下方式添加新表单:

def cart_detail(request):
    cart = Cart(request)
    for item in cart:
        item['update_quantity_form'] = CartAddProductForm(
            initial={'quantity': item['quantity'], 'override': True}
        )
 **coupon_apply_form = CouponApplyForm()**
return render(
        request,
        'cart/detail.html',
        {
            'cart': cart**,**
**'coupon_apply_form'****: coupon_apply_form**
        }
    ) 

编辑cart应用的cart/detail.html模板,并定位到以下行:

<tr class="total">
  <td>Total</td>
  <td colspan="4"></td>
  <td class="num">${{ cart.get_total_price }}</td>
</tr> 

用以下代码替换它们:

**{% if cart.coupon %}**
**<****tr****class****=****"subtotal"****>**
**<****td****>****Subtotal****</****td****>**
**<****td****colspan****=****"4"****></****td****>**
**<****td****class****=****"num"****>****${{ cart.get_total_price|floatformat:2 }}****</****td****>**
**</****tr****>**
**<****tr****>**
**<****td****>**
 **"{{ cart.coupon.code }}" coupon**
 **({{ cart.coupon.discount }}% off)**
**</****td****>**
**<****td****colspan****=****"4"****></****td****>**
**<****td****class****=****"num neg"****>**
 **- ${{ cart.get_discount|floatformat:2 }}**
**</****td****>**
**</****tr****>**
**{% endif %}**
<tr class="total">
<td>Total</td>
<td colspan="4"></td>
<td class="num">
    ${{ cart.**get_total_price_after_discount|floatformat:2** }}
  </td>
</tr> 

这是显示可选优惠券及其折扣率的代码。如果购物车包含优惠券,您将显示第一行,包括购物车的总金额作为小计。然后,您使用第二行来显示当前应用于购物车的优惠券。最后,通过调用cart对象的get_total_price_after_discount()方法来显示包括任何折扣在内的总价。

在同一文件中,在</table> HTML 标签之后包含以下代码:

<p>Apply a coupon:</p>
<form action="{% url "coupons:apply" %}" method="post">
  {{ coupon_apply_form }}
  <input type="submit" value="Apply">
  {% csrf_token %}
</form> 

这将显示输入优惠券代码并将其应用于当前购物车的表单。

在您的浏览器中打开http://127.0.0.1:8000/并添加一个产品到购物车。您会看到购物车页面现在包括一个应用优惠券的表单:

图片

图 10.5:包含应用优惠券表单的购物车详情页面

茶叶粉末的图片:由 Phuong Nguyen 在 Unsplash 上拍摄

代码字段中,输入您使用管理网站创建的优惠券代码:

图片

图 10.6:包含优惠券代码的表单的购物车详情页面

点击应用按钮。优惠券将被应用,购物车将显示如下优惠券折扣:

图片

图 10.7:包含已应用优惠券的购物车详情页面

让我们将优惠券添加到购买过程的下一步。编辑orders应用的orders/order/create.html模板,并定位到以下行:

<ul>
  {% for item in cart %}
    <li>
      {{ item.quantity }}x {{ item.product.name }}
      <span>${{ item.total_price }}</span>
</li>
  {% endfor %}
</ul> 

用以下代码替换它们:

<ul>
  {% for item in cart %}
    <li>
      {{ item.quantity }}x {{ item.product.name }}
      <span>${{ item.total_price|floatformat:2 }}</span>
</li>
  {% endfor %}
 **{% if cart.coupon %}**
**<****li****>**
 **"{{ cart.coupon.code }}" ({{ cart.coupon.discount }}% off)**
**<****span****class****=****"neg"****>****- ${{ cart.get_discount|floatformat:2 }}****</****span****>**
**</****li****>**
 **{% endif %}**
</ul> 

订单摘要现在应包括已应用的优惠券(如果有的话)。现在找到以下行:

<p>Total: ${{ cart.get_total_price }}</p> 

用以下代码替换:

<p>Total: ${{ cart.**get_total_price_after_discount|floatformat:2** }}</p> 

通过这样做,总价也将通过应用优惠券的折扣来计算。

在您的浏览器中打开http://127.0.0.1:8000/orders/create/。您应该看到订单摘要包括已应用的优惠券,如下所示:

图片

图 10.8:包含应用至购物车的优惠券的订单摘要

用户现在可以将优惠券应用于他们的购物车。然而,您仍然需要在用户结账时创建优惠券信息的订单中存储优惠券信息。

应用优惠券到订单

您将存储应用于每个订单的优惠券。首先,您需要修改 Order 模型以存储相关的 Coupon 对象(如果有的话)。

编辑 orders 应用的 models.py 文件,并向其中添加以下导入:

from decimal import Decimal
from django.core.validators import MaxValueValidator, MinValueValidator
from coupons.models import Coupon 

然后,将以下字段添加到 Order 模型中:

class Order(models.Model):
    # ...
**coupon = models.ForeignKey(**
 **Coupon,**
 **related_name=****'orders'****,**
 **null=****True****,**
 **blank=****True****,**
 **on_delete=models.SET_NULL**
 **)**
 **discount = models.IntegerField(**
 **default=****0****,**
 **validators=[MinValueValidator(****0****), MaxValueValidator(****100****)]**
 **)** 

这些字段允许您存储订单的可选优惠券及其应用的折扣百分比。折扣存储在相关的 Coupon 对象中,但您可以将它包含在 Order 模型中以保留它,以防优惠券被修改或删除。您将 on_delete 设置为 models.SET_NULL,这样如果优惠券被删除,coupon 字段将被设置为 Null,但折扣将被保留。

您需要创建一个迁移以包含 Order 模型的新的字段。从命令行运行以下命令:

python manage.py makemigrations 

您应该看到以下输出:

Migrations for 'orders':
  orders/migrations/0003_order_coupon_order_discount.py
    - Add field coupon to order
    - Add field discount to order 

使用以下命令应用新的迁移:

python manage.py migrate orders 

您应该看到以下确认信息,表明新迁移已被应用:

Applying orders.0003_order_coupon_order_discount... OK 

Order 模型字段更改现在已与数据库同步。

编辑 models.py 文件,并为 Order 模型添加两个新方法,get_total_cost_before_discount()get_discount(),如下所示。新代码加粗显示:

class Order(models.Model):
    # ...
**def****get_total_cost_before_discount****(****self****):**
**return****sum****(item.get_cost()** **for** **item** **in** **self.items.****all****())**
**def****get_discount****(****self****):**
 **total_cost = self.get_total_cost_before_discount()**
**if** **self.discount:**
**return** **total_cost * (self.discount / Decimal(****100****))**
**return** **Decimal(****0****)** 

然后,按照以下方式编辑 Order 模型的 get_total_cost() 方法。新代码加粗显示:

 def get_total_cost(self):
 **total_cost = self.get_total_cost_before_discount()**
**return** **total_cost - self.get_discount()** 

Order 模型的 get_total_cost() 方法现在将考虑应用的折扣,如果有的话。

编辑 orders 应用的 views.py 文件,并修改 order_create 视图以在创建新订单时保存相关的优惠券及其折扣。将以下加粗代码添加到 order_create 视图中:

def order_create(request):
    cart = Cart(request)
    if request.method == 'POST':
        form = OrderCreateForm(request.POST)
        if form.is_valid():
            order = form.save(**commit=****False**)
            **if** **cart.coupon:**
 **order.coupon = cart.coupon**
 **order.discount = cart.coupon.discount**
 **order.save()**
for item in cart:
                OrderItem.objects.create(
                    order=order,
                    product=item['product'],
                    price=item['price'],
                    quantity=item['quantity']
                )
            # clear the cart
            cart.clear()
            # launch asynchronous task
            order_created.delay(order.id)
            # set the order in the session
            request.session['order_id'] = order.id
# redirect for payment
return redirect('payment:process')
    else:
        form = OrderCreateForm()
    return render(
        request,
        'orders/order/create.html',
        {'cart': cart, 'form': form}
    ) 

在新代码中,您使用 OrderCreateForm 表单的 save() 方法创建一个 Order 对象。您通过使用 commit=False 来避免将其保存到数据库中。如果购物车包含优惠券,您将存储相关的优惠券及其应用的折扣。然后,您将 order 对象保存到数据库中。

编辑 payment/process.html 模板,并定位以下行:

<tr class="total">
<td>Total</td>
<td colspan="4"></td>
<td class="num">${{ order.get_total_cost }}</td>
</tr> 

将它们替换为以下代码。新行加粗显示:

**{% if order.coupon %}**
**<****tr****class****=****"subtotal"****>**
**<****td****>****Subtotal****</****td****>**
**<****td****colspan****=****"3"****></****td****>**
**<****td****class****=****"num"****>**
 **${{ order.get_total_cost_before_discount|floatformat:2 }}**
**</****td****>**
**</****tr****>**
**<****tr****>**
**<****td****>**
 **"{{ order.coupon.code }}" coupon**
 **({{ order.discount }}% off)**
**</****td****>**
**<****td****colspan****=****"3"****></****td****>**
**<****td****class****=****"****num neg"****>**
 **- ${{ order.get_discount|floatformat:2 }}**
**</****td****>**
**</****tr****>**
**{% endif %}**
<tr class="total">
<td>Total</td>
<td colspan="3"></td>
<td class="num">
    ${{ order.get_total_cost**|floatformat:2** }}
  </td>
</tr> 

我们在付款前已更新订单摘要。

确保使用以下命令开发服务器正在运行:

python manage.py runserver 

确保 Docker 正在运行,并在另一个 shell 中执行以下命令以使用 Docker 启动 RabbitMQ 服务器:

docker run -it --rm --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3.13.1-management 

在另一个 shell 中,使用以下命令从您的项目目录启动 Celery 工作进程:

celery -A myshop worker -l info 

打开另一个 shell 并执行以下命令以将 Stripe 事件转发到您的本地 webhook URL:

stripe listen --forward-to localhost:8000/payment/webhook/ 

在您的浏览器中打开 http://127.0.0.1:8000/ 并使用您创建的优惠券创建一个订单。在验证购物车中的项目后,在 订单摘要 页面上,您将看到应用于订单的优惠券:

图 10.9:订单摘要页面,包括应用于订单的折扣券

如果您点击 立即支付,您将看到 Stripe 并未意识到应用的折扣,如图 图 10.10 所示:

图 10.10:Stripe Checkout 页面的商品详情,包括无折扣券

Stripe 显示需要支付的全额,没有任何扣除。这是因为我们没有将折扣传递给 Stripe。请记住,在 payment_process 视图中,我们将订单项作为 line_items 传递给 Stripe,包括每个订单项的成本和数量。

为 Stripe Checkout 创建折扣券

Stripe 允许您定义折扣券并将它们链接到一次性付款。您可以在 stripe.com/docs/payments/checkout/discounts 找到有关为 Stripe Checkout 创建折扣的更多信息。

让我们编辑 payment_process 视图以创建 Stripe Checkout 的折扣券。编辑 payment 应用程序的 views.py 文件,并将以下加粗代码添加到 payment_process 视图中:

def payment_process(request):
    order_id = request.session.get('order_id')
    order = get_object_or_404(Order, id=order_id)
    if request.method == 'POST':
        success_url = request.build_absolute_uri(
            reverse('payment:completed')
        )
        cancel_url = request.build_absolute_uri(
            reverse('payment:canceled')
        )
        # Stripe checkout session data
        session_data = {
            'mode': 'payment',
            'client_reference_id': order.id,
            'success_url': success_url,
            'cancel_url': cancel_url,
            'line_items': []
        }
        # add order items to the Stripe checkout session
for item in order.items.all():
            session_data['line_items'].append(
                {
                    'price_data': {
                        'unit_amount': int(item.price * Decimal('100')),
                        'currency': 'usd',
                        'product_data': {
                            'name': item.product.name,
                        },
                    },
                    'quantity': item.quantity,
                }
            )
**# Stripe coupon**
**if** **order.coupon:**
 **stripe_coupon = stripe.Coupon.create(**
 **name=order.coupon.code,**
 **percent_off=order.discount,**
 **duration=****'once'**
 **)**
 **session_data[****'discounts'****] = [{****'coupon'****: stripe_coupon.****id****}]**
# create Stripe checkout session
        session = stripe.checkout.Session.create(**session_data)
        # redirect to Stripe payment form
return redirect(session.url, code=303)
    else:
        return render(request, 'payment/process.html', locals()) 

在新代码中,您检查订单是否有相关的折扣券。如果是这样,您使用 Stripe SDK 通过 stripe.Coupon.create() 创建 Stripe 折扣券。您使用以下属性为折扣券:

  • name: 使用与 order 对象相关的折扣券的 code

  • percent_off: 发行的 order 对象的 discount

  • duration: 使用值 once。这表示向 Stripe 指明这是一个一次性付款的折扣券。

创建折扣券后,其 id 被添加到用于创建 Stripe Checkout 会话的 session_data 字典中。这使折扣券与结账会话相关联。

在您的浏览器中打开 http://127.0.0.1:8000/ 并使用您创建的折扣券完成购买。当重定向到 Stripe Checkout 页面时,您将看到应用的折扣券:

图 10.11:Stripe Checkout 页面的商品详情,包括名为 SUMMER 的折扣券

Stripe Checkout 页面现在包括订单折扣,应付总额现在包括使用折扣扣除的金额。

完成购买后,在您的浏览器中打开 http://127.0.0.1:8000/admin/orders/order/。点击使用折扣券的 order 对象。编辑表单将显示应用的折扣,如图 图 10.12 所示:

图 10.12:订单编辑表单,包括应用的折扣券和折扣

您已成功存储订单折扣并处理了带折扣的支付。接下来,您将向管理站点的订单详情视图和订单的 PDF 发票添加折扣券。

将折扣券添加到管理站点上的订单和 PDF 发票

让我们在管理站点的订单详情页面上添加折扣券。编辑 orders 应用的 admin/orders/order/detail.html 模板,并添加以下加粗代码:

...
<table style="width:100%">
...
<tbody>
    {% for item in order.items.all %}
      <tr class="row{% cycle "1" "2" %}">
<td>{{ item.product.name }}</td>
<td class="num">${{ item.price }}</td>
<td class="num">{{ item.quantity }}</td>
<td class="num">${{ item.get_cost }}</td>
</tr>
    {% endfor %}
 **{% if order.coupon %}**
**<****tr****class****=****"subtotal"****>**
**<****td****colspan****=****"3"****>****Subtotal****</****td****>**
**<****td****class****=****"num"****>**
 **${{ order.get_total_cost_before_discount|floatformat:2 }}**
**</****td****>**
**</****tr****>**
**<****tr****>**
**<****td****colspan****=****"3"****>**
 **"{{ order.coupon.code }}" coupon**
 **({{ order.discount }}% off)**
**</****td****>**
**<****td****class****=****"num neg"****>**
 **- ${{ order.get_discount|floatformat:2 }}**
**</****td****>**
**</****tr****>**
 **{% endif %}**
<tr class="total">
<td colspan="3">Total</td>
<td class="num">
        ${{ order.get_total_cost**|floatformat:2** }}
      </td>
</tr>
</tbody>
</table>
... 

使用浏览器访问http://127.0.0.1:8000/admin/orders/order/,并点击最新订单的查看链接。现在,购买的商品表将包括使用的优惠券,如图图 10.13所示:

图片

图 10.13:管理网站上包含使用的优惠券的产品详情页

现在,让我们修改订单发票模板以包含用于订单的优惠券。编辑orders/order/pdf.html模板的orders应用程序,并添加以下加粗显示的代码:

...
<table>
<thead>
<tr>
<th>Product</th>
<th>Price</th>
<th>Quantity</th>
<th>Cost</th>
</tr>
</thead>
<tbody>
    {% for item in order.items.all %}
      <tr class="row{% cycle "1" "2" %}">
<td>{{ item.product.name }}</td>
<td class="num">${{ item.price }}</td>
<td class="num">{{ item.quantity }}</td>
<td class="num">${{ item.get_cost }}</td>
</tr>
    {% endfor %}
 **{% if order.coupon %}**
**<****tr****class****=****"subtotal"****>**
**<****td****colspan****=****"3"****>****Subtotal****</****td****>**
**<****td****class****=****"num"****>**
 **${{ order.get_total_cost_before_discount|floatformat:2 }}**
**</****td****>**
**</****tr****>**
**<****tr****>**
**<****td****colspan****=****"3"****>**
 **"{{ order.coupon.code }}" coupon**
 **({{ order.discount }}% off)**
**</****td****>**
**<****td****class****=****"num neg"****>**
 **- ${{ order.get_discount|floatformat:2 }}**
**</****td****>**
**</****tr****>**
 **{% endif %}**
<tr class="total">
<td colspan="3">Total</td>
<td class="num">${{ order.get_total_cost**|floatformat:2** }}</td>
</tr>
</tbody>
</table>
... 

使用浏览器访问http://127.0.0.1:8000/admin/orders/order/,并点击最新订单的PDF链接。现在,购买的商品表将包括使用的优惠券,如图图 10.14所示:

图片

图 10.14:包含使用的优惠券的 PDF 订单发票

您已成功将优惠券系统添加到您的商店。接下来,您将构建一个产品推荐引擎。

构建推荐引擎

推荐引擎是一个预测用户会对某项物品给予的偏好或评分的系统。系统根据用户的行为和对其的了解选择与用户相关的物品。如今,推荐系统被广泛应用于许多在线服务中。它们通过从大量对用户无关的数据中筛选出他们可能感兴趣的内容来帮助用户。提供良好的推荐可以增强用户参与度。电子商务网站通过提高每用户的平均收入来从提供相关产品推荐中受益。

您将创建一个简单而强大的推荐引擎,建议通常一起购买的产品。您将根据历史销售数据来建议产品,从而识别通常一起购买的产品。您将在两种不同的场景中建议互补产品:

  • 产品详情页:您将显示通常与给定产品一起购买的产品列表。这将以购买此产品的用户还购买了 X、Y 和 Z的形式显示。您需要一个数据结构,以便存储每个产品与显示的产品一起购买次数。

  • 购物车详情页:基于用户添加到购物车的产品,您将建议通常与这些产品一起购买的产品。在这种情况下,您计算以获得相关产品的分数需要汇总。

您将使用 Redis 来存储通常一起购买的产品。请记住,您已经在第七章跟踪用户行为中使用了 Redis。如果您还没有安装 Redis,可以在该章节中找到安装说明。

基于以往购买推荐产品

我们将根据经常一起购买的项目向用户推荐产品。为此,我们将使用 Redis 排序集。记住,你在 第七章跟踪用户行为 中使用了排序集来创建网站上最常查看的图像的排名。

图 10.15 展示了一个排序集的表示,其中集合成员是与分数关联的字符串:

图 10.15:Redis 排序集表示

我们将存储一个键在 Redis 中,用于该网站上购买的每个产品。产品键将包含一个带有分数的 Redis 排序集。每次完成新的购买后,我们将为一起购买的产品增加分数 1。排序集将允许你为一起购买的产品分配分数。我们将使用产品与另一个产品一起购买次数作为该项目的分数。

请记住,使用以下命令在你的环境中安装 redis-py

python -m pip install redis==5.0.4 

编辑你的项目中的 settings.py 文件,并向其中添加以下设置:

# Redis settings
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 1 

这些是建立与 Redis 服务器连接所需的设置。在 shop 应用程序目录内创建一个新文件,并将其命名为 recommender.py。向其中添加以下代码:

import redis
from django.conf import settings
from .models import Product
# connect to redis
r = redis.Redis(
    host=settings.REDIS_HOST,
    port=settings.REDIS_PORT,
    db=settings.REDIS_DB
)
class Recommender:
    def get_product_key(self, id):
        return f'product:{id}:purchased_with'
def products_bought(self, products):
        product_ids = [p.id for p in products]
        for product_id in product_ids:
            for with_id in product_ids:
                # get the other products bought with each product
if product_id != with_id:
                    # increment score for product purchased together
                    r.zincrby(
                        self.get_product_key(product_id), 1, with_id
                    ) 

这是 Recommender 类,它将允许你存储产品购买并检索给定产品或产品的产品建议。

get_product_key() 方法接收一个 Product 对象的 ID,并构建存储相关产品的 Redis 排序集的键,其格式如下:product:[id]:purchased_with

products_bought() 方法接收一个列表,其中包含一起购买(即属于同一订单)的 Product 对象。

在此方法中,你执行以下任务:

  1. 你获取给定 Product 对象的产品 ID。

  2. 你遍历产品 ID。对于每个 ID,你再次遍历产品 ID 并跳过相同的 ID,以便你得到与每个产品一起购买的产品。

  3. 使用 get_product_id() 方法获取每个购买产品的 Redis 产品键。对于 ID 为 33 的产品,此方法返回键 product:33:purchased_with。这是包含与该产品一起购买的产品 ID 的排序集的键。

  4. 使用 Redis 的 ZINCRBY 操作,通过增量增加排序集中每个产品 ID 的分数 1。分数表示另一个产品与给定产品一起购买了多少次。

图 10.16 展示了五个不同产品(ID 从 1 到 5)和五种不同产品组合的购买订单的示例:

图 10.16:具有相应 ID 和购买订单组合的五个产品

在图中,您可以看到为每个产品在 Redis 中创建的有序集合,键为 product:<id>:purchased_with,其中 <id> 是产品的唯一标识符。有序集合的成员是与主要产品一起购买的产品 ID。每个成员的分数反映了联合购买的总数。图显示了 ZINCRBY Redis 操作,用于将一起购买的产品分数增加 1。

现在,您有一个存储和评分一起购买的产品的方法。接下来,您需要一个方法来检索给定产品列表中一起购买的产品。将以下 suggest_products_for() 方法添加到 Recommender 类中:

def suggest_products_for(self, products, max_results=6):
    product_ids = [p.id for p in products]
    if len(products) == 1:
        # only 1 product
        suggestions = r.zrange(
            self.get_product_key(product_ids[0]), 0, -1, desc=True
 )[:max_results]
    else:
        # generate a temporary key
        flat_ids = ''.join([str(id) for id in product_ids])
        tmp_key = f'tmp_{flat_ids}'
# multiple products, combine scores of all products
# store the resulting sorted set in a temporary key
        keys = [self.get_product_key(id) for id in product_ids]
        r.zunionstore(tmp_key, keys)
        # remove ids for the products the recommendation is for
        r.zrem(tmp_key, *product_ids)
        # get the product ids by their score, descendant sort
        suggestions = r.zrange(
            tmp_key, 0, -1, desc=True
 )[:max_results]
        # remove the temporary key
        r.delete(tmp_key)
    suggested_products_ids = [int(id) for id in suggestions]
    # get suggested products and sort by order of appearance
    suggested_products = list(
        Product.objects.filter(id__in=suggested_products_ids)
    )
    suggested_products.sort(
        key=lambda x: suggested_products_ids.index(x.id)
    )
    return suggested_products 

suggest_products_for() 方法接收以下参数:

  • products:这是一个要获取推荐的 Product 对象列表。它可以包含一个或多个产品。

  • max_results:这是一个表示要返回的最大推荐数量的整数。

在此方法中,您执行以下操作:

  1. 您可以获取给定 Product 对象的产品 ID。

  2. 如果只提供了一个产品,您将检索与给定产品一起购买的产品 ID,按它们一起购买的总次数排序。为此,您使用 Redis 的 ZRANGE 命令。您将结果数量限制在 max_results 属性指定的数量(默认为 6)。您可以在 redis.io/commands/zrange/ 了解更多关于 ZRANGE 命令的信息。

  3. 如果提供了多个产品,您将使用产品的 ID 生成一个临时 Redis 键。

  4. 使用 Redis ZUNIONSTORE 命令合并给定产品中每个有序集合包含的项目分数,并求和。ZUNIONSTORE 命令对给定键的有序集合执行并集操作,并将元素分数的聚合总和存储在一个新的 Redis 键中。您可以在 redis.io/commands/zunionstore/ 了解更多关于此命令的信息。您将聚合分数保存到临时键中。

  5. 由于您正在聚合分数,您可能会获得您正在获取推荐的产品。您可以使用 ZREM 命令从生成的有序集合中删除它们。您可以在 redis.io/commands/zrem/ 了解更多关于 ZREM 命令的信息。

  6. 您使用 ZRANGE 命令从临时键中检索产品 ID,并按它们的分数排序。您将结果数量限制在 max_results 属性指定的数量。

  7. 然后,您使用执行 Redis DEL 命令的 redis-py delete() 方法删除临时键。您可以在 redis.io/commands/del/ 了解更多关于 DEL 命令的信息。

  8. 最后,你获取具有给定 ID 的 Product 对象,并按排序集合成员的顺序排列产品。

图 10.17 展示了一个示例会话,其中已将两个产品添加到购物车,并执行了 Redis 操作以获取相关产品推荐:

图 10.17:产品推荐系统

在图中,你可以看到生成购物车中项目产品推荐的四个步骤:

  1. ZUNIONSTORE Redis 命令用于聚合经常与购物车中的产品一起购买的产品评分。此操作的排序集合存储在以购物车产品 ID 命名的新 Redis 键中,对于 ID 34,键名为 tmp_34

  2. 使用 ZREM 命令从排序集中删除正在购买的产品,以避免推荐已经在购物车中的产品。

  3. 使用 ZRANGE 命令按分数返回 tmp_34 排序集合成员。

  4. 最后,使用 DEL 命令删除 Redis 键 tmp_34

为了实际应用,我们还要添加一个清除推荐的方法。将以下方法添加到 Recommender 类中:

def clear_purchases(self):
    for id in Product.objects.values_list('id', flat=True):
        r.delete(self.get_product_key(id)) 

让我们尝试推荐引擎。确保你在数据库中包含几个 Product 对象,并使用以下命令初始化 Redis Docker 容器:

docker run -it --rm --name redis -p 6379:6379 redis:7.2.4 

打开另一个 shell 并运行以下命令以打开 Python shell:

python manage.py shell 

确保你的数据库中至少有四种不同的产品。通过名称检索四种不同的产品:

>>> from shop.models import Product
>>> black_tea = Product.objects.get(name='Black tea')
>>> red_tea = Product.objects.get(name='Red tea')
>>> green_tea = Product.objects.get(name='Green tea')
>>> tea_powder = Product.objects.get(name='Tea powder') 

然后,向推荐引擎添加一些测试购买:

>>> from shop.recommender import Recommender
>>> r = Recommender()
>>> r.products_bought([black_tea, red_tea])
>>> r.products_bought([black_tea, green_tea])
>>> r.products_bought([red_tea, black_tea, tea_powder])
>>> r.products_bought([green_tea, tea_powder])
>>> r.products_bought([black_tea, tea_powder])
>>> r.products_bought([red_tea, green_tea]) 

你已存储以下评分:

black_tea:  red_tea (2), tea_powder (2), green_tea (1)
red_tea:    black_tea (2), tea_powder (1), green_tea (1)
green_tea:  black_tea (1), tea_powder (1), red_tea(1)
tea_powder: black_tea (2), red_tea (1), green_tea (1) 

这表示与每个产品一起购买的产品,包括它们一起购买了多少次。

让我们检索单个产品的产品推荐:

>>> r.suggest_products_for([black_tea])
[<Product: Tea powder>, <Product: Red tea>, <Product: Green tea>]
>>> r.suggest_products_for([red_tea])
[<Product: Black tea>, <Product: Tea powder>, <Product: Green tea>]
>>> r.suggest_products_for([green_tea])
[<Product: Black tea>, <Product: Tea powder>, <Product: Red tea>]
>>> r.suggest_products_for([tea_powder])
[<Product: Black tea>, <Product: Red tea>, <Product: Green tea>] 

你可以看到推荐产品的顺序是基于它们的评分。让我们获取具有聚合评分的多个产品的推荐:

>>> r.suggest_products_for([black_tea, red_tea])
[<Product: Tea powder>, <Product: Green tea>]
>>> r.suggest_products_for([green_tea, red_tea])
[<Product: Black tea>, <Product: Tea powder>]
>>> r.suggest_products_for([tea_powder, black_tea])
[<Product: Red tea>, <Product: Green tea>] 

你可以看到建议产品的顺序与聚合评分相匹配。例如,为 black_teared_tea 建议的产品是 tea_powder (2+1) 和 green_tea (1+1)。

你已验证你的推荐算法按预期工作。

让我们存储每次支付确认时一起购买的产品。编辑 payment 应用程序的 webhooks.py 文件,并添加以下加粗代码:

# ...
**from** **shop.models** **import** **Product**
**from** **shop.recommender** **import** **Recommender**
@csrf_exempt
def stripe_webhook(request):
    # ...
if event.type == 'checkout.session.completed':
        session = event.data.object
if (
            session.mode == 'payment'
and session.payment_status == 'paid'
        ):
            try:
                order = Order.objects.get(
                    id=session.client_reference_id
                )
            except Order.DoesNotExist:
                return HttpResponse(status=404)
            # mark order as paid
            order.paid = True
# store Stripe payment ID
            order.stripe_id = session.payment_intent
            order.save()
            **# save items bought for product recommendations**
 **product_ids = order.items.values_list(****'product_id'****)**
 **products = Product.objects.****filter****(id__in=product_ids)**
 **r = Recommender()**
 **r.products_bought(products)**
# launch asynchronous task
            payment_completed.delay(order.id)
    return HttpResponse(status=200) 

在新代码中,当确认新的订单支付时,你检索与订单项目关联的 Product 对象。然后,你创建 Recommender 类的实例并调用 products_bought() 方法将一起购买的产品存储在 Redis 中。

你现在存储了在订单支付时一起购买的相关产品。现在让我们在你的网站上显示产品推荐。

编辑shop应用的views.py文件,添加功能以在product_detail视图中检索最多四个推荐产品,如下所示:

**from** **.recommender** **import** **Recommender**
def product_detail(request, id, slug):
    product = get_object_or_404(
        Product, id=id, slug=slug, available=True
 )
    cart_product_form = CartAddProductForm()
 **r = Recommender()**
 **recommended_products = r.suggest_products_for([product],** **4****)**
return render(
        request,
        'shop/product/detail.html',
        {
            'product': product,
            'cart_product_form': cart_product_form**,**
 **'recommended_products'****: recommended_products**
        }
    ) 

编辑shop应用的shop/product/detail.html模板,并在{{ product.description|linebreaks }}之后添加以下代码:

{% if recommended_products %}
  <div class="recommendations">
<h3>People who bought this also bought</h3>
    {% for p in recommended_products %}
      <div class="item">
<a href="{{ p.get_absolute_url }}">
<img src="{% if p.image %}{{ p.image.url }}{% else %}
          {% static  "img/no_image.png" %}{% endif %}">
</a>
<p><a href="{{ p.get_absolute_url }}">{{ p.name }}</a></p>
</div>
    {% endfor %}
  </div>
{% endif %} 

运行开发服务器,并在浏览器中打开http://127.0.0.1:8000/。点击任何产品查看其详细信息。你应该看到推荐产品显示在产品下方,如图图 10.18所示:

图片

图 10.18:产品详情页面,包括推荐产品

本章中的图片:

  • 绿茶:由 Jia Ye 在 Unsplash 上的照片

  • 红茶:由 Manki Kim 在 Unsplash 上的照片

  • 茶粉:由 Phuong Nguyen 在 Unsplash 上的照片

你还将包括购物车中的产品推荐。这些推荐将基于用户添加到购物车中的产品。

编辑cart应用内的views.py,导入Recommender类,并编辑cart_detail视图,使其看起来如下:

**from** **shop.recommender** **import** **Recommender**
def cart_detail(request):
    cart = Cart(request)
    for item in cart:
        item['update_quantity_form'] = CartAddProductForm(
            initial={'quantity': item['quantity'], 'override': True}
        )
    coupon_apply_form = CouponApplyForm()
 **r = Recommender()**
 **cart_products = [item[****'product'****]** **for** **item** **in** **cart]**
**if****(cart_products):**
 **recommended_products = r.suggest_products_for(**
 **cart_products, max_results=****4**
**)**
**else****:**
 **recommended_products = []**
return render(
        request,
        'cart/detail.html',
        {
            'cart': cart,
            'coupon_apply_form': coupon_apply_form**,**
 **'recommended_products'****: recommended_products})**
        }
    ) 

编辑cart应用的cart/detail.html模板,并在</table>HTML 标签之后添加以下代码:

{% if recommended_products %}
  <div class="recommendations cart">
<h3>People who bought this also bought</h3>
    {% for p in recommended_products %}
      <div class="item">
<a href="{{ p.get_absolute_url }}">
<img src="{% if p.image %}{{ p.image.url }}{% else %}
          {% static "img/no_image.png" %}{% endif %}">
</a>
<p><a href="{{ p.get_absolute_url }}">{{ p.name }}</a></p>
</div>
    {% endfor %}
  </div>
{% endif %} 

在浏览器中打开http://127.0.0.1:8000/en/,并添加几个产品到购物车。当你导航到http://127.0.0.1:8000/en/cart/时,你应该看到购物车中商品的聚合推荐,如下所示:

图片

图 10.19:购物车详情页面,包括推荐产品

恭喜!你已经使用 Django 和 Redis 构建了一个完整的推荐引擎。

摘要

在本章中,你使用 Django 会话创建了一个优惠券系统,并将其与 Stripe 集成。你还使用 Redis 构建了一个推荐引擎,以推荐通常一起购买的产品。

下一章将为你揭示 Django 项目的国际化本地化。你将学习如何翻译代码以及如何使用 Rosetta 管理翻译。你将实现翻译的 URL 并构建语言选择器。你还将使用django-parler实现模型翻译,并使用django-localflavor验证本地化表单字段。

其他资源

以下资源提供了与本章涵盖主题相关的额外信息:

第十一章:将国际化添加到您的商店

在上一章中,您为您的商店添加了优惠券系统并构建了一个产品推荐引擎。

在本章中,您将学习国际化与本地化的工作原理。通过使您的应用程序支持多种语言,您可以服务更广泛的用户。此外,通过适应本地格式约定,如日期或数字格式,您可以提高其可用性。通过翻译和本地化您的应用程序,您将使其对来自不同文化背景的用户更加直观,并增加用户参与度。

本章将涵盖以下主题:

  • 为项目准备国际化

  • 管理翻译文件

  • 翻译 Python 代码

  • 翻译模板

  • 使用 Rosetta 管理翻译

  • 翻译 URL 模式和使用 URL 中的语言前缀

  • 允许用户切换语言

  • 使用django-parler翻译模型

  • 使用 ORM 进行模型翻译

  • 适配视图以使用翻译

  • 使用django-localflavor的本地化表单字段

功能概述

图 11.1显示了本章将构建的视图、模板和功能表示:

图片

图 11.1:第十一章构建的功能图

在本章中,您将在项目中实现国际化并翻译模板、URL 和模型。您将在网站页眉中添加语言选择链接并创建特定语言的 URL。您将修改shop应用程序的product_listproduct_detail视图,通过其翻译的别名检索CategoryProduct对象。您还将向order_create视图中使用的表单添加一个本地化邮政编码字段。

本章的源代码可以在github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter11找到。

本章中使用的所有 Python 模块都包含在本章提供的源代码中的requirements.txt文件中。您可以按照以下说明安装每个 Python 模块,或者可以使用命令python -m pip install -r requirements.txt一次性安装所有依赖。

使用 Django 进行国际化

Django 提供了完整的国际化与本地化支持。它允许您将应用程序翻译成多种语言,并处理日期、时间、数字和时区等特定地区的格式。让我们明确国际化与本地化的区别:

  • 国际化(通常缩写为i18n)是指适应软件以适应不同语言和地区的潜在使用,使其不会硬编码到特定的语言或地区。

  • 本地化(缩写为 l10n)是将软件实际翻译并适应特定区域的过程。Django 本身使用其国际化框架翻译成 50 多种语言。

国际化框架允许您轻松地为字符串标记翻译,无论是在 Python 代码中还是在您的模板中。它依赖于 GNU gettext 工具集来生成和管理消息文件。消息文件是一个纯文本文件,代表一种语言。它包含应用程序中找到的翻译字符串及其相应翻译的某个部分或全部。消息文件具有 .po 扩展名。一旦完成翻译,消息文件将被编译以提供对翻译字符串的快速访问。编译后的翻译文件具有 .mo 扩展名。

让我们回顾 Django 为国际化和本地化提供的设置。

国际化和本地化设置

Django 为国际化提供了几个设置。以下设置是最相关的:

  • USE_I18N: 一个布尔值,指定 Django 的翻译系统是否启用。默认情况下为 True

  • USE_TZ: 一个布尔值,指定日期时间是否具有时区意识。使用 startproject 命令创建项目时,此设置为 True

  • LANGUAGE_CODE: 项目的默认语言代码。这是标准语言 ID 格式,例如,en-us 代表美国英语或 en-gb 代表英国英语。此设置需要将 USE_I18N 设置为 True 才能生效。您可以在 http://www.i18nguy.com/unicode/language-identifiers.html 找到有效语言 ID 的列表。

  • LANGUAGES: 包含项目可用语言的元组。它们以两个元组的形式出现,包含 语言代码语言名称。您可以在 django.conf.global_settings 中查看可用语言的列表。当您选择您的网站将支持的语言时,将 LANGUAGES 设置为该列表的子集。

  • LOCALE_PATHS: Django 查找包含项目翻译消息文件的目录列表。

  • TIME_ZONE: 表示项目时区的字符串。使用 startproject 命令创建新项目时,此设置为 'UTC'。您可以将其设置为任何其他时区,例如 'Europe/Madrid'

这些是一些可用的国际化和本地化设置。您可以在 docs.djangoproject.com/en/5.0/ref/settings/#globalization-i18n-l10n 找到完整的列表。

在审查了国际化和本地化最重要的设置之后,让我们学习如何为我们的应用程序创建翻译。

国际化管理命令

Django 包含以下管理命令来管理翻译:

  • makemessages:这个命令会遍历源代码树,找到所有标记为翻译的字符串,并在locale目录中创建或更新.po消息文件。每种语言都会创建一个单独的.po文件。

  • compilemessages:这个命令将现有的.po消息文件编译成.mo文件,这些文件用于检索翻译。

Django 依赖于gettext工具包来生成和编译翻译文件。让我们回顾一下如何安装它。

安装gettext工具包

你需要gettext工具包来创建、更新和编译消息文件。大多数 Linux 发行版都包含gettext工具包。如果你使用 macOS,最简单的方法是通过 Homebrew 安装它,在brew.sh/,使用以下命令:

brew install gettext 

你可能还需要使用以下命令强制链接:

brew link --force gettext 

如果你使用的是 Windows,请遵循docs.djangoproject.com/en/5.0/topics/i18n/translation/#gettext-on-windows中的步骤。你可以从mlocati.github.io/articles/gettext-iconv-windows.html下载预编译的gettext二进制安装程序。

一旦安装了gettext工具包,你就可以开始翻译你的项目了。首先,你需要了解翻译项目所需的步骤以及 Django 如何确定用户的语言。

如何向 Django 项目添加翻译

让我们探索国际化项目的过程。以下是翻译 Django 项目所需的步骤:

  1. 在你的 Python 代码和模板中标记需要翻译的字符串。

  2. 运行makemessages命令以创建或更新包含所有翻译字符串的消息文件。

  3. 翻译消息文件中包含的字符串。

  4. 使用compilemessages管理命令编译消息文件。

我们将遵循这个过程在本章中为我们的项目添加翻译。

接下来,你将学习 Django 如何确定当前用户的语言。

Django 如何确定当前语言

Django 附带一个中间件,根据请求数据确定当前语言。这是位于django.middleware.locale.LocaleMiddlewareLocaleMiddleware中间件,它执行以下任务:

  1. 如果你使用的是i18n_patterns,即你使用的是翻译过的 URL 模式,它会查找请求 URL 中的语言前缀以确定当前语言。你将在翻译 URL 模式部分中学习如何翻译 URL 模式。

  2. 如果没有找到语言前缀,它会在当前用户的会话中查找现有的LANGUAGE_SESSION_KEY

  3. 如果会话中没有设置语言,它将查找包含当前语言的现有 cookie。可以在LANGUAGE_COOKIE_NAME设置中提供此 cookie 的自定义名称。默认情况下,此 cookie 的名称为django_language

  4. 如果找不到 cookie,它将查找请求的Accept-Language HTTP 头。

  5. 如果Accept-Language头没有指定语言,Django 将使用LANGUAGE_CODE设置中定义的语言。

默认情况下,Django 将使用LANGUAGE_CODE设置中定义的语言,除非您正在使用LocaleMiddleware。这里描述的过程仅适用于使用此中间件的情况。

我们还可以让用户更改他们的语言。您将在允许用户切换语言部分了解如何实现语言选择器。

让我们从配置我们的项目以进行国际化开始。

为您的项目准备国际化

我们将准备我们的项目以使用不同的语言。我们将为在线商店创建英语和西班牙语版本:

编辑您的项目的settings.py文件,并向其中添加以下LANGUAGES设置。将其放置在LANGUAGE_CODE设置旁边:

LANGUAGES = [
    ('en', 'English'),
    ('es', 'Spanish'),
] 

LANGUAGES设置包含两个元组,由语言代码和名称组成。语言代码可以是区域特定的,如en-usen-gb,也可以是通用的,如en。通过此设置,您指定应用程序将仅提供英语和西班牙语。如果您没有定义自定义的LANGUAGES设置,则站点将提供 Django 翻译成所有语言。

让您的LANGUAGE_CODE设置看起来像以下这样:

LANGUAGE_CODE = **'****en'** 

'django.middleware.locale.LocaleMiddleware'添加到MIDDLEWARE设置中。确保这个中间件在SessionMiddleware之后,因为LocaleMiddleware需要使用会话数据。它还必须放在CommonMiddleware之前,因为后者需要一个有效的语言来解析请求的 URL。MIDDLEWARE设置现在应该看起来像以下这样:

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
**'django.middleware.locale.LocaleMiddleware'****,**
'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
] 

中间件类的顺序非常重要,因为每个中间件都可能依赖于之前执行的其他中间件设置的数据。中间件按照MIDDLEWARE中出现的顺序应用于请求,对于响应则按相反的顺序。

在主项目目录内,在manage.py文件旁边创建以下目录结构:

locale/
    en/
    es/ 

locale目录是您的应用程序消息文件存放的地方。

再次编辑settings.py文件,并向其中添加以下设置:

LOCALE_PATHS = [
    BASE_DIR / 'locale',
] 

LOCALE_PATHS设置指定了 Django 必须查找翻译文件的目录。首先出现的区域路径具有最高优先级。

当您从项目目录中使用makemessages命令时,将在您创建的locale/路径下生成消息文件。然而,对于包含locale/目录的应用程序,消息文件将在该目录下生成。

您的项目现在已配置为国际化。接下来,您将学习如何在 Python 代码中翻译字符串。

翻译 Python 代码

我们将探讨在 Python 代码中处理翻译的各种方法。我们将涵盖以下方法:

  • 标准翻译

  • 延迟翻译:在访问值时执行,而不是在调用函数时。

  • 包含变量的翻译:用于在要翻译的字符串中插入变量。

  • 翻译中的复数形式:管理依赖于可能影响要翻译的字符串的数值的技术。

对于翻译 Python 代码中的字面量,您可以使用包含在django.utils.translation中的gettext()函数标记字符串以进行翻译。此函数翻译消息并返回一个字符串。惯例是将此函数导入为简短的别名_(下划线字符)。

您可以在docs.djangoproject.com/en/5.0/topics/i18n/translation/找到有关翻译的所有文档。

让我们回顾一下 Python 字符串的不同翻译方法。

标准翻译

以下代码展示了如何标记字符串以进行翻译:

from django.utils.translation import gettext as _
output = _('Text to be translated.') 

此方法允许您通过使用gettext()函数(为方便起见别名为_)将翻译应用于 Python 代码中的大多数字符串。

延迟翻译

Django 为其所有翻译函数都包含延迟版本,这些版本具有后缀_lazy()。当使用延迟函数时,字符串在访问值时进行翻译,而不是在调用函数时(这就是为什么它们是延迟翻译的)。当标记为翻译的字符串位于在模块加载时执行的路径中时,延迟翻译函数非常有用。

延迟翻译的一个常见示例是在项目的settings.py文件中,在那里立即翻译不切实际,因为必须在翻译系统完全准备就绪之前定义设置。

使用gettext_lazy()而不是gettext()意味着字符串在访问值时进行翻译。Django 为所有翻译函数提供了一种延迟版本。

包含变量的翻译

标记为翻译的字符串可以包含占位符以在翻译中包含变量。以下代码是一个包含占位符的翻译字符串的示例:

from django.utils.translation import gettext as _
month = _('April')
day = '14'
output = _('Today is %(month)s %(day)s') % {'month': month, 'day': day} 

通过使用占位符,您可以重新排序文本变量。例如,上一个示例的英文翻译可能是“今天是 4 月 14 日”,而西班牙语的翻译可能是“今天是 4 月 14 日”。当翻译字符串有多个参数时,始终使用字符串插值而不是位置插值。这样做,您将能够重新排序占位符文本。

翻译中的复数形式

对于复数形式,Django 提供了ngettext()ngettext_lazy()函数。这些函数根据表示对象数量的参数翻译单数和复数形式。以下示例展示了如何使用它们:

output = ngettext(
    'there is %(count)d product',    # Singular form
'there are %(count)d products',  # Plural form
    count                            # Numeric value to determine form
) % {'count': count} 

在这个例子中,如果count1ngettext()将使用第一个字符串并输出there is 1 product。对于任何其他数字,它将使用第二个字符串,适当地输出,例如,there are 5 products。这允许在需要复数规则的语种中进行更准确和语法正确的翻译。

现在你已经了解了在 Python 代码中翻译字面量的基础知识,是时候将翻译应用到你的项目中了。

将自己的代码翻译成其他语言

首先,我们将翻译语言名称。为此,你可以按照以下说明操作:

编辑你的项目的settings.py文件,导入gettext_lazy()函数,并更改LANGUAGES设置,如下所示:

**from** **django.utils.translation** **import** **gettext_lazy** **as** **_**
# ...
LANGUAGES = [
    ('en', **_(**'English'**)**),
    ('es', **_(**'Spanish'**)**),
] 

在这里,你使用gettext_lazy()函数而不是gettext()函数,以避免循环导入,从而在访问时翻译语言名称。

在你的项目目录中打开 shell,并运行以下命令:

django-admin makemessages --all 

你应该会看到以下输出:

processing locale es
processing locale en 

看一下locale/目录。你应该会看到以下文件结构:

en/
    LC_MESSAGES/
        django.po
es/
    LC_MESSAGES/
        django.po 

为每种语言创建了一个.po消息文件。

使用文本编辑器打开es/LC_MESSAGES/django.po。在文件末尾,你应该能看到以下内容:

#: myshop/settings.py:118
msgid "English"
msgstr ""
#: myshop/settings.py:119
msgid "Spanish"
msgstr "" 

每个翻译字符串前面都有一个注释,显示有关文件和找到该行的详细信息。每个翻译包括两个字符串:

  • msgid:源代码中出现的翻译字符串。

  • msgstr:语言翻译,默认为空。这是你需要输入给定字符串的实际翻译的地方。

按照以下方式填写给定msgid字符串的msgstr翻译:

#: myshop/settings.py:118
msgid "English"
msgstr **"Inglés"**
#: myshop/settings.py:119
msgid "Spanish"
msgstr **"Español"** 

保存修改后的消息文件,打开 shell,并运行以下命令:

django-admin compilemessages 

如果一切顺利,你应该会看到以下输出:

processing file django.po in myshop/locale/en/LC_MESSAGES
processing file django.po in myshop/locale/es/LC_MESSAGES 

输出会给你关于正在编译的消息文件的信息。再次查看myshop项目的locale目录。你应该会看到以下文件:

en/
    LC_MESSAGES/
        django.mo
        django.po
es/
    LC_MESSAGES/
        django.mo
        django.po 

你可以看到,为每种语言都生成了一个.mo编译的消息文件。

现在你已经翻译了语言名称,让我们翻译网站上显示的模型字段名称:

编辑orders应用程序的models.py文件,并将标记为翻译的名称添加到Order模型字段中,如下所示:

**from** **django.utils.translation** **import** **gettext_lazy** **as** **_**
class Order(models.Model):
    first_name = models.CharField(**_(****'first name'****)**, max_length=50)
    last_name = models.CharField(**_(****'last name'****)**, max_length=50)
    email = models.EmailField(**_(****'e-mail'****)**)
    address = models.CharField(**_(****'address'****)**, max_length=250)
    postal_code = models.CharField(**_(****'postal code'****)**, max_length=20)
    city = models.CharField(**_(****'city'****)**, max_length=100)
    # ... 

你已经添加了当用户放置新订单时显示的字段名称。这些是first_namelast_nameemailaddresspostal_codecity。记住,你也可以使用verbose_name属性来命名字段。

orders应用程序目录内创建以下目录结构:

locale/
    en/
    es/ 

通过创建一个 locale 目录,此应用程序的翻译字符串将存储在此目录中的消息文件中,而不是主消息文件中。这样,您可以为每个应用程序生成单独的翻译文件。

从项目目录打开 shell 并运行以下命令:

django-admin makemessages --all 

您应该看到以下输出:

processing locale es
processing locale en 

使用文本编辑器打开 order 应用程序的 locale/es/LC_MESSAGES/django.po 文件。您将看到 Order 模型的翻译字符串。为给定的 msgid 字符串填写以下 msgstr 翻译:

#: orders/models.py:12
msgid "first name"
msgstr **"nombre"**
#: orders/models.py:14
msgid "last name"
msgstr **"apellidos"**
#: orders/models.py:16
msgid "e-mail"
msgstr **"e-mail"**
#: orders/models.py:17
msgid "address"
msgstr **"dirección"**
#: orders/models.py:19
msgid "postal code"
msgstr **"código postal"**
#: orders/models.py:21
msgid "city"
msgstr **"ciudad"** 

在您完成添加翻译后,保存文件。

除了文本编辑器外,您还可以使用 Poedit 来编辑翻译。Poedit 是一款使用 gettext 的翻译编辑软件,适用于 Linux、Windows 和 macOS。您可以从 poedit.net/ 下载 Poedit。

让我们也翻译您项目的表单。orders 应用程序的 OrderCreateForm 不需要翻译。这是因为它是一个 ModelForm,并使用 Order 模型字段的 verbose_name 属性作为表单字段标签。您将翻译 cartcoupons 应用程序的表单:

编辑 cart 应用程序目录内的 forms.py 文件,并将 label 属性添加到 CartAddProductFormquantity 字段。然后,按照以下方式标记此字段以进行翻译:

from django import forms
**from** **django.utils.translation** **import** **gettext_lazy** **as** **_**
PRODUCT_QUANTITY_CHOICES = [(i, str(i)) for i in range(1, 21)]
class CartAddProductForm(forms.Form):
    quantity = forms.TypedChoiceField(
        choices=PRODUCT_QUANTITY_CHOICES,
        coerce=int**,**
 **label=_(****'Quantity'****)**
    )
    override = forms.BooleanField(
        required=False,
        initial=False,
        widget=forms.HiddenInput
    ) 

编辑 coupons 应用程序的 forms.py 文件,并按照以下方式翻译 CouponApplyForm 表单:

from django import forms
**from** **django.utils.translation** **import** **gettext_lazy** **as** **_**
class CouponApplyForm(forms.Form):
    code = forms.CharField(**label=_(****'Coupon'****)**) 

您已经为 code 字段添加了一个标签,并标记了它以进行翻译。

您已经完成了对 Python 字符串的标记以进行翻译。接下来,您将学习如何在模板中标记文本以进行翻译。

翻译模板

Django 提供了 {% translate %}{% blocktranslate %} 模板标签来使用模板进行字符串翻译。为了使用翻译模板标签,您必须在模板顶部添加 {% load i18n %} 来加载它们。

{% translate %} 模板标签

{% translate %} 模板标签允许您标记一个文字以进行翻译。内部,Django 对给定文本执行 gettext()。这是在模板中标记字符串以进行翻译的方法:

{% translate "Text to be translated" %} 

您可以使用 as 将翻译内容存储在一个变量中,这样您就可以在整个模板中使用它。以下示例将翻译文本存储在一个名为 greeting 的变量中:

{% translate "Hello!" as greeting %}
<h1>{{ greeting }}</h1> 

{% translate %} 标签对于简单的翻译字符串很有用,但它无法处理包含变量的翻译内容。

{% blocktranslate %} 模板标签

{% blocktranslate %} 模板标签允许您使用占位符标记包含文字和变量内容的文本。以下示例展示了如何使用 {% blocktranslate %} 标签,包括在内容中为翻译添加一个 name 变量:

{% blocktranslate %}Hello {{ name }}!{% endblocktranslate %} 

你可以使用 with 来包含模板表达式,例如访问对象属性或对变量应用模板过滤器。你总是必须为这些使用占位符。你无法在 blocktrans 块内访问表达式或对象属性。以下示例展示了如何使用 with 来包含一个应用了 capfirst 过滤器的对象属性:

{% blocktranslate with name=user.name|capfirst %}
  Hello {{ name }}!
{% endblocktranslate %} 

当你需要将变量内容包含在翻译字符串中时,使用 {% blocktranslate %} 标签而不是 {% translate %}

现在你已经熟悉了翻译模板标签,让我们来使用它们。

翻译商店模板

编辑 shop/base.html 模板。确保你在模板顶部加载 i18n 标签,并按照以下方式标记字符串为翻译。新的代码以粗体显示:

{% load **i18n** static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>
    {% block title %}**{% translate "My shop" %}**{% endblock %}
  </title>
<link href="{% static "css/base.css" %}" rel="stylesheet">
</head>
<body>
<div id="header">
<a href="/" class="logo">**{% translate "My shop" %}**</a>
</div>
<div id="subheader">
<div class="cart">
      {% with total_items=cart|length %}
        {% if total_items > 0 %}
          **{% translate "Your cart" %}**:
          <a href="{% url "cart:cart_detail" %}">
**{% blocktranslate with total=cart.get_total_price count items=total_items %}**
 **{{ items }} item, ${{ total }}**
 **{% plural %}**
 **{{ items }} items, ${{ total }}**
 **{% endblocktranslate %}**
</a>
        {% elif not order %}
          **{% translate "Your cart is empty." %}**
        {% endif %}
      {% endwith %}
    </div>
</div>
<div id="content">
    {% block content %}
    {% endblock %}
  </div>
</body>
</html> 

确保不要将模板标签拆分到多行。

注意显示购物车摘要的 {% blocktranslate %} 标签。购物车摘要之前如下所示:

{{ total_items }} item{{ total_items|pluralize }},
${{ cart.get_total_price }} 

你已经更改了它,现在你使用 {% blocktranslate with ... %} 来设置占位符 total,其值为 cart.get_total_price(这里调用的是对象方法)。你同样使用了 count,这允许你为 Django 设置一个计数对象的变量,以便选择正确的复数形式。你将 items 变量设置为计数具有 total_items 值的对象。

这允许你在 {% blocktranslate %} 块内使用 {% plural %} 标签来设置单数和复数形式的翻译,你通过这个标签来分隔它们。生成的代码如下:

{% blocktranslate with total=cart.get_total_price count items=total_items %}
  {{ items }} item, ${{ total }}
{% plural %}
  {{ items }} items, ${{ total }}
{% endblocktranslate %} 

接下来,编辑 shop/product/detail.html 模板,并添加 i18n{% load %} 标签中:

{% extends "shop/base.html" %}
{% load **i18n** static %}
... 

注意 {% load %} 允许你通过包含由空格分隔的模块一次性加载所有模板标签。在这种情况下,我们加载了包含模板标签的 i18nstatic 模块。

然后,找到以下行:

<input type="submit" value="Add to cart"> 

用以下内容替换它:

<input type="submit" value="**{% translate "****Add****to****cart****" %}**"> 

然后,找到以下行:

<h3>People who bought this also bought</h3> 

用以下内容替换它:

<h3>**{% translate "People who bought this also bought" %}**</h3> 

现在,翻译 orders 应用程序模板。编辑 orders/order/create.html 模板,并按照以下方式标记文本为翻译:

{% extends "shop/base.html" %}
**{% load i18n %}**
{% block title %}
 **{% translate** **"Checkout"** **%}**
{% endblock %}
{% block content %}
  <h1>**{% translate** **"Checkout"** **%}**</h1>
  <div class="order-info">
    <h3>**{% translate** **"Your order"** **%}**</h3>
    <ul>
      {% for item in cart %}
        <li>
          {{ item.quantity }}x {{ item.product.name }}
          <span>${{ item.total_price }}</span>
        </li>
      {% endfor %}
      {% if cart.coupon %}
        <li>
          **{% blocktranslate with code=cart.coupon.code discount=cart.coupon.discount %}**
**"****{{ code }}"** **({{ discount }}% off)**
 **{% endblocktranslate %}**
          <span class="neg">- ${{ cart.get_discount|floatformat:2 }}</span>
        </li>
      {% endif %}
    </ul>
    <p>**{% translate** **"Total"** **%}**: ${{
    cart.get_total_price_after_discount|floatformat:2 }}</p>
  </div>
  <form method="post" class="order-form">
    {{ form.as_p }}
    <p><input type="submit" value="**{% translate "****Place order****" %}**"></p>
    {% csrf_token %}
  </form>
{% endblock %} 

确保不要将模板标签拆分到多行。查看伴随本章节的代码中的以下文件,以了解字符串是如何标记为翻译的:

  • shop 应用程序:模板 shop/product/list.html

  • orders 应用程序:模板 orders/order/pdf.html

  • cart 应用程序:模板 cart/detail.html

  • payments 应用程序:模板 payment/process.htmlpayment/completed.htmlpayment/canceled.html

记住,你可以在这个章节的源代码在 github.com/PacktPublishing/Django-5-by-Example/tree/master/Chapter11 找到。

让我们更新消息文件以包含新的翻译字符串:

打开 shell 并运行以下命令:

django-admin makemessages --all 

.po文件位于myshop项目的locale目录中,您会看到orders应用程序现在包含了您标记为翻译的所有字符串。

编辑项目的.po翻译文件和orders应用程序,并在msgstr中包含西班牙语翻译。您还可以在伴随本章节的源代码中使用翻译后的.po文件。

运行以下命令以编译翻译文件:

django-admin compilemessages 

您将看到以下输出:

processing file django.po in myshop/locale/en/LC_MESSAGES
processing file django.po in myshop/locale/es/LC_MESSAGES
processing file django.po in myshop/orders/locale/en/LC_MESSAGES
processing file django.po in myshop/orders/locale/es/LC_MESSAGES 

为每个.po翻译文件生成了一个包含编译翻译的.mo文件。

现在,您已经使用文本编辑器或 Poedit 编辑了.po文件。接下来,我们将使用 Django 应用程序直接在浏览器中编辑翻译。

使用 Rosetta 翻译界面

Rosetta 是一个第三方应用程序,允许您使用与 Django 管理站点相同的界面直接在浏览器中编辑翻译。Rosetta 使编辑.po文件变得容易,并更新编译的翻译文件。这消除了下载和上传翻译文件的需要,并支持多用户协作编辑。

让我们将 Rosetta 集成到您的项目中:

使用以下命令通过pip安装 Rosetta:

python -m pip install django-rosetta==0.10.0 

然后,将'rosetta'添加到项目settings.py文件中的INSTALLED_APPS设置,如下所示:

INSTALLED_APPS = [
    # ...
**'rosetta'****,**
] 

您需要将 Rosetta 的 URL 添加到您的主 URL 配置中。编辑项目的主urls.py文件并添加以下加粗的 URL 模式:

urlpatterns = [
    path('admin/', admin.site.urls),
    path('cart/', include('cart.urls', namespace='cart')),
    path('orders/', include('orders.urls', namespace='orders')),
    path('payment/', include('payment.urls', namespace='payment')),
    path('coupons/', include('coupons.urls', namespace='coupons')),
 **path(****'rosetta/'****, include(****'rosetta.urls'****)),**
    path('', include('shop.urls', namespace='shop')),
] 

确保将其放置在shop.urls模式之前,以防止不希望的匹配模式。

打开http://127.0.0.1:8000/admin/并以超级用户身份登录。然后,在浏览器中导航到http://127.0.0.1:8000/rosetta/。在过滤器菜单中,点击第三方以显示所有可用的消息文件,包括属于orders应用程序的文件。

您应该看到一个现有语言的列表,如下所示:

图 11.2:Rosetta 管理界面

西班牙语部分点击Myshop链接来编辑西班牙语翻译。您应该看到一个翻译字符串列表,如下所示:

图 11.3:使用 Rosetta 编辑西班牙语翻译

您可以在西班牙语列中输入翻译。出现次数列显示每个翻译字符串被找到的文件和代码行。

包含占位符的翻译将如下所示:

图 11.4:包含占位符的翻译

Rosetta 使用不同的背景颜色来显示占位符。在翻译内容时,请确保您保留占位符未翻译。例如,以下字符串:

%(items)s items, $%(total)s 

它可以被翻译成西班牙语如下:

%(items)s productos, $%(total)s 

您可以查看本章附带源代码,以使用相同的西班牙语翻译为您的项目。

当你完成翻译编辑后,点击保存并翻译下一块按钮将翻译保存到.po文件。Rosetta 在保存翻译时编译消息文件,因此你不需要运行compilemessages命令。然而,Rosetta 需要写入locale目录的权限来写入消息文件。确保目录具有有效的权限。

如果你希望其他用户能够编辑翻译,请在浏览器中打开http://127.0.0.1:8000/admin/auth/group/add/并创建一个名为translators的新组。然后,访问http://127.0.0.1:8000/admin/auth/user/来编辑你想要授予权限以编辑翻译的用户。在编辑用户时,在权限部分,为每个用户将translators组添加到选择的组。Rosetta 仅对超级用户或属于translators组的用户可用。

你可以在django-rosetta.readthedocs.io/阅读 Rosetta 的文档。

当你向生产环境添加新翻译时,如果你使用真实 Web 服务器运行 Django,你必须在运行compilemessages命令或使用 Rosetta 保存翻译后重新加载服务器,以便任何更改生效。

在编辑翻译时,可以将翻译标记为模糊的。让我们回顾一下模糊翻译是什么。

模糊翻译

在 Rosetta 编辑翻译时,你可以看到一个FUZZY列。这不是 Rosetta 的功能;它是由gettext提供的。如果翻译的FUZZY标志处于激活状态,则它将不会包含在编译的消息文件中。此标志标记需要翻译员审查的翻译字符串。当.po文件更新为新翻译字符串时,某些翻译字符串可能会自动标记为模糊。这发生在gettext发现某些msgid被略微修改时。gettext将其与它认为的旧翻译配对,并将其标记为模糊以供审查。然后,翻译员应审查模糊翻译,移除FUZZY标志,并再次编译翻译文件。

你已经翻译了项目界面,但国际化并不止于此。你还可以翻译 URL 模式,为每种支持的语言提供定制的 URL。

国际化的 URL 模式

Django 为 URL 提供了国际化功能。它包括两个主要功能用于国际化 URL:

  • URL 模式中的语言前缀:在 URL 中添加语言前缀,以便在不同的基本 URL 下提供每种语言版本

  • 翻译后的 URL 模式:翻译 URL 模式,使每个 URL 针对每种语言都不同

翻译 URL 的一个原因是为了优化您的网站以适应搜索引擎。通过在您的模式中添加语言前缀,您将能够为每种语言索引一个 URL,而不是为所有语言索引一个单一的 URL。此外,通过将 URL 翻译成每种语言,您将为搜索引擎提供在每种语言中排名更好的 URL。

在 URL 模式中添加语言前缀

Django 允许您在 URL 模式中添加语言前缀。例如,您网站的英文版本可以通过以/en/开头的路径提供服务,而西班牙语版本在/es/下。要使用 URL 模式中的语言,您必须使用 Django 提供的LocaleMiddleware。框架将使用它从请求的 URL 中识别当前语言。之前,您已将其添加到项目的MIDDLEWARE设置中,因此现在您不需要再这样做。

让我们在 URL 模式中添加一个语言前缀:

编辑myshop项目的主体urls.py文件,并添加i18n_patterns(),如下所示:

**from** **django.conf.urls.i18n** **import** **i18n_patterns**
urlpatterns = **i18n_patterns(**
    path('admin/', admin.site.urls),
    path('cart/', include('cart.urls', namespace='cart')),
    path('orders/', include('orders.urls', namespace='orders')),
    path('payment/', include('payment.urls', namespace='payment')),
    path('coupons/', include('coupons.urls', namespace='coupons')),
    path('rosetta/', include('rosetta.urls')),
    path('', include('shop.urls', namespace='shop')),
**)** 

您可以将不可翻译的标准 URL 模式和i18n_patterns下的模式结合起来,以便某些模式包含语言前缀,而其他模式则不包含。然而,最好只使用翻译的 URL,以避免不小心翻译的 URL 与非翻译的 URL 模式匹配的可能性。

运行开发服务器并在您的浏览器中打开http://127.0.0.1:8000/。Django 将执行如何确定当前语言部分中描述的步骤以确定当前语言,并将您重定向到请求的 URL,包括语言前缀。查看您浏览器中的 URL;现在它应该看起来像http://127.0.0.1:8000/en/。当前语言是您的浏览器Accept-Language头中设置的语言,如果是西班牙语或英语;否则,它是您设置中定义的默认LANGUAGE_CODE(英语)。

您已经为您的 URL 添加了语言前缀,为每种可用的语言生成不同的 URL。这有助于您在搜索引擎中索引不同的版本。

接下来,我们将翻译 URL 模式,以便我们可以将完全翻译的 URL 添加到我们的网站上。

翻译 URL 模式

Django 支持在 URL 模式中使用翻译字符串。您可以为单个 URL 模式使用不同的翻译。您可以使用与字面量相同的方式标记需要翻译的 URL 模式,使用gettext_lazy()函数。为此,请按照以下步骤操作:

编辑myshop项目的主体urls.py文件,并为cartorderspaymentcoupons应用的 URL 模式正则表达式添加翻译字符串,如下所示:

**from** **django.utils.translation** **import** **gettext_lazy** **as** **_**
urlpatterns = i18n_patterns(
    path('admin/', admin.site.urls),
    path(**_(**'cart/'**)**, include('cart.urls', namespace='cart')),
    path(**_(**'orders/'**)**, include('orders.urls', namespace='orders')),
    path(**_(**'payment/'**)**, include('payment.urls', namespace='payment')),
    path(**_(**'coupons/'**)**, include('coupons.urls', namespace='coupons')),
    path('rosetta/', include('rosetta.urls')),
    path('', include('shop.urls', namespace='shop')),
) 

编辑orders应用的urls.py文件,并标记order_create URL 模式为翻译,如下所示:

**from** **django.utils.translation** **import** **gettext_lazy** **as** **_**
urlpatterns = [
    path(**_(**'create/'**)**, views.order_create, name='order_create'),
    # ...
] 

编辑payment应用的urls.py文件,并将代码更改为以下内容:

**from** **django.utils.translation** **import** **gettext_lazy** **as** **_**
urlpatterns = [
    path(**_(**'process/'**)**, views.payment_process, name='process'),
    path(**_(****'**completed/'**)**, views.payment_completed, name='completed'),
    path(**_(**'canceled/'**)**, views.payment_canceled, name='canceled'),
    path('webhook/', webhooks.stripe_webhook, name='stripe-webhook'),
] 

注意,这些 URL 模式将包括语言前缀,因为它们包含在项目的main urls.py文件中的i18n_patterns()下。这将使每个 URL 模式对于每种可用语言都有一个不同的 URI,一个以/en/开头,另一个以/es/开头,依此类推。然而,我们需要一个用于 Stripe 通知事件的单个 URL,并且我们需要在webhook` URL 中避免语言前缀。

payment应用的urls.py文件中删除webhook URL 模式。文件现在应如下所示:

from django.utils.translation import gettext_lazy as _
urlpatterns = [
    path(_('process/'), views.payment_process, name='process'),
    path(_('completed/'), views.payment_completed, name='completed'),
    path(_('canceled/'), views.payment_canceled, name='canceled'),
] 

然后,将以下webhook URL 模式添加到myshop项目的main urls.py`文件中。新的代码以粗体显示:

from django.utils.translation import gettext_lazy as _
**from** **payment** **import** **webhooks**
urlpatterns = i18n_patterns(
    path('admin/', admin.site.urls),
    path(_('cart/'), include('cart.urls', namespace='cart')),
    path(_('orders/'), include('orders.urls', namespace='orders')),
    path(_('payment/'), include('payment.urls', namespace='payment')),
    path(_('coupons/'), include('coupons.urls', namespace='coupons')),
    path('rosetta/', include('rosetta.urls')),
    path('', include('shop.urls', namespace='shop')),
)
**urlpatterns += [**
 **path(**
**'payment/webhook/'****,**
 **webhooks.stripe_webhook,**
 **name=****'stripe-webhook'**
 **),**
**]**
if settings.DEBUG:
    urlpatterns += static(
        settings.MEDIA_URL, document_root=settings.MEDIA_ROOT
    ) 

我们已将webhook URL 模式添加到urlpatterns之外,以确保我们保持 Stripe 事件通知的单个 URL。

您不需要翻译shop应用的 URL 模式,因为它们是用变量构建的,不包含任何其他文字。

打开 shell 并运行以下命令以更新消息文件中的新翻译:

django-admin makemessages --all 

确保开发服务器正在以下命令下运行:

python manage.py runserver 

在您的浏览器中打开http://127.0.0.1:8000/en/rosetta/并点击西班牙语部分下的Myshop链接。点击仅未翻译以仅查看尚未翻译的字符串。现在,您将看到翻译的 URL 模式,如图图 11.5所示:

图 11.5:Rosetta 界面中的翻译 URL 模式

为每个 URL 添加不同的翻译字符串。不要忘记在每个 URL 的末尾包括一个斜杠字符/,如图图 11.6所示:

图 11.6:Rosetta 界面中的 URL 模式西班牙语翻译

完成后,点击保存并翻译下一块

然后,点击仅模糊。您将看到被标记为模糊的翻译,因为这些翻译与类似原始字符串的旧翻译配对。在图 11.7中显示的情况下,翻译是错误的,需要更正:

图 11.7:Rosetta 界面中的模糊翻译

为模糊翻译输入正确的文本。当您为翻译输入新文本时,Rosetta 将自动取消选中模糊选择框。完成输入后,点击保存并翻译下一块

图 11.8:在 Rosetta 界面中更正模糊翻译

您现在可以回到http://127.0.0.1:8000/en/rosetta/files/third-party/并编辑orders应用的西班牙语翻译。

在将字符串翻译成西班牙语后,我们的网站将提供两种语言。您已经学习了 Django 如何确定当前语言。然而,用户可能希望切换语言。让我们创建允许用户更改语言偏好的功能。

允许用户切换语言

由于你提供的内容支持多种语言,你应该允许用户切换网站的语种。你将向你的网站添加一个语言选择器。语言选择器将包含一个显示链接的可用语言列表。

编辑 shop/base.html 模板中的 shop 应用程序,并定位以下行:

<div id="header">
<a href="/" class="logo">{% translate "My shop" %}</a>
</div> 

将它们替换为以下代码:

<div id="header">
<a href="/" class="logo">{% translate "My shop" %}</a>
 **{% get_current_language as LANGUAGE_CODE %}**
 **{% get_available_languages as LANGUAGES %}**
 **{% get_language_info_list for LANGUAGES as languages %}**
**<****div****class****=****"languages"****>**
**<****p****>****{% translate "Language" %}:****</****p****>**
**<****ul****class****=****"languages"****>**
 **{% for language in languages %}**
**<****li****>**
**<****a****href****=****"/{{ language.code }}/"**
 **{%** **if****language.code** **==** **LANGUAGE_CODE** **%}** **class****=****"selected"****{%** **endif** **%}>**
 **{{ language.name_local }}**
**</****a****>**
**</****li****>**
 **{% endfor %}**
**</****ul****>**
**</****div****>**
</div> 

确保没有任何模板标签被拆分成多行。

这就是构建你的语言选择器的方式:

  1. 你使用 {% load i18n %} 加载国际化标签。

  2. 你使用 {% get_current_language %} 标签来检索当前语言。

  3. 你可以通过使用 {% get_available_languages %} 模板标签来获取在 LANGUAGES 设置中定义的语言。

  4. 你使用 {% get_language_info_list %} 标签来提供对语言属性的便捷访问。

  5. 你构建一个 HTML 列表来显示所有可用语言,并给当前活动的语言添加一个 selected 类属性。

在语言选择器的代码中,你使用了由 i18n 提供的模板标签,基于你项目设置中的可用语言。现在,在你的浏览器中打开 http://127.0.0.1:8000/ 并查看。你应该在网站右上角看到语言选择器,如下所示:

图 11.9:产品列表页面,包括网站页眉中的语言选择器

本章中的图片:

绿茶:由 Jia Ye 在 Unsplash 上拍摄的照片

红茶:由 Manki Kim 在 Unsplash 上拍摄的照片

茶粉:由 Phuong Nguyen 在 Unsplash 上拍摄的照片

用户现在可以通过选择语言选择器中提供的选项轻松切换到他们喜欢的语言。

使用 django-parler 翻译模型

Django 不包括内置的模型翻译支持。要管理多语言内容,你可以开发一个自定义解决方案,或者选择一个促进模型翻译的第三方模块。有几种第三方应用程序可用,每种应用程序都采用独特的方法来存储和检索翻译。其中之一是 django-parler。此模块为翻译模型提供了一种非常有效的方法,并且与 Django 的管理站点无缝集成。

django-parler 为每个包含翻译的模型生成一个单独的数据库表。此表包括所有翻译字段以及一个外键,指向翻译所属的原始对象。它还包含一个语言字段,因为每一行存储的是单一语言的内容。

django-parler 包已经好几年没有更新了。尽管如此,许多开发者仍然继续使用它,因为它在促进模型翻译方面的有效性得到了证明。

安装 django-parler

使用以下命令通过 pip 安装 django-parler

python -m pip install django-parler==2.3 

编辑你的项目中的 settings.py 文件,并将 'parler' 添加到 INSTALLED_APPS 设置中,如下所示:

INSTALLED_APPS = [
    # ...
**'parler'****,**
] 

此外,将以下代码添加到你的设置中:

# django-parler settings
PARLER_LANGUAGES = {
    None: (
        {'code': 'en'},
        {'code': 'es'},
    ),
    'default': {
        'fallback': 'en',
        'hide_untranslated': False,
    }
} 

此设置定义了django-parler可用的语言,enes,对于django-parler。您指定默认语言en,并指示django-parler不应隐藏未翻译的内容。

Parler 现在已在我们的项目中激活。让我们为我们的模型字段添加翻译功能。

翻译模型字段

让我们为您的产品目录添加翻译。django-parler提供了一个TranslatableModel模型类和一个TranslatedFields包装器来翻译模型字段。您可以按照以下说明操作:

编辑shop应用程序目录内的models.py文件并添加以下导入:

from parler.models import TranslatableModel, TranslatedFields 

然后,修改Category模型,使nameslug字段可翻译,如下所示:

class Category(**TranslatableModel**):
 **translations = TranslatedFields(**
       name = models.CharField(max_length=200)**,**
        slug = models.SlugField(max_length=200, unique=True)**,**
 **)** 

Category模型现在从models.Model继承,而不是从TranslatableModel继承,并且nameslug字段都包含在TranslatedFields包装器中。

编辑Product模型以添加nameslugdescription字段的翻译,如下所示:

class Product(**TranslatableModel**):
 **translations = TranslatedFields(**
        name = models.CharField(max_length=200)**,**
        slug = models.SlugField(max_length=200)**,**
        description = models.TextField(blank=True)
 **)**
    category = models.ForeignKey(
        Category,
        related_name='products',
        on_delete=models.CASCADE
    )
    image = models.ImageField(
        upload_to='products/%Y/%m/%d',
        blank=True
    )
    price = models.DecimalField(max_digits=10, decimal_places=2)
    available = models.BooleanField(default=True)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True) 

django-parler通过为每个可翻译模型生成另一个模型来管理翻译。在以下架构中,您可以查看Product模型的字段以及生成的ProductTranslation模型将是什么样子:

图 11.10:由 django-parler 生成的产品模型和相关产品翻译模型

django-parler生成的ProductTranslation模型包括nameslugdescription可翻译字段、一个language_code字段以及指向主Product对象的ForeignKey。从ProductProductTranslation存在一对一的关系。每个Product对象将存在一个对应每种可用语言的ProductTranslation对象。

由于 Django 使用单独的表来存储翻译,因此您无法使用一些 Django 功能。无法使用带有翻译字段的默认排序。您可以在查询中按翻译字段进行筛选,但不能在ordering Meta选项中包含可翻译字段。此外,您不能为翻译字段使用索引,因为这些字段将不会存在于原始模型中,因为它们将位于翻译模型中。

编辑shop应用程序的models.py文件并取消注释Category Meta类的orderingindexes属性:

class Category(TranslatableModel):
    # ...
class Meta:
        **#** ordering = ['name']
**#** indexes = [
**#**     models.Index(fields=['name']),
**#** ]
        verbose_name = 'category'
        verbose_name_plural = 'categories' 

您还必须取消注释Product Meta类的ordering属性和引用翻译字段的索引。取消注释以下Product Meta类的行:

class Product(TranslatableModel):
    # ...
class Meta:
        **#** ordering = ['name']
        indexes = [
            **#** models.Index(fields=['id', 'slug']),
**#** models.Index(fields=['name']),
            models.Index(fields=['-created']),
        ] 

您可以阅读有关django-parler模块与 Django 兼容性的更多信息,请参阅django-parler.readthedocs.io/en/latest/compatibility.html

让我们继续将可翻译模型集成到管理站点中。

将翻译集成到管理站点

django-parler与 Django 管理站点无缝集成。这允许您通过用户友好的管理界面轻松编辑对象的多种翻译。它包括一个TranslatableAdmin类,该类覆盖了 Django 提供的ModelAdmin类以管理模型翻译。

编辑shop应用的admin.py文件,并向其中添加以下导入:

from parler.admin import TranslatableAdmin 

CategoryAdminProductAdmin类修改为从TranslatableAdmin继承,而不是从ModelAdmin继承。django-parler模块不支持prepopulated_fields属性,但它支持get_prepopulated_fields()方法,该方法提供相同的功能。让我们相应地进行更改。编辑admin.py文件,使其看起来如下:

from django.contrib import admin
**from** **parler.admin** **import** **TranslatableAdmin**
from .models import Category, Product
@admin.register(Category)
class CategoryAdmin(**TranslatableAdmin**):
    list_display = ['name', 'slug']
**def****get_prepopulated_fields****(****self, request, obj=****None****):**
**return** **{****'slug'****: (****'name'****,)}**
@admin.register(Product)
class ProductAdmin(**TranslatableAdmin**):
    list_display = [
        'name',
        'slug',
        'price',
        'available',
        'created',
        'updated'
    ]
    list_filter = ['available', 'created', 'updated']
    list_editable = ['price', 'available']
**def****get_prepopulated_fields****(****self, request, obj=****None****):**
**return** **{****'slug'****: (****'name'****,)}** 

您已将管理站点调整为与新的翻译模型一起工作。您现在可以同步数据库,以与您所做的模型更改保持一致。

创建模型翻译的迁移

要创建迁移,请按照以下说明操作:

打开终端并运行以下命令以创建模型翻译的新迁移:

python manage.py makemigrations shop --name "translations" 

您将看到以下输出:

Migrations for 'shop':
  shop/migrations/0002_translations.py
    - Create model CategoryTranslation
    - Create model ProductTranslation
    - Change Meta options on category
    - Change Meta options on product
    - Remove index shop_catego_name_289c7e_idx from category
    - Remove index shop_produc_id_f21274_idx from product
    - Remove index shop_produc_name_a2070e_idx from product
    - Remove field name from category
    - Remove field slug from category
    - Remove field description from product
    - Remove field name from product
    - Remove field slug from product
    - Add field master to producttranslation
    - Add field master to categorytranslation
    - Alter unique_together for producttranslation (1 constraint(s))
    - Alter unique_together for categorytranslation (1 constraint(s)) 

此迁移自动包括由django-parler动态创建的CategoryTranslationProductTranslation模型。请注意,此迁移会删除模型中先前存在的字段。

这意味着您将丢失这些数据,在运行它之后需要在管理站点上重新设置类别和产品。

编辑shop应用的migrations/0002_translations.py文件,并识别以下行的两个出现:

bases=(parler.models.TranslatedFieldsModelMixin, models.Model), 

将这些出现替换为以下内容:

bases=(parler.models.**TranslatableModel**, models.Model), 

这是针对您使用的django-parler版本中发现的微小问题的修复。此更改是必要的,以防止在应用迁移时迁移失败。此问题与在模型中创建现有字段的翻译有关,应在较新的django-parler版本中得到修复。

运行以下命令以应用迁移:

python manage.py migrate shop 

您将看到一个以以下行为结尾的输出:

Applying shop.0002_ categorytranslation_producttranslation_and_more... OK 

您的模型现在与数据库同步。

使用以下命令运行开发服务器:

python manage.py runserver 

在浏览器中打开http://127.0.0.1:8000/en/admin/shop/category/。您将看到由于删除了这些字段并使用由django-parler生成的可翻译模型,现有的类别失去了名称和 slug。您将只看到每列下的破折号,如图 11.11所示:

图 11.11:创建翻译模型后在 Django 管理站点上的类别列表

点击类别名称下的破折号以编辑它。您将看到更改类别页面包括两个不同的选项卡,一个用于英语翻译,一个用于西班牙语翻译:

图 11.12:包括由 django-parler 添加的语言选项卡的类别编辑表单

确保为所有现有类别填写一个名称和 slug。当您编辑一个类别时,输入英语详情并点击 保存并继续编辑。然后,点击 西班牙语,为字段添加西班牙语翻译,并点击 保存

图 11.13:类别编辑表单的西班牙语翻译

在切换语言选项卡之间确保保存更改。

完成现有类别的数据后,打开 http://127.0.0.1:8000/en/admin/shop/product/ 并编辑每个产品,提供英语和西班牙语名称、slug 和描述。

一旦翻译就绪,下一步将是探索如何通过 Django ORM 与翻译字段交互。

在 QuerySets 中使用翻译

让我们看看如何在 QuerySets 中处理翻译。运行以下命令以打开 Python shell:

python manage.py shell 

让我们看看如何检索和查询翻译字段。要获取具有可翻译字段的特定语言的对象,您可以使用 Django 的 activate() 函数,如下所示:

>>> from shop.models import Product
>>> from django.utils.translation import activate
>>> activate('es')
>>> product=Product.objects.first()
>>> product.name
'Té verde' 

另一种方法是使用 django-parler 提供的 language() 管理器,如下所示:

>>> product=Product.objects.language('en').first()
>>> product.name
'Green tea' 

当您访问翻译字段时,它们将使用当前语言进行解析。您可以为对象设置不同的当前语言以访问特定的翻译,如下所示:

>>> product.set_current_language('es')
>>> product.name
'Té verde'
>>> product.get_current_language()
'es' 

当使用 filter() 执行 QuerySet 时,您可以使用 translations__ 语法通过相关翻译对象进行过滤,如下所示:

>>> Product.objects.filter(translations__name='Green tea')
<TranslatableQuerySet [<Product: Té verde>]> 

让我们将所学知识应用到我们的视图中。

调整视图以进行翻译

让我们调整产品目录视图:

编辑 shop 应用程序的 views.py 文件,并在 product_list 视图中添加以下加粗显示的代码:

def product_list(request, category_slug=None):
    category = None
    categories = Category.objects.all()
    products = Product.objects.filter(available=True)
    if category_slug:
 **language = request.LANGUAGE_CODE**
        category = get_object_or_404(
            Category,
 **translations__language_code=language,**
 **translations__**slug=category_slug
        )
        products = products.filter(category=category)
    return render(
        request,
        'shop/product/list.html',
        {
            'category': category,
            'categories': categories,
            'products': products
        }
    ) 

然后,编辑 product_detail 视图,并添加以下加粗显示的代码:

def product_detail(request, id, slug):
 **language = request.LANGUAGE_CODE**
    product = get_object_or_404(
        Product,
        id=id,
        **translations__language_code=language,**
 **translations__**slug=slug,
        available=True
    )
    cart_product_form = CartAddProductForm()
    r = Recommender()
    recommended_products = r.suggest_products_for([product], 4)
    return render(
        request,
        'shop/product/detail.html',
        {
            'product': product,
            'cart_product_form': cart_product_form,
            'recommended_products': recommended_products
        }
    ) 

product_listproduct_detail 视图现在已调整以使用翻译字段检索对象。

使用以下命令运行开发服务器:

python manage.py runserver 

在您的浏览器中打开 http://127.0.0.1:8000/es/。您应该看到产品列表页面,包括所有翻译成西班牙语的产品:

图 11.14:产品列表页面的西班牙语版本

现在,每个产品的 URL 都是使用当前语言的 slug 字段构建的。例如,西班牙语产品的 URL 是 http://127.0.0.1:8000/es/2/te-rojo/,而在英语中,URL 是 http://127.0.0.1:8000/en/2/red-tea/。如果您导航到产品详情页面,您将看到翻译后的 URL 和所选语言的內容,如下例所示:

图 11.15:产品详情页面的西班牙语版本

如果您想了解更多关于 django-parler 的信息,您可以在 django-parler.readthedocs.io/en/latest/ 找到完整的文档。

您已经学习了如何翻译 Python 代码、模板、URL 模式和模型字段。为了完成国际化区域化过程,您还需要使用日期、时间和数字的区域化格式。

格式区域化

为了提升用户体验,将日期、时间和数字以与用户区域设置一致的方式呈现非常重要。将您的网站调整为适应不同地区用户熟悉的数据格式,可以显著提高其可访问性。自 Django 5.0 以来,数据区域化格式化始终是启用的。Django 使用当前区域设置的格式显示数字和日期。

Django 试图在模板中输出值时使用区域特定的格式。图 11.16显示了网站英文和西班牙语文本中十进制数字的格式区域化:

图片

图 11.16:英文和西班牙语文本中的格式区域化

英语文本中的十进制数字使用点作为小数分隔符显示,而在西班牙语文本中,使用逗号作为分隔符。这是由于 Django 为enes区域设置的格式。您可以在github.com/django/django/blob/stable/5.0.x/django/conf/locale/en/formats.py查看英文格式配置,并在github.com/django/django/blob/stable/5.0.x/django/conf/locale/es/formats.py查看西班牙语文本格式配置。

默认情况下,Django 为每个区域设置应用格式区域化。但是,可能存在您不想使用区域化值的情况。这尤其适用于输出 JavaScript 或 JSON,它们必须提供机器可读的格式。

Django 提供了一个{% localize %}模板标签,允许您为模板片段打开/关闭区域化。这为您提供了对区域化格式的控制。您必须加载l10n(区域化)标签才能使用此模板标签。以下是在模板中打开和关闭区域化的示例:

{% load l10n %}
{% localize on %}
  {{ value }}
{% endlocalize %}
{% localize off %}
  {{ value }}
{% endlocalize %} 

Django 还提供了localizeunlocalize模板过滤器,可以强制或避免对值进行区域化。这些过滤器可以按以下方式应用:

{{ value|localize }}
{{ value|unlocalize }} 

您还可以创建自定义格式文件来指定区域设置格式。您可以在docs.djangoproject.com/en/5.0/topics/i18n/formatting/找到有关格式区域设置的更多信息。

接下来,您将学习如何创建区域化表单字段。

使用 django-localflavor 验证表单字段

django-localflavor是一个第三方模块,包含一系列特定于每个国家的实用工具,如表单字段或模型字段。它对于验证本地区域、本地电话号码、身份证号码、社会保险号码等非常有用。该软件包组织成一系列以 ISO 3166 国家代码命名的模块。按照以下说明进行设置:

使用以下命令安装django-localflavor

python -m pip install django-localflavor==4.0 

编辑您项目的settings.py文件,并将localflavor添加到INSTALLED_APPS设置中,如下所示:

INSTALLED_APPS = [
    # ...
**'localflavor'****,**
] 

您将添加美国邮政编码字段,以便创建新订单时必须输入有效的美国邮政编码。编辑orders应用的forms.py文件,使其看起来如下所示:

from django import forms
**from** **localflavor.us.forms** **import** **USZipCodeField**
from .models import Order
class OrderCreateForm(forms.ModelForm):
 **postal_code = USZipCodeField()**
class Meta:
        model = Order
        fields = [
            'first_name',
            'last_name',
            'email',
            'address',
            'postal_code',
            'city'
        ] 

您从localflavorus包中导入USZipCodeField字段,并将其用于OrderCreateForm表单的postal_code字段。

使用以下命令运行开发服务器:

python manage.py runserver 

在您的浏览器中打开http://127.0.0.1:8000/en/orders/create/。填写所有字段,输入三位字母的邮政编码,然后提交表单。您将得到以下验证错误,这是由USZipCodeField引发的:

Enter a zip code in the format XXXXX or XXXXX-XXXX. 

图 11.17 展示了表单验证错误:

图 11.17:无效美国邮政编码的验证错误

这只是一个如何在自己的项目中使用localflavor中的自定义字段进行验证的简要示例。localflavor提供的本地组件对于使您的应用程序适应特定国家非常有用。您可以阅读django-localflavor文档,查看每个国家可用的所有本地组件,请参阅django-localflavor.readthedocs.io/en/latest/

使用 AI 扩展您的项目

在本节中,您将面临一个扩展您项目的任务,并附有 ChatGPT 的示例提示以供协助。要参与 ChatGPT,请访问chat.openai.com/。如果您是第一次与 ChatGPT 互动,您可以回顾第三章,扩展您的博客应用程序中的使用 AI 扩展您的项目部分。

在本项目示例中,我们已经实现了一个在线商店。我们添加了订单、支付和优惠券系统。现在,电子商务平台的另一个典型功能是管理运费。让我们考虑为产品添加重量属性,并基于运输物品的总重量实现运费。使用 ChatGPT 帮助您实现基于产品重量的运费,确保 Stripe 收取正确的金额,包括计算出的运费。您可以使用提供的提示github.com/PacktPublishing/Django-5-by-example/blob/main/Chapter11/prompts/task.md

将 ChatGPT 作为调试伴侣使用。如果你发现自己卡在一个特别顽固的 bug 上,描述问题及其上下文。它可以从一个全新的角度提供帮助,通常能促使你考虑可能忽略的角度,从而更快地解决问题解决。

摘要

在本章中,你学习了 Django 项目的国际化本地化的基础知识。你为代码和模板字符串标记了翻译,并发现了如何生成和编译翻译文件。你还将在项目中安装 Rosetta 以通过 Web 界面管理翻译。你翻译了 URL 模式,并创建了一个语言选择器,允许用户切换站点的语言。然后,你使用 django-parler 翻译模型,并使用 django-localflavor 验证本地化表单字段。

在下一章中,你将开始一个新的 Django 项目,该项目将包括一个在线学习平台。你将学习如何使用模型继承来实现多态性,并为一个灵活的内容管理系统打下基础。你将创建应用程序模型,并学习如何创建和应用 fixtures 为模型提供初始数据。你将构建自定义模型字段并在模型中使用它。你还将为你的新应用程序构建身份验证视图。

其他资源

以下资源提供了与本章所涵盖主题相关的额外信息:

第十二章:构建在线学习平台

在上一章中,您学习了 Django 项目的国际化本地化基础,使您的项目适应用户的本地格式和语言。

在本章中,您将启动一个新的 Django 项目,该项目将包含一个带有您自己的内容管理系统CMS)的在线学习平台。在线学习平台是需要高级内容处理工具的应用程序的绝佳例子。您将学习如何创建灵活的数据模型,以适应多种数据类型,并了解如何实现可应用于您未来 Django 项目的自定义模型功能。

在本章中,您将学习如何:

  • 为 CMS 创建模型

  • 为您的模型创建数据并应用它们

  • 使用模型继承创建多态内容的数据模型

  • 创建自定义模型字段

  • 排序课程内容和模块

  • 为 CMS 构建认证视图

功能概述

在前面的章节中,开头的图表代表了视图、模板和端到端功能。然而,在本章中,焦点转向了实现模型继承和创建自定义模型字段,这些内容不易在我们的常规图表中捕捉到。相反,您将在本章中看到具体的图表来阐述这些概念。

本章的源代码可以在github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter12找到。

本章中使用的所有 Python 模块都包含在本章源代码中的requirements.txt文件中。您可以按照以下说明安装每个 Python 模块,或者可以使用以下命令一次性安装所有需求:python -m pip install -r requirements.txt

设置在线学习项目

您的最终实践项目将是一个在线学习平台。首先,在env/目录下使用以下命令为您的新项目创建一个虚拟环境:

python -m venv env/educa 

如果您使用的是 Linux 或 macOS,请运行以下命令以激活您的虚拟环境:

source env/educa/bin/activate 

如果您使用的是 Windows,请使用以下命令代替:

.\env\educa\Scripts\activate 

使用以下命令在您的虚拟环境中安装 Django:

python -m pip install Django~=5.0.4 

您将在项目中管理图像上传,因此您还需要使用以下命令安装Pillow

python -m pip install Pillow==10.3.0 

使用以下命令创建一个新的项目:

django-admin startproject educa 

进入新的educa目录,并使用以下命令创建一个新的应用程序:

cd educa
django-admin startapp courses 

编辑educa项目的settings.py文件,并将courses添加到INSTALLED_APPS设置中,如下所示。新行以粗体突出显示:

INSTALLED_APPS = [
    **'courses.apps.CoursesConfig'****,**
'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
] 

courses应用程序现在对项目是激活的。接下来,我们将准备我们的项目以提供媒体文件,并为课程和课程内容定义模型。

服务器端媒体文件

在创建课程和课程内容的模型之前,我们将准备项目以服务媒体文件。课程讲师将能够使用我们将构建的 CMS 将媒体文件上传到课程内容。因此,我们将配置项目以服务媒体文件。

编辑项目的 settings.py 文件并添加以下行:

MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media' 

这将使 Django 能够管理文件上传并服务媒体文件。MEDIA_URL 是用于服务用户上传的媒体文件的基 URL。MEDIA_ROOT 是它们所在的本地路径。文件路径和 URL 是通过在它们前面添加项目路径或媒体 URL 来动态构建的,以实现可移植性。

现在,编辑 educa 项目的 urls.py 主文件并修改代码,如下。新行以粗体显示:

**from** **django.conf** **import** **settings**
**from** **django.conf.urls.static** **import** **static**
from django.contrib import admin
from django.urls import path
urlpatterns = [
    path('admin/', admin.site.urls),
]
**if** **settings.DEBUG:**
 **urlpatterns += static(**
 **settings.MEDIA_URL, document_root=settings.MEDIA_ROOT**
 **)** 

我们已将 static() 辅助函数添加到开发期间使用 Django 开发服务器服务媒体文件(即,当 DEBUG 设置为 True 时)。

记住,static() 辅助函数适用于开发,但不适用于生产使用。Django 在服务静态文件方面效率低下。永远不要在生产环境中使用 Django 开发服务器来服务静态文件。你将在第十七章“上线”中学习如何在生产环境中服务静态文件。

项目现在已准备好服务媒体文件。让我们为课程和课程内容创建模型。

构建课程模型

你的在线学习平台将提供各种主题的课程。每个课程将被划分为可配置数量的模块,每个模块将包含可配置数量的内容。内容将包括各种类型:文本、文件、图片或视频。以下示例显示了你的课程目录的数据结构:

Subject 1
  Course 1
    Module 1
      Content 1 (image)
      Content 2 (text)
    Module 2
      Content 3 (text)
      Content 4 (file)
      Content 5 (video)
      ... 

让我们构建课程模型。编辑 courses 应用的 models.py 文件并向其中添加以下代码:

**from** **django.contrib.auth.models** **import** **User**
from django.db import models
**class****Subject****(models.Model):**
 **title = models.CharField(max_length=****200****)**
 **slug = models.SlugField(max_length=****200****, unique=****True****)**
**class****Meta****:**
 **ordering = [****'title'****]**
**def****__str__****(****self****):**
**return** **self.title**
**class****Course****(models.Model):**
 **owner = models.ForeignKey(**
 **User,**
 **related_name=****'****courses_created'****,**
 **on_delete=models.CASCADE**
 **)**
 **subject = models.ForeignKey(**
 **Subject,**
 **related_name=****'courses'****,**
 **on_delete=models.CASCADE**
 **)**
 **title = models.CharField(max_length=****200****)**
 **slug = models.SlugField(max_length=****200****, unique=****True****)**
 **overview = models.TextField()**
 **created = models.DateTimeField(auto_now_add=****True****)**
**class****Meta****:**
 **ordering = [****'-created'****]**
**def****__str__****(****self****):**
**return** **self.title**
**class****Module****(models.Model):**
 **course = models.ForeignKey(**
 **Course, related_name=****'modules'****, on_delete=models.CASCADE**
 **)**
 **title = models.CharField(max_length=****200****)**
 **description = models.TextField(blank=****True****)**
**def****__str__****(****self****):**
**return** **self.title** 

这些是初始的 SubjectCourseModule 模型。Course 模型的字段如下:

  • owner:创建此课程的讲师。

  • subject:此课程所属的主题。它是一个指向 Subject 模型的 ForeignKey 字段。

  • title:课程的标题。

  • slug:课程的缩略名。这将在稍后的 URL 中使用。

  • overview:一个用于存储课程概述的 TextField 列。

  • created:课程创建的日期和时间。由于 auto_now_add=True,Django 将在创建新对象时自动设置它。

每个课程被划分为几个模块。因此,Module 模型包含一个指向 Course 模型的 ForeignKey 字段。

打开 shell 并运行以下命令以创建此应用的初始迁移:

python manage.py makemigrations 

你将看到以下输出:

Migrations for 'courses':
  courses/migrations/0001_initial.py:
    - Create model Course
    - Create model Module
    - Create model Subject
    - Add field subject to course 

然后,运行以下命令将所有迁移应用到数据库中:

python manage.py migrate 

你应该会看到包括 Django 在内的所有应用的迁移输出,输出将包含以下行:

Applying courses.0001_initial... OK 

你的 courses 应用程序的模型已与数据库同步。接下来,我们将把课程模型添加到管理站点。

在管理站点注册模型

让我们在管理站点注册课程模型,这样我们就可以轻松地管理数据。编辑 courses 应用程序目录内的 admin.py 文件,并向其中添加以下代码:

from django.contrib import admin
**from** **.models** **import** **Subject, Course, Module**
**@admin.register(****Subject****)**
**class****SubjectAdmin****(admin.ModelAdmin):**
 **list_display = [****'title'****,** **'slug'****]**
 **prepopulated_fields = {****'slug'****: (****'title'****,)}**
**class****ModuleInline****(admin.StackedInline):**
 **model = Module**
**@admin.register(****Course****)**
**class****CourseAdmin****(admin.ModelAdmin):**
 **list_display = [****'title'****,** **'subject'****,** **'created'****]**
 **list_filter = [****'created'****,** **'subject'****]**
 **search_fields = [****'title'****,** **'overview'****]**
 **prepopulated_fields = {****'slug'****: (****'title'****,)}**
 **inlines = [ModuleInline]** 

courses 应用程序的模型现在已在管理站点注册。记住,你使用 @admin.register() 装饰器在管理站点注册模型。

在下一节中,你将学习如何创建初始数据来填充你的模型。

使用固定值提供模型初始数据

有时,你可能希望预先用硬编码的数据填充你的数据库。这对于在项目设置中自动包含初始数据非常有用,而不是手动添加。Django 提供了一种简单的方法来从数据库中加载数据和转储数据到称为 固定值 的文件。Django 支持固定值在 JSON、XML 或 YAML 格式。固定值的结构紧密类似于模型的 API 表示,这使得在内部数据库格式和外部应用程序之间转换数据变得简单。你将创建一个固定值来包含几个初始的 Subject 对象。

首先,使用以下命令创建一个超级用户:

python manage.py createsuperuser 

然后,使用以下命令运行开发服务器:

python manage.py runserver 

在你的浏览器中打开 http://127.0.0.1:8000/admin/courses/subject/。使用管理站点创建几个主题。更改列表页面应如下所示:

图片

图 12.1:管理站点上的主题更改列表视图

从 shell 中运行以下命令:

python manage.py dumpdata courses --indent=2 

你将看到类似以下输出:

[
{
  "model": "courses.subject",
  "pk": 1,
  "fields": {
    "title": "Mathematics",
    "slug": "mathematics"
  }
},
{
  "model": "courses.subject",
  "pk": 2,
  "fields": {
    "title": "Music",
    "slug": "music"
  }
},
{
  "model": "courses.subject",
  "pk": 3,
  "fields": {
    "title": "Physics",
    "slug": "physics"
  }
},
{
  "model": "courses.subject",
  "pk": 4,
  "fields": {
    "title": "Programming",
    "slug": "programming"
  }
}
] 

dumpdata 命令将数据从数据库转储到标准输出,默认情况下以 JSON 格式序列化。结果数据结构包括有关模型及其字段的信息,以便 Django 能够将其加载到数据库中。

你可以通过提供应用程序名称给命令或指定使用 app.Model 格式输出数据的单个模型来限制输出到应用程序的模型。

你也可以使用 --format 标志指定格式。默认情况下,dumpdata 将序列化数据输出到标准输出。但是,你可以使用 --output 标志指示输出文件,这允许你存储输出。--indent 标志允许你指定缩进。有关 dumpdata 参数的更多信息,请运行 python manage.py dumpdata --help

使用以下命令将此转储保存到 courses 应用程序中的新 fixtures/ 目录的固定值文件中:

mkdir courses/fixtures
python manage.py dumpdata courses --indent=2 --output=courses/fixtures/subjects.json 

运行开发服务器并使用管理站点删除你创建的主题,如图 12.2 所示:

图片

图 12.2:删除所有现有主题

删除所有主题后,使用以下命令将固定数据加载到数据库中:

python manage.py loaddata subjects.json 

固定数据中包含的所有Subject对象再次被加载到数据库中:

图片

图 12.3:固定数据中的主题现在已加载到数据库中

默认情况下,Django 在每个应用程序的fixtures/目录中查找文件,但你可以为loaddata命令指定固定文件的完整路径。你还可以使用FIXTURE_DIRS设置告诉 Django 查找固定数据的附加目录。

固定数据不仅对设置初始数据有用,还可以为你的应用程序提供样本数据或测试所需的数据。你还可以使用固定数据为生产环境填充必要的数据。

你可以在docs.djangoproject.com/en/5.0/topics/testing/tools/#fixture-loading了解如何使用固定数据(fixtures)进行测试。

如果你想在模型迁移中加载固定数据,请查看 Django 关于数据迁移的文档。你可以在docs.djangoproject.com/en/5.0/topics/migrations/#data-migrations找到迁移数据的文档。

你已经创建了用于管理课程主题、课程和课程模块的模型。接下来,你将创建用于管理不同类型模块内容的模型。

创建多态内容模型

你计划向课程模块添加不同类型的内容,如文本、图片、文件和视频。多态性是提供单一接口以访问不同类型的实体。你需要一个灵活的数据模型,允许你通过单一接口存储各种内容。在第七章跟踪用户行为中,你了解到使用通用关系创建可以指向任何模型对象的键外键(foreign keys)的便利性。你将创建一个Content模型来表示模块的内容,并定义一个通用关系以将任何对象与内容对象关联起来。

编辑courses应用程序的models.py文件并添加以下导入:

from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType 

然后,将以下代码添加到文件末尾:

class Content(models.Model):
    module = models.ForeignKey(
        Module,
        related_name='contents',
        on_delete=models.CASCADE
    )
    content_type = models.ForeignKey(
        ContentType,
        on_delete=models.CASCADE
    )
    object_id = models.PositiveIntegerField()
    item = GenericForeignKey('content_type', 'object_id') 

这是内容模型。一个模块包含多个内容,因此你需要定义一个指向Module模型的ForeignKey字段。你也可以设置一个通用关系来关联不同模型中的对象,这些模型代表不同类型的内容。记住,你需要三个不同的字段来设置一个通用关系。在你的Content模型中,这些字段是:

  • content_type:一个指向ContentType模型的ForeignKey字段

  • object_id:一个用于存储相关对象主键的PositiveIntegerField

  • item:一个结合前两个字段的GenericForeignKey字段,指向相关对象

只有content_typeobject_id字段在这个模型的数据库表中对应有列。item字段允许你直接检索或设置相关对象,其功能建立在其他两个字段之上。

你将为每种内容类型使用一个不同的模型;文本、图片、视频和文档。你的Content模型将共享一些公共字段,但它们在存储的具体数据上会有所不同。例如,对于文本内容,你将存储实际的文本,但对于视频内容,你将存储视频 URL。为了实现这一点,你需要使用模型继承。在我们构建Content模型之前,我们将深入了解 Django 为模型继承提供的选项。

使用模型继承

Django 支持模型继承。它的工作方式与 Python 中的标准类继承类似。如果你不熟悉类继承,它涉及到定义一个新的类,该类从现有的类继承方法和属性。这有助于代码重用,并可以简化相关类的创建。你可以在docs.python.org/3/tutorial/classes.html#inheritance了解更多关于类继承的信息。

Django 提供了以下三种选项来使用模型继承:

  • 抽象模型:当你想在几个模型中放入一些公共信息时很有用

  • 多表模型继承:适用于每个模型在继承层次中都被视为一个完整的模型

  • 代理模型:当你需要更改模型的行为时很有用,例如,通过包含额外的方法、更改默认管理器或使用不同的元选项

让我们逐一深入了解每个选项。

抽象模型

抽象模型是一个基类,在其中你定义你想要包含在所有子模型中的字段。Django 不会为抽象模型创建任何数据库表。为每个子模型创建数据库表,包括从抽象类继承的字段和在子模型中定义的字段。

要将模型标记为抽象,你需要在它的Meta类中包含abstract=True。Django 将识别它是一个抽象模型,并且不会为它创建数据库表。要创建子模型,你只需将抽象模型子类化即可。

以下示例展示了一个抽象的BaseContent模型和一个子Text模型:

from django.db import models
class BaseContent(models.Model):
    title = models.CharField(max_length=100)
    created = models.DateTimeField(auto_now_add=True)
    class Meta:
        abstract = True
class Text(BaseContent):
    body = models.TextField() 

在这种情况下,Django 只为Text模型创建一个表,包括titlecreatedbody字段。

图 12.4展示了提供的代码示例中的模型和相关数据库表:

图 12.4:使用抽象模型进行继承的示例模型和数据库表

接下来,我们将学习关于不同模型继承方法的知识,其中将创建多个数据库表。

多表模型继承

在多表继承中,每个模型对应一个数据库表。Django 为子模型与其父模型之间的关系创建一个OneToOneField字段。要使用多表继承,你必须继承一个现有的模型。Django 将为原始模型和子模型都创建数据库表。以下示例显示了多表继承:

from django.db import models
class BaseContent(models.Model):
    title = models.CharField(max_length=100)
    created = models.DateTimeField(auto_now_add=True)
class Text(BaseContent):
    body = models.TextField() 

Django 将在Text模型中包含一个自动生成的OneToOneField字段,该字段指向BaseContent模型。这个字段的名称是basecontent_ptr,其中ptr代表指针。为每个模型创建一个数据库表。

图 12.5显示了提供的多表模型继承代码示例中的模型及其相关的数据库表:

图 12.5:多表模型继承的示例模型和数据库表

接下来,我们将学习另一种模型继承方法,其中多个模型作为单个数据库表的代理。

代理模型

代理模型改变了模型的行为。这两个模型都在原始模型的数据库表上操作。这允许你为不同的模型定制行为,而无需创建新的数据库表,创建针对不同目的定制的同一模型的多个版本。要创建代理模型,请在模型的Meta类中添加proxy=True。以下示例说明了如何创建代理模型:

from django.db import models
from django.utils import timezone
class BaseContent(models.Model):
    title = models.CharField(max_length=100)
    created = models.DateTimeField(auto_now_add=True)
class OrderedContent(BaseContent):
    class Meta:
        proxy = True
        ordering = ['created']
    def created_delta(self):
        return timezone.now() - self.created 

在这里,你定义一个OrderedContent模型,它是Content模型的代理模型。此模型为 QuerySets 提供默认排序,并额外提供created_delta()方法。这两个模型,ContentOrderedContent,在同一个数据库表上操作,对象可以通过 ORM 通过任一模型访问。

图 12.6显示了提供的代理模型继承代码示例中的模型及其相关的数据库表:

图 12.6:使用代理模型进行继承的示例模型和数据库表

你现在已经熟悉了三种模型继承类型。有关模型继承的更多信息,你可以访问docs.djangoproject.com/en/5.0/topics/db/models/#model-inheritance。现在,我们将通过使用基抽象模型来开发各种内容类型的模型来实际应用模型继承。

创建内容模型

让我们使用模型继承来实现多态模型。你将创建一个通用的数据模型,它能够通过统一的接口存储各种内容。对于这种情况,理想的方案是创建一个抽象基模型,然后由模型扩展——每个模型都设计用来存储特定类型的数据:文本、图像、视频和文件。这种灵活的方法将为你提供在需要多态性的场景中所需的工具。

你的courses应用程序的Content模型包含一个通用关系,用于将其与不同类型的内容关联起来。你将为每种类型的内容创建不同的模型。所有Content模型都将有一些共同的字段,以及用于存储自定义数据的附加字段。你将创建一个抽象模型,为所有Content模型提供常用字段。

编辑courses应用程序的models.py文件,并向其中添加以下代码:

class ItemBase(models.Model):
    owner = models.ForeignKey(User,
        related_name='%(class)s_related',
        on_delete=models.CASCADE
    )
    title = models.CharField(max_length=250)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)
    class Meta:
        abstract = True
def __str__(self):
        return self.title
class Text(ItemBase):
    content = models.TextField()
class File(ItemBase):
    file = models.FileField(upload_to='files')
class Image(ItemBase):
    file = models.FileField(upload_to='images')
class Video(ItemBase):
    url = models.URLField() 

在此代码中,你定义了一个名为ItemBase的抽象模型。因此,你在其Meta类中设置abstract=True

在此模型中,你定义了ownertitlecreatedupdated字段。这些常用字段将用于所有类型的内容。

owner字段允许你存储创建内容的用户。由于此字段定义在抽象类中,因此你需要为每个子模型指定不同的related_name。Django 允许你在related_name属性中指定模型类名的占位符为%(class)s。通过这样做,每个子模型的related_name将自动生成。由于你使用'%(class)s_related'作为related_name,子模型的反向关系分别为text_relatedfile_relatedimage_relatedvideo_related

你已经定义了四个不同的继承自ItemBase抽象模型的Content模型。它们如下所示:

  • Text:用于存储文本内容

  • File:用于存储文件,例如 PDF 文件

  • Image:用于存储图像文件

  • Video:用于存储视频;你使用URLField字段提供视频 URL 以便嵌入

每个子模型都包含在ItemBase类中定义的字段,以及它自己的字段。将分别为TextFileImageVideo模型创建数据库表。由于ItemBase是一个抽象模型,因此不会与它关联任何数据库表。

图 12.7显示了Content模型及其相关的数据库表:

图 12.7:内容模型及其相关的数据库表

编辑你之前创建的Content模型,并修改其content_type字段,如下所示:

content_type = models.ForeignKey(
        ContentType,
        on_delete=models.CASCADE,
 **limit_choices_to={**
**'model__in'****:(****'text'****,** **'video'****,** **'image'****,** **'file'****)**
 **}**
    ) 

你可以通过添加limit_choices_to参数来限制可用于通用关系的ContentType对象。你使用model__in字段查找来过滤查询,使其仅针对具有model属性为'text''video''image''file'ContentType对象。

让我们创建一个迁移来包含你已添加的新模型。从命令行运行以下命令:

python manage.py makemigrations 

你将看到以下输出:

Migrations for 'courses':
  courses/migrations/0002_video_text_image_file_content.py
    - Create model Video
    - Create model Text
    - Create model Image
    - Create model File
    - Create model Content 

然后,运行以下命令以应用新的迁移:

python manage.py migrate 

你看到的输出应以以下行结束:

Applying courses.0002_video_text_image_file_content... OK 

你已经创建了适合向课程模块添加各种内容的模型。然而,在你的模型中仍有一些不足:课程模块和内容应该遵循特定的顺序。你需要一个字段,以便你可以轻松地对它们进行排序。

创建自定义模型字段

Django 附带了一套完整的模型字段集合,您可以使用它们来构建模型。然而,您也可以创建自己的模型字段来存储自定义数据或修改现有字段的操作。自定义字段允许您存储独特的数据类型,实现自定义验证,封装与字段相关的复杂数据逻辑,或使用自定义小部件定义特定的渲染表单。

您需要一个允许您为对象定义排序的字段。使用现有的 Django 字段为对象指定排序的一个简单方法是向您的模型中添加一个PositiveIntegerField。使用整数,您可以轻松指定对象的排序。您可以创建一个自定义排序字段,它继承自PositiveIntegerField并提供额外的行为。

您将在排序字段中构建两个相关功能:

  • 在没有提供特定排序时自动分配排序值:当保存没有特定排序的新对象时,您的字段应自动分配最后一个现有排序对象的下一个数字。如果有两个对象分别具有排序12,当保存第三个对象时,如果没有提供特定排序,应自动将其分配为排序3

  • 根据其他字段排序对象:课程模块将根据所属课程进行排序,模块内容将根据所属模块进行排序。

courses应用程序目录内创建一个新的fields.py文件,并将以下代码添加到其中:

from django.core.exceptions import ObjectDoesNotExist
from django.db import models
class OrderField(models.PositiveIntegerField):
    def __init__(self, for_fields=None, *args, **kwargs):
        self.for_fields = for_fields
        super().__init__(*args, **kwargs)
    def pre_save(self, model_instance, add):
        if getattr(model_instance, self.attname) is None:
            # no current value
try:
                qs = self.model.objects.all()
                if self.for_fields:
                    # filter by objects with the same field values
# for the fields in "for_fields"
                    query = {
                        field: getattr(model_instance, field)
                        for field in self.for_fields
                    }
                    qs = qs.filter(**query)
                # get the order of the last item
                last_item = qs.latest(self.attname)
                value = getattr(last_item, self.attname) + 1
except ObjectDoesNotExist:
                value = 0
setattr(model_instance, self.attname, value)
            return value
        else:
            return super().pre_save(model_instance, add) 

这是自定义的OrderField。它继承自 Django 提供的PositiveIntegerField字段。您的OrderField字段接受一个可选的for_fields参数,允许您指示用于排序数据的字段。

您的字段覆盖了PositiveIntegerField字段的pre_save()方法,该方法在将字段保存到数据库之前执行。在此方法中,您执行以下操作:

  1. 您检查模型实例中此字段是否已存在值。您使用self.attname,这是在模型中赋予字段的属性名。如果该属性值不同于None,您将按照以下方式计算应赋予它的排序:

    1. 您构建一个 QuerySet 来检索该字段模型的所有对象。您通过访问self.model来检索该字段所属的模型类。

    2. 如果字段在for_fields属性中有任何字段名,您将通过for_fields中模型字段的当前值来过滤 QuerySet。通过这样做,您根据给定的字段计算排序。

    3. 您从数据库中检索具有最高排序的对象last_item = qs.latest(self.attname)。如果没有找到对象,您假设此对象是第一个,并将其分配为排序0

    4. 如果找到对象,您将1添加到找到的最高排序。

    5. 你使用setattr()将计算出的顺序分配给模型实例的字段值,并返回它。

  2. 如果模型实例当前字段有值,则使用该值而不是计算它。

当你创建自定义模型字段时,使它们通用。避免硬编码依赖于特定模型或字段的依赖数据。你的字段应该适用于任何模型。

你可以在docs.djangoproject.com/en/5.0/howto/custom-model-fields/找到有关编写自定义模型字段的更多信息。

接下来,我们将使用我们创建的自定义字段。

为模块和内容对象添加排序

让我们将新字段添加到你的模型中。编辑courses应用的models.py文件,并将OrderField类和一个字段导入到Module模型中,如下所示:

**from** **.fields** **import** **OrderField**
class Module(models.Model):
    # ...
 **order = OrderField(blank=****True****, for_fields=[****'course'****])** 

你将新字段命名为order,并通过设置for_fields=['course']指定排序是根据课程计算的。这意味着新模块的顺序将通过将1添加到相同Course对象的最后一个模块来分配。

现在,你可以编辑Module模型的__str__()方法,以包含其顺序,如下所示:

class Module(models.Model):
    # ...
def __str__(self):
**return****f'****{self.order}****.** **{self.title}****'** 

模块内容也需要遵循特定的顺序。将OrderField字段添加到Content模型中,如下所示:

class Content(models.Model):
    # ...
 **order = OrderField(blank=****True****, for_fields=[****'module'****])** 

这次,你指定顺序是根据module字段计算的。

最后,让我们为两个模型添加默认排序。将以下Meta类添加到ModuleContent模型中:

class Module(models.Model):
    # ...
**class****Meta****:**
 **ordering = [****'order'****]**
class Content(models.Model):
    # ...
**class****Meta****:**
 **ordering = [****'order'****]** 

ModuleContent模型现在应该如下所示:

class Module(models.Model):
    course = models.ForeignKey(
        Course, related_name='modules', on_delete=models.CASCADE
    )
    title = models.CharField(max_length=200)
    description = models.TextField(blank=True)
    order = OrderField(blank=True, for_fields=['course'])
    class Meta:
        ordering = ['order']
    def __str__(self):
        return f'{self.order}. {self.title}'
class Content(models.Model):
    module = models.ForeignKey(
        Module,
        related_name='contents',
        on_delete=models.CASCADE
    )
    content_type = models.ForeignKey(
        ContentType,
        on_delete=models.CASCADE,
        limit_choices_to={
            'model__in':('text', 'video', 'image', 'file')
        }
    )
    object_id = models.PositiveIntegerField()
    item = GenericForeignKey('content_type', 'object_id')
    order = OrderField(blank=True, for_fields=['module'])
    class Meta:
            ordering = ['order'] 

让我们创建一个新的模型迁移,以反映新的order字段。打开 shell 并运行以下命令:

python manage.py makemigrations courses 

你将看到以下输出:

It is impossible to add a non-nullable field 'order' to content without specifying a default. This is because the database needs something to populate existing rows.
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
 2) Quit and manually define a default value in models.py.
Select an option: 

Django 告诉你,你必须为数据库中现有行的新的order字段提供一个默认值。如果字段包含null=True,它接受空值,并且 Django 会自动创建迁移而不是请求默认值。你可以在创建迁移之前指定默认值或取消迁移,并在models.py文件中将default属性添加到order字段。

输入1并按Enter为现有记录提供一个默认值。你将看到以下输出:

Please enter the default value as valid Python.
The datetime and django.utils.timezone modules are available, so it is possible to provide e.g. timezone.now as a value.
Type 'exit' to exit this prompt
>>> 

输入0,以便这是现有记录的默认值,然后按Enter。Django 也会要求你为Module模型提供一个默认值。选择第一个选项,再次输入0作为默认值。最后,你将看到以下类似的输出:

Migrations for 'courses':
courses/migrations/0003_alter_content_options_alter_module_options_and_more.py
    - Change Meta options on content
    - Change Meta options on module
    - Add field order to content
    - Add field order to module 

然后,使用以下命令应用新的迁移:

python manage.py migrate 

命令的输出将通知你迁移已成功应用,如下所示:

Applying courses.0003_alter_content_options_alter_module_options_and_more... OK 

让我们来测试你的新字段。使用以下命令打开 shell:

python manage.py shell 

创建一个新的课程,如下所示:

>>> from django.contrib.auth.models import User
>>> from courses.models import Subject, Course, Module
>>> user = User.objects.last()
>>> subject = Subject.objects.last()
>>> c1 = Course.objects.create(subject=subject, owner=user, title='Course 1', slug='course1') 

您已在数据库中创建了一个课程。现在,您将向课程中添加模块并查看它们的顺序是如何自动计算的。您创建一个初始模块并检查其顺序:

>>> m1 = Module.objects.create(course=c1, title='Module 1')
>>> m1.order
0 

OrderField 将其值设置为 0,因为这是为给定课程创建的第一个 Module 对象。您可以为同一课程创建第二个模块:

>>> m2 = Module.objects.create(course=c1, title='Module 2')
>>> m2.order
1 

OrderField 计算下一个订单值,为现有对象的最高订单号加 1。让我们创建第三个模块,强制指定一个顺序:

>>> m3 = Module.objects.create(course=c1, title='Module 3', order=5)
>>> m3.order
5 

如果在创建或保存对象时提供了自定义顺序,OrderField 将使用该值而不是计算顺序。

让我们添加第四个模块:

>>> m4 = Module.objects.create(course=c1, title='Module 4')
>>> m4.order
6 

此模块的顺序已自动设置。您的 OrderField 字段不保证所有顺序值都是连续的。然而,它尊重现有的顺序值,并始终根据最高的现有顺序分配下一个顺序。

让我们创建第二个课程并向其中添加一个模块:

>>> c2 = Course.objects.create(subject=subject, title='Course 2', slug='course2', owner=user)
>>> m5 = Module.objects.create(course=c2, title='Module 1')
>>> m5.order
0 

为了计算新模块的顺序,该字段仅考虑属于同一课程的现有模块。由于这是第二个课程的第一个模块,因此生成的顺序是 0。这是因为您在 Module 模型的 order 字段中指定了 for_fields=['course']

恭喜!您已成功创建了第一个自定义模型字段。接下来,您将创建一个用于 CMS 的认证系统。

添加认证视图

现在您已经创建了一个多态数据模型,您将构建一个 CMS 来管理课程及其内容。第一步是为 CMS 添加一个认证系统。

添加认证系统

您将使用 Django 的认证框架为用户创建对电子学习平台的认证。您在 第四章,构建社交网站 中学习了如何使用 Django 认证视图。

教师和学生都将成为 Django 的 User 模型的实例,因此他们可以使用 django.contrib.auth 的认证视图登录到网站。

编辑 educa 项目的 urls.py 主文件,并包含 Django 认证框架的 loginlogout 视图:

from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
**from** **django.contrib.auth** **import** **views** **as** **auth_views**
from django.urls import path
urlpatterns = [
 **path(**
**'accounts/login/'****, auth_views.LoginView.as_view(), name=****'login'**
 **),**
 **path(**
**'accounts/logout/'****,**
 **auth_views.LogoutView.as_view(),**
 **name=****'logout'**
 **),**
    path('admin/', admin.site.urls),
]
if settings.DEBUG:
    urlpatterns += static(
        settings.MEDIA_URL, document_root=settings.MEDIA_ROOT
    ) 

接下来,我们将为 Django 认证视图创建认证模板。

创建认证模板

courses 应用程序目录内创建以下文件结构:

templates/
    base.html
    registration/
        login.html
        logged_out.html 

在构建认证模板之前,您需要为您的项目准备基础模板。编辑 base.html 模板文件并向其中添加以下内容:

{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>{% block title %}Educa{% endblock %}</title>
<link href="{% static "css/base.css" %}" rel="stylesheet">
</head>
<body>
<div id="header">
<a href="/" class="logo">Educa</a>
<ul class="menu">
        {% if request.user.is_authenticated %}
          <li>
<form action="{% url "logout" %}" method="post">
<button type="submit">Sign out</button>
</form>
</li>
        {% else %}
          <li><a href="{% url "login" %}">Sign in</a></li>
        {% endif %}
      </ul>
</div>
<div id="content">
      {% block content %}
      {% endblock %}
    </div>
<script>
 document.addEventListener('DOMContentLoaded', (event) => {
 // DOM loaded
        {% block domready %}
        {% endblock %}
      })
 </script>
</body>
</html> 

这是将被其他模板扩展的基础模板。在这个模板中,您定义以下块:

  • title:其他模板添加自定义标题的块。

  • content:内容的主要块。所有扩展基础模板的模板都应该向此块添加内容。

  • domready:位于DOMContentLoaded事件的 JavaScript 事件监听器内部。这允许你在文档对象模型DOM)加载完成后执行代码。

此模板中使用的 CSS 样式位于本章附带代码中courses应用的static/目录中。将static/目录复制到你的项目相同目录下以使用它们。你可以在github.com/PacktPublishing/Django-5-by-Example/tree/main/Chapter12/educa/courses/static找到目录内容。

编辑registration/login.html模板,并向其中添加以下代码:

{% extends "base.html" %}
{% block title %}Log-in{% endblock %}
{% block content %}
  <h1>Log-in</h1>
<div class="module">
    {% if form.errors %}
      <p>Your username and password didn't match. Please try again.</p>
    {% else %}
      <p>Please, use the following form to log-in:</p>
    {% endif %}
    <div class="login-form">
<form action="{% url 'login' %}" method="post">
        {{ form.as_p }}
        {% csrf_token %}
        <input type="hidden" name="next" value="{{ next }}" />
<p><input type="submit" value="Log-in"></p>
</form>
</div>
</div>
{% endblock %} 

这是一个 Django 的login视图的标准登录模板。

编辑registration/logged_out.html模板,并向其中添加以下代码:

{% extends "base.html" %}
{% block title %}Logged out{% endblock %}
{% block content %}
  <h1>Logged out</h1>
<div class="module">
<p>
      You have been successfully logged out.
      You can <a href="{% url "login" %}">log-in again</a>.
     </p>
</div>
{% endblock %} 

这是用户注销后显示的模板。使用以下命令运行开发服务器:

python manage.py runserver 

在你的浏览器中打开http://127.0.0.1:8000/accounts/login/。你应该能看到登录页面:

图形用户界面,应用程序描述自动生成

图 12.8:账户登录页面

使用超级用户凭据登录。你将被重定向到http://127.0.0.1:8000/accounts/profile/,这是auth模块的默认重定向 URL。你将得到 HTTP 404响应,因为给定的 URL 尚不存在。成功登录后重定向用户的 URL 定义在设置LOGIN_REDIRECT_URL中。你将在第十四章“渲染和缓存内容”中定义一个自定义重定向 URL。

再次在浏览器中打开http://127.0.0.1:8000/accounts/login/。现在,你应该在页面头部看到注销按钮。点击注销按钮。现在你应该看到注销页面,如图 12.9 所示:

文本描述自动生成

图 12.9:账户注销页面

你已成功为 CMS 创建了一个认证系统。

摘要

在本章中,你学习了如何使用数据固定为模型提供初始数据。通过使用模型继承,你创建了一个灵活的系统来管理课程模块的不同类型的内容。你还实现了订单对象的自定义模型字段,并为电子学习平台创建了一个认证系统。

在下一章中,你将使用基于类的视图实现 CMS 功能来管理课程内容。你将使用 Django 的组和权限系统来限制对视图的访问,并实现表单集来编辑课程内容。你还将创建一个拖放功能,使用 JavaScript 和 Django 重新排序课程模块及其内容。

其他资源

以下资源提供了与本章涵盖的主题相关的额外信息:

加入我们的 Discord!

与其他用户、Django 开发专家以及作者本人一起阅读本书。提问、为其他读者提供解决方案、通过 Ask Me Anything 会话与作者聊天,等等。扫描二维码或访问链接加入社区。

packt.link/Django5ByExample

二维码

第十三章:创建一个内容管理系统

在上一章中,你为在线学习平台创建了应用程序模型,并学习了如何为模型创建和应用数据固定值。你创建了一个自定义模型字段来排序对象,并实现了用户认证。

在本章中,你将学习如何以灵活和高效的方式为讲师构建创建课程和管理课程内容的功能。

你将介绍基于类的视图,与你在前例中构建的基于函数的视图相比,它为你构建应用程序提供了新的视角。你还将通过使用混合类来探索代码的可重用性和模块化,这些是你可以应用于未来项目的技术。

在本章中,你将学习如何:

  • 使用基于类的视图和混合类创建一个 内容管理系统CMS

  • 构建表单集和模型表单集以编辑课程模块和模块内容

  • 管理组和权限

  • 实现一个拖放功能以重新排序模块和内容

功能概述

图 13.1 展示了本章将构建的视图、模板和功能:

图片

图 13.1:第十三章构建的功能图

在本章中,你将实现不同的基于类的视图。你将创建混合类 OwnerMixinOwnerEditMixinOwnerCourseMixin,这些类将包含你将在其他类中重用的常见功能。你将通过实现 ManageCourseListView 来列出课程、CourseCreateView 来创建课程、CourseUpdateView 来更新课程和 CourseDeleteView 来删除课程,为 Course 模型创建 CRUD创建读取更新删除)视图。你将构建 CourseModuleUpdateView 视图来添加/编辑/删除课程模块,以及 ModuleContentListView 来列出模块的内容。你还将实现 ContentCreateUpdateView 来创建和更新课程内容,以及 ContentDeleteView 来删除内容。你最终将通过使用 ModuleOrderViewContentOrderView 视图来实现拖放功能,分别重新排序课程模块和内容。

注意,所有继承自混合类 OwnerCourseMixin 的视图在成功执行操作后都会将用户重定向回 ManageCourseListView 视图。为了简化,这些重定向并未添加到图中。

本章的源代码可以在 github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter13 找到。

本章中使用的所有 Python 模块都包含在本章源代码中的 requirements.txt 文件中。你可以按照以下说明安装每个 Python 模块,或者你可以使用命令 python -m pip install -r requirements.txt 一次性安装所有依赖。

创建一个 CMS

现在你已经创建了一个通用的数据模型,你将构建 CMS。CMS 将允许讲师创建课程并管理其内容。你需要提供以下功能:

  • 列出讲师创建的课程

  • 创建、编辑和删除课程

  • 向课程添加模块并重新排序它们

  • 向每个模块添加不同类型的内容

  • 重新排序课程模块和内容

让我们从基本的 CRUD 视图开始。

创建基于类的视图

你将构建视图来创建、编辑和删除课程。你将使用基于类的视图来完成这项工作。编辑courses应用的views.py文件,并添加以下代码:

from django.views.generic.list import ListView
from .models import Course
class ManageCourseListView(ListView):
    model = Course
    template_name = 'courses/manage/course/list.html'
def get_queryset(self):
        qs = super().get_queryset()
        return qs.filter(owner=self.request.user) 

这是ManageCourseListView视图。它继承自 Django 的通用ListView。你将覆盖视图的get_queryset()方法,以检索由当前用户创建的课程。为了防止用户编辑、更新或删除他们未创建的课程,你还需要在创建、更新和删除视图中覆盖get_queryset()方法。当你需要为多个基于类的视图提供特定行为时,建议使用mixins

使用mixins为基于类的视图

Mixins 是一种特殊的类多重继承。如果你对 Python 中的 mixins 还不熟悉,你需要了解的是,它们是一种旨在为其他类提供方法但不是为了独立使用的类。这允许你通过让这些类从 mixins 继承来以模块化的方式开发共享功能。这个概念类似于基类,但你可以使用多个 mixins 来扩展给定类的功能。

使用 mixins 主要有两种情况:

  • 你想为类提供多个可选功能

  • 你想在几个类中使用特定的功能

Django 附带了一些 mixins,它们为你的基于类的视图提供了额外的功能。你可以在docs.djangoproject.com/en/5.0/topics/class-based-views/mixins/了解更多关于 mixins 的信息。

你将在混合类中实现多个视图的通用行为,并将其用于课程视图。编辑courses应用的views.py文件,并按以下方式修改它:

**from** **django.urls** **import** **reverse_lazy**
**from** **django.views.generic.edit** **import** **CreateView, DeleteView, UpdateView**
from django.views.generic.list import ListView
from .models import Course
**class****OwnerMixin****:**
**def****get_queryset****(****self****):**
 **qs =** **super****().get_queryset()**
**return** **qs.****filter****(owner=self.request.user)**
**class****OwnerEditMixin****:**
**def****form_valid****(****self, form****):**
 **form.instance.owner = self.request.user**
**return****super****().form_valid(form)**
**class****OwnerCourseMixin****(****OwnerMixin****):**
 **model = Course**
 **fields = [****'subject'****,** **'title'****,** **'slug'****,** **'overview'****]**
 **success_url = reverse_lazy(****'manage_course_list'****)**
**class****OwnerCourseEditMixin****(OwnerCourseMixin, OwnerEditMixin):**
 **template_name =** **'courses/manage/course/form.html'**
class ManageCourseListView(**OwnerCourseMixin,** ListView):
    template_name = 'courses/manage/course/list.html'
**class****CourseCreateView****(OwnerCourseEditMixin, CreateView):**
**pass**
**class****CourseUpdateView****(OwnerCourseEditMixin, UpdateView):**
**pass**
**class****CourseDeleteView****(OwnerCourseMixin, DeleteView):**
 **template_name =** **'courses/manage/course/delete.html'** 

在此代码中,你创建了OwnerMixinOwnerEditMixin混合类。你将使用这些混合类与 Django 提供的ListViewCreateViewUpdateViewDeleteView视图一起使用。OwnerMixin实现了get_queryset()方法,该方法由视图用于获取基础 QuerySet。你的混合类将覆盖此方法,通过owner属性过滤对象以检索属于当前用户(request.user)的对象。

OwnerEditMixin实现了form_valid()方法,该方法由使用 Django 的ModelFormMixin混合类(即具有表单或模型表单的视图,如CreateViewUpdateView)使用的视图调用。当提交的表单有效时执行form_valid()

此方法的默认行为是保存实例(对于模型表单)并将用户重定向到success_url。您重写此方法来自动设置正在保存的对象的owner属性中的当前用户。通过这样做,您在对象保存时自动设置对象的拥有者。

您的OwnerMixin类可用于与任何包含owner属性的模型交互的视图。

您还定义了一个继承自OwnerMixinOwnerCourseMixin类,为子视图提供了以下属性:

  • model:用于查询集的模型;它被所有视图使用。

  • fields:用于构建CreateViewUpdateView视图模型表单的模型字段。

  • success_url:由CreateViewUpdateViewDeleteView使用,在表单成功提交或对象被删除后将用户重定向到。您使用一个名为manage_course_list的 URL,您将在稍后创建。

您可以使用以下属性定义一个OwnerCourseEditMixin混合类:

  • template_name:您将用于CreateViewUpdateView视图的模板。

最后,您创建了以下继承自OwnerCourseMixin的视图:

  • ManageCourseListView:列出用户创建的课程。它继承自OwnerCourseMixinListView,并为列出课程定义了特定的template_name属性。

  • CourseCreateView:使用模型表单创建新的Course对象。它使用在OwnerCourseMixin中定义的字段来构建模型表单,并且继承自CreateView。它使用OwnerCourseEditMixin中定义的模板。

  • CourseUpdateView:允许编辑现有的Course对象。它使用在OwnerCourseMixin中定义的字段来构建模型表单,并且继承自UpdateView。它使用OwnerCourseEditMixin中定义的模板。

  • CourseDeleteView:继承自OwnerCourseMixin和通用DeleteView,并为确认课程删除定义了特定的template_name属性。

您已创建了管理课程的基本视图。虽然您已经实现了自己的 CRUD 视图,但第三方应用程序 Neapolitan 允许您在单个视图中实现标准列表、详情、创建和删除视图。您可以在github.com/carltongibson/neapolitan了解更多关于 Neapolitan 的信息。

接下来,您将使用 Django 认证组和权限来限制对这些视图的访问。

与组和权限一起工作

目前,任何用户都可以访问管理课程的视图。您希望限制这些视图,以便只有讲师才有权创建和管理课程。

Django 的认证框架包括一个权限系统。默认情况下,Django 为安装的应用程序中的每个模型生成四个权限:addviewchangedelete。这些权限对应于创建新实例、查看现有实例、编辑和删除模型实例的操作。

权限可以直接分配给单个用户或用户组。这种方法通过分组权限简化了用户管理,并增强了您应用程序的安全性。

你将创建一个用于讲师用户的组,并分配创建、更新和删除课程的权限。

使用以下命令运行开发服务器:

python manage.py runserver 

在浏览器中打开http://127.0.0.1:8000/admin/auth/group/add/以创建一个新的Group对象。添加名称Instructors并选择courses应用的所有权限,除了Subject模型的权限,如下所示:

图 13.2:讲师组权限

如您所见,每个模型都有四种不同的权限:可以查看可以添加可以更改可以删除。在选择此组的权限后,点击保存按钮。

Django 会自动为模型创建权限,但您也可以创建自定义权限。您将在第十五章“构建 API”中学习如何创建自定义权限。您可以在docs.djangoproject.com/en/5.0/topics/auth/customizing/#custom-permissions了解更多关于添加自定义权限的信息。

打开http://127.0.0.1:8000/admin/auth/user/add/并在浏览器中创建一个新用户。编辑用户并将其添加到讲师组,如下所示:

图 13.3:用户组选择

用户继承他们所属组的权限,但您也可以使用管理站点为单个用户添加单独的权限。将is_superuser设置为True的用户将自动拥有所有权限。

接下来,您将通过将它们整合到我们的视图中来实际应用权限。

限制基于类的视图的访问

您将限制对视图的访问,以便只有具有适当权限的用户才能添加、更改或删除Course对象。您将使用django.contrib.auth提供的以下两个 mixins 来限制对视图的访问:

  • LoginRequiredMixin:复制login_required装饰器的功能。

  • PermissionRequiredMixin:授予具有特定权限的用户访问视图的权限。请记住,超级用户自动拥有所有权限。

编辑courses应用的views.py文件,并添加以下导入:

from django.contrib.auth.mixins import (
    LoginRequiredMixin,
    PermissionRequiredMixin
) 

OwnerCourseMixin继承LoginRequiredMixinPermissionRequiredMixin,如下所示:

class OwnerCourseMixin(
    OwnerMixin**, LoginRequiredMixin, PermissionRequiredMixin**
):
    model = Course
    fields = ['subject', 'title', 'slug', 'overview']
    success_url = reverse_lazy('manage_course_list') 

然后,将permission_required属性添加到课程视图中,如下所示:

class ManageCourseListView(OwnerCourseMixin, ListView):
    template_name = 'courses/manage/course/list.html'
 **permission_required =** **'courses.view_course'**
class CourseCreateView(OwnerCourseEditMixin, CreateView):
 **permission_required =** **'courses.add_course'**
class CourseUpdateView(OwnerCourseEditMixin, UpdateView):
 **permission_required =** **'courses.change_course'**
class CourseDeleteView(OwnerCourseMixin, DeleteView):
    template_name = 'courses/manage/course/delete.html'
 **permission_required =** **'courses.delete_course'** 

PermissionRequiredMixin检查访问视图的用户是否具有permission_required属性中指定的权限。您的视图现在仅对具有适当权限的用户可访问。

让我们为这些视图创建 URL。在courses应用程序目录内创建一个新文件,并将其命名为urls.py。向其中添加以下代码:

from django.urls import path
from . import views
urlpatterns = [
    path(
        'mine/',
        views.ManageCourseListView.as_view(),
        name='manage_course_list'
    ),
    path(
        'create/',
        views.CourseCreateView.as_view(),
        name='course_create'
    ),
    path(
        '<pk>/edit/',
        views.CourseUpdateView.as_view(),
        name='course_edit'
    ),
    path(
        '<pk>/delete/',
        views.CourseDeleteView.as_view(),
        name='course_delete'
    ),
] 

这些是列表、创建、编辑和删除课程视图的 URL 模式。pk参数指的是主键字段。请记住,pk 是主键的缩写。每个 Django 模型都有一个作为其主键的字段。默认情况下,主键是自动生成的id字段。Django 的单个对象通用视图通过其pk字段检索对象。编辑educa项目的主体urls.py文件,并包含courses应用程序的 URL 模式,如下所示。

新代码以粗体突出显示:

from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import **include,** path
urlpatterns = [
    path(
        'accounts/login/', auth_views.LoginView.as_view(), name='login'
    ),
    path(
        'accounts/logout/',
        auth_views.LogoutView.as_view(),
        name='logout'
    ),
    path('admin/', admin.site.urls),
 **path(****'course/'****, include(****'courses.urls'****)),**
]
if settings.DEBUG:
    urlpatterns += static(
        settings.MEDIA_URL, document_root=settings.MEDIA_ROOT
    ) 

您需要为这些视图创建模板。在courses应用程序的templates/目录内创建以下目录和文件:

courses/
    manage/
        course/
            list.html
            form.html
            delete.html 

编辑courses/manage/course/list.html模板并向其中添加以下代码:

{% extends "base.html" %}
{% block title %}My courses{% endblock %}
{% block content %}
  <h1>My courses</h1>
<div class="module">
    {% for course in object_list %}
      <div class="course-info">
<h3>{{ course.title }}</h3>
<p>
<a href="{% url "course_edit" course.id %}">Edit</a>
<a href="{% url "course_delete" course.id %}">Delete</a>
</p>
</div>
    {% empty %}
      <p>You haven't created any courses yet.</p>
    {% endfor %}
    <p>
<a href="{% url "course_create" %}" class="button">Create new course</a>
</p>
</div>
{% endblock %} 

这是ManageCourseListView视图的模板。在这个模板中,您列出当前用户创建的课程。您包括编辑或删除每个课程的链接以及创建新课程的链接。

使用以下命令运行开发服务器:

python manage.py runserver 

在您的浏览器中打开http://127.0.0.1:8000/accounts/login/?next=/course/mine/并使用属于Instructors组的用户登录。登录后,您将被重定向到http://127.0.0.1:8000/course/mine/ URL,并且您应该看到以下页面:

图形用户界面,文本,应用程序,聊天或文本消息,自动生成描述

图 13.4:没有课程的讲师课程页面

此页面将显示当前用户创建的所有课程。

让我们创建用于创建和更新课程视图的模板。编辑courses/manage/course/form.html模板并写入以下代码:

{% extends "base.html" %}
{% block title %}
  {% if object %}
    Edit course "{{ object.title }}"
  {% else %}
    Create a new course
  {% endif %}
{% endblock %}
{% block content %}
  <h1>
    {% if object %}
      Edit course "{{ object.title }}"
    {% else %}
      Create a new course
    {% endif %}
  </h1>
<div class="module">
<h2>Course info</h2>
<form method="post">
      {{ form.as_p }}
      {% csrf_token %}
      <p><input type="submit" value="Save course"></p>
</form>
</div>
{% endblock %} 

form.html模板用于CourseCreateViewCourseUpdateView视图。在这个模板中,您检查上下文中是否存在object变量。如果object存在于上下文中,您知道您正在更新现有课程,并在页面标题中使用它。否则,您正在创建一个新的Course对象。

在您的浏览器中打开http://127.0.0.1:8000/course/mine/并点击创建新课程按钮。您将看到以下页面:

图形用户界面,文本,应用程序,自动生成描述

图 13.5:创建新课程的表单

填写表格并点击保存课程按钮。课程将被保存,并将您重定向到课程列表页面。它应该看起来如下所示:

图形用户界面、文本、应用程序、聊天或短信 描述自动生成

图 13.6:包含一个课程的教师课程页面

然后,点击你刚刚创建的课程编辑链接。你将再次看到表单,但这次,你是在编辑现有的Course对象,而不是创建一个新对象。

最后,编辑courses/manage/course/delete.html模板,并添加以下代码:

{% extends "base.html" %}
{% block title %}Delete course{% endblock %}
{% block content %}
  <h1>Delete course "{{ object.title }}"</h1>
<div class="module">
<form action="" method="post">
      {% csrf_token %}
      <p>Are you sure you want to delete "{{ object }}"?</p>
<input type="submit" value="Confirm">
</form>
</div>
{% endblock %} 

这是CourseDeleteView视图的模板。这个视图继承自 Django 提供的DeleteView,它期望用户确认以删除对象。

在浏览器中打开课程列表,点击你课程的删除链接。你应该看到以下确认页面:

图形用户界面、文本、应用程序、聊天或短信 描述自动生成

图 13.7:删除课程确认页面

点击确认按钮。课程将被删除,你将被重定向回课程列表页面。

现在,教师可以创建、编辑和删除课程。接下来,你需要为他们提供一个 CMS 来添加课程模块及其内容。你将首先管理课程模块。

管理课程模块及其内容

你将构建一个系统来管理课程模块及其内容。你需要构建可以用于管理每个课程中的多个模块以及每个模块的不同类型内容的表单。模块及其内容都需要遵循特定的顺序,并且你应该能够使用 CMS 重新排序它们。

使用表单集管理课程模块

Django 提供了一个抽象层,用于在同一页面上处理多个表单。这些表单组被称为表单集。表单集管理特定FormModelForm的多个实例。所有表单都会一次性提交,表单集负责显示表单的初始数量,限制可以提交的最大表单数量,并验证所有表单。

表单集包含一个is_valid()方法,可以一次性验证所有表单。你也可以为表单提供初始数据,并指定要显示的额外空表单的数量。你可以在docs.djangoproject.com/en/5.0/topics/forms/formsets/了解更多关于表单集的信息,以及在docs.djangoproject.com/en/5.0/topics/forms/modelforms/#model-formsets了解更多关于模型表单集的信息。

由于课程被划分为可变数量的模块,因此使用表单集来管理它们是有意义的。在courses应用程序目录中创建一个forms.py文件,并将以下代码添加到其中:

from django.forms.models import inlineformset_factory
from .models import Course, Module
ModuleFormSet = inlineformset_factory(
    Course,
    Module,
    fields=['title', 'description'],
    extra=2,
    can_delete=True
) 

这是 ModuleFormSet 表单集。你使用 Django 提供的 inlineformset_factory() 函数构建它。内联表单集是在表单集之上的一小部分抽象,它简化了与相关对象一起工作。此函数允许你动态地为与 Course 对象相关的 Module 对象构建模型表单集。

使用以下参数来构建表单集:

  • fields:将包含在表单集中的每个表单中的字段。

  • extra:允许你设置在表单集中显示的空额外表单的数量。

  • can_delete:如果你将其设置为 True,Django 将为每个表单包含一个布尔字段,该字段将渲染为复选框输入。它允许你标记你想要删除的对象。

编辑 courses 应用程序的 views.py 文件,并向其中添加以下代码:

from django.shortcuts import get_object_or_404, redirect
from django.views.generic.base import TemplateResponseMixin, View
from .forms import ModuleFormSet
class CourseModuleUpdateView(TemplateResponseMixin, View):
    template_name = 'courses/manage/module/formset.html'
    course = None
def get_formset(self, data=None):
        return ModuleFormSet(instance=self.course, data=data)
    def dispatch(self, request, pk):
        self.course = get_object_or_404(
            Course, id=pk, owner=request.user
        )
        return super().dispatch(request, pk)
    def get(self, request, *args, **kwargs):
        formset = self.get_formset()
        return self.render_to_response(
            {'course': self.course, 'formset': formset}
        )
    def post(self, request, *args, **kwargs):
        formset = self.get_formset(data=request.POST)
        if formset.is_valid():
            formset.save()
            return redirect('manage_course_list')
        return self.render_to_response(
            {'course': self.course, 'formset': formset}
        ) 

CourseModuleUpdateView 视图处理表单集,为特定课程添加、更新和删除模块。此视图继承以下混合类和视图:

  • TemplateResponseMixin:此混合类负责渲染模板并返回 HTTP 响应。它需要一个 template_name 属性,该属性指示要渲染的模板,并提供 render_to_response() 方法,将上下文传递给它并渲染模板。

  • View:Django 提供的基本基于类的视图。

在此视图中,你实现以下方法:

  • get_formset():你定义此方法以避免重复构建表单集的代码。你为给定的 Course 对象使用可选数据创建一个 ModuleFormSet 对象。

  • dispatch():此方法由 View 类提供。它接受 HTTP 请求及其参数,并尝试将请求委派给与 HTTP 方法匹配的小写方法。GET 请求委派给 get() 方法,POST 请求委派给 post(),分别。在此方法中,你使用 get_object_or_404() 快捷函数获取属于当前用户的给定 id 参数的 Course 对象。你将此代码包含在 dispatch() 方法中,因为你需要为 GETPOST 请求检索课程。你将其保存到视图的 course 属性中,以便其他方法可以访问它。

  • get():用于 GET 请求。你使用 TemplateResponseMixin 提供的 render_to_response() 方法构建一个空的 ModuleFormSet 表单集,并将其与当前 Course 对象一起渲染到模板中。

  • post():用于 POST 请求。在此方法中,你执行以下操作:

    1. 使用提交的数据创建一个 ModuleFormSet 实例。

    2. 你执行表单集的 is_valid() 方法来验证其所有表单。

    3. 如果表单集有效,您通过调用save()方法来保存它。此时,任何更改,如添加、更新或标记模块为删除,都将应用到数据库中。然后,您将用户重定向到manage_course_list URL。如果表单集无效,您将渲染模板以显示任何错误。

编辑courses应用的urls.py文件,并向其中添加以下 URL 模式:

path(
    '<pk>/module/',
    views.CourseModuleUpdateView.as_view(),
    name='course_module_update'
), 

courses/manage/模板目录内创建一个新目录,命名为module。创建一个courses/manage/module/formset.html模板,并向其中添加以下代码:

{% extends "base.html" %}
{% block title %}
  Edit "{{ course.title }}"
{% endblock %}
{% block content %}
  <h1>Edit "{{ course.title }}"</h1>
<div class="module">
<h2>Course modules</h2>
<form method="post">
      {{ formset }}
      {{ formset.management_form }}
      {% csrf_token %}
      <input type="submit" value="Save modules">
</form>
</div>
{% endblock %} 

在此模板中,您创建一个包含formset<form> HTML 元素。您还包含表单集的管理表单,变量为{{ formset.management_form }}。管理表单包括隐藏字段,用于控制表单的初始、总数、最小和最大数量。

编辑courses/manage/course/list.html模板,并在课程编辑删除链接下方添加以下链接到course_module_update URL:

<a href="{% url "course_edit" course.id %}">Edit</a>
<a href="{% url "course_delete" course.id %}">Delete</a>
**<a href=****"{% url "****course_module_update****" course.id %}"****>Edit modules</a>** 

您已包含编辑课程模块的链接。

在您的浏览器中打开http://127.0.0.1:8000/course/mine/。创建一个课程并点击其编辑模块链接。您应该看到一个表单集,如下所示:

图形用户界面,文本,应用程序  自动生成的描述

图 13.8:课程编辑页面,包括课程模块的表单集

表单集包含课程中每个Module对象对应的表单。之后,会显示两个额外的空表单,因为您为ModuleFormSet设置了extra=2。当您保存表单集时,Django 会包含另外两个额外的字段来添加新模块。

您可以看到,表单集对于在单个页面上管理多个表单实例来说非常有用。表单集简化了从类似表单集中收集和验证数据的过程。

在了解表单集的工作原理后,您将通过创建动态表单来探索高级表单功能,这些表单可以适应将要添加到课程模块的各种内容类型。

添加课程模块内容

现在,您需要一种方法来添加课程模块的内容。您有四种不同类型的内容:文本、视频、图像和文件。您可以考虑创建四个不同的视图来创建内容,每个模型一个表单。然而,您将采取更灵活的方法,创建一个可以处理任何内容模型对象的创建或更新视图。您将根据讲师想要添加到课程中的内容类型(TextVideoImageFile)动态构建此视图的表单。

编辑courses应用的views.py文件,并向其中添加以下代码:

from django.apps import apps
from django.forms.models import modelform_factory
from .models import Module, Content
class ContentCreateUpdateView(TemplateResponseMixin, View):
    module = None
    model = None
    obj = None
    template_name = 'courses/manage/content/form.html'
def get_model(self, model_name):
        if model_name in ['text', 'video', 'image', 'file']:
            return apps.get_model(
                app_label='courses', model_name=model_name
            )
        return None
def get_form(self, model, *args, **kwargs):
        Form = modelform_factory(
            model, exclude=['owner', 'order', 'created', 'updated']
        )
        return Form(*args, **kwargs)
    def dispatch(self, request, module_id, model_name, id=None):
        self.module = get_object_or_404(
            Module, id=module_id, course__owner=request.user
        )
        self.model = self.get_model(model_name)
        if id:
            self.obj = get_object_or_404(
                self.model, id=id, owner=request.user
            )
        return super().dispatch(request, module_id, model_name, id) 

这是ContentCreateUpdateView的第一部分。它将允许您创建和更新不同模型的内 容。此视图定义了以下方法:

  • get_model(): 在这里,你检查给定的模型名称是否是以下四种内容模型之一:TextVideoImageFile。然后,你使用 Django 的 apps 模块来获取给定模型名称的实际类。如果给定的模型名称不是有效的之一,你返回 None

  • get_form(): 你使用表单框架的 modelform_factory() 函数构建一个动态表单。由于你将构建 TextVideoImageFile 模型的表单,你使用 exclude 参数指定要排除的公共字段,并让所有其他属性自动包含。这样做,你不必知道根据模型要包含哪些字段。

  • dispatch(): 这接收以下 URL 参数并将相应的模块、模型和内容对象存储为类属性:

    • module_id: 与内容关联的模块的 ID。

    • model_name: 要创建/更新的内容的模型名称。

    • id: 正在更新的对象的 ID。创建新对象时为 None

将以下 get()post() 方法添加到 ContentCreateUpdateView

def get(self, request, module_id, model_name, id=None):
    form = self.get_form(self.model, instance=self.obj)
    return self.render_to_response(
        {'form': form, 'object': self.obj}
    )
def post(self, request, module_id, model_name, id=None):
    form = self.get_form(
        self.model,
        instance=self.obj,
        data=request.POST,
        files=request.FILES
    )
    if form.is_valid():
        obj = form.save(commit=False)
        obj.owner = request.user
        obj.save()
        if not id:
            # new content
            Content.objects.create(module=self.module, item=obj)
        return redirect('module_content_list', self.module.id)
    return self.render_to_response(
        {'form': form, 'object': self.obj}
    ) 

这些方法如下:

  • get(): 当接收到 GET 请求时执行。你为正在更新的 TextVideoImageFile 实例构建模型表单。否则,由于没有提供 ID,self.objNone,因此不传递任何实例以创建新对象。

  • post(): 当接收到 POST 请求时执行。你构建模型表单,将任何提交的数据和文件传递给它。然后,你验证它。如果表单有效,你创建一个新的对象,并将 request.user 分配为其所有者,然后将其保存到数据库中。你检查 id 参数。如果没有提供 ID,你知道用户正在创建一个新的对象而不是更新现有的一个。如果是新对象,你为给定的模块创建一个 content 对象,并将新的内容与之关联。

编辑 courses 应用程序的 urls.py 文件,并向其中添加以下 URL 模式:

path(
    'module/<int:module_id>/content/<model_name>/create/',
    views.ContentCreateUpdateView.as_view(),
    name='module_content_create'
),
path(
    'module/<int:module_id>/content/<model_name>/<id>/',
    views.ContentCreateUpdateView.as_view(),
    name='module_content_update'
), 

新的 URL 模式如下:

  • module_content_create: 创建新的文本、视频、图像或文件对象并将它们添加到模块中。它包括 module_idmodel_name 参数。前者允许你将新的内容对象链接到给定的模块。后者指定要构建表单的内容模型。

  • module_content_update: 更新现有的文本、视频、图像或文件对象。它包括 module_idmodel_name 参数以及一个 id 参数来标识正在更新的内容。

courses/manage/ 模板目录内创建一个新的目录,并命名为 content。创建模板 courses/manage/content/form.html 并向其中添加以下代码:

{% extends "base.html" %}
{% block title %}
  {% if object %}
    Edit content "{{ object.title }}"
  {% else %}
    Add new content
  {% endif %}
{% endblock %}
{% block content %}
  <h1>
    {% if object %}
      Edit content "{{ object.title }}"
    {% else %}
      Add new content
    {% endif %}
  </h1>
<div class="module">
<h2>Course info</h2>
<form action="" method="post" enctype="multipart/form-data">
      {{ form.as_p }}
      {% csrf_token %}
      <p><input type="submit" value="Save content"></p>
</form>
</div>
{% endblock %} 

这是 ContentCreateUpdateView 视图的模板。在此模板中,你检查 object 变量是否存在于上下文中。如果 object 存在于上下文中,你正在更新现有对象。否则,你正在创建一个新对象。

你在 <form> HTML 元素中包含 enctype="multipart/form-data",因为表单包含用于 FileImage 内容模型的文件上传。

运行开发服务器,打开 http://127.0.0.1:8000/course/mine/,点击现有课程的 编辑模块,创建一个模块。

然后,使用以下命令打开 Python shell:

python manage.py shell 

按照以下方式获取最近创建的模块的 ID:

>>> from courses.models import Module
>>> Module.objects.latest('id').id
6 

运行开发服务器,并在浏览器中打开 http://127.0.0.1:8000/course/module/6/content/image/create/,将模块 ID 替换为你之前获得的 ID。你将看到创建 Image 对象的表单,如下所示:

图形用户界面,文本,应用程序  自动生成描述

图 13.9:课程添加新内容表单

还未提交表单。如果你这样做,它将失败,因为你还没有定义 module_content_list URL。你将在稍后创建它。

你还需要一个用于删除内容的视图。编辑 courses 应用程序的 views.py 文件,并向其中添加以下代码:

class ContentDeleteView(View):
    def post(self, request, id):
        content = get_object_or_404(
            Content, id=id, module__course__owner=request.user
        )
        module = content.module
        content.item.delete()
        content.delete()
        return redirect('module_content_list', module.id) 

ContentDeleteView 类通过给定的 ID 获取 content 对象。它删除相关的 TextVideoImageFile 对象。最后,它删除 content 对象并将用户重定向到 module_content_list URL 以列出模块的其他内容。

编辑 courses 应用程序的 urls.py 文件,并向其中添加以下 URL 模式:

path(
    'content/<int:id>/delete/',
    views.ContentDeleteView.as_view(),
    name='module_content_delete'
), 

现在,讲师可以轻松地创建、更新和删除内容。在本节中学到的这种方法对于以通用方式管理具有多种数据的形式非常有用。此方法可以应用于需要灵活解决方案来处理数据输入的其他情况。

在下一节中,我们将创建用于显示课程模块和内容的视图和模板。

管理模块及其内容

你已经创建了用于创建、编辑和删除课程模块及其内容的视图。接下来,你需要一个视图来显示课程的所有模块并列出特定模块的内容。

编辑 courses 应用程序的 views.py 文件,并向其中添加以下代码:

class ModuleContentListView(TemplateResponseMixin, View):
    template_name = 'courses/manage/module/content_list.html'
def get(self, request, module_id):
        module = get_object_or_404(
            Module, id=module_id, course__owner=request.user
        )
        return self.render_to_response({'module': module}) 

这是 ModuleContentListView 视图。此视图获取属于当前用户的给定 ID 的 Module 对象,并渲染一个包含给定模块的模板。

编辑 courses 应用程序的 urls.py 文件,并向其中添加以下 URL 模式:

path(
    'module/<int:module_id>/',
    views.ModuleContentListView.as_view(),
    name='module_content_list'
), 

templates/courses/manage/module/ 目录中创建一个新的模板,并将其命名为 content_list.html。向其中添加以下代码:

{% extends "base.html" %}
{% block title %}
  Module {{ module.order|add:1 }}: {{ module.title }}
{% endblock %}
{% block content %}
{% with course=module.course %}
  <h1>Course "{{ course.title }}"</h1>
<div class="contents">
<h3>Modules</h3>
<ul id="modules">
      {% for m in course.modules.all %}
        <li data-id="{{ m.id }}" {% if m == module %}
 class="selected"{% endif %}>
<a href="{% url "module_content_list" m.id %}">
<span>
              Module <span class="order">{{ m.order|add:1 }}</span>
</span>
<br>
            {{ m.title }}
          </a>
</li>
      {% empty %}
        <li>No modules yet.</li>
      {% endfor %}
    </ul>
<p><a href="{% url "course_module_update" course.id %}">
    Edit modules</a></p>
</div>
<div class="module">
<h2>Module {{ module.order|add:1 }}: {{ module.title }}</h2>
<h3>Module contents:</h3>
<div id="module-contents">
      {% for content in module.contents.all %}
        <div data-id="{{ content.id }}">
          {% with item=content.item %}
            <p>{{ item }}</p>
<a href="#">Edit</a>
<form action="{% url "module_content_delete" content.id %}"
 method="post">
<input type="submit" value="Delete">
              {% csrf_token %}
            </form>
          {% endwith %}
        </div>
      {% empty %}
        <p>This module has no contents yet.</p>
      {% endfor %}
    </div>
<h3>Add new content:</h3>
<ul class="content-types">
<li>
<a href="{% url "module_content_create" module.id "text" %}">
          Text
        </a>
</li>
<li>
<a href="{% url "module_content_create" module.id "image" %}">
          Image
        </a>
</li>
<li>
<a href="{% url "module_content_create" module.id "video" %}">
          Video
        </a>
</li>
<li>
<a href="{% url "module_content_create" module.id "file" %}">
          File
        </a>
</li>
</ul>
</div>
{% endwith %}
{% endblock %} 

确保没有模板标签被拆分到多行;Django 模板引擎期望标签被明确定义且不间断。

这是显示课程所有模块及其所选模块内容的模板。你遍历课程模块以在侧边栏中显示它们。你遍历模块的内容并访问content.item以获取相关的TextVideoImageFile对象。你还可以包括创建新的文本、视频、图像或文件内容的链接。

你想知道每个item对象是哪种类型:TextVideoImageFile。你需要模型名称来构建编辑对象的 URL。除此之外,你还可以根据内容类型在模板中不同地显示每个项目。你可以从模型的Meta类通过访问对象的后缀_meta属性来获取对象的模型名称。然而,Django 不允许你在模板中访问以下划线开头的变量或属性,以防止检索私有属性或调用私有方法。你可以通过编写自定义模板过滤器来解决这个问题。

courses应用程序目录内创建以下文件结构:

templatetags/
    __init__.py
    course.py 

编辑course.py模块并向其中添加以下代码:

from django import template
register = template.Library()
@register.filter
def model_name(obj):
    try:
        return obj._meta.model_name
    except AttributeError:
        return None 

这是model_name模板过滤器。你可以在模板中将其应用为object|model_name以获取对象的模型名称。

编辑templates/courses/manage/module/content_list.html模板并在{% extends %}模板标签下方添加以下行:

{% load course %} 

这将加载course模板标签。然后,找到以下行:

<p>{{ item }}</p>
<a href="#">Edit</a> 

将它们替换为以下内容:

<p>{{ item }} **({{ item|model_name }})**</p>
<a href="**{% url "****module_content_update****" module.id item****|model_name item.id %}**">
  Edit
</a> 

在前面的代码中,你在模板中显示项目模型名称,并使用模型名称来构建编辑对象的链接。

编辑courses/manage/course/list.html模板并添加对module_content_list URL 的链接,如下所示:

<a href="{% url "course_module_update" course.id %}">Edit modules</a>
**{% if course.modules.count > 0 %}**
**<****a****href****=****"{% url "****module_content_list****"** **course.modules.first.id %}"****>**
 **Manage contents**
**</****a****>**
**{% endif %}** 

新的链接允许用户访问课程的第一个模块内容(如果有)。

停止开发服务器并使用以下命令重新运行它:

python manage.py runserver 

通过停止并运行开发服务器,你可以确保course模板标签文件被加载。

打开http://127.0.0.1:8000/course/mine/并点击包含至少一个模块的课程中的管理内容链接。你会看到一个如下所示的页面:

图形用户界面,应用程序描述自动生成

图 13.10:管理课程模块内容页面

当你在左侧侧边栏中点击一个模块时,其内容将在主区域中显示。模板还包括为显示的模块添加新的文本、视频、图像或文件内容的链接。

向模块添加几种不同类型的内容并查看结果。模块内容将出现在模块内容下方:

图形用户界面,应用程序描述自动生成

图 13.11:管理不同的模块内容

接下来,我们将允许课程讲师通过简单的拖放功能重新排序模块和模块内容。

重新排序模块及其内容

我们将实现一个 JavaScript 拖放功能,允许课程讲师通过拖动来重新排序课程模块。拖放功能增强了用户界面,提供了一种比使用数字或点击按钮更直观的自然排序元素方式。它也是课程讲师的省时工具,使他们能够轻松重新组织课程模块及其内容。

为了实现这个功能,我们将使用 HTML5 Sortable 库,它简化了使用原生 HTML5 拖放 API 创建可排序列表的过程。

当用户完成拖动模块后,您将使用 JavaScript Fetch API 向存储新模块顺序的服务器发送异步 HTTP 请求。

您可以在www.w3schools.com/html/html5_draganddrop.asp上阅读有关 HTML5 拖放 API 的更多信息。您可以在lukasoppermann.github.io/html5sortable/找到使用 HTML5 Sortable 库构建的示例。HTML5 Sortable 库的文档可在github.com/lukasoppermann/html5sortable找到。

让我们实现更新课程模块和模块内容顺序的视图。

使用django-braces的混入类

django-braces是一个第三方模块,其中包含了一组 Django 通用的混入类。这些混入类为基于类的视图提供了额外的功能,这些功能在多种常见场景中非常有用。您可以在django-braces.readthedocs.io/查看django-braces提供的所有混入类的列表。

您将使用以下django-braces的混入类:

  • CsrfExemptMixin:用于避免在POST请求中检查跨站请求伪造CSRF)令牌。您需要这个混入类来执行不需要传递csrf_token的 AJAX POST请求。

  • JsonRequestResponseMixin:将请求数据解析为 JSON,并将响应序列化为 JSON,并返回一个带有application/json内容类型的 HTTP 响应。

使用以下命令通过pip安装django-braces

python -m pip install django-braces==1.15.0 

您需要一个视图来接收编码为 JSON 的模块 ID 的新顺序,并相应地更新顺序。编辑courses应用的views.py文件,并向其中添加以下代码:

from braces.views import CsrfExemptMixin, JsonRequestResponseMixin
class ModuleOrderView(CsrfExemptMixin, JsonRequestResponseMixin, View):
    def post(self, request):
        for id, order in self.request_json.items():
            Module.objects.filter(
                id=id, course__owner=request.user
            ).update(order=order)
        return self.render_json_response({'saved': 'OK'}) 

这是ModuleOrderView视图,它允许您更新课程模块的顺序。

您可以构建一个类似的视图来排序模块的内容。将以下代码添加到views.py文件中:

class ContentOrderView(CsrfExemptMixin, JsonRequestResponseMixin, View):
    def post(self, request):
        for id, order in self.request_json.items():
            Content.objects.filter(
                id=id, module__course__owner=request.user
            ).update(order=order)
        return self.render_json_response({'saved': 'OK'}) 

现在,编辑courses应用的urls.py文件,并向其中添加以下 URL 模式:

path(
    'module/order/',
    views.ModuleOrderView.as_view(),
    name='module_order'
),
path(
    'content/order/',
    views.ContentOrderView.as_view(),
    name='content_order'
), 

最后,你需要在模板中实现拖放功能。我们将使用 HTML5 Sortable 库,它简化了使用标准 HTML 拖放 API 创建可排序元素。还有其他 JavaScript 库可以实现相同的功能,但我们选择了 HTML5 Sortable,因为它轻量级且利用了原生的 HTML5 拖放 API。

编辑位于 courses 应用程序 templates/ 目录中的 base.html 模板,并添加以下加粗显示的块:

{% load static %}
<!DOCTYPE html>
<html>
<head>
    # ...
  </head>
<body>
<div id="header">
      # ...
    </div>
<div id="content">
      {% block content %}
      {% endblock %}
    </div>
 **{% block include_js %}**
 **{% endblock %}**
<script>
 document.addEventListener('DOMContentLoaded', (event) => {
 // DOM loaded
        {% block domready %}
        {% endblock %}
      })
 </script>
</body>
</html> 

这个名为 include_js 的新块将允许你在扩展 base.html 模板的任何模板中插入 JavaScript 文件。

接下来,编辑 courses/manage/module/content_list.html 模板,并将以下加粗显示的代码添加到模板底部:

# ...
{% block content %}
  # ...
{% endblock %}
**{% block include_js %}**
**<****script****src****=****"https://cdnjs.cloudflare.com/ajax/libs/html5sortable/0.13.3/html5sortable.min.js"****></****script****>**
**{% endblock %}** 

在此代码中,你从公共 内容分发网络CDN)加载了 HTML5 Sortable 库。记住,你在 第六章在您的网站上共享内容 中之前已经从一个 CDN 加载了一个 JavaScript 库。

现在将以下加粗显示的 domready 块添加到 courses/manage/module/content_list.html 模板中:

# ...
{% block content %}
  # ...
{% endblock %}
{% block include_js %}
  <script src="img/html5sortable.min.js"></script>
{% endblock %}
**{% block domready %}**
 **var options = {**
 **method: 'POST',**
 **mode: 'same-origin'**
 **}**
 **const moduleOrderUrl = '{% url "module_order" %}';**
**{% endblock %}** 

在这些新行中,你向在 base.html 模板中 DOMContentLoaded 事件监听器中定义的 {% block domready %} 块中添加 JavaScript 代码。这保证了你的 JavaScript 代码将在页面加载后执行。使用此代码,你定义了将实现模块排序的 HTTP 请求的选项。你将使用 Fetch API 发送 POST 请求来更新模块顺序。module_order URL 路径由 JavaScript 常量 moduleOrderUrl 构建。

将以下加粗显示的代码添加到 domready 块中:

{% block domready %}
  var options = {
      method: 'POST',
      mode: 'same-origin'
  }
  const moduleOrderUrl = '{% url "module_order" %}';
 **sortable('#modules', {**
 **forcePlaceholderSize: true,**
 **placeholderClass: 'placeholder'**
 **});**
{% endblock %} 

在新代码中,你为具有 id="modules" 的 HTML 元素定义了一个 sortable 元素,这是侧边栏中的模块列表。记住,你使用 CSS 选择器 # 来选择具有给定 id 的元素。当你开始拖动一个项目时,HTML5 Sortable 库创建一个占位符项目,这样你可以轻松地看到元素将被放置的位置。

你将 forcePlacehoderSize 选项设置为 true,以强制占位符元素具有高度,并使用 placeholderClass 来定义占位符元素的 CSS 类。你使用在 base.html 模板中加载的 css/base.css 静态文件中定义的名为 placeholder 的类。

在你的浏览器中打开 http://127.0.0.1:8000/course/mine/,然后点击任何课程的 管理内容。现在,你可以将课程模块拖放到左侧边栏中,如图 13.12 所示:

图形用户界面,文本,应用程序  自动生成的描述

图 13.12:使用拖放功能重新排序模块

当你拖动元素时,你会看到 Sortable 库创建的占位符项,它有一个虚线边框。占位符元素允许你识别拖动元素将要放置的位置。

当你将模块拖动到不同的位置时,你需要向服务器发送一个 HTTP 请求来存储新的顺序。这可以通过将事件处理器附加到可排序元素,并使用 JavaScript Fetch API 向服务器发送请求来实现。

编辑 courses/manage/module/content_list.html 模板的 domready 块,并添加以下加粗代码:

{% block domready %}
  var options = {
      method: 'POST',
      mode: 'same-origin'
  }
  const moduleOrderUrl = '{% url "module_order" %}';
  sortable('#modules', {
    forcePlaceholderSize: true,
    placeholderClass: 'placeholder'
  })**[0].addEventListener('sortupdate', function(e) {**
 **modulesOrder = {};**
 **var modules = document.querySelectorAll('#modules li');**
 **modules.forEach(function (module, index) {**
 **// update module index**
 **modulesOrder[module.dataset.id] = index;**
 **// update index in HTML element**
 **module.querySelector('.order').innerHTML = index + 1;**
 **});**
 **// add new order to the HTTP request options**
 **options['body'] = JSON.stringify(modulesOrder);**
 **// send HTTP request**
 **fetch(moduleOrderUrl, options)**
 **});**
{% endblock %} 

在新代码中,为可排序元素的 sortupdate 事件创建了一个事件监听器。当元素被拖放到不同的位置时,会触发 sortupdate 事件。在事件函数中执行以下任务:

  1. 创建一个空的 modulesOrder 字典。这个字典的键将是模块 ID,值将包含每个模块的索引。

  2. 使用 document.querySelectorAll() 选择 #modules HTML 元素的列表项,使用 #modules li CSS 选择器。

  3. forEach() 用于遍历列表中的每个元素。

  4. 将每个模块的新索引存储在 modulesOrder 字典中。通过访问 module.dataset.id 从 HTML 的 data-id 属性中检索每个模块的 ID。你使用 ID 作为 modulesOrder 字典的键,并将模块的新索引作为值。

  5. 通过选择具有 order CSS 类的元素来更新每个模块显示的顺序。由于索引是从零开始的,而我们想显示基于一的索引,所以我们向 index 添加 1

  6. options 字典中添加一个名为 body 的键,其中包含 modulesOrder 中的新顺序。JSON.stringify() 方法将 JavaScript 对象转换为 JSON 字符串。这是更新模块顺序的 HTTP 请求的正文。

  7. 通过创建一个 fetch() HTTP 请求来更新模块顺序。与 module_order URL 对应的 ModuleOrderView 视图负责更新模块的顺序。

你现在可以拖放模块。当你完成模块的拖动后,会向 module_order URL 发送一个 HTTP 请求来更新模块的顺序。如果你刷新页面,最新的模块顺序将被保留,因为它已在数据库中更新。图 13.13 展示了使用拖放排序后侧边栏中模块的不同顺序:

图形用户界面,应用程序描述自动生成

图 13.13:重新排列模块后的新顺序

如果遇到任何问题,请记住使用浏览器开发者工具来调试 JavaScript 和 HTTP 请求。通常,你可以在网站上任何地方右键单击以打开上下文菜单,然后单击 InspectInspect Element 以访问浏览器的网络开发者工具。

让我们添加相同的拖放功能,以便课程讲师可以排序模块内容。

编辑courses/manage/module/content_list.html模板中的domready块,并添加以下加粗的代码:

{% block domready %}
  // ...
 **const contentOrderUrl = '{% url "content_order" %}';**
 **sortable('#module-contents', {**
 **forcePlaceholderSize: true,**
 **placeholderClass: 'placeholder'**
 **})[0].addEventListener('sortupdate', function(e) {**
 **contentOrder = {};**
 **var contents = document.querySelectorAll('#module-contents div');**
 **contents.forEach(function (content, index) {**
 **// update content index**
 **contentOrder[content.dataset.id] = index;**
 **});**
 **// add new order to the HTTP request options**
 **options['body'] = JSON.stringify(contentOrder);**
 **// send HTTP request**
 **fetch(contentOrderUrl, options)**
 **});**
{% endblock %} 

在这种情况下,您使用content_order URL 而不是module_order,并在具有 ID module-contents的 HTML 元素上构建sortable功能。该功能与排序课程模块的功能基本相同。在这种情况下,您不需要更新内容的编号,因为它们不包含任何可见的索引。

现在,您可以拖放模块和模块内容,如图 13.14 所示:

图形用户界面,应用程序描述自动生成

图 13.14:使用拖放功能重新排序模块内容

太棒了!您为课程讲师构建了一个非常通用的 CMS。

摘要

在本章中,您学习了如何使用基于类的视图和混入创建 CMS。您获得了可重用性和模块化知识,这些知识可以应用于您未来的应用程序。您还与组和权限一起工作,以限制对视图的访问,深入了解安全性和如何控制数据上的操作。您学习了如何使用表单集和模型表单集以灵活的方式管理课程模块及其内容。您还使用 JavaScript 构建了拖放功能,以改进用户界面重新排序课程模块及其内容。

在下一章中,您将创建学生注册系统并管理学生课程的注册。您还将学习如何渲染不同类型的内容,并通过使用 Django 的缓存框架缓存内容来提高应用程序的性能。

其他资源

以下资源提供了与本章涵盖主题相关的额外信息:

第十四章:渲染和缓存内容

在上一章中,你使用了模型继承和通用关系来创建灵活的课程内容模型。你实现了一个自定义模型字段,并使用基于类的视图构建了一个课程管理系统。最后,你使用异步 HTTP 请求创建了一个 JavaScript 拖放功能,以对课程模块及其内容进行排序。

在本章中,你将构建创建学生注册系统和管理课程中学生注册的功能。你将实现不同类型课程内容的渲染,并学习如何使用 Django 缓存框架缓存数据。

在电子学习平台上渲染多种内容类型至关重要,在这些平台上,课程通常由灵活的模块结构组成,这些模块包括文本、图片、视频和文档的混合。在这种情况下,缓存也变得至关重要。由于课程内容通常在较长时间内保持不变——几天、几周甚至几个月——缓存有助于节省计算资源,并减少每次学生访问相同材料时查询数据库的需要。通过缓存数据,你不仅可以节省系统资源,而且还能在向大量学生提供内容时提高性能。

在本章中,你将:

  • 创建用于显示课程信息的公共视图

  • 构建学生注册系统

  • 管理课程中的学生注册

  • 渲染课程模块的多样化内容

  • 安装和配置 Memcached

  • 使用 Django 缓存框架缓存内容

  • 使用 Memcached 和 Redis 缓存后端

  • 在 Django 管理站点监控您的 Redis 服务器

功能概述

图 14.1 展示了本章将构建的视图、模板和功能:

图片

图 14.1:第十四章构建的功能图

在本章中,你将实现CourseListView公共视图以列出课程和CourseDetailView以显示课程的详细信息。你将实现StudentRegistrationView以允许学生创建用户账户,以及StudentCourseListView以让学生注册课程。你将为学生创建StudentCourseListView以查看他们已注册的课程列表,以及StudentCourseDetailView以访问课程的所有内容,这些内容按不同的课程模块组织。你还将使用 Django 缓存框架在你的视图中添加缓存,首先使用 Memcached 后端,然后替换为 Redis 缓存后端。

本章的源代码可在github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter14找到。

本章中使用的所有 Python 模块都包含在本章源代码附带的requirements.txt文件中。你可以按照以下说明安装每个 Python 模块,或者你可以使用python -m pip install -r requirements.txt命令一次性安装所有依赖。

显示课程目录

你可能急于进行渲染和缓存,但在进行这些操作之前,我们还有一些事情需要设置。让我们从课程目录开始。对于你的课程目录,你必须构建以下功能:

  • 列出所有可用的课程,可选地按主题过滤。

  • 显示单个课程概要

这将允许学生看到平台上所有可用的课程,并报名参加他们感兴趣的。编辑courses应用的views.py文件,并添加以下代码:

from django.db.models import Count
from .models import Subject
class CourseListView(TemplateResponseMixin, View):
    model = Course
    template_name = 'courses/course/list.html'
def get(self, request, subject=None):
        subjects = Subject.objects.annotate(
            total_courses=Count('courses')
        )
        courses = Course.objects.annotate(
            total_modules=Count('modules')
        )
        if subject:
            subject = get_object_or_404(Subject, slug=subject)
            courses = courses.filter(subject=subject)
        return self.render_to_response(
            {
                'subjects': subjects,
                'subject': subject,
                'courses': courses
            }
        ) 

这是CourseListView视图。它继承自TemplateResponseMixinView。在这个视图中,执行以下任务:

  1. 使用 ORM 的annotate()方法和Count()聚合函数检索所有主题,包括每个主题的课程总数。

  2. 检索所有可用的课程,包括每个课程包含的模块总数。

  3. 如果提供了主题 slug URL 参数,检索相应的subject对象,并将查询限制为属于给定主题的课程。

  4. 使用TemplateResponseMixin提供的render_to_response()方法将对象渲染到模板中,并返回一个 HTTP 响应。

让我们创建一个用于显示单个课程概要的详细视图。将以下代码添加到views.py文件中:

from django.views.generic.detail import DetailView
class CourseDetailView(DetailView):
    model = Course
    template_name = 'courses/course/detail.html' 

这个视图继承自 Django 提供的通用DetailView。你指定modeltemplate_name属性。Django 的DetailView期望一个主键(pk)或 slug URL 参数来检索给定模型的单个对象。视图渲染在template_name中指定的模板,包括模板上下文变量object中的Course对象。

编辑educa项目的主体urls.py文件,并向其中添加以下 URL 模式:

**from** **courses.views** **import** **CourseListView**
urlpatterns = [
    # ...
 **path(****''****, CourseListView.as_view(), name=****'course_list'****),**
] 

你将course_list URL 模式添加到项目的主体urls.py文件中,因为你想在 URL http://127.0.0.1:8000/上显示课程列表,并且courses应用的其它所有 URL 都有/course/前缀。

编辑courses应用的urls.py文件,并添加以下 URL 模式:

path(
    'subject/<slug:subject>/',
    views.CourseListView.as_view(),
    name='course_list_subject'
),
path(
    '<slug:slug>/',
    views.CourseDetailView.as_view(),
    name='course_detail'
), 

你定义以下 URL 模式:

  • course_list_subject:用于显示某个主题的所有课程

  • course_detail:用于显示单个课程概要

让我们为CourseListViewCourseDetailView视图构建模板。

courses应用的templates/courses/目录内创建以下文件结构:

course/
    list.html
    detail.html 

编辑courses/course/list.html模板,并编写以下代码:

{% extends "base.html" %}
{% block title %}
  {% if subject %}
    {{ subject.title }} courses
  {% else %}
    All courses
  {% endif %}
{% endblock %}
{% block content %}
  <h1>
    {% if subject %}
      {{ subject.title }} courses
    {% else %}
      All courses
    {% endif %}
  </h1>
<div class="contents">
<h3>Subjects</h3>
<ul id="modules">
<li {% if not subject %}class="selected"{% endif %}>
<a href="{% url "course_list" %}">All</a>
</li>
      {% for s in subjects %}
        <li {% if subject == s %}class="selected"{% endif %}>
<a href="{% url "course_list_subject" s.slug %}">
            {{ s.title }}
            <br>
<span>
              {{ s.total_courses }} course{{ s.total_courses|pluralize }}
            </span>
</a>
</li>
      {% endfor %}
    </ul>
</div>
<div class="module">
    {% for course in courses %}
      {% with subject=course.subject %}
        <h3>
<a href="{% url "course_detail" course.slug %}">
            {{ course.title }}
          </a>
</h3>
<p>
<a href="{% url "course_list_subject" subject.slug %}">{{ subject }}</a>.
            {{ course.total_modules }} modules.
            Instructor: {{ course.owner.get_full_name }}
        </p>
      {% endwith %}
    {% endfor %}
  </div>
{% endblock %} 

确保没有模板标签被拆分成多行。

这是列出可用课程的模板。你创建一个 HTML 列表来显示所有的Subject对象,并为每个对象构建一个指向course_list_subject URL 的链接。你还包括每个科目的课程总数,并使用pluralize模板过滤器在数量不是1时给单词course添加复数后缀,以显示0 门课程1 门课程2 门课程等。如果你选择了科目,你还会添加一个selected HTML 类来突出显示当前科目。你遍历每个Course对象,显示模块总数和讲师姓名。

运行开发服务器,并在浏览器中打开http://127.0.0.1:8000/。你应该看到一个类似于以下页面的页面:

图形用户界面、文本、应用程序 描述自动生成

图 14.2:课程列表页面

左侧边栏包含所有科目,包括每个科目的课程总数。你可以点击任何科目来过滤显示的课程。

编辑courses/course/detail.html模板,并向其中添加以下代码:

{% extends "base.html" %}
{% block title %}
  {{ object.title }}
{% endblock %}
{% block content %}
  {% with subject=object.subject %}
    <h1>
      {{ object.title }}
    </h1>
<div class="module">
<h2>Overview</h2>
<p>
<a href="{% url "course_list_subject" subject.slug %}">
        {{ subject.title }}</a>.
        {{ object.modules.count }} modules.
        Instructor: {{ object.owner.get_full_name }}
      </p>
      {{ object.overview|linebreaks }}
    </div>
  {% endwith %}
{% endblock %} 

此模板显示单个课程的概览和详细信息。在浏览器中打开http://127.0.0.1:8000/,并点击其中一个课程。你应该看到一个具有以下结构的页面:

包含文本的图片 描述自动生成

图 14.3:课程概览页面

你已经创建了一个用于显示课程的公共区域。接下来,你需要允许用户注册为学生并选课。

添加学生注册

我们需要实现学生注册以允许选课和访问内容。使用以下命令创建一个新的应用程序:

python manage.py startapp students 

编辑educa项目的settings.py文件,并将新应用程序添加到INSTALLED_APPS设置中,如下所示:

INSTALLED_APPS = [
    # ...
**'students.apps.StudentsConfig'****,**
] 

创建学生注册视图

编辑students应用程序的views.py文件,并写入以下代码:

from django.contrib.auth import authenticate, login
from django.contrib.auth.forms import UserCreationForm
from django.urls import reverse_lazy
from django.views.generic.edit import CreateView
class StudentRegistrationView(CreateView):
    template_name = 'students/student/registration.html'
    form_class = UserCreationForm
    success_url = reverse_lazy('student_course_list')
    def form_valid(self, form):
        result = super().form_valid(form)
        cd = form.cleaned_data
        user = authenticate(
            username=cd['username'], password=cd['password1']
        )
        login(self.request, user)
        return result 

这是允许学生在你的网站上注册的视图。你使用通用的CreateView,它提供了创建模型对象的功能。此视图需要以下属性:

  • template_name:渲染此视图的模板路径。

  • form_class:用于创建对象的表单,必须是ModelForm。你使用 Django 的UserCreationForm作为注册表单来创建User对象。

  • success_url:当表单成功提交时,将用户重定向到的 URL。为此,你需要反转名为student_course_list的 URL,我们将在访问课程内容部分创建它,用于列出学生已注册的课程。

当有效的表单数据被提交时,会执行form_valid()方法。它必须返回一个 HTTP 响应。你需要覆盖这个方法,在用户成功注册后登录用户。

students应用目录内创建一个新文件,命名为urls.py。将以下代码添加到该文件中:

from django.urls import path
from . import views
urlpatterns = [
    path(
        'register/',
        views.StudentRegistrationView.as_view(),
        name='student_registration'
    ),
] 

然后,编辑educa项目的主体urls.py文件,并通过添加以下模式到你的 URL 配置中,包含students应用的 URL:

urlpatterns = [
    # ...
 **path(****'students/'****, include(****'students.urls'****)),**
] 

students应用目录内创建以下文件结构:

templates/
    students/
        student/
            registration.html 

编辑students/student/registration.html模板,并向其中添加以下代码:

{% extends "base.html" %}
{% block title %}
  Sign up
{% endblock %}
{% block content %}
  <h1>
    Sign up
  </h1>
<div class="module">
<p>Enter your details to create an account:</p>
<form method="post">
      {{ form.as_p }}
      {% csrf_token %}
      <p><input type="submit" value="Create my account"></p>
</form>
</div>
{% endblock %} 

运行开发服务器并在浏览器中打开http://127.0.0.1:8000/students/register/。你应该看到一个类似于以下注册表单的界面:

图形用户界面,文本,应用程序,电子邮件  自动生成的描述

图 14.4:学生注册表单

注意,在StudentRegistrationView视图的success_url属性中指定的student_course_list URL 尚不存在。如果你提交表单,Django 将找不到在成功注册后重定向你的 URL。如前所述,你将在访问课程内容部分创建此 URL。

报名参加课程

用户创建账户后,应该能够报名参加课程。为了存储报名信息,您需要在CourseUser模型之间创建一个多对多关系。

编辑courses应用的models.py文件,并将以下字段添加到Course模型中:

students = models.ManyToManyField(
    User,
    related_name='courses_joined',
    blank=True
) 

从 shell 中执行以下命令以创建此更改的迁移:

python manage.py makemigrations 

你将看到类似于以下的输出:

Migrations for 'courses':
  courses/migrations/0004_course_students.py
    - Add field students to course 

然后,执行以下命令以应用挂起的迁移:

python manage.py migrate 

你应该看到一些输出,以以下行结束:

Applying courses.0004_course_students... OK 

现在,你可以将学生与他们报名的课程关联起来。让我们创建学生报名课程的函数。

students应用目录内创建一个新文件,命名为forms.py。将以下代码添加到该文件中:

from django import forms
from courses.models import Course
class CourseEnrollForm(forms.Form):
    course = forms.ModelChoiceField(
        queryset=Course.objects.none(),
        widget=forms.HiddenInput
    )
    def __init__ (self, form):
        super(CourseEnrollForm, self).__init__(*args, **kwargs)
        self.fields['course'].queryset = Course.objects.all() 

此表单将用于将学生报名到课程。course字段是用户将要报名的课程;因此,它是ModelChoiceField。你使用HiddenInput小部件,因为这个字段不打算对用户可见。最初,你将查询集定义为Course.objects.none()。使用none()创建一个空的查询集,它不会返回任何对象,并且重要的是,它不会查询数据库。这避免了在表单初始化期间不必要的数据库负载。你将在表单的__init__()方法中填充实际的查询集。这种动态设置允许你根据不同的情况调整表单,例如根据特定标准过滤可用的课程。总体而言,这种方法为你提供了更大的灵活性来管理表单数据,确保数据是根据表单使用的上下文来获取的。这种方法也与 Django 处理表单查询集的最佳实践相一致。

您将在 CourseDetailView 视图中使用此表单来显示一个报名按钮。编辑 students 应用程序的 views.py 文件,并添加以下代码:

from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic.edit import FormView
from .forms import CourseEnrollForm
class StudentEnrollCourseView(LoginRequiredMixin, FormView):
    course = None
    form_class = CourseEnrollForm
    def form_valid(self, form):
        self.course = form.cleaned_data['course']
        self.course.students.add(self.request.user)
        return super().form_valid(form)
    def get_success_url(self):
        return reverse_lazy(
            'student_course_detail', args=[self.course.id]
        ) 

这是 StudentEnrollCourseView 视图。它处理学生的课程报名。视图从 LoginRequiredMixin 混合继承,因此只有登录用户可以访问该视图。它还从 Django 的 FormView 视图继承,因为它处理表单提交。您使用 CourseEnrollForm 表单作为 form_class 属性,并为存储提供的 Course 对象定义一个 course 属性。当表单有效时,当前用户将被添加到该课程的已报名学生中。

get_success_url() 方法返回用户在表单成功提交后将被重定向到的 URL。此方法等同于 success_url 属性。然后,你反转名为 student_course_detail 的 URL。

编辑 students 应用程序的 urls.py 文件,并向其中添加以下 URL 模式:

path(
    'enroll-course/',
    views.StudentEnrollCourseView.as_view(),
    name='student_enroll_course'
), 

让我们在课程概览页面上添加报名按钮表单。编辑 courses 应用程序的 views.py 文件,并修改 CourseDetailView 以使其看起来如下:

**from** **students.forms** **import** **CourseEnrollForm**
class CourseDetailView(DetailView):
    model = Course
    template_name = 'courses/course/detail.html'
**def****get_context_data****(****self, **kwargs****):**
 **context =** **super****().get_context_data(**kwargs)**
 **context[****'enroll_form'****] = CourseEnrollForm(**
 **initial={****'course'****:self.****object****}**
 **)**
**return** **context** 

您使用 get_context_data() 方法将报名表单包含在渲染模板的上下文中。您初始化表单的隐藏 course 字段,使其使用当前的 Course 对象,以便可以直接提交。

编辑 courses/course/detail.html 模板,并找到以下行:

{{ object.overview|linebreaks }} 

将其替换为以下代码:

{{ object.overview|linebreaks }}
**{%** **if** **request.user.is_authenticated %}**
 **<form action=****"{% url "****student_enroll_course****" %}"** **method=****"post"****>**
 **{{ enroll_form }}**
 **{% csrf_token %}**
 **<****input****type****=****"submit"** **value=****"Enroll now"****>**
 **</form>**
**{%** **else** **%}**
 **<a href=****"{% url "****student_registration****" %}"****class****=****"button"****>**
 **Register to enroll**
 **</a>**
**{% endif %}** 

这是报名课程的按钮。如果用户已认证,将显示报名按钮,包括指向 student_enroll_course URL 的隐藏表单。如果用户未认证,将显示一个注册平台的链接。

确保开发服务器正在运行,在您的浏览器中打开 http://127.0.0.1:8000/,并点击一个课程。如果您已登录,您应该在课程概览下方看到一个 立即报名 按钮如下所示:

图形用户界面、文本、应用程序、电子邮件  自动生成的描述

图 14.5:课程概览页面,包括一个 立即报名 按钮

如果您未登录,您将看到一个 注册报名 按钮。

渲染课程内容

一旦学生报名参加了课程,他们需要一个中心位置来访问他们已报名的所有课程。我们需要编译学生已报名的课程列表,并提供访问每个课程内容的方式。然后,我们需要实现一个系统来渲染各种类型的内容,如文本、图片、视频和文档,这些内容构成了课程模块。让我们构建必要的视图和模板,以便用户访问课程内容。

访问课程内容

你需要一个用于显示学生所选修的课程视图和一个用于访问实际课程内容的视图。编辑students应用的views.py文件,并向其中添加以下代码:

from django.views.generic.list import ListView
from courses.models import Course
class StudentCourseListView(LoginRequiredMixin, ListView):
    model = Course
    template_name = 'students/course/list.html'
def get_queryset(self):
        qs = super().get_queryset()
        return qs.filter(students__in=[self.request.user]) 

这是一个用于查看学生所选修课程的视图。它继承自LoginRequiredMixin以确保只有登录用户才能访问该视图。它还继承自通用ListView以显示Course对象的列表。你重写了get_queryset()方法以检索学生所选修的课程;通过过滤学生ManyToManyField字段来实现这一点。

然后,将以下代码添加到students应用的views.py文件中:

from django.views.generic.detail import DetailView
class StudentCourseDetailView(LoginRequiredMixin, DetailView):
    model = Course
    template_name = 'students/course/detail.html'
def get_queryset(self):
        qs = super().get_queryset()
        return qs.filter(students__in=[self.request.user])
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        # get course object
        course = self.get_object()
        if 'module_id' in self.kwargs:
            # get current module
            context['module'] = course.modules.get(
                id=self.kwargs['module_id']
            )
        else:
            # get first module
            context['module'] = course.modules.all()[0]
        return context 

这是StudentCourseDetailView视图。你重写了get_queryset()方法以限制基本查询集只包含学生所选修的课程。你还重写了get_context_data()方法,如果提供了module_id URL 参数,则在上下文中设置一个课程模块。否则,你设置课程的第一模块。这样,已注册的学生将能够在课程内导航模块。

编辑students应用的urls.py文件,并向其中添加以下 URL 模式:

path(
    'courses/',
    views.StudentCourseListView.as_view(),
    name='student_course_list'
),
path(
    'course/<pk>/',
    views.StudentCourseDetailView.as_view(),
    name='student_course_detail'
),
path(
    'course/<pk>/<module_id>/',
    views.StudentCourseDetailView.as_view(),
    name='student_course_detail_module'
), 

students应用的templates/students/目录内创建以下文件结构:

course/
    detail.html
    list.html 

编辑students/course/list.html模板,并向其中添加以下代码:

{% extends "base.html" %}
{% block title %}My courses{% endblock %}
{% block content %}
  <h1>My courses</h1>
<div class="module">
    {% for course in object_list %}
      <div class="course-info">
<h3>{{ course.title }}</h3>
<p><a href="{% url "student_course_detail" course.id %}">
        Access contents</a></p>
</div>
    {% empty %}
      <p>
        You are not enrolled in any courses yet.
        <a href="{% url "course_list" %}">Browse courses</a>
        to enroll in a course.
      </p>
    {% endfor %}
  </div>
{% endblock %} 

此模板显示学生所选修的课程。记住,当新学生成功注册到平台时,他们将被重定向到student_course_list URL。让我们在学生登录平台时也将他们重定向到这个 URL。

编辑educa项目的settings.py文件,并向其中添加以下代码:

from django.urls import reverse_lazy
LOGIN_REDIRECT_URL = reverse_lazy('student_course_list') 

这是auth模块在请求中不存在next参数时,在登录成功后重定向学生的设置。登录成功后,学生将被重定向到student_course_list URL 以查看他们所选修的课程。

编辑students/course/detail.html模板,并向其中添加以下代码:

{% extends "base.html" %}
{% block title %}
  {{ object.title }}
{% endblock %}
{% block content %}
  <h1>
    {{ module.title }}
  </h1>
<div class="contents">
<h3>Modules</h3>
<ul id="modules">
      {% for m in object.modules.all %}
        <li data-id="{{ m.id }}" {% if m == module %}class="selected"{% endif %}>
<a href="{% url "student_course_detail_module" object.id m.id %}">
<span>
              Module <span class="order">{{ m.order|add:1 }}</span>
</span>
<br>
            {{ m.title }}
          </a>
</li>
      {% empty %}
        <li>No modules yet.</li>
      {% endfor %}
    </ul>
</div>
<div class="module">
    {% for content in module.contents.all %}
      {% with item=content.item %}
        <h2>{{ item.title }}</h2>
        {{ item.render }}
      {% endwith %}
    {% endfor %}
  </div>
{% endblock %} 

确保没有模板标签被拆分到多行。这是已注册学生访问课程内容的模板。首先,你构建一个包含所有课程模块并突出当前模块的 HTML 列表。然后,你遍历当前模块的内容,并使用{{ item.render }}访问每个内容项以显示它。你将在下一个步骤中将render()方法添加到内容模型中。此方法将负责正确渲染内容。

现在,你可以访问http://127.0.0.1:8000/students/register/,注册一个新的学生账户,并选修任何课程。

渲染不同类型的内容

要显示课程内容,你需要渲染你创建的不同内容类型:文本图片视频文件

编辑courses应用程序的models.py文件,并将以下render()方法添加到ItemBase模型中:

**from** **django.template.loader** **import** **render_to_string**
class ItemBase(models.Model):
    # ...
**def****render****(****self****):**
**return** **render_to_string(**
**f'courses/content/****{self._meta.model_name}****.html'****,**
 **{****'item'****: self}**
 **)** 

此方法使用render_to_string()函数来渲染模板并返回渲染后的内容作为字符串。每种内容都使用以内容模型命名的模板进行渲染。self._meta.model_name用于动态生成每个内容模型适当的模板名称。render()方法提供了一个渲染不同内容的通用接口。

courses应用程序的templates/courses/目录内创建以下文件结构:

content/
    text.html
    file.html
    image.html
    video.html 

编辑courses/content/text.html模板并写入以下代码:

{{ item.content|linebreaks }} 

这是渲染文本内容的模板。linebreaks模板过滤器将纯文本中的换行符替换为 HTML 换行符。

编辑courses/content/file.html模板并添加以下内容:

<p>
<a href="{{ item.file.url }}" class="button">Download file</a>
</p> 

这是渲染文件的模板。它生成一个下载文件的链接。

编辑courses/content/image.html模板并写入:

<p>
<img src="img/{{ item.file.url }}" alt="{{ item.title }}">
</p> 

这是渲染图像的模板。

你还必须创建一个用于渲染Video对象的模板。你将使用django-embed-video来嵌入视频内容。django-embed-video是一个第三方 Django 应用程序,它允许你通过简单地提供它们的公共 URL,在模板中嵌入来自 YouTube 或 Vimeo 等来源的视频。

使用以下命令安装该包:

python -m pip install django-embed-video==1.4.9 

编辑你的项目中的settings.py文件,并将应用程序添加到INSTALLED_APPS设置中,如下所示:

INSTALLED_APPS = [
    # ...
**'embed_video'****,**
] 

你可以在django-embed-video.readthedocs.io/en/latest/找到django-embed-video应用程序的文档。

编辑courses/content/video.html模板并写入以下代码:

{% load embed_video_tags %}
{% video item.url "small" %} 

这是渲染视频的模板。

现在,运行开发服务器,并在浏览器中访问http://127.0.0.1:8000/course/mine/。使用属于Instructors组的用户访问网站,并向课程添加多个内容。要包含视频内容,只需复制任何 YouTube URL,例如https://www.youtube.com/watch?v=bgV39DlmZ2U,并将其包含在表单的url字段中。

在课程中添加内容后,打开http://127.0.0.1:8000/,点击课程,然后点击立即报名按钮。你应该已经报名并重定向到student_course_detail URL。图 14.6显示了示例课程内容页面:

图形用户界面、应用程序、网站  自动生成的描述

图 14.6:课程内容页面

太棒了!你已经为具有不同类型内容的课程创建了一个通用渲染接口。

使用缓存框架

处理对您的 Web 应用的 HTTP 请求通常涉及数据库访问、数据处理和模板渲染。与仅提供静态网站相比,这要昂贵得多。当您的网站开始获得越来越多的流量时,某些请求的开销可能会很大。这就是缓存变得至关重要的地方。通过在 HTTP 请求中缓存查询、计算结果或渲染内容,您将避免在后续需要返回相同数据的请求中进行昂贵的操作。这转化为更短的响应时间和服务器端更少的处理。

Django 包含一个强大的缓存系统,允许您以不同粒度级别缓存数据。您可以缓存单个查询、特定视图的输出、渲染模板内容的一部分,或整个站点。项目默认存储在缓存系统中,但您可以在缓存数据时指定超时时间。

这就是当您的应用程序处理 HTTP 请求时通常如何使用缓存框架:

  1. 尝试在缓存中找到所需数据。

  2. 如果找到,则返回缓存数据。

  3. 如果未找到,请执行以下步骤:

    1. 执行生成数据所需的数据库查询或处理。

    2. 将生成的数据保存到缓存中。

    3. 返回数据。

您可以在docs.djangoproject.com/en/5.0/topics/cache/上阅读有关 Django 缓存系统的详细信息。

可用的缓存后端

Django 附带以下缓存后端:

  • backends.memcached.PyMemcacheCachebackends.memcached.PyLibMCCache:Memcached 后端。Memcached 是一个快速高效的基于内存的缓存服务器。要使用哪个后端取决于您选择的 Memcached Python 绑定。

  • backends.redis.RedisCache:Redis 缓存后端。此后端是在 Django 4.0 中添加的。

  • backends.db.DatabaseCache:使用数据库作为缓存系统。

  • backends.filebased.FileBasedCache:使用文件存储系统。它将每个缓存值序列化并存储为单独的文件。

  • backends.locmem.LocMemCache:本地内存缓存后端。这是默认的缓存后端。

  • backends.dummy.DummyCache:一个仅用于开发的虚拟缓存后端。它实现了缓存接口,但实际上并不缓存任何内容。此缓存是按进程和线程安全的。

为了获得最佳性能,使用基于内存的缓存后端,如 Memcached 或 Redis,因为访问内存比访问数据库或文件中的数据要快。

安装 Memcached

Memcached 是一个流行的、高性能的基于内存的缓存服务器。我们将使用 Memcached 和PyMemcacheCache Memcached 后端。

安装 Memcached Docker 镜像

从 shell 运行以下命令以拉取 Memcached Docker 镜像:

docker pull memcached:1.6.26 

这将下载 Memcached Docker 镜像到您的本地机器。您可以在 hub.docker.com/_/memcached 找到有关官方 Memcached Docker 镜像的更多信息。如果您不想使用 Docker,您也可以从 memcached.org/downloads 下载 Memcached。

使用以下命令运行 Memcached Docker 容器:

docker run -it --rm --name memcached -p 11211:11211 memcached:1.6.26 -m 64 

Memcached 默认在端口 11211 上运行。-p 选项用于将 11211 端口发布到同一主机接口端口。-m 选项用于将容器的内存限制为 64 MB。Memcached 在内存中运行,并分配指定数量的 RAM。当分配的 RAM 满时,Memcached 开始删除最旧的数据以存储新数据。如果您想在分离模式下运行命令(在终端的背景中),可以使用 -d 选项。

您可以在 memcached.org 找到有关 Memcached 的更多信息。

安装 Memcached Python 绑定

安装 Memcached 后,您必须安装一个 Memcached Python 绑定。我们将安装 pymemcache,这是一个快速、纯 Python 的 Memcached 客户端。在 shell 中运行以下命令:

python -m pip install pymemcache==4.0.0 

您可以在 github.com/pinterest/pymemcache 上阅读有关 pymemcache 库的更多信息。

Django 缓存设置

Django 提供以下缓存设置:

  • CACHES: 包含项目中所有可用缓存的字典。

  • CACHE_MIDDLEWARE_ALIAS: 用于存储的缓存别名。

  • CACHE_MIDDLEWARE_KEY_PREFIX: 缓存键的前缀。如果您在多个站点之间共享相同的缓存,设置前缀以避免键冲突。

  • CACHE_MIDDLEWARE_SECONDS: 缓存页面的默认秒数。

可以使用 CACHES 设置配置项目的缓存系统。此设置允许您指定多个缓存的配置。CACHES 字典中包含的每个缓存都可以指定以下数据:

  • BACKEND: 要使用的缓存后端。

  • KEY_FUNCTION: 包含一个点分隔路径的字符串,指向一个可调用的函数,该函数接受前缀、版本和键作为参数,并返回最终的缓存键。

  • KEY_PREFIX: 所有缓存键的字符串前缀,以避免冲突。

  • LOCATION: 缓存的位置。根据缓存后端,这可能是一个目录、一个主机和端口,或者内存后端的一个名称。

  • OPTIONS: 传递给缓存后端的任何附加参数。

  • TIMEOUT: 存储缓存键的默认超时时间,以秒为单位。默认为 300 秒,即 5 分钟。如果设置为 None,则缓存键不会过期。

  • VERSION: 缓存键的默认版本号。对于缓存版本控制很有用。

您可以在 docs.djangoproject.com/en/5.0/ref/settings/#caches 找到有关 CACHES 设置的更多信息。

将 Memcached 添加到您的项目中

让我们为您的项目配置缓存。编辑educa项目的settings.py文件,并向其中添加以下代码:

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.memcached.PyMemcacheCache',
        'LOCATION': '127.0.0.1:11211',
    }
} 

您正在使用PyMemcacheCache后端。您使用address:port表示法指定其位置。如果您有多个 Memcached 实例,您可以使用列表来指定LOCATION

您已为项目设置了 Memcached。让我们开始缓存数据!

缓存级别

Django 提供了以下级别的缓存,按粒度升序排列:

  • 低级缓存 API:提供最高粒度。允许您缓存特定的查询或计算。

  • 模板缓存:允许您缓存模板片段。

  • 视图级缓存:为单个视图提供缓存。

  • 站点级缓存:最高级别的缓存。它缓存了您的整个站点。

在实施缓存之前,先考虑您的缓存策略。首先关注那些不是按用户计算的昂贵查询或计算。

在接下来的章节中,我们将探讨如何在我们的项目中使用这些缓存级别。

让我们从学习如何在 Python 代码中使用低级缓存 API 开始。

使用低级缓存 API

低级缓存 API 允许您以任何粒度将对象存储在缓存中。它位于django.core.cache。您可以像这样导入它:

from django.core.cache import cache 

这使用的是默认缓存。它与caches['default']等价。通过其别名也可以访问特定的缓存:

from django.core.cache import caches
my_cache = caches['alias'] 

让我们看看缓存 API 是如何工作的。使用以下命令打开 Django shell:

python manage.py shell 

执行以下代码:

>>> from django.core.cache import cache
>>> cache.set('musician', 'Django Reinhardt', 20) 

您访问默认缓存后端,并使用set(key, value, timeout)将名为'musician'的键存储一个值为字符串'Django Reinhardt'的值,持续 20 秒。如果您没有指定超时,Django 将使用在CACHES设置中指定的缓存后端的默认超时。现在,执行以下代码:

>>> cache.get('musician')
'Django Reinhardt' 

您从缓存中检索键。等待 20 秒后,执行相同的代码:

>>> cache.get('musician') 

这次没有返回值。'musician'缓存键已过期,get()方法返回None,因为该键不再在缓存中。

总是避免在缓存键中存储None值,因为您将无法区分实际值和缓存未命中。

让我们使用以下代码缓存一个查询集:

>>> from courses.models import Subject
>>> subjects = Subject.objects.all()
>>> cache.set('my_subjects', subjects) 

您对Subject模型执行查询集,并将返回的对象存储在'my_subjects'键中。让我们检索缓存的数据:

>>> cache.get('my_subjects')
<QuerySet [<Subject: Mathematics>, <Subject: Music>, <Subject: Physics>, <Subject: Programming>]> 

您将在视图中缓存一些查询。编辑courses应用的views.py文件,并添加以下导入:

from django.core.cache import cache 

CourseListViewget()方法中找到以下行:

subjects = Subject.objects.annotate(
    total_courses=Count('courses')
) 

将以下行替换为以下内容:

**subjects = cache.get(****'****all_subjects'****)**
**if****not** **subjects:**
    subjects = Subject.objects.annotate(
        total_courses=Count('courses')
    )
 **cache.****set****(****'all_subjects'****, subjects)** 

在此代码中,你尝试使用cache.get()从缓存中获取all_subjects键。如果给定的键未找到,则返回None。如果没有找到键(尚未缓存或已缓存但已超时),则执行查询以检索所有Subject对象及其课程数量,并使用cache.set()将结果缓存。

使用 Django Debug Toolbar 检查缓存请求

让我们添加 Django Debug Toolbar 到项目中以检查缓存查询。你已经在第七章跟踪用户行为中学习了如何使用 Django Debug Toolbar。

首先,使用以下命令安装 Django Debug Toolbar:

python -m pip install django-debug-toolbar==4.3.0 

编辑你项目的settings.py文件,并将debug_toolbar添加到INSTALLED_APPS设置中,如下所示。新行以粗体突出显示:

INSTALLED_APPS = [
    # ...
**'debug_toolbar'****,**
] 

在同一文件中,将以下以粗体突出显示的行添加到MIDDLEWARE设置中:

MIDDLEWARE = [
**'debug_toolbar.middleware.DebugToolbarMiddleware'****,**
'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',
] 

记住,DebugToolbarMiddleware必须放在任何其他中间件之前,除了那些编码响应内容的中间件,例如GZipMiddleware,如果存在,则应该放在最前面。

settings.py文件的末尾添加以下行:

INTERNAL_IPS = [
    '127.0.0.1',
] 

Django Debug Toolbar 只有在你的 IP 地址与INTERNAL_IPS设置中的条目匹配时才会显示。

编辑项目的主体urls.py文件,并将以下 URL 模式添加到urlpatterns中:

path('__debug__/', include('debug_toolbar.urls')), 

运行开发服务器,并在浏览器中打开http://127.0.0.1:8000/

你现在应该在页面的右侧看到 Django Debug Toolbar。点击侧边栏菜单中的Cache。你会看到以下面板:

图片

图 14.7:Django Debug Toolbar 的缓存面板,包括在缓存未命中时对CourseListView的缓存请求

总调用次数下,你应该看到2。第一次执行CourseListView视图时,有两个缓存请求。在命令下,你会看到get命令执行了一次,同样set命令也执行了一次。get命令对应于检索all_subjects缓存键的调用。这是在调用下显示的第一个调用。第一次执行视图时,由于还没有缓存数据,因此发生缓存未命中。这就是为什么在缓存未命中下有1的原因。然后,使用set命令将subjects QuerySet 的结果存储在缓存中,使用all_subjects缓存键。这是在调用下显示的第二个调用。

在 Django Debug Toolbar 的SQL菜单项中,你会看到在此请求中执行的 SQL 查询总数。这包括检索所有主题并随后存储在缓存中的查询:

图形用户界面,应用程序描述自动生成

图 14.8:在缓存未命中时对CourseListView执行的 SQL 查询

在浏览器中重新加载页面,并点击侧边栏菜单中的Cache

图片

图 14.9:Django Debug Toolbar 的缓存面板,包括对CourseListView视图的缓存请求(缓存命中)

现在,只有一个缓存请求。在总调用次数下,你应该看到1,在命令下,你可以看到缓存请求对应于一个get命令。在这种情况下,由于数据已在缓存中找到,因此这是一个缓存命中(见缓存命中)而不是缓存未命中。在调用下,你可以看到检索all_subjects缓存键的get请求。

检查调试工具栏的SQL菜单项。你应该看到这个请求中有一个 SQL 查询更少。你节省了一个 SQL 查询,因为视图在缓存中找到了数据,不需要从数据库中检索它:

图形用户界面,应用程序描述自动生成

图 14.10:在缓存命中时为 CourseListView 执行的 SQL 查询

在这个例子中,对于单个请求,从缓存中检索项目所需的时间比额外 SQL 查询节省的时间要多。然而,当你有大量用户访问你的网站时,你会发现通过从缓存中检索数据而不是直接访问数据库,可以实现显著的时间节省,并且你将能够为更多并发用户提供服务。

对同一 URL 的连续请求将从缓存中检索数据。由于我们在CourseListView视图中使用cache.set('all_subjects', subjects)缓存数据时没有指定超时,将使用默认超时(默认为 300 秒,即 5 分钟)。当超时到达时,下一个请求到 URL 将生成缓存未命中,查询集将被执行,数据将被缓存另外 5 分钟。你可以在CACHES设置的TIMEOUT元素中定义不同的默认超时。

基于动态数据的低级缓存

通常,你将想要缓存基于动态数据的内容。在这些情况下,你必须构建包含所有必要信息以唯一标识缓存数据的动态键。

编辑courses应用的views.py文件,并修改CourseListView视图,使其看起来像这样:

class CourseListView(TemplateResponseMixin, View):
    model = Course
    template_name = 'courses/course/list.html'
def get(self, request, subject=None):
        subjects = cache.get('all_subjects')
        if not subjects:
            subjects = Subject.objects.annotate(
                total_courses=Count('courses')
            )
            cache.set('all_subjects', subjects)
        **all_courses** = Course.objects.annotate(
            total_modules=Count('modules')
        )
        if subject:
            subject = get_object_or_404(Subject, slug=subject)
 **key =** **f'subject_****{subject.****id****}****_courses'**
 **courses = cache.get(key)**
**if****not** **courses:**
 **courses = all_courses.****filter****(subject=subject)**
 **cache.****set****(key, courses)**
**else****:**
 **courses = cache.get(****'all_courses'****)**
**if****not** **courses:**
 **courses = all_courses**
 **cache.****set****(****'all_courses'****, courses)**
return self.render_to_response(
            {
                'subjects': subjects,
                'subject': subject,
                'courses': courses
            }
        ) 

在这种情况下,你也缓存了所有课程和按主题筛选的课程。如果没有给出主题,你使用all_courses缓存键来存储所有课程。如果有主题,你使用f'subject_{subject.id}_courses'动态构建键。

重要的是要注意,你不能使用缓存的查询集来构建其他查询集,因为你所缓存的是查询集的结果。所以你不能这样做:

courses = cache.get('all_courses')
courses.filter(subject=subject) 

相反,你必须创建基础查询集 Course.objects.annotate(total_modules=Count('modules')),这个查询集在强制执行之前不会执行,并且使用它通过 all_courses.filter(subject=subject) 进一步限制查询集,以处理数据未在缓存中找到的情况。

缓存模板片段

缓存模板片段是一种高级方法。您需要在模板中使用 {% load cache %} 加载缓存模板标签。然后,您将能够使用 {% cache %} 模板标签来缓存特定的模板片段。您通常按如下方式使用模板标签:

{% cache 300 fragment_name %}
    ...
{% endcache %} 

{% cache %} 模板标签有两个必需的参数:以秒为单位的超时时间和片段的名称。如果您需要根据动态数据缓存内容,可以通过将额外的参数传递给 {% cache %} 模板标签来唯一地标识片段。

编辑 students 应用的 /students/course/detail.html 文件。在 {% extends %} 标签之后添加以下代码:

{% load cache %} 

然后,找到以下行:

{% for content in module.contents.all %}
  {% with item=content.item %}
    <h2>{{ item.title }}</h2>
    {{ item.render }}
  {% endwith %}
{% endfor %} 

将它们替换为以下内容:

**{% cache** **600** **module_contents module %}**
  {% for content in module.contents.all %}
    {% with item=content.item %}
      <h2>{{ item.title }}</h2>
      {{ item.render }}
    {% endwith %}
  {% endfor %}
**{% endcache %}** 

您使用名称 module_contents 缓存此模板片段,并将当前的 Module 对象传递给它。这样,您可以唯一地标识片段。这很重要,以避免缓存模块的内容,并在请求不同模块时提供错误的内容。

如果 USE_I18N 设置为 True,则按站点中间件缓存将尊重活动语言。如果您使用 {% cache %} 模板标签,您必须使用模板中可用的翻译特定变量之一来实现相同的结果,例如 {% cache 600 name request.LANGUAGE_CODE %}

缓存视图

您可以使用位于 django.views.decorators.cachecache_page 装饰器来缓存单个视图的输出。该装饰器需要一个 timeout 参数(以秒为单位)。

让我们在您的视图中使用它。编辑 students 应用的 urls.py 文件并添加以下导入:

from django.views.decorators.cache import cache_page 

然后,将 cache_page 装饰器应用于 student_course_detailstudent_course_detail_module URL 模式,如下所示:

path(
    'course/<pk>/',
 **cache_page(****60** ***** **15****)(**views.StudentCourseDetailView.as_view()**)**,
    name='student_course_detail'
),
path(
    'course/<pk>/<module_id>/',
 **cache_page(****60** ***** **15****)(**views.StudentCourseDetailView.as_view()**)**,
    name='student_course_detail_module'
), 

现在,StudentCourseDetailView 返回的完整内容被缓存了 15 分钟。

按视图缓存使用 URL 来构建缓存键。指向同一视图的多个 URL 将分别缓存。

使用按站点缓存

这是最高级别的缓存。它允许您缓存整个站点。要允许按站点缓存,编辑您项目的 settings.py 文件并将 UpdateCacheMiddlewareFetchFromCacheMiddleware 类添加到 MIDDLEWARE 设置中,如下所示:

MIDDLEWARE = [
    'debug_toolbar.middleware.DebugToolbarMiddleware',
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
**'django.middleware.cache.UpdateCacheMiddleware'****,**
'django.middleware.common.CommonMiddleware',
**'django.middleware.cache.FetchFromCacheMiddleware'****,**
'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
] 

请记住,中间件在请求阶段按给定顺序执行,在响应阶段按相反顺序执行。UpdateCacheMiddleware 被放置在 CommonMiddleware 之前,因为它在响应时间执行,此时中间件按相反顺序执行。FetchFromCacheMiddleware 故意放置在 CommonMiddleware 之后,因为它需要访问后者设置的请求数据集。

接下来,将以下设置添加到 settings.py 文件中:

CACHE_MIDDLEWARE_ALIAS = 'default'
CACHE_MIDDLEWARE_SECONDS = 60 * 15 # 15 minutes
CACHE_MIDDLEWARE_KEY_PREFIX = 'educa' 

在这些设置中,你使用缓存中间件的默认缓存,并将全局缓存超时设置为 15 分钟。你还指定了所有缓存键的前缀,以避免在为多个项目使用相同的 Memcached 后端时发生冲突。现在,你的网站将为所有 GET 请求缓存和返回缓存内容。

你可以使用 Django Debug Toolbar 访问不同的页面并检查缓存请求。对于许多网站来说,按站点缓存不可行,因为它会影响所有视图,甚至是你可能不想缓存的视图,比如管理视图,你希望数据从数据库返回以反映最新的更改。

在这个项目中,最佳做法是缓存用于向学生显示课程内容的模板或视图,同时对于讲师的内容管理视图不进行任何缓存。

让我们停用按站点缓存。编辑你项目的 settings.py 文件,并在 MIDDLEWARE 设置中注释掉 UpdateCacheMiddlewareFetchFromCacheMiddleware 类,如下所示:

MIDDLEWARE = [
    'debug_toolbar.middleware.DebugToolbarMiddleware',
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    **#** 'django.middleware.cache.UpdateCacheMiddleware',
'django.middleware.common.CommonMiddleware',
    **#** 'django.middleware.cache.FetchFromCacheMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
] 

你已经看到了 Django 提供的不同缓存方法的概述。你应该始终明智地定义你的缓存策略,考虑到昂贵的 QuerySets 或计算,不会频繁更改的数据,以及将被许多用户并发访问的数据。

使用 Redis 缓存后端

Django 还提供了 Redis 缓存后端。让我们更改设置,将 Redis 作为项目缓存后端而不是 Memcached。记住,你已经在 第七章跟踪用户行为第十章扩展你的商店 中使用了 Redis。

使用以下命令在你的环境中安装 redis-py

python -m pip install redis==5.0.4 

然后,编辑 educa 项目的 settings.py 文件并修改 CACHES 设置,如下所示:

CACHES = {
    'default': {
        'BACKEND': **'django.core.cache.backends.redis.RedisCache'****,**
'LOCATION': **'redis://127.0.0.1:6379'****,**
    }
} 

项目现在将使用 RedisCache 缓存后端。位置格式定义为 redis://[host]:[port]。你使用 127.0.0.1 指向本地主机,6379 是 Redis 的默认端口。

你可以在 docs.djangoproject.com/en/5.0/topics/cache/#redis 上阅读更多关于 Redis 缓存后端的信息。

使用以下命令初始化 Redis Docker 容器:

docker run -it --rm --name redis -p 6379:6379 redis:7.2.4 

如果你想在后台(分离模式)运行命令,可以使用 -d 选项。

运行开发服务器并在浏览器中打开 http://127.0.0.1:8000/。检查 Django Debug Toolbar 的 缓存 面板中的缓存请求。你现在正在使用 Redis 作为项目缓存后端而不是 Memcached。

使用 Django Redisboard 监控 Redis

你可以使用 Django Redisboard 监控你的 Redis 服务器。Django Redisboard 将 Redis 统计数据添加到 Django 管理站点。你可以在 github.com/ionelmc/django-redisboard 上找到有关 Django Redisboard 的更多信息。

使用以下命令在你的环境中安装 django-redisboard

python -m pip install django-redisboard==8.4.0 

编辑你的项目中的 settings.py 文件,并将应用程序添加到 INSTALLED_APPS 设置中,如下所示:

INSTALLED_APPS = [
    # ...
**'redisboard'****,**
] 

从你的项目目录运行以下命令以运行 Django Redisboard 迁移:

python manage.py migrate redisboard 

在浏览器中运行开发服务器并打开 http://127.0.0.1:8000/admin/redisboard/redisserver/add/ 以添加要监控的 Redis 服务器。对于 标签,输入 redis,对于 URL,输入 redis://localhost:6379/0,如 图 14.11 所示:

图形用户界面,文本,应用程序,电子邮件  自动生成的描述

图 14.11:在管理网站上为 Django Redisboard 添加 Redis 服务器的表单

我们将监控运行在本地的 Redis 实例,该实例在端口 6379 上运行并使用编号为 0 的 Redis 数据库。点击 保存。信息将被保存到数据库中,你将能够在 Django 管理网站上看到 Redis 配置和指标:

图 14.12:Django Redisboard 在管理网站上的 Redis 监控

恭喜!你已经成功实现了项目的缓存功能。

摘要

在本章中,你实现了课程目录的公共视图。你为学生们构建了一个注册和选修课程的系统。你还创建了为课程模块渲染不同类型内容的功能。最后,你学习了如何使用 Django 缓存框架,并在你的项目中使用了 Memcached 和 Redis 缓存后端。

在下一章中,你将使用 Django REST 框架为你的项目构建一个 RESTful API,并使用 Python Requests 库来消费它。

其他资源

以下资源提供了与本章所涵盖主题相关的额外信息:

第十五章:构建 API

在上一章中,您构建了一个学生课程注册和报名的系统。您创建了用于显示课程内容的视图,并学习了如何使用 Django 的缓存框架。

在本章中,您将为您的在线学习平台创建一个 RESTful API。API 是一种常见的可编程接口,可以在多个平台如网站、移动应用程序、插件等上使用。例如,您可以为您的在线学习平台创建一个 API,使其被移动应用程序消费。如果您向第三方提供 API,他们就能够以编程方式消费信息和操作您的应用程序。API 允许开发者自动化平台上的操作,并将您的服务与其他应用程序或在线服务集成。您将为您的在线学习平台构建一个功能齐全的 API。

在本章中,您将:

  • 安装 Django REST 框架

  • 为您的模型创建序列化器

  • 构建 RESTful API

  • 实现序列化方法字段

  • 创建嵌套序列化器

  • 实现视图集视图和路由器

  • 构建 API 视图

  • 处理 API 认证

  • 将权限添加到 API 视图

  • 创建自定义权限

  • 使用 Requests 库消费 API

功能概述

图 15.1 展示了本章将要构建的视图和 API 端点的表示:

图片

图 15.1:第十五章中要构建的 API 视图和端点的示意图

在本章中,您将创建两组不同的 API 视图,SubjectViewSetCourseViewSet。前者将包括主题的列表和详情视图。后者将包括课程的列表和详情视图。您还将实现 CourseViewSet 中的 enroll 动作,以便将学生报名到课程中。此动作仅对经过身份验证的用户可用,使用 IsAuthenticated 权限。您将在 CourseViewSet 中创建 contents 动作以访问课程内容。要访问课程内容,用户必须经过身份验证并已报名参加指定的课程。您将实现自定义的 IsEnrolled 权限,以限制对内容的访问仅限于已报名课程的用户。

如果您不熟悉 API 端点,您只需知道它们是 API 中接受并响应请求的特定位置。每个端点对应一个可能接受一个或多个 HTTP 方法(如 GETPOSTPUTDELETE)的 URL。

本章的源代码可以在 github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter15 找到。

本章中使用的所有 Python 模块都包含在本章源代码的 requirements.txt 文件中。您可以根据以下说明安装每个 Python 模块,或者您可以使用命令 python -m pip install -r requirements.txt 一次性安装所有依赖。

构建 RESTful API

在构建 API 时,您有多种方式可以构建其端点和操作,但遵循 REST 原则是被鼓励的。

REST 架构来自 表征状态转移。RESTful API 是基于资源的;您的模型代表资源,HTTP 方法如 GETPOSTPUTDELETE 用于检索、创建、更新或删除对象。HTTP 响应代码也用于此上下文中。不同的 HTTP 响应代码被返回以指示 HTTP 请求的结果,例如,2XX 响应代码表示成功,4XX 表示错误,等等。

在 RESTful API 中交换数据的常见格式是 JSON 和 XML。您将为您的项目构建一个使用 JSON 序列化的 RESTful API。您的 API 将提供以下功能:

  • 获取科目

  • 获取可用课程

  • 获取课程内容

  • 注册课程

您可以使用 Django 通过创建自定义视图从头开始构建 API。然而,有几个第三方模块可以简化为您的项目创建 API;其中最受欢迎的是 Django REST 框架DRF)。

DRF 为您的项目构建 RESTful API 提供了一套全面的工具。以下是我们将用于构建我们的 API 的最相关组件之一:

  • 序列化器:将数据转换为其他程序可以理解的标准格式,或者通过将数据转换为程序可以处理的格式来 反序列化 数据。

  • 解析器和渲染器:在将序列化数据返回为 HTTP 响应之前,适当地渲染(或格式化)数据。同样,解析传入的数据以确保其处于正确的形式。

  • API 视图:用于实现应用程序逻辑。

  • URLs:用于定义可用的 API 端点。

  • 身份验证和权限:用于定义 API 的身份验证方法和每个视图所需的权限。

我们将首先安装 DRF,然后我们将学习更多关于这些组件以构建我们的第一个 API。

安装 Django REST framework

您可以在 www.django-rest-framework.org/ 找到有关 DRF 的所有信息。

打开终端并使用以下命令安装框架:

python -m pip install djangorestframework==3.15.1 

编辑 educa 项目的 settings.py 文件,并将 rest_framework 添加到 INSTALLED_APPS 设置中,以激活应用程序,如下所示:

INSTALLED_APPS = [
    # ...
**'rest_framework'****,**
] 

然后,将以下代码添加到 settings.py 文件中:

REST_FRAMEWORK = {
    'DEFAULT_PERMISSION_CLASSES': [
        'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly'
    ]
} 

您可以使用 REST_FRAMEWORK 设置为您的 API 提供特定的配置。DRF 提供了广泛的设置来配置默认行为。DEFAULT_PERMISSION_CLASSES 设置指定了读取、创建、更新或删除对象的默认权限。您将 DjangoModelPermissionsOrAnonReadOnly 设置为唯一的默认权限类。此类依赖于 Django 的权限系统,允许用户创建、更新或删除对象,同时为匿名用户提供只读访问。您将在 添加权限到视图 部分中了解更多关于权限的内容。

要获取 DRF 的完整设置列表,您可以访问 www.django-rest-framework.org/api-guide/settings/

定义序列化器

在设置 DRF 后,您需要指定数据序列化的方式。输出数据必须以特定格式序列化,输入数据在处理之前将进行反序列化。该框架提供了以下类来为单个对象构建序列化器:

  • Serializer:为常规 Python 类实例提供序列化

  • ModelSerializer:为模型实例提供序列化

  • HyperlinkedModelSerializer:与 ModelSerializer 相同,但它使用链接而不是主键来表示对象关系

让我们构建我们的第一个序列化器。在 courses 应用程序目录内创建以下文件结构:

api/
    __init__.py
    serializers.py 

您将在 api 目录内构建所有 API 功能,以保持一切井井有条。编辑 api/serializers.py 文件并添加以下代码:

from rest_framework import serializers
from courses.models import Subject
class SubjectSerializer(serializers.ModelSerializer):
    class Meta:
        model = Subject
        fields = ['id', 'title', 'slug'] 

这是 Subject 模型的序列化器。序列化器定义的方式与 Django 的 FormModelForm 类类似。Meta 类允许您指定要序列化的模型以及要包含在序列化中的字段。如果不设置 fields 属性,则所有模型字段都将被包含。

让我们尝试序列化器。打开命令行,使用以下命令启动 Django 命令行:

python manage.py shell 

运行以下代码:

>>> from courses.models import Subject
>>> from courses.api.serializers import SubjectSerializer
>>> subject = Subject.objects.latest('id')
>>> serializer = SubjectSerializer(subject)
>>> serializer.data
{'id': 4, 'title': 'Programming', 'slug': 'programming'} 

在本例中,您获取一个 Subject 对象,创建 SubjectSerializer 的实例,并访问序列化数据。您可以看到模型数据被转换为 Python 原生数据类型。

您可以在 www.django-rest-framework.org/api-guide/serializers/ 上了解更多关于序列化器的信息。

理解解析器和渲染器

在将序列化数据返回为 HTTP 响应之前,必须以特定格式渲染序列化数据。同样,在接收到 HTTP 请求时,您必须解析传入的数据并在操作之前对其进行反序列化。DRF 包括渲染器和解析器来处理这些操作。

让我们看看如何解析传入的数据。在 Python 命令行中执行以下代码:

>>> from io import BytesIO
>>> from rest_framework.parsers import JSONParser
>>> data = b'{"id":4,"title":"Programming","slug":"programming"}'
>>> JSONParser().parse(BytesIO(data))
{'id': 4, 'title': 'Programming', 'slug': 'programming'} 

给定一个 JSON 字符串输入,您可以使用 DRF 提供的 JSONParser 类将其转换为 Python 对象。

DRF 还包括允许您格式化 API 响应的Renderer类。框架通过检查请求的Accept头,确定响应的预期内容类型,通过内容协商来决定使用哪个渲染器。可选地,渲染器也可以由 URL 的格式后缀确定。例如,URL http://127.0.0.1:8000/api/data.json可能是一个触发JSONRenderer以返回 JSON 响应的端点。

返回到 shell 并执行以下代码以从上一个序列化器示例中渲染serializer对象:

>>> from rest_framework.renderers import JSONRenderer
>>> JSONRenderer().render(serializer.data) 

您将看到以下输出:

b'{"id":4,"title":"Programming","slug":"programming"}' 

您使用JSONRenderer将序列化数据渲染成 JSON。默认情况下,DRF 使用两个不同的渲染器:JSONRendererBrowsableAPIRenderer。后者提供了一个网页界面,可以轻松浏览您的 API。您可以使用REST_FRAMEWORK设置的DEFAULT_RENDERER_CLASSES选项更改默认的渲染器类。

您可以在www.django-rest-framework.org/api-guide/renderers/www.django-rest-framework.org/api-guide/parsers/找到有关渲染器和解析器的更多信息。

接下来,您将学习如何构建 API 视图并使用序列化器在视图中。

构建列表和详细视图

DRF 附带了一系列通用的视图和混入,您可以使用它们来构建您的 API 视图。自第二章“增强您的博客和添加社交功能”以来,您一直在使用通用视图,并在第十三章“创建内容管理系统”中学习了混入。

基础视图和混入提供了检索、创建、更新或删除模型对象的功能。您可以在www.django-rest-framework.org/api-guide/generic-views/查看 DRF 提供的所有通用混入和视图。

让我们创建列表和详细视图来检索Subject对象。在courses/api/目录内创建一个新文件,命名为views.py。向其中添加以下代码:

from rest_framework import generics
from courses.api.serializers import SubjectSerializer
from courses.models import Subject
class SubjectListView(generics.ListAPIView):
    queryset = Subject.objects.all()
    serializer_class = SubjectSerializer
class SubjectDetailView(generics.RetrieveAPIView):
    queryset = Subject.objects.all()
    serializer_class = SubjectSerializer 

在此代码中,您使用了 DRF 的通用ListAPIViewRetrieveAPIView视图。这两个视图都有以下属性:

  • queryset:用于检索对象的基 QuerySet

  • serializer_class:用于序列化对象的类

让我们为您的视图添加 URL 模式。在courses/api/目录内创建一个新文件,命名为urls.py,并使其看起来如下:

from django.urls import path
from . import views
app_name = 'courses'
urlpatterns = [
    path(
        'subjects/',
        views.SubjectListView.as_view(),
        name='subject_list'
    ),
    path(
        'subjects/<pk>/',
        views.SubjectDetailView.as_view(),
        name='subject_detail'
    ),
] 

SubjectDetailView视图的 URL 模式中,您包括一个pk URL 参数,用于检索具有给定Subject模型主键的对象,即id字段。编辑educa项目的主体urls.py文件,并包含 API 模式,如下所示:

urlpatterns = [
    # ...
 **path(****'api/'****, include(****'courses.api.urls'****, namespace=****'api'****)),**
] 

您使用api命名空间为您的 API URL。我们的初始 API 端点已准备好使用。

消费 API

通过通过 URL 提供我们的视图,我们已经创建了我们的第一个 API 端点。现在让我们尝试自己的 API。确保您的服务器正在以下命令下运行:

python manage.py runserver 

我们将使用curl来消费 API。curl是一个命令行工具,允许您在服务器之间传输数据。如果您使用 Linux、macOS 或 Windows 10/11,curl很可能已经包含在您的系统中。但是,您可以从curl.se/download.html下载curl

打开 shell,使用curl检索 URL http://127.0.0.1:8000/api/subjects/,如下所示:

curl http://127.0.0.1:8000/api/subjects/ 

您将获得以下类似的响应:

[
    {
        "id":1,
        "title":"Mathematics",
        "slug":"mathematics"
    },
    {
        "id":2,
        "title":"Music",
        "slug":"music"
    },
    {
        "id":3,
        "title":"Physics",
        "slug":"physics"
    },
    {
        "id":4,
        "title":"Programming",
        "slug":"programming"
    }
] 

要获得更易读、缩进良好的 JSON 响应,您可以使用带有json_pp工具的curl,如下所示:

curl http://127.0.0.1:8000/api/subjects/ | json_pp 

HTTP 响应包含 JSON 格式的Subject对象列表。

除了curl之外,您还可以使用任何其他工具发送自定义 HTTP 请求,包括 Postman 浏览器扩展,您可以在www.getpostman.com/获取。

在您的浏览器中打开http://127.0.0.1:8000/api/subjects/。您将看到 DRF 的可浏览 API,如下所示:

图形用户界面,文本,自动生成的描述

图 15.2:REST 框架可浏览 API 中的主题列表页面

这个 HTML 界面是由BrowsableAPIRenderer渲染器提供的。它显示结果标题和内容,并允许您执行请求。您还可以通过在 URL 中包含其 ID 来访问Subject对象的 API 详情视图。

在您的浏览器中打开http://127.0.0.1:8000/api/subjects/1/。您将看到一个单独的主题对象以 JSON 格式呈现。

图形用户界面,文本,应用程序,电子邮件,自动生成的描述

图 15.3:REST 框架可浏览 API 中的主题详情页面

这是SubjectDetailView的响应。让我们学习如何丰富每个主题返回的内容。在下一节中,我们将深入探讨如何通过添加额外的字段和方法来扩展序列化器。

扩展序列化器

您已经学习了如何序列化您的模型对象;然而,通常,您可能希望通过添加额外的相关数据或计算字段来丰富响应。让我们看看扩展序列化器的一些选项。

向序列化器中添加额外的字段

让我们编辑主题视图,包括每个主题可用的课程数量。您将使用 Django 聚合函数来注释每个主题的相关课程数量。

编辑courses应用的api/views.py文件,并添加以下加粗显示的代码:

**from** **django.db.models** **import** **Count**
# ...
class SubjectListView(generics.ListAPIView):
    queryset = Subject.objects.**annotate(total_courses=Count(****'courses'****))**
    serializer_class = SubjectSerializer
class SubjectDetailView(generics.RetrieveAPIView):
    queryset = Subject.objects.**annotate(total_courses=Count(****'courses'****))**
    serializer_class = SubjectSerializer 

您现在正在使用SubjectListViewSubjectDetailView的 QuerySet,它使用Count聚合函数来注释相关课程的数量。

编辑courses应用的api/serializers.py文件,并添加以下加粗显示的代码:

from rest_framework import serializers
from courses.models import Subject
class SubjectSerializer(serializers.ModelSerializer):
 **total_courses = seralizers.IntegerField()**
class Meta:
        model = Subject
        fields = ['id', 'title', 'slug'**,** **'total_courses'**] 

您已将 total_courses 字段添加到 SubjectSerializer 类中。该字段是一个 IntegerField,用于表示整数。该字段将自动从正在序列化的对象的 total_courses 属性获取其值。通过使用 annotate(),我们将 total_courses 属性添加到 QuerySet 的结果对象中。

在浏览器中打开 http://127.0.0.1:8000/api/subjects/1/。现在序列化的 JSON 对象包括 total_courses 属性,如图 15.4 所示:

图 15.4:主题详细页面,包括 total_courses 属性

您已成功将 total_courses 属性添加到主题列表和详细视图中。现在,让我们看看如何使用自定义序列化方法添加其他属性。

实现序列化方法字段

DRF 提供 SerializerMethodField,允许您实现通过调用序列化类的方法来获取值的只读字段。当您想在序列化对象中包含一些自定义格式化的数据或执行不是模型实例直接部分的复杂计算时,这特别有用。

我们将创建一个方法,用于序列化一个主题的前 3 个热门课程。我们将根据注册学生的数量对课程进行排名。编辑 courses 应用程序的 api/serializers.py 文件,并添加以下加粗代码:

**from** **django.db.models** **import** **Count**
from rest_framework import serializers
from courses.models import Subject
class SubjectSerializer(serializers.ModelSerializer):
    total_courses = serializers.IntegerField()
 **popular_courses = seralizers.SerializerMethodField()**
**def****get_popular_courses****(****self, obj****):**
 **courses = obj.courses.annotate(**
 **total_students=Count(****'students'****)**
 **).order_by(****'****total_students'****)[:****3****]**
**return** **[**
**f'****{c.title}** **(****{c.total_students}****)'****for** **c** **in** **courses**
 **]**
class Meta:
        model = Subject
        fields = [
            'id',
            'title',
            'slug',
            'total_courses'**,**
**'popular_courses'**
        ] 

在新代码中,您将新的 popular_courses 序列化方法字段添加到 SubjectSerializer。该字段从 get_popular_courses() 方法获取其值。您可以使用 method_field 参数提供要调用的序列化方法名称。如果不包含,则默认为 get_<field_name>

在浏览器中打开 http://127.0.0.1:8000/api/subjects/1/。现在序列化的 JSON 对象包括 total_courses 属性,如图 15.5 所示:

图 15.5:主题详细页面,包括热门课程属性

您已成功实现了一个 SerializerMethodField。请注意,现在,为 SubjectListView 返回的每个结果都会生成一个额外的 SQL 查询。接下来,您将学习如何通过向 SubjectListView 添加分页来控制返回的结果数量。

向视图中添加分页

DRF 包含内置的分页功能,用于控制 API 响应中发送的对象数量。当您网站的内容开始增长时,您可能会拥有大量的主题和课程。分页在处理大量数据集时可以特别有用,以改善性能和用户体验。

让我们更新 SubjectListView 视图以包括分页。首先,我们将定义一个分页类。

courses/api/ 目录中创建一个新文件,并将其命名为 pagination.py。向其中添加以下代码:

from rest_framework.pagination import PageNumberPagination
class StandardPagination(PageNumberPagination):
    page_size = 10
    page_size_query_param = 'page_size'
    max_page_size = 50 

在这个类中,我们继承自PageNumberPagination。这个类提供了基于页码的分页支持。我们设置了以下属性:

  • page_size:确定当请求中没有提供页面大小时,默认页面大小(每页返回的项目数)

  • page_size_query_params:定义用于页面大小的查询参数的名称

  • max_page_size:指示允许的最大请求页面大小

现在,编辑courses应用的api/views.py文件,并添加以下加粗的行:

from django.db.models import Count
from rest_framework import generics
from courses.models import Subject
**from** **courses.api.pagination** **import** **StandardPagination**
from courses.api.serializers import SubjectSerializer
class SubjectListView(generics.ListAPIView):
    queryset = Subject.objects.annotate(total_courses=Count('courses'))
    serializer_class = SubjectSerializer
 **pagination_class = StandardPagination**
# ... 

您现在可以分页SubjectListView返回的对象。在浏览器中打开http://127.0.0.1:8000/api/subjects/。您会看到由于分页,视图返回的 JSON 结构现在不同。您将看到以下结构:

{
"count": 4,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"title": "Mathematics",
            ...
        },
        ...
    ]
} 

以下项现在包含在返回的 JSON 中:

  • count:结果总数。

  • next:获取下一页的 URL。当没有下一页时,值为null

  • previous:获取上一页的 URL。当没有上一页时,值为null

  • results:本页返回的序列化对象列表。

在浏览器中打开http://127.0.0.1:8000/api/subjects/?page_size=2&page=1。这将按每页两个项目分页结果并检索第一页的结果,如图 15.6所示:

图 15.6:主题列表分页的第一页结果,页面大小为 2

我们已经根据页码实现了分页,但 DRF 还提供了一个类来实现基于 limit/offset 和游标的分页。您可以在www.django-rest-framework.org/api-guide/pagination/上了解更多关于分页的信息。

您已为主题视图创建了 API 端点。接下来,您将向 API 中添加课程端点。

构建课程序列化器

我们将创建Course模型的序列化器。编辑courses应用的api/serializers.py文件,并添加以下加粗的代码:

# ...
from courses.models import **Course,** Subject
**class****CourseSerializer****(serializers.ModelSerializer):**
**class****Meta****:**
 **model = Course**
 **fields = [**
**'id'****,**
**'subject'****,**
**'****title'****,**
**'slug'****,**
**'overview'****,**
**'created'****,**
**'owner'****,**
**'modules'**
 **]** 

让我们看看Course对象是如何序列化的。打开 shell 并执行以下命令:

python manage.py shell 

运行以下代码:

>>> from rest_framework.renderers import JSONRenderer
>>> from courses.models import Course
>>> from courses.api.serializers import CourseSerializer
>>> course = Course.objects.latest('id')
>>> serializer = CourseSerializer(course)
>>> JSONRenderer().render(serializer.data) 

您将获得一个包含您在CourseSerializer中包含的字段的 JSON 对象。您可以看到,modules管理器的相关对象被序列化为一个主键列表,如下所示:

"modules": [6, 7, 9, 10] 

这些是相关Module对象的 ID。接下来,您将学习不同的方法来序列化相关对象。

序列化关系

DRF 提供了不同类型的关联字段来表示模型关系。这适用于ForeignKeyManyToManyFieldOneToOneField关系,以及通用模型关系。

我们将使用StringRelatedField来更改相关Module对象的序列化方式。StringRelatedField使用其__str__()方法表示相关对象。

编辑courses应用的api/serializers.py文件,并添加以下加粗代码:

# ...
class CourseSerializer(serializers.ModelSerializer):
 **modules = serializers.StringRelatedField(many=****True****, read_only=****True****)**
class Meta:
        # ... 

在新代码中,你定义了modules字段,该字段为相关的Module对象提供序列化。你使用many=True来表示你正在序列化多个相关对象。read_only参数表示该字段为只读,不应包含在任何创建或更新对象的输入中。

打开 shell 并再次创建CourseSerializer的实例。使用JSONRenderer渲染序列化器的data属性。这次,列出的模块使用它们的__str__()方法进行序列化,如下所示:

"modules": ["0\. Installing Django", "1\. Configuring Django"] 

注意,DRF 不优化查询集。当序列化课程列表时,将为每个课程结果生成一个 SQL 查询来检索相关的Module对象。你可以通过在查询集中使用prefetch_related()来减少额外的 SQL 请求的数量,例如Course.objects.prefetch_related('modules')。我们将在创建视图集和路由器部分中稍后介绍这一点。

你可以在www.django-rest-framework.org/api-guide/relations/了解更多关于序列化关系的信息。

让我们进一步前进,并定义使用嵌套序列化器的相关对象的序列化。

创建嵌套序列化器

如果我们想要包含关于每个模块的更多信息,我们需要序列化Module对象并将它们嵌套。修改courses应用的api/serializers.py文件的先前代码,使其看起来如下所示:

from django.db.models import Count
from rest_framework import serializers
from courses.models import Course, **Module,** Subject
**class****ModuleSerializer****(serializers.ModelSerializer):**
**class****Meta****:**
 **model = Module**
 **fields = [****'order'****,** **'title'****,** **'description'****]**
class CourseSerializer(serializers.ModelSerializer):
    modules = **ModuleSerializer**(many=True, read_only=True)
    class Meta:
        # ... 

在新代码中,你定义了ModuleSerializer来为Module模型提供序列化。然后,你修改了CourseSerializermodules属性以嵌套ModuleSerializer序列化器。你保持many=True以表示你正在序列化多个对象,并保持read_only=True以保持该字段为只读。

打开 shell 并再次创建CourseSerializer的实例。使用JSONRenderer渲染序列化器的data属性。这次,列出的模块使用嵌套的ModuleSerializer序列化器进行序列化,如下所示:

"modules": [
{
"order": 0,
"title": "Introduction to overview",
"description": "A brief overview about the Web Framework."
},
{
"order": 1,
"title": "Configuring Django",
"description": "How to install Django."
},
    ...
] 

创建视图集和路由器

视图集允许你定义你的 API 交互,并让 DRF 使用Router对象动态构建 URL。通过使用视图集,你可以避免为多个视图重复逻辑。视图集包括以下标准操作的动作:

  • 创建操作:create()

  • 查询操作:list()retrieve()

  • 更新操作:update()partial_update()

  • 删除操作:destroy()

让我们为Course模型创建一个视图集。编辑api/views.py文件,并添加以下加粗代码:

from django.db.models import Count
from rest_framework import generics
**from** **rest_framework** **import** **viewsets**
from courses.api.pagination import StandardPagination
from courses.api.serializers import **CourseSerializer,** SubjectSerializer
from courses.models import **Course,** Subject
**class****CourseViewSet****(viewsets.ReadOnlyModelViewSet):**
 **queryset = Course.objects.prefetch_related(****'modules'****)**
 **serializer_class = CourseSerializer**
 **pagination_class = StandardPagination** 

新的 CourseViewSet 类继承自 ReadOnlyModelViewSet,它提供了只读操作 list()retrieve(),分别用于列出对象或检索单个对象。您指定用于检索对象的基查询集。您使用 prefetch_related('modules') 以高效方式获取相关的 Module 对象。这将避免在序列化每个课程的嵌套模块时进行额外的 SQL 查询。在此类中,您还定义了用于 ViewSet 的序列化和分页类。

编辑 api/urls.py 文件,并为您的 ViewSet 创建一个路由器,如下所示:

from django.urls import **include,** path
**from** **rest_framework** **import** **routers**
from . import views
app_name = 'courses'
**router = routers.DefaultRouter()**
**router.register(****'****courses'****, views.CourseViewSet)**
urlpatterns = [
    # ...
**path(****''****, include(router.urls)),**
] 

您创建一个 DefaultRouter 对象,并使用 courses 前缀注册 CourseViewSet。路由器负责为您的 ViewSet 自动生成 URL。

在浏览器中打开 http://127.0.0.1:8000/api/。您将看到路由器在其基本 URL 中列出了 courses ViewSet,如图 图 15.7 所示:

Graphical user interface, text, application, email  Description automatically generated

图 15.7:REST 框架可浏览 API 的 API 根页面

您可以通过访问 http://127.0.0.1:8000/api/courses/ 来获取课程列表,如图 图 15.8 所示:

![img/B21088_15_08.png]

图 15.8:REST 框架可浏览 API 中的课程列表页面

让我们将 SubjectListViewSubjectDetailView 视图转换为单个 ViewSet。编辑 api/views.py 文件,并删除或注释掉 SubjectListViewSubjectDetailView 类。然后,添加以下加粗的代码:

# ...
**class****SubjectViewSet****(viewsets.ReadOnlyModelViewSet):**
 **queryset = Subject.objects.annotate(total_courses=Count(****'courses'****))**
 **serializer_class = SubjectSerializer**
 **pagination_class = StandardPagination** 

编辑 api/urls.py 文件,并删除或注释掉以下 URL,因为您不再需要它们:

**#** path(
**# **    subjects/',
**# **    views.SubjectListView.as_view(),
**# **    name=subject_list'
**#** ),
**#** path(
**# **    subjects/<pk>/ ',
**# **     views.SubjectDetailView.as_view(),
**# **     name='subject_detail'
**#**), 

在同一文件中,添加以下加粗的代码:

from django.urls import include, path
from rest_framework import routers
from . import views
app_name = 'courses'
router = routers.DefaultRouter()
router.register('courses', views.CourseViewSet)
**router.register(****'subjects'****, views.SubjectViewSet)**
urlpatterns = [
    path('', include(router.urls)),
] 

在浏览器中打开 http://127.0.0.1:8000/api/。您将看到路由器现在包括了 coursessubjects ViewSets 的 URL,如图 图 15.9 所示:

![img/B21088_15_09.png]

图 15.9:REST 框架可浏览 API 的 API 根页面

您可以在 www.django-rest-framework.org/api-guide/viewsets/ 上了解更多关于 ViewSets 的信息。您还可以在 www.django-rest-framework.org/api-guide/routers/ 上找到有关路由器的更多信息。

通用 API 视图和 ViewSets 对于基于您的模型和序列化器构建 REST API 非常有用。然而,您可能还需要实现自己的视图并添加自定义逻辑。让我们学习如何创建一个自定义 API 视图。

构建自定义 API 视图

DRF 提供了一个 APIView 类,它基于 Django 的 View 类构建 API 功能。APIView 类与 View 不同,因为它使用 DRF 的自定义 RequestResponse 对象,并处理 APIException 异常以返回适当的 HTTP 响应。它还内置了身份验证和授权系统来管理对视图的访问。

您将创建一个用户注册课程的视图。编辑courses应用的api/views.py文件,并添加以下加粗代码:

from django.db.models import Count
**from** **django.shortcuts** **import** **get_object_or_404**
from rest_framework import generics
from rest_framework import viewsets
**from** **rest_framework.response** **import** **Response**
**from** **rest_framework.views** **import** **APIView**
from courses.api.pagination import StandardPagination
from courses.api.serializers import CourseSerializer, SubjectSerializer
from courses.models import Course, Subject
# ...
**class****CourseEnrollView****(****APIView****):**
**def****post****(****self, request, pk,** **format****=****None****):**
 **course = get_object_or_404(Course, pk=pk)**
 **course.students.add(request.user)**
**return** **Response({****'enrolled'****:** **True****})** 

CourseEnrollView视图处理用户在课程中的注册。前面的代码如下:

  1. 您创建一个自定义视图,该视图继承自APIView

  2. 您为POST操作定义一个post()方法。此视图不允许其他 HTTP 方法。

  3. 您期望一个包含课程 ID 的pk URL 参数。您通过给定的pk参数检索课程,如果未找到则引发404异常。

  4. 您将当前用户添加到Course对象的students多对多关系,并返回一个成功的响应。

编辑api/urls.py文件,并将以下 URL 模式添加到CourseEnrollView视图:

path(
    'courses/<pk>/enroll/',
    views.CourseEnrollView.as_view(),
    name='course_enroll'
), 

理论上,您现在可以执行一个POST请求来注册当前用户到课程中。然而,您需要能够识别用户并防止未经认证的用户访问此视图。让我们看看 API 认证和权限是如何工作的。

处理认证

DRF 提供了认证类来识别执行请求的用户。如果认证成功,框架将在request.user中设置认证的User对象。如果没有用户认证,则设置 Django 的AnonymousUser实例。

DRF 提供了以下认证后端:

  • BasicAuthentication:这是 HTTP 基本认证。用户名和密码由客户端通过Authorization HTTP 头发送,并使用 Base64 编码。您可以在en.wikipedia.org/wiki/Basic_access_authentication了解更多信息。

  • TokenAuthentication:这是基于令牌的认证。使用Token模型来存储用户令牌。用户将令牌包含在Authorization HTTP 头中进行认证。

  • SessionAuthentication:这使用 Django 的会话后端进行认证。此后端对于从您的网站前端向 API 执行认证的 AJAX 请求很有用。

  • RemoteUserAuthentication:这允许您将认证委托给您的 Web 服务器,该服务器设置一个REMOTE_USER环境变量。

您可以通过继承 DRF 提供的BaseAuthentication类并重写authenticate()方法来构建自定义认证后端。

实现基本认证

您可以按视图设置认证,或使用DEFAULT_AUTHENTICATION_CLASSES设置全局设置认证。

认证仅识别执行请求的用户。它不会允许或拒绝对视图的访问。您必须使用权限来限制对视图的访问。

您可以在www.django-rest-framework.org/api-guide/authentication/找到有关认证的所有信息。

让我们在视图中添加BasicAuthentication。编辑courses应用的api/views.py文件,并将authentication_classes属性添加到CourseEnrollView中,如下所示:

# ...
**from** **rest_framework.authentication** **import** **BasicAuthentication**
class CourseEnrollView(APIView):
 **authentication_classes = [BasicAuthentication]**
# ... 

用户将通过 HTTP 请求的Authorization头中设置的凭证来识别。

向视图中添加权限

DRF 包含一个权限系统来限制对视图的访问。DRF 的一些内置权限包括:

  • AllowAny: 无限制访问,无论用户是否已认证。

  • IsAuthenticated: 仅允许认证用户访问。

  • IsAuthenticatedOrReadOnly: 完全访问认证用户。匿名用户仅允许执行读取方法,如GETHEADOPTIONS

  • DjangoModelPermissions: 与django.contrib.auth相关的权限。视图需要一个queryset属性。只有被分配了模型权限的认证用户才被授予权限。

  • DjangoObjectPermissions: 基于对象的 Django 权限。

如果用户被拒绝权限,他们通常会收到以下 HTTP 错误代码之一:

  • HTTP 401: 未授权

  • HTTP 403: 权限被拒绝

您可以在www.django-rest-framework.org/api-guide/permissions/上阅读有关权限的更多信息。

编辑courses应用的api/views.py文件,并将permission_classes属性添加到CourseEnrollView中,如下所示:

# ...
from rest_framework.authentication import BasicAuthentication
**from** **rest_framework.permissions** **import** **IsAuthenticated**
class CourseEnrollView(APIView):
    authentication_classes = [BasicAuthentication]
 **permission_classes = [IsAuthenticated]**
# ... 

您包含了IsAuthenticated权限。这将阻止匿名用户访问视图。现在,您可以向新的 API 方法发送一个POST请求。

确保开发服务器正在运行。打开 shell 并运行以下命令:

curl -i -X POST http://127.0.0.1:8000/api/courses/1/enroll/ 

您将得到以下响应:

HTTP/1.1 401 Unauthorized
...
{"detail": "Authentication credentials were not provided."} 

如预期,您得到了401 HTTP 代码,因为您尚未认证。让我们使用基本认证,使用您的其中一个用户。运行以下命令,将student:password替换为现有用户的凭证:

curl -i -X POST -u student:password http://127.0.0.1:8000/api/courses/1/enroll/ 

您将得到以下响应:

HTTP/1.1 200 OK
...
{"enrolled": true} 

您可以访问管理站点并检查用户是否已注册课程。

向 ViewSets 添加额外操作

您可以向ViewSets添加额外的操作。让我们将CourseEnrollView视图改为自定义ViewSet操作。编辑api/views.py文件,并将CourseViewSet类修改如下:

# ...
**from** **rest_framework.decorators** **import** **action**
class CourseViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = Course.objects.prefetch_related('modules')
    serializer_class = CourseSerializer
 **@action(**
**detail=****True****,**
**methods=[****'post'****],**
**authentication_classes=[BasicAuthentication],**
**permission_classes=[IsAuthenticated]**
**)**
**def****enroll****(****self, request, *args, **kwargs****):**
 **course = self.get_object()**
 **course.students.add(request.user)**
**return** **Response({****'enrolled'****:** **True****})** 

在前面的代码中,您添加了一个自定义的enroll()方法,代表此ViewSet的附加操作。前面的代码如下:

  1. 您使用框架的action装饰器,并设置参数detail=True来指定这是一个针对单个对象执行的操作。

  2. 该装饰器允许您为操作添加自定义属性。您指定仅允许此视图的post()方法,并设置认证和权限类。

  3. 您使用self.get_object()来检索Course对象。

  4. 您将当前用户添加到students多对多关系,并返回一个自定义成功响应。

编辑api/urls.py文件,删除或注释掉以下 URL,因为您不再需要它:

path(
    'courses/<pk>/enroll/',
    views.CourseEnrollView.as_view(),
    name='course_enroll'
), 

然后,编辑api/views.py文件,删除或注释掉CourseEnrollView类。

注册课程的 URL 现在由路由器自动生成。URL 保持不变,因为它是通过使用动作名称enroll动态构建的。

学生注册课程后,需要访问课程内容。接下来,您将学习如何确保只有注册的学生才能访问课程。

创建自定义权限

您希望学生能够访问他们注册的课程内容。只有注册了课程的学生才能访问其内容。最佳方式是使用自定义权限类。DRF 提供了一个BasePermission类,允许您定义以下方法:

  • has_permission():视图级别的权限检查

  • has_object_permission():实例级别的权限检查

这些方法应返回True以授予访问权限,否则返回False

courses/api/目录内创建一个新文件,并将其命名为permissions.py。向其中添加以下代码:

from rest_framework.permissions import BasePermission
class IsEnrolled(BasePermission):
    def has_object_permission(self, request, view, obj):
        return obj.students.filter(id=request.user.id).exists() 

您通过继承BasePermission类并重写has_object_permission()方法来创建子类。您检查执行请求的用户是否存在于Course对象的students关系中。您将使用IsEnrolled权限。

序列化课程内容

您需要序列化课程内容。Content模型包含一个通用的外键,允许您关联不同内容模型的对象。然而,在前一章中,您为所有内容模型添加了一个通用的render()方法。您可以使用此方法向 API 提供渲染后的内容。

编辑courses应用的api/serializers.py文件,并向其中添加以下代码:

from courses.models import **Content,** Course, Module, Subject
**class****ItemRelatedField****(serializers.RelatedField):**
**def****to_representation****(****self, value****):**
**return** **value.render()**
**class****ContentSerializer****(serializers.ModelSerializer):**
**item = ItemRelatedField(read_only=****True****)**
**class****Meta****:**
**model = Content**
**fields = [****'order'****,** **'item'****]** 

在此代码中,您通过继承 DRF 提供的RelatedField序列化器字段并重写to_representation()方法来定义一个自定义字段。您为Content模型定义了ContentSerializer序列化器,并使用自定义字段为item通用外键。

您需要一个替代序列化器,用于Module模型及其内容,以及扩展的Course序列化器。编辑api/serializers.py文件,并向其中添加以下代码:

class ModuleWithContentsSerializer(serializers.ModelSerializer):
    contents = ContentSerializer(many=True)
    class Meta:
        model = Module
        fields = ['order', 'title', 'description', 'contents']
class CourseWithContentsSerializer(serializers.ModelSerializer):
    modules = ModuleWithContentsSerializer(many=True)
    class Meta:
        model = Course
        fields = [
            'id',
            'subject',
            'title',
            'slug',
            'overview',
            'created',
            'owner',
            'modules'
        ] 

让我们创建一个视图,模拟retrieve()操作的行为,但包括课程内容。编辑api/views.py文件,并向CourseViewSet类中添加以下方法:

# ...
**from** **courses.api.permissions** **import** **IsEnrolled**
**from** **courses.api.serializers** **import** **CourseWithContentsSerializer**
class CourseViewSet(viewsets.ReadOnlyModelViewSet):
    # ...
 **@action(**
**detail=****True****,**
**methods=[****'get'****],**
**serializer_class=CourseWithContentsSerializer,**
**authentication_classes=[BasicAuthentication],**
**permission_classes=[IsAuthenticated, IsEnrolled]**
**)**
**def****contents****(****self, request, *args, **kwargs****):**
**return** **self.retrieve(request, *args, **kwargs)** 

此方法的描述如下:

  1. 您使用带有参数detail=Trueaction装饰器来指定对单个对象执行的操作。

  2. 您指定仅允许对此操作使用GET方法。

  3. 您使用包含渲染后的课程内容的新的CourseWithContentsSerializer序列化器类。

  4. 你使用IsAuthenticated和你的自定义IsEnrolled权限。通过这样做,你确保只有注册了课程的用户才能访问其内容。

  5. 你使用现有的retrieve()操作来返回Course对象。

在你的浏览器中打开http://127.0.0.1:8000/api/courses/1/contents/。如果你使用正确的凭据访问视图,你会看到每个课程模块都包括课程内容的渲染 HTML,如下所示:

{
"order": 0,
"title": "Introduction to Django",
"description": "Brief introduction to the Django Web Framework.",
"contents": [
{
"order": 0,
"item": "<p>Meet Django. Django is a high-level
            Python Web framework
            ...</p>"
},
{
"order": 1,
"item": "\n<iframe width=\"480\" height=\"360\"
            src=\"http://www.youtube.com/embed/bgV39DlmZ2U?
            wmode=opaque\"
            frameborder=\"0\" allowfullscreen></iframe>\n"
}
]
} 

你已经构建了一个简单的 API,允许其他服务以编程方式访问课程应用程序。DRF 还允许你使用ModelViewSet类处理创建和编辑对象。我们已经涵盖了 DRF 的主要方面,但你将在其广泛的文档中找到有关其功能的更多信息,文档位于www.django-rest-framework.org/

消费 RESTful API

现在你已经实现了 API,你可以从其他应用程序以编程方式消费它。你可以使用应用程序的前端 JavaScript Fetch API 与 API 交互,类似于你在第六章中构建的功能,在您的网站上共享内容。你也可以从用 Python 或任何其他编程语言构建的应用程序中消费 API。

你将创建一个简单的 Python 应用程序,该程序使用 RESTful API 检索所有可用的课程,然后为学生注册所有这些课程。你将学习如何使用 HTTP 基本认证对 API 进行身份验证,并执行GETPOST请求。

我们将使用 Python Requests 库来消费 API。我们在第六章在您的网站上共享内容中使用了 Requests 来通过 URL 检索图像。Requests 抽象了处理 HTTP 请求的复杂性,并提供了一个非常简单的接口来消费 HTTP 服务。你可以在requests.readthedocs.io/en/master/找到 Requests 库的文档。

打开 shell,使用以下命令安装 Requests 库:

python -m pip install requests==2.31.0 

educa项目目录旁边创建一个新的目录,并将其命名为api_examples。在api_examples/目录中创建一个新的文件,并将其命名为enroll_all.py。现在的文件结构应该如下所示:

api_examples/
    enroll_all.py
educa/
    ... 

编辑enroll_all.py文件,并向其中添加以下代码:

import requests
base_url = 'http://127.0.0.1:8000/api/'
url = f'{base_url}courses/'
available_courses = []
while url is not None:
    print(f'Loading courses from {url}')
    r = requests.get(url)
    response = r.json()
    url = response['next']
    courses = response['results']
    available_courses += [course['title'] for course in courses]
print(f'Available courses: {", ".join(available_courses)}') 

在此代码中,你执行以下操作:

  1. 你导入 Requests 库并定义 API 的基础 URL 和课程列表 API 端点的 URL。

  2. 你定义了一个空的available_courses列表。

  3. 你使用while语句遍历所有结果页面。

  4. 你使用requests.get()通过向 URL http://127.0.0.1:8000/api/courses/ 发送GET请求来从 API 检索数据。此 API 端点是公开可访问的,因此不需要任何身份验证。

  5. 你使用响应对象的json()方法来解码 API 返回的 JSON 数据。

  6. 你将next属性存储在url变量中,以便在while语句中检索下一页的结果。

  7. 你将每个课程的title属性添加到available_courses列表中。

  8. url变量为None时,你将转到结果列表的最新页面,并且不会检索任何额外的页面。

  9. 你打印可用的课程列表。

使用以下命令从educa项目目录启动开发服务器:

python manage.py runserver 

在另一个 shell 中,从api_examples/目录运行以下命令:

python enroll_all.py 

你将看到如下列有所有课程标题的输出:

Available courses: Introduction to Django, Python for beginners, Algebra basics 

这是向你的 API 发出的第一个自动化调用。

编辑enroll_all.py文件,并添加以下加粗的行:

import requests
**username =** **''**
**password =** **''**
base_url = 'http://127.0.0.1:8000/api/'
url = f'{base_url}courses/'
available_courses = []
while url is not None:
    print(f'Loading courses from {url}')
    r = requests.get(url)
    response = r.json()
    url = response['next']
    courses = response['results']
    available_courses += [course['title'] for course in courses]
print(f'Available courses: {", ".join(available_courses)}')
**for** **course** **in** **courses:**
 **course_id = course[****'id'****]**
 **course_title = course[****'title'****]**
 **r = requests.post(**
**f'****{base_url}****courses/****{course_id}****/enroll/'****,**
 **auth=(username, password)**
 **)**
**if** **r.status_code ==** **200****:**
**# successful request**
**print****(****f'Successfully enrolled in** **{course_title}****'****)** 

usernamepassword变量的值替换为现有用户的凭据,或者从环境变量中加载这些值。你可以使用我们在第二章“增强博客和添加社交功能”中的使用环境变量部分所用的python-decouple,从环境变量中加载凭据。

使用新代码,你将执行以下操作:

  1. 你定义你想要注册到课程的学生的用户名和密码。

  2. 你遍历从 API 检索到的可用课程。

  3. 你将课程 ID 属性存储在course_id变量中,将title属性存储在course_title变量中。

  4. 你使用requests.post()向 URL http://127.0.0.1:8000/api/courses/[id]/enroll/发送每个课程的POST请求。这个 URL 对应于CourseEnrollView API 视图,它允许你将用户注册到课程中。你使用course_id变量为每个课程构建 URL。CourseEnrollView视图需要身份验证。它使用IsAuthenticated权限和BasicAuthentication身份验证类。Requests 库支持开箱即用的 HTTP 基本身份验证。你使用auth参数传递一个包含用户名和密码的元组以使用 HTTP 基本身份验证来验证用户。

  5. 如果响应的状态码是200 OK,你将打印一条消息,表明用户已成功注册到课程中。

你可以使用不同的身份验证方式与 Requests 一起使用。你可以在requests.readthedocs.io/en/master/user/authentication/找到关于使用 Requests 进行身份验证的更多信息。

api_examples/目录运行以下命令:

python enroll_all.py 

你现在将看到如下输出:

Available courses: Introduction to Django, Python for beginners, Algebra basics
Successfully enrolled in Introduction to Django
Successfully enrolled in Python for beginners
Successfully enrolled in Algebra basics 

太好了!你已经使用 API 成功地将用户注册到所有可用的课程中。你将在平台上看到每个课程的Successfully enrolled消息。正如你所看到的,从任何其他应用程序中消费 API 非常简单。

摘要

在本章中,你学习了如何使用 DRF 为你的项目构建 RESTful API。你为模型创建了序列化和视图,并构建了自定义 API 视图。你还为 API 添加了身份验证,并使用权限限制了 API 视图的访问。接下来,你发现了如何创建自定义权限,并实现了 ViewSets 和路由器。最后,你使用 Requests 库从外部 Python 脚本中消费 API。

下一章将教你如何使用 Django Channels 构建聊天服务器。你将使用 WebSockets 实现异步通信,并使用 Redis 设置通道层。

其他资源

以下资源提供了与本章涵盖主题相关的附加信息:

第十六章:构建聊天服务器

在上一章中,您为项目创建了一个 RESTful API,为您的应用程序提供了一个可编程的接口。

在本章中,您将使用 Django Channels 开发一个面向学生的聊天服务器,使学生能够在课程聊天室中进行实时消息交流。您将学习如何通过 Django Channels 的异步编程构建实时应用程序。通过通过 异步服务器网关接口 (ASGI) 提供您的 Django 项目,并实现异步通信,您将提高服务器的响应性和可扩展性。此外,您将把聊天消息持久化到数据库中,构建一个全面的聊天历史,丰富聊天应用的用户体验和功能。

在本章中,您将:

  • 将 Channels 添加到您的项目中

  • 构建 WebSocket 消费者和适当的路由

  • 实现 WebSocket 客户端

  • 启用带有 Redis 的通道层

  • 使您的消费者完全异步

  • 将聊天消息持久化到数据库中

功能概述

图 16.1 展示了本章将构建的视图、模板和功能:

图片

图 16.1:本章构建的功能图

在本章中,您将在 chat 应用程序中实现 course_chat_room 视图。此视图将提供显示给定课程聊天室的模板。当用户加入聊天室时,将显示最新的聊天消息。您将使用 JavaScript 在浏览器中建立 WebSocket 连接,并构建 ChatConsumer WebSocket 消费者来处理 WebSocket 连接和交换消息。您将使用 Redis 实现允许向聊天室中的所有用户广播消息的通道层。

本章的源代码可以在 github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter16 找到。

本章中使用的所有 Python 模块都包含在本章源代码的 requirements.txt 文件中。您可以根据以下说明安装每个 Python 模块,或者使用命令 python -m pip install -r requirements.txt 一次性安装所有依赖。

创建一个聊天应用

您将实现一个聊天服务器,为每个课程提供聊天室,以便学生可以访问课程聊天室并实时交换消息。您将使用 Channels 来构建此功能。Channels 是一个 Django 应用程序,它扩展了 Django 以处理需要长时间运行连接的协议,例如 WebSockets、聊天机器人或 MQTT(一种轻量级的发布/订阅消息传输,常用于 物联网 (IoT) 项目)。

使用 Channels,您可以在项目中轻松实现实时或异步功能,除了您标准的 HTTP 同步视图。您将首先向项目中添加一个新的应用程序。新的应用程序将包含聊天服务器的逻辑。

您可以在channels.readthedocs.io/找到 Django Channels 的文档。

让我们开始实现聊天服务器。从项目的educa目录运行以下命令以创建新的应用程序文件结构:

django-admin startapp chat 

编辑educa项目的settings.py文件,通过编辑INSTALLED_APPS设置来激活项目中的chat应用程序,如下所示:

INSTALLED_APPS = [
    # ...
**'chat.apps.ChatConfig'****,**
] 

新的chat应用程序现在已在您的项目中激活。接下来,您将构建一个用于课程聊天室的视图。

实现聊天室视图

您将为每个课程提供不同的聊天室。您需要创建一个视图,让学生能够加入指定课程的聊天室。只有注册了课程的学生的才能访问课程聊天室。

编辑新chat应用程序的views.py文件,并向其中添加以下代码:

**from** **django.contrib.auth.decorators** **import** **login_required**
**from** **django.http** **import** **HttpResponseForbidden**
from django.shortcuts import render
**from** **courses.models** **import** **Course**
**@login_required**
**def****course_chat_room****(****request, course_id****):**
**try****:**
**# retrieve course with given id joined by the current user**
 **course = request.user.courses_joined.get(****id****=course_id)**
**except** **Course.DoesNotExist:**
**# user is not a student of the course or course does not exist**
**return** **HttpResponseForbidden()**
**return** **render(request,** **'chat/room.html'****, {****'course'****: course})** 

这是course_chat_room视图。在这个视图中,您使用@login_required装饰器来阻止任何未经认证的用户访问视图。视图的工作方式如下:

  1. 视图接收一个必需的course_id参数,用于检索具有给定id的课程。

  2. 用户注册的课程通过courses_joined关系检索,并从这些课程子集中获取具有给定id的课程。如果具有给定id的课程不存在或用户未注册,则返回HttpResponseForbidden响应,这相当于 HTTP 状态403的响应。

  3. 如果具有给定id的课程存在且用户已注册,则渲染chat/room.html模板,并将course对象传递到模板上下文中。

您需要为这个视图添加一个 URL 模式。在chat应用程序目录内创建一个新文件,命名为urls.py。向其中添加以下代码:

from django.urls import path
from . import views
app_name = 'chat'
urlpatterns = [
    path(
        'room/<int:course_id>/',
        views.course_chat_room,
        name='course_chat_room'),
] 

这是chat应用程序的初始 URL 模式文件。您定义了course_chat_room URL 模式,包括带有int前缀的course_id参数,因为您只期望这里是一个整数值。

在项目的主体 URL 模式中包含chat应用程序的新 URL 模式。编辑educa项目的主体urls.py文件,并向其中添加以下行:

urlpatterns = [
    # ...
 **path(****'chat/'****, include(****'chat.urls'****, namespace=****'chat'****)),**
] 

chat应用程序的 URL 模式已添加到项目的chat/路径下。

您需要为course_chat_room视图创建一个模板。此模板将包含一个用于可视化聊天中交换的消息的区域,以及一个带有提交按钮的文本输入框,用于向聊天发送文本消息。

chat应用程序目录内创建以下文件结构:

templates/
    chat/
        room.html 

编辑 chat/room.html 模板,并向其中添加以下代码:

{% extends "base.html" %}
{% block title %}Chat room for "{{ course.title }}"{% endblock %}
{% block content %}
  <div id="chat">
</div>
<div id="chat-input">
<input id="chat-message-input" type="text">
<input id="chat-message-submit" type="submit" value="Send">
</div>
{% endblock %}
{% block include_js %}
{% endblock %}
{% block domready %}
{% endblock %} 

这是课程聊天室的模板。在这个模板中,你执行以下操作:

  1. 你扩展了项目的 base.html 模板,并填充其 content 块。

  2. 你定义了一个带有 chat ID 的 <div> HTML 元素,你将使用它来显示用户和其他学生发送的聊天消息。

  3. 你还定义了一个带有 text 输入和提交按钮的第二个 <div> 元素,这将允许用户发送消息。

  4. 你添加了 base.html 模板中定义的 include_jsdomready 块,你将在稍后实现它们,以建立与 WebSocket 的连接并发送或接收消息。

运行开发服务器,并在你的浏览器中打开 http://127.0.0.1:8000/chat/room/1/,将 1 替换为数据库中现有课程的 id

使用注册了课程的登录用户访问聊天室。你将看到以下屏幕:

图片 B21088_16_02.png

图 16.2:课程聊天室页面

这是学生将在其中讨论课程主题的课程聊天室屏幕。

你已经创建了课程聊天室的基视图。现在你需要处理学生之间的消息。下一节将介绍使用 Channels 的异步支持来实现实时通信。

实时 Django 与 Channels

你正在构建一个聊天服务器,为每个课程提供学生聊天室。注册了课程的学生的将能够访问课程聊天室并交换消息。此功能需要服务器和客户端之间的实时通信。

标准的 HTTP 请求/响应模型在这里不适用,因为你需要浏览器在新消息到达时立即接收通知。你可以通过使用 AJAX 轮询或长轮询结合将消息存储在你的数据库或 Redis 中来实现此功能。然而,使用标准的同步 Web 应用程序实现实时通信没有高效的方法。

你需要异步通信,这允许实时交互,其中服务器可以在新消息到达时立即将更新推送到客户端,而无需客户端定期请求更新。异步通信还带来其他优势,例如降低延迟、提高负载下的性能,以及更好的整体用户体验。你将使用 ASGI 通过异步通信构建聊天服务器。

使用 ASGI 的异步应用程序

Django 通常使用Web 服务器网关接口WSGI)进行部署,这是 Python 应用程序处理 HTTP 请求的标准接口。然而,为了与异步应用程序一起工作,你需要使用另一个名为 ASGI 的接口,它可以处理 WebSocket 请求。ASGI 是异步 Web 服务器和应用程序的 Python 标准。通过使用 ASGI,我们将使 Django 能够独立并实时地处理每条消息,为学生创造一个流畅和实时的聊天体验。

你可以在asgi.readthedocs.io/en/latest/introduction.html找到 ASGI 的介绍。

Django 自带通过 ASGI 运行异步 Python 的支持。自 Django 3.1 以来,就支持编写异步视图,而 Django 4.1 引入了基于类的视图的异步处理程序。Django 5.0 在生成响应之前添加了对异步视图中的断开事件的处理。它还向认证框架添加了异步函数,提供了异步信号分发的支持,并将异步支持添加到多个内置装饰器中。

Channels 建立在 Django 中可用的原生 ASGI 支持之上,并为处理需要长时间运行连接的协议(如 WebSockets、IoT 协议和聊天协议)提供了额外的功能。

WebSockets 通过在服务器和客户端之间建立一个持久、开放、双向的传输控制协议TCP)连接来提供全双工通信。你不需要向服务器发送 HTTP 请求,而是与服务器建立连接;一旦通道打开,就可以在两个方向上交换消息,而不需要每次都建立新的连接。你将使用 WebSockets 来实现你的聊天服务器。

你可以在en.wikipedia.org/wiki/WebSocket上了解更多关于 WebSockets 的信息。

你可以在docs.djangoproject.com/en/5.0/howto/deployment/asgi/找到有关使用 ASGI 部署 Django 的更多信息。

你可以在docs.djangoproject.com/en/5.0/topics/async/找到有关 Django 支持编写异步视图的更多信息,以及 Django 对异步类视图的支持docs.djangoproject.com/en/5.0/topics/class-based-views/#async-class-based-views

接下来,我们将学习如何使用 Channels 改变 Django 的请求/响应周期。

使用 Channels 的请求/响应周期

理解标准同步请求周期与 Channels 实现之间的差异非常重要。以下图示展示了同步 Django 设置的请求周期:

图片

图 16.3:Django 请求/响应周期

当浏览器向 Web 服务器发送 HTTP 请求时,Django 处理请求并将 HttpRequest 对象传递给相应的视图。视图处理请求并返回一个 HttpResponse 对象,该对象作为 HTTP 响应发送回浏览器。没有机制在不需要相关 HTTP 请求的情况下保持打开的连接或向浏览器发送数据。

以下架构显示了使用 WebSocket 的 Django 项目使用 Channels 的请求周期:

图片

图 16.4:Django Channels 请求/响应周期

Channels 用跨通道发送的消息替换了 Django 的请求/响应周期。HTTP 请求仍然通过 Django 路由到视图函数,但它们通过通道进行路由。这允许处理 WebSocket 消息,其中您有生产者和消费者在通道层之间交换消息。Channels 保留了 Django 的同步架构,允许您在编写同步代码和异步代码之间进行选择,或者两者结合。

您现有的同步视图将与我们将使用 Daphne 实现的 WebSocket 功能共存,并且您将同时处理 HTTP 和 WebSocket 请求。

接下来,您将安装 Channels 并将其添加到项目中。

安装 Channels 和 Daphne

您将向项目中添加 Channels 并为其设置所需的基本 ASGI 应用程序路由,以便管理 HTTP 请求。

使用以下命令在您的虚拟环境中安装 Channels:

python -m pip install -U 'channels[daphne]==4.1.0' 

这将同时安装 Channels 和 Daphne ASGI 应用程序服务器。处理异步请求需要一个 ASGI 服务器,我们选择 Daphne,因为它简单且兼容,并且与 Channels 一起打包。

编辑 educa 项目的 settings.py 文件,并将 daphne 添加到 INSTALLED_APPS 设置的开头,如下所示:

INSTALLED_APPS = [
**'daphne'****,**
# ...
] 

daphne 被添加到 INSTALLED_APPS 设置中时,它将接管 runserver 命令,替换标准的 Django 开发服务器。这将允许您在开发期间处理异步请求。除了处理同步请求的 URL 路由到 Django 视图外,Daphne 还管理 WebSocket 消费者的路由。您可以在 github.com/django/daphne 找到有关 Daphne 的更多信息。

Channels 预期您定义一个单一的根应用程序,该应用程序将执行所有请求。您可以通过将 ASGI_APPLICATION 设置添加到项目中来定义根应用程序。这与指向项目基本 URL 模式的 ROOT_URLCONF 设置类似。您可以在项目的任何位置放置根应用程序,但建议将其放在项目级别的文件中。您可以直接将根路由配置添加到 asgi.py 文件中,其中将定义 ASGI 应用程序。

educa项目目录中编辑asgi.py文件,并添加以下粗体显示的代码:

import os
**from** **channels.routing** **import** **ProtocolTypeRouter**
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'educa.settings')
**django_asgi_app** = get_asgi_application()
**application = ProtocolTypeRouter({**
**'http'****: django_asgi_app,**
**})** 

在前面的代码中,你定义了当通过 ASGI 服务 Django 项目时将被执行的主要 ASGI 应用程序。你使用 Channels 提供的ProtocolTypeRouter类作为路由系统的主入口点。ProtocolTypeRouter接受一个映射通信类型(如httpwebsocket)到 ASGI 应用程序的字典。你使用 HTTP 协议的默认应用程序实例化这个类。稍后,你将添加 WebSocket 的协议。

将以下行添加到你的项目settings.py文件中:

ASGI_APPLICATION = 'educa.asgi.application' 

ASGI_APPLICATION设置被 Channels 用于定位根路由配置。

使用以下命令启动开发服务器:

python manage.py runserver 

你将看到类似以下输出的内容:

Watching for file changes with StatReloader
Performing system checks...
System check identified no issues (0 silenced).
April 14, 2024 - 08:02:57
Django version 5.0.4, using settings 'educa.settings'
Starting ASGI/Daphne version 4.1.0 development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C. 

检查输出是否包含以下行Starting ASGI/Daphne version 4.1.0 development server。这一行确认你正在使用 Daphne 开发服务器,它能够管理同步和异步请求,而不是标准的 Django 开发服务器。HTTP 请求的行为与之前相同,但它们将通过通道路由。

现在 Channels 已安装到你的项目中,你可以构建课程的聊天服务器。为了实现你项目的聊天服务器,你需要采取以下步骤:

  1. 设置消费者:消费者是能够以与传统 HTTP 视图非常相似的方式处理 WebSocket 的独立代码块。你将构建一个消费者来读取和写入通信通道的消息。

  2. 配置路由:Channels 提供了路由类,允许你组合和堆叠你的消费者。你将为你的聊天消费者配置 URL 路由。

  3. 实现 WebSocket 客户端:当学生访问聊天室时,你将从浏览器连接到 WebSocket,并使用 JavaScript 发送或接收消息。

  4. 启用通道层:通道层允许你在应用程序的不同实例之间进行通信。它们是构建分布式实时应用程序的有用部分。你将使用 Redis 设置一个通道层。

让我们从编写自己的消费者开始,以处理连接到 WebSocket、接收和发送消息以及断开连接。

编写消费者

消费者是对异步应用程序中 Django 视图的等效。如前所述,它们以与传统视图处理 HTTP 请求非常相似的方式处理 WebSocket。消费者是能够处理消息、通知和其他内容的 ASGI 应用程序。与 Django 视图不同,消费者是为长时间运行的通信而构建的。通过允许你组合和堆叠消费者的路由类,URL 被映射到消费者。

让我们实现一个基本的消费者,它可以接受 WebSocket 连接,并将从 WebSocket 接收到的每条消息回显给它。此初始功能将允许学生向消费者发送消息,并接收回发送的消息。

chat应用程序目录内创建一个新文件,并将其命名为consumers.py。将以下代码添加到其中:

import json
from channels.generic.websocket import WebsocketConsumer
class ChatConsumer(WebsocketConsumer):
    def connect(self):
        # accept connection
        self.accept()
    def disconnect(self, close_code):
        pass
# receive message from WebSocket
def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']
        # send message to WebSocket
        self.send(text_data=json.dumps({'message': message})) 

这是ChatConsumer消费者。此类从 Channels 的WebsocketConsumer类继承,以实现基本的 WebSocket 消费者。在此消费者中,您实现了以下方法:

  • connect(): 当接收到新的连接时调用。您可以使用self.accept()接受任何连接。您也可以通过调用self.close()拒绝连接。

  • disconnect(): 当套接字关闭时调用。您使用pass,因为当客户端关闭连接时,您不需要实现任何操作。

  • receive(): 当从 WebSocket 接收到数据时调用。您期望接收到的数据是text_data(这也可以是binary_data,用于二进制数据)。您将接收到的文本数据视为 JSON。因此,您使用json.loads()将接收到的 JSON 数据加载到 Python 字典中。您访问message键,您期望它在接收到的 JSON 结构中存在。为了回显消息,您使用self.send()将消息发送回 WebSocket,并通过json.dumps()将其再次转换为 JSON 格式。

您的ChatConsumer消费者初始版本接受任何 WebSocket 连接,并将接收到的每条消息回显到 WebSocket 客户端。请注意,消费者尚未向其他客户端广播消息。您将通过实现通道层来构建此功能。

首先,让我们通过将其添加到项目的 URL 中,来公开我们的消费者。

路由

您需要定义一个 URL,以便将连接路由到您已实现的ChatConsumer消费者。Channels 提供了路由类,允许您组合和堆叠消费者,根据连接的内容进行分发。您可以将它们视为 Django 异步应用的 URL 路由系统。

chat应用程序目录内创建一个新文件,并将其命名为routing.py。将以下代码添加到其中:

from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
    re_path(
        r'ws/chat/room/(?P<course_id>\d+)/$',
        consumers.ChatConsumer.as_asgi()
    ),
] 

在此代码中,您将一个 URL 模式映射到您在chat/consumers.py文件中定义的ChatConsumer类。有一些细节值得审查:

  • 您使用 Django 的re_path()来定义带有正则表达式的路径,而不是path()。如果内部路由器被额外的中间件包装,Channels 的 URL 路由可能与path()路由不正确地工作,因此这种方法有助于避免任何问题。

  • URL 包含一个名为course_id的整数参数。此参数将在消费者的作用域内可用,并允许您识别用户连接到的课程聊天室。

  • 你调用consumer类的as_asgi()方法,以获取一个 ASGI 应用程序,该应用程序将为每个用户连接实例化消费者实例。这种行为类似于 Django 的as_view()方法对于基于类的视图。

将 WebSocket URL 以/ws/开头是一种良好的实践,以区分用于标准同步 HTTP 请求的 URL。这也简化了生产设置,当 HTTP 服务器根据路径路由请求时。

编辑位于settings.py文件旁边的全局asgi.py文件,使其看起来像这样:

import os
**from** **channels.auth** **import** **AuthMiddlewareStack**
from channels.routing import ProtocolTypeRouter**, URLRouter**
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'educa.settings')
django_asgi_app = get_asgi_application()
**from** **chat.routing** **import** **websocket_urlpatterns**
application = ProtocolTypeRouter({
    'http': django_asgi_app,
    **'websocket'****: AuthMiddlewareStack(**
 **URLRouter(websocket_urlpatterns)**
 **),**
}) 

在此代码中,你添加了:

  • websocket协议创建一个新的路由。你使用URLRouterwebsocket连接映射到chat.routing模块中定义的websocket_urlpatterns列表中的 URL 模式。

  • AuthMiddlewareStack作为一个 URL 路由器的包装函数。由 Channels 提供的AuthMiddlewareStack类支持标准的 Django 认证,其中用户详细信息存储在会话中。稍后,你将在消费者作用域内访问用户实例以识别发送消息的用户。

注意,websocket_urlpatterns的导入位于get_asgi_application()函数调用下方。这是为了确保在导入可能导入 ORM 模型的代码之前,应用程序注册已填充。

现在我们已经有一个通过 URL 可用的功能 WebSocket 消费者,我们可以实现 WebSocket 客户端。

实现 WebSocket 客户端

到目前为止,你已经创建了course_chat_room视图及其对应的模板,以便学生访问课程聊天室。你已经实现了聊天服务器的 WebSocket 消费者并将其与 URL 路由绑定。现在,你需要构建一个 WebSocket 客户端以与课程聊天室模板中的 WebSocket 建立连接,并能够发送/接收消息。

你将使用 JavaScript 实现 WebSocket 客户端,以在浏览器中打开并维护连接,并且你将使用 JavaScript 与文档对象模型DOM)交互。

你将执行以下与 WebSocket 客户端相关的任务:

  1. 当页面加载时,打开与服务器的 WebSocket 连接。

  2. 当通过 WebSocket 接收到数据时,向 HTML 容器添加消息。

  3. 将监听器附加到提交按钮上,以便在用户点击发送按钮或按下Enter键时通过 WebSocket 发送消息。

让我们从打开 WebSocket 连接开始。

编辑chat/room.html模板的chat应用程序,并修改include_jsdomready块,如下所示:

{% block include_js %}
 **{{ course.id|json_script:"course-id" }}**
{% endblock %}
{% block domready %}
 **const courseId = JSON.parse(**
 **document.getElementById('course-id').textContent**
 **);**
 **const url = 'ws://' + window.location.host +**
 **'/ws/chat/room/' + courseId + '/';**
 **const chatSocket = new WebSocket(url);**
{% endblock %} 

include_js 块中,您使用 json_script 模板过滤器安全地使用 course.id 的值与 JavaScript 一起使用。Django 提供的 json_script 模板过滤器将 Python 对象作为 JSON 输出,并用 <script> 标签包裹,这样您就可以安全地与 JavaScript 一起使用。代码 {{ course.id|json_script:"course-id" }} 被渲染为 <script id="course-id" type="application/json">6</script>。然后,在 domready 块中,通过使用 JSON.parse() 解析具有 id="course-id" 的元素的文本内容来检索此值。这是在 JavaScript 中安全使用 Python 对象的方法。

json_script 模板过滤器安全地将 Python 对象编码为 JSON,并安全地将其嵌入到 <script> HTML 标签中,通过转义潜在的有害字符来防止 跨站脚本攻击XSS)。

您可以在 docs.djangoproject.com/en/5.0/ref/templates/builtins/#json-script 找到有关 json_script 模板过滤器的更多信息。

domready 块中,您定义一个使用 WebSocket 协议的 URL,它看起来像 ws://(或者对于安全的 WebSocket,像 https:// 一样,使用 wss://)。您使用浏览器当前的位置构建 URL,您可以从 window.location.host 获取它。其余的 URL 使用您在 chat 应用的 routing.py 文件中定义的聊天室 URL 模式路径构建。

您直接写入 URL 而不是使用解析器构建它,因为 Channels 不提供反转 URL 的方法。您使用当前课程的 ID 生成当前课程的 URL,并将 URL 存储在名为 url 的新常量中。

然后您使用 new WebSocket(url) 打开到存储的 URL 的 WebSocket 连接。您将实例化的 WebSocket 客户端对象分配给新的常量 chatSocket

您已创建 WebSocket 消费者,为其添加了路由,并实现了一个基本的 WebSocket 客户端。让我们尝试您的聊天初始版本。

使用以下命令启动开发服务器:

python manage.py runserver 

在您的浏览器中打开 URL http://127.0.0.1:8000/chat/room/1/,将 1 替换为数据库中现有课程的 id。查看控制台输出。除了对页面及其静态文件的 HTTP GET 请求外,您应该看到两行,包括 WebSocket HANDSHAKINGWebSocket CONNECT,如下所示输出:

HTTP GET /chat/room/1/ 200 [0.02, 127.0.0.1:57141]
WebSocket HANDSHAKING /ws/chat/room/1/ [127.0.0.1:57144]
WebSocket CONNECT /ws/chat/room/1/ [127.0.0.1:57144] 

Daphne 服务器使用标准 TCP 套接字监听传入的套接字连接。握手是 HTTP 到 WebSocket 的桥梁。在握手过程中,连接的详细信息被协商,任何一方都可以在完成前关闭连接。请记住,您在 ChatConsumer 类的 connect() 方法中使用 self.accept() 接受任何连接,该类在 chat 应用的 consumers.py 文件中实现。连接被接受,因此您在控制台看到 WebSocket CONNECT 消息。

如果您使用浏览器开发者工具跟踪网络连接,您还可以看到已建立的 WebSocket 连接的信息。

它应该看起来像图 16.5

图形用户界面、应用程序、表格  自动生成的描述

图 16.5:浏览器开发者工具显示已建立 WebSocket 连接

现在您已经可以连接到 WebSocket,是时候与之交互了。您将实现处理常见事件的方法,例如接收消息和关闭连接。编辑chat/room.html模板的chat应用程序并修改domready块,如下所示:

{% block domready %}
  const courseId = JSON.parse(
    document.getElementById('course-id').textContent
  );
  const url = 'ws://' + window.location.host +
              '/ws/chat/room/' + courseId + '/';
  const chatSocket = new WebSocket(url);
 **chatSocket.****onmessage =** **function****(event) {**
 **const data =** **JSON****.****parse****(event.data);**
**const** **chat =** **document****.****getElementById****(****'chat'****);**
 **chat.innerHTML +=** **'<div class="message">'** **+**
 **data.message +** **'****</div>'****;**
 **chat.scrollTop = chat.scrollHeight;**
 **};**
 **chatSocket.onclose =** **function****(event) {**
**console****.****error****(****'Chat socket closed unexpectedly'****);**
 **};**
{% endblock %} 

在此代码中,您为 WebSocket 客户端定义以下事件:

  • onmessage:当通过 WebSocket 接收到数据时触发。您解析消息,您期望其以 JSON 格式,并访问其message属性。然后您将一个新的<div>元素与接收到的消息附加到具有chat ID 的 HTML 元素。这将向聊天记录添加新消息,同时保留已添加到日志的所有先前消息。您将聊天记录的<div>滚动到最底部以确保新消息可见。您通过滚动到聊天记录的总可滚动高度来实现这一点,这可以通过访问其scrollHeight属性来获得。

  • onclose:当 WebSocket 连接关闭时触发。您不期望关闭连接,因此如果发生这种情况,您将错误Chat socket closed unexpectedly写入控制台日志。

您已实现了在接收到新消息时显示消息的动作。您还需要实现向套接字发送消息的功能。

编辑chat/room.html模板的chat应用程序,并将以下 JavaScript 代码添加到domready块的底部:

const input = document.getElementById('chat-message-input');
const submitButton = document.getElementById('chat-message-submit');
submitButton.addEventListener('click', function(event) {
  const message = input.value;
  if(message) {
    // send message in JSON format
    chatSocket.send(JSON.stringify({'message': message}));
    // clear input
    input.value = '';
    input.focus();
  }
}); 

在此代码中,您为提交按钮的click事件定义了一个事件监听器,您通过其 ID chat-message-submit选择该按钮。当按钮被点击时,您执行以下操作:

  1. 您从具有 ID chat-message-input的文本输入元素的值中读取用户输入的消息。

  2. 您使用if(message)检查消息是否有内容。

  3. 如果用户已输入消息,您可以使用JSON.stringify()形成如{'message': '用户输入的字符串'}这样的 JSON 内容。

  4. 您通过调用chatSocket客户端的send()方法将 JSON 内容通过 WebSocket 发送。

  5. 您通过将文本输入的值设置为空字符串input.value = ''来清除文本输入的内容。

  6. 您使用input.focus()将焦点返回到文本输入,以便用户可以立即写入新消息。

用户现在可以使用文本输入和点击提交按钮来发送消息。

为了提高用户体验,当页面加载时,你将焦点放在文本输入框上,使用户可以立即开始输入,而无需先点击它。你还将捕获键盘按键事件以识别Enter键,并在提交按钮上触发click事件。用户可以通过点击按钮或按Enter键来发送消息。

编辑chat应用程序的chat/room.html模板,并将以下 JavaScript 代码添加到domready块的底部:

input.addEventListener('keypress', function(event) {
    if (event.key === 'Enter') {
      // cancel the default action, if needed
      event.preventDefault();
      // trigger click event on button
      submitButton.click();
    }
  });

input.focus(); 

在此代码中,你还定义了一个用于input元素的keypress事件函数。对于用户按下的任何键,你执行以下操作:

  1. 你检查其键是否为Enter

  2. 如果按下Enter键:

    1. 你可以通过event.preventDefault()来阻止此键的默认行为。

    2. 然后你在提交按钮上触发click事件,将消息发送到 WebSocket。

在事件处理器外部,在domready块的 JavaScript 主代码中,你使用input.focus()将焦点放在文本输入框上。这样做的话,当 DOM 加载完成后,焦点将设置在input元素上,以便用户可以输入信息。

chat/room.html模板的domready块现在应该如下所示:

{% block domready %}
  const courseId = JSON.parse(
    document.getElementById('course-id').textContent
  );
  const url = 'ws://' + window.location.host +
              '/ws/chat/room/' + courseId + '/';
  const chatSocket = new WebSocket(url);
  chatSocket.onmessage = function(event) {
    const data = JSON.parse(event.data);
    const chat = document.getElementById('chat');
    chat.innerHTML += '<div class="message">' +
                      data.message + '</div>';
    chat.scrollTop = chat.scrollHeight;
  };
  chatSocket.onclose = function(event) {
    console.error('Chat socket closed unexpectedly');
  };
  const input = document.getElementById('chat-message-input');
  const submitButton = document.getElementById('chat-message-submit');
  submitButton.addEventListener('click', function(event) {
    const message = input.value;
    if(message) {
      // send message in JSON format
      chatSocket.send(JSON.stringify({'message': message}));
      // clear input
      input.value = '';
      input.focus();
    }
  });
  input.addEventListener('keypress', function(event) {
    if (event.key === 'Enter') {
      // cancel the default action, if needed
      event.preventDefault();
      // trigger click event on button
      submitButton.click();
    }
  });

  input.focus();
{% endblock %} 

在你的浏览器中打开 URL http://127.0.0.1:8000/chat/room/1/,将1替换为数据库中现有课程的id。对于已登录并注册该课程的用户,在输入框中输入一些文本,然后点击发送按钮或按Enter键。

你将看到你的消息出现在聊天记录中:

图 16.6:聊天室页面,包括通过 WebSocket 发送的消息

太好了!消息已经通过 WebSocket 发送,ChatConsumer消费者已经收到消息并通过 WebSocket 将其发送回来。chatSocket客户端收到消息事件并触发onmessage函数,将消息添加到聊天记录中。

你已经使用 WebSocket 消费者和 WebSocket 客户端实现了功能,以建立客户端/服务器通信并可以发送或接收事件。然而,聊天服务器无法向其他客户端广播消息。如果你打开第二个浏览器标签页并输入一条消息,该消息将不会出现在第一个标签页上。为了在消费者之间建立通信,你必须启用通道层。

启用通道层

通道层允许你在应用程序的不同实例之间进行通信。通道层是允许多个消费者实例相互通信以及与 Django 的其他部分通信的传输机制。

在你的聊天服务器中,你计划为同一课程聊天室创建多个 ChatConsumer 消费者实例。每个加入聊天室的学生将在他们的浏览器中实例化 WebSocket 客户端,这将与 WebSocket 消费者实例建立连接。你需要一个公共通道层来在消费者之间分配消息。

通道和组

通道层提供了两个抽象来管理通信:通道和组:

  • 通道:你可以将通道想象成一个可以发送或接收消息的收件箱,或者是一个任务队列。每个通道都有一个名称。消息可以通过知道通道名称的任何人发送到通道,然后传递给在该通道上监听的消费者。

  • :多个通道可以被组合成一个组。每个组都有一个名称。任何人都可以通过知道组名称来向组中添加或移除通道。使用组名称,你还可以向组中的所有通道发送消息。

你将通过使用通道组来实现聊天服务器。通过为每个课程聊天室创建一个通道组,ChatConsumer 实例将能够相互通信。

让我们向我们的项目中添加一个通道层。

使用 Redis 设置通道层

虽然 Channels 支持其他类型的通道层,但 Redis 是通道层的首选选项。Redis 作为通道层的通信存储。记住,你已经在 第七章跟踪用户行为第十章扩展你的商店第十四章渲染和缓存内容 中使用了 Redis。

如果你还没有安装 Redis,你可以在 第七章跟踪用户行为 中找到安装说明。

要使用 Redis 作为通道层,你必须安装 channels-redis 包。使用以下命令在你的虚拟环境中安装 channels-redis

python -m pip install channels-redis==4.2.0 

编辑 educa 项目的 settings.py 文件,并向其中添加以下代码:

CHANNEL_LAYERS = {
    'default': {
        'BACKEND': 'channels_redis.core.RedisChannelLayer',
        'CONFIG': {
            'hosts': [('127.0.0.1', 6379)],
        },
    },
} 

CHANNEL_LAYERS 设置定义了项目可用的通道层的配置。你使用 channels-redis 提供的 RedisChannelLayer 后端定义一个默认的通道层,并指定 Redis 运行的主机 127.0.0.1 和端口 6379

让我们尝试通道层。使用以下命令初始化 Redis Docker 容器:

docker run -it --rm --name redis -p 6379:6379 redis:7.2.4 

如果你想在后台(分离模式)运行命令,你可以使用 -d 选项。

使用以下命令从项目目录中打开 Django shell:

python manage.py shell 

为了验证通道层可以与 Redis 通信,编写以下代码向名为 test_channel 的测试通道发送消息并接收它:

>>> import channels.layers
>>> from asgiref.sync import async_to_sync
>>> channel_layer = channels.layers.get_channel_layer()
>>> async_to_sync(channel_layer.send)('test_channel', {'message': 'hello'})
>>> async_to_sync(channel_layer.receive)('test_channel') 

你应该得到以下输出:

{'message': 'hello'} 

在前面的代码中,你通过通道层向一个测试通道发送消息,然后从通道层检索它。通道层正在成功与 Redis 通信。

接下来,我们将向我们的项目中添加通道层。

更新消费者以广播消息

让我们编辑 ChatConsumer 消费者,使用我们用 Redis 实现的通道层。对于每个课程聊天室,你将使用一个通道组。因此,你将使用课程 id 来构建组名。ChatConsumer 实例将知道组名,并且能够相互通信。

编辑 chat 应用的 consumers.py 文件,导入 async_to_sync() 函数,并修改 ChatConsumer 类的 connect() 方法,如下所示:

import json
**from** **asgiref.sync** **import** **async_to_sync**
from channels.generic.websocket import WebsocketConsumer
class ChatConsumer(WebsocketConsumer):
    def connect(self):
 **self.****id** **= self.scope[****'url_route'****][****'kwargs'****][****'course_id'****]**
 **self.room_group_name =** **f'chat_****{self.****id****}****'**
**# join room group**
 **async_to_sync(self.channel_layer.group_add)(**
 **self.room_group_name, self.channel_name**
 **)**
# accept connection
        self.accept()
    # ... 

在此代码中,你导入 async_to_sync() 辅助函数来包装对异步通道层方法的调用。ChatConsumer 是一个同步的 WebsocketConsumer 消费者,但它需要调用通道层的异步方法。

在新的 connect() 方法中,你执行以下任务:

  1. 你从作用域中检索课程 id,以了解聊天室关联的课程。你通过访问 self.scope['url_route']['kwargs']['course_id'] 来从 URL 中检索 course_id 参数。每个消费者都有一个包含其连接信息、通过 URL 传递的参数以及(如果有)认证用户的作用域。

  2. 你使用与组对应的课程的 id 来构建组名。记住,你将为每个课程聊天室有一个通道组。你将组名存储在消费者的 room_group_name 属性中。

  3. 你通过将当前通道添加到组中来加入组。你从消费者的 channel_name 属性中获取通道名称。你使用通道层的 group_add 方法将通道添加到组中。你使用 async_to_sync() 包装器来使用通道层的异步方法。

  4. 你保留 self.accept() 调用来接受 WebSocket 连接。

ChatConsumer 消费者接收到新的 WebSocket 连接时,它将其通道添加到其作用域内与课程关联的组中。消费者现在能够接收发送到该组的任何消息。

在相同的 consumers.py 文件中,修改 ChatConsumer 类的 disconnect() 方法,如下所示:

 class ChatConsumer(WebsocketConsumer):
    # ...
def disconnect(self, close_code):
**# leave room group**
 **async_to_sync(self.channel_layer.group_discard)(**
 **self.room_group_name, self.channel_name**
 **)**
# ... 

当连接关闭时,你调用通道层的 group_discard() 方法来离开组。你使用 async_to_sync() 包装器来使用通道层的异步方法。

在相同的 consumers.py 文件中,修改 ChatConsumer 类的 receive() 方法,如下所示:

class ChatConsumer(WebsocketConsumer):
    # ...
# receive message from WebSocket
def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']
**# send message to room group**
 **async_to_sync(self.channel_layer.group_send)(**
 **self.room_group_name,**
 **{**
**'type'****:** **'chat_message'****,**
**'message'****: message,**
 **}**
 **)** 

当你从 WebSocket 连接接收到消息时,而不是将消息发送到关联的通道,你将消息发送到组。你通过调用通道层的 group_send() 方法来完成此操作。你使用 async_to_sync() 包装器来使用通道层的异步方法。你将以下信息传递给发送到组的事件:

  • type:事件类型。这是一个特殊键,对应于接收事件的消费者应该调用的方法名称。你可以在消费者中实现一个与消息类型相同名称的方法,这样每次接收到具有该特定类型的消息时,它都会被执行。

  • message:你实际发送的消息。

在相同的 consumers.py 文件中,在 ChatConsumer 类中添加一个新的 chat_message() 方法,如下所示:

class ChatConsumer(WebsocketConsumer):
    # ...
**# receive message from room group**
**def****chat_message****(****self, event****):**
**# send message to WebSocket**
 **self.send(text_data=json.dumps(event))** 

你将此方法命名为 chat_message() 以匹配从 WebSocket 接收消息时发送到通道组的 type 键。当向组发送类型为 chat_message 的消息时,所有订阅该组的消费者将接收到消息并执行 chat_message() 方法。在 chat_message() 方法中,你将接收到的消息事件发送到 WebSocket。

完整的 consumers.py 文件现在应该看起来像这样:

import json
from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer
class ChatConsumer(WebsocketConsumer):
    def connect(self):
        self.id = self.scope['url_route']['kwargs']['course_id']
        self.room_group_name = f'chat_{self.id}'
# join room group
        async_to_sync(self.channel_layer.group_add)(
            self.room_group_name, self.channel_name
        )
        # accept connection
        self.accept()
    def disconnect(self, close_code):
        # leave room group
        async_to_sync(self.channel_layer.group_discard)(
            self.room_group_name, self.channel_name
        )
    # receive message from WebSocket
def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']
        # send message to room group
        async_to_sync(self.channel_layer.group_send)(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message,
            }
        )
    # receive message from room group
def chat_message(self, event):
        # send message to WebSocket
        self.send(text_data=json.dumps(event)) 

你已经在 ChatConsumer 中实现了通道层,允许消费者广播消息并相互通信。

使用以下命令运行开发服务器:

python manage.py runserver 

在你的浏览器中打开 URL http://127.0.0.1:8000/chat/room/1/,将 1 替换为数据库中现有课程的 id。写一条消息并发送。然后,打开第二个浏览器窗口并访问相同的 URL。从每个浏览器窗口发送一条消息。

结果应该看起来像这样:

图 16.7:来自不同浏览器窗口发送的消息的聊天室页面

你会看到第一条消息只在第一个浏览器窗口中显示。当你打开第二个浏览器窗口时,来自任何浏览器窗口的消息都会在两个窗口中显示。当你打开一个新的浏览器窗口并访问聊天室 URL 时,浏览器中的 JavaScript WebSocket 客户端和服务器中的 WebSocket 消费者之间将建立一个新的 WebSocket 连接。每个通道都被添加到与课程 ID 关联的组中,并通过 URL 传递给消费者。消息被发送到组,并由所有消费者接收。

接下来,我们将通过添加额外的上下文来丰富消息。

给消息添加上下文

现在聊天室中的所有用户都可以互相发送消息,你可能想要显示谁发送了哪条消息以及发送的时间。让我们给消息添加一些上下文。

编辑 chat 应用程序的 consumers.py 文件并实现以下更改:

import json
from asgiref.sync import async_to_sync
from channels.generic.websocket import WebsocketConsumer
**from** **django.utils** **import** **timezone**
class ChatConsumer(WebsocketConsumer):
    def connect(self):
 **self.user = self.scope[****'user'****]**
        self.id = self.scope['url_route']['kwargs']['course_id']
        self.room_group_name = f'chat_{self.id}'
# join room group
        async_to_sync(self.channel_layer.group_add)(
            self.room_group_name, self.channel_name
        )
        # accept connection
        self.accept()
    def disconnect(self, close_code):
        # leave room group
        async_to_sync(self.channel_layer.group_discard)(
            self.room_group_name, self.channel_name
        )
    # receive message from WebSocket
def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']
 **now = timezone.now()**
# send message to room group
        async_to_sync(self.channel_layer.group_send)(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message,
**'user'****: self.user.username,**
**'datetime'****: now.isoformat(),**
            }
        )
    # receive message from room group
def chat_message(self, event):
        # send message to WebSocket
        self.send(text_data=json.dumps(event)) 

你现在导入 Django 提供的 timezone 模块。在消费者的 connect() 方法中,你使用 self.scope['user'] 从作用域中检索当前用户,并将它们存储在消费者新的 user 属性中。

当消费者通过 WebSocket 接收到消息时,它使用 timezone.now() 获取当前时间,并将当前 userdatetime 以 ISO 8601 格式以及消息一起传递给发送到通道组的消息。

编辑chat/room.html模板的chat应用,并将以下加粗的行添加到include_js块中:

{% block include_js %}
  {{ course.id|json_script:"course-id" }}
 **{{ request.user.username|json_script:****"request-user"** **}}**
{% endblock %} 

使用json_script模板,你安全地打印出请求用户的用户名,以便与 JavaScript 一起使用。

chat/room.html模板的domready块中,添加以下加粗的行:

{% block domready %}
  const courseId = JSON.parse(
    document.getElementById('course-id').textContent
  );
**const** **requestUser =** **JSON****.****parse****(**
**document****.****getElementById****(****'request-user'****).textContent**
 **);**
  # ...
{% endblock %} 

在新代码中,你安全地解析具有 ID request-user的元素的数据,并将其存储在requestUser常量中。

然后,在domready块中,找到以下行:

const data = JSON.parse(event.data);
const chat = document.getElementById('chat');
chat.innerHTML += '<div class="message">' +
                  data.message + '</div>';
chat.scrollTop = chat.scrollHeight; 

将这些行替换为以下代码:

const data = JSON.parse(event.data);
const chat = document.getElementById('chat');
**const dateOptions = {hour: 'numeric', minute: 'numeric', hour12: true};**
**const datetime = new Date(data.datetime).toLocaleString('en', dateOptions);**
**const isMe = data.user === requestUser;**
**const source = isMe ? 'me' : 'other';**
**const name = isMe ? 'Me' : data.user;**
chat.innerHTML += '<div class="message **' + source + '**">' +
 **'****<****strong****>****' + name + '****</****strong****>** **' +**
 **'****<****span****class****=****"date"****>****' + datetime + '****</****span****><****br****>****' +**
                  data.message + '</div>';
chat.scrollTop = chat.scrollHeight; 

在此代码中,你实现了以下更改:

  1. 你将接收到的datetime转换为 JavaScript 的Date对象,并使用特定的区域设置进行格式化。

  2. 你将接收到的消息中的用户名与两个不同的常量作为辅助工具来识别用户。

  3. 如果发送消息的用户是当前用户,则常量source获取值me,否则获取other

  4. 如果发送消息的用户是当前用户,则常量name获取值Me,否则获取发送消息用户的名称。你用它来显示发送消息用户的名称。

  5. 你使用source值作为主<div>消息元素的class,以区分当前用户发送的消息和其他用户发送的消息。基于class属性应用不同的 CSS 样式。这些 CSS 样式在css/base.css静态文件中声明。

  6. 你在附加到聊天记录的消息中使用用户名和datetime

在你的浏览器中打开 URL http://127.0.0.1:8000/chat/room/1/,将1替换为数据库中现有课程的id。使用已登录并注册该课程的用户,写一条消息并发送。

然后,在隐身模式下打开第二个浏览器窗口,以防止使用相同的会话。使用不同用户登录,该用户也注册了同一课程,并发送一条消息。

你将能够使用两个不同的用户交换消息,并看到用户和时间,明确区分用户发送的消息和其他用户发送的消息。两个用户之间的对话应该看起来类似于以下的一个:

图片

图 16.8:包含来自两个不同用户会话的消息的聊天室页面

太好了!你已经使用 Channels 构建了一个功能性的实时聊天应用。接下来,你将学习如何通过使其完全异步来改进聊天消费者。

将消费者修改为完全异步

你实现的 ChatConsumer 类继承自同步基类 WebsocketConsumer。同步消费者以这种方式操作,即每个请求必须按顺序依次处理。同步消费者便于访问 Django 模型并调用常规同步 I/O 函数。然而,异步消费者由于能够执行非阻塞操作,可以在等待第一个操作完成之前转移到另一个任务,因此性能更佳。它们在处理请求时不需要额外的线程,从而减少了等待时间并增加了同时处理更多用户和请求的能力。

既然你已经使用了异步通道层函数,你可以无缝地重写 ChatConsumer 类以使其异步。

编辑 chat 应用程序的 consumers.py 文件并实现以下更改:

import json
**from** **channels.generic.websocket** **import** **AsyncWebsocketConsumer**
from django.utils import timezone
class ChatConsumer(**AsyncWebsocketConsumer**):
    **async** def connect(self):
        self.user = self.scope['user']
        self.id = self.scope['url_route']['kwargs']['course_id']
        self.room_group_name = 'chat_%s' % self.id
# join room group
**await** **self.channel_layer.group_add(**
            self.room_group_name, self.channel_name
        **)**
# accept connection
**await** self.accept()
    **async** def disconnect(self, close_code):
        # leave room group
**await** **self.channel_layer.group_discard(**
            self.room_group_name, self.channel_name
        **)**
# receive message from WebSocket
**async** def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']
        now = timezone.now()
        # send message to room group
**await** **self.channel_layer.group_send(**
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message,
                'user': self.user.username,
                'datetime': now.isoformat(),
            }
        **)**
# receive message from room group
**async** def chat_message(self, event):
        # send message to WebSocket
**await** self.send(text_data=json.dumps(event)) 

你实现了以下更改:

  1. ChatConsumer 消费者现在继承自 AsyncWebsocketConsumer 类以实现异步调用。

  2. 你将所有方法的定义从 def 改为了 async def

  3. 你使用 await 调用执行 I/O 操作的异步函数。

  4. 当你在通道层上调用方法时,不再使用 async_to_sync() 辅助函数。

再次用两个不同的浏览器窗口打开 URL http://127.0.0.1:8000/chat/room/1/ 并验证聊天服务器是否仍然正常工作。聊天服务器现在是完全异步的!

接下来,我们将通过在数据库中存储消息来实现聊天历史。

将消息持久化到数据库

让我们通过添加消息持久化来增强聊天应用程序。我们将开发将消息存储在数据库中的功能,这样我们就可以在用户加入聊天室时向他们展示聊天历史。这个特性对于实时应用至关重要,在这些应用中,显示当前和以前生成数据都是必要的。例如,考虑一个股票交易应用:用户登录时,应看到不仅当前的股票价值,还应从股市开盘以来的历史价值。

为了实现聊天历史功能,我们将遵循以下步骤:

  1. 我们将创建 Django 模型以存储聊天消息并将其添加到管理站点。

  2. 我们将修改 WebSocket 消费者以持久化消息。

  3. 我们将检索聊天历史,以便在用户进入聊天室时显示最新消息。

让我们从创建消息模型开始。

创建聊天消息模型

编辑 chat 应用程序的 models.py 文件并添加以下加粗的行:

**from** **django.conf** **import** **settings**
from django.db import models
**class****Message****(models.Model):**
 **user = models.ForeignKey(**
 **settings.AUTH_USER_MODEL,**
 **on_delete=models.PROTECT,**
 **related_name=****'chat_messages'**
 **)**
 **course = models.ForeignKey(**
**'courses.Course'****,**
 **on_delete=models.PROTECT,**
 **related_name=****'chat_messages'**
 **)**
 **content = models.TextField()**
 **sent_on = models.DateTimeField(auto_now_add=****True****)**
**def****__str__****(****self****):**
**return****f'****{self.user}** **on** **{self.course}** **at** **{self.sent_on}****'** 

这是持久化聊天消息的数据模型。让我们看看 Message 模型的字段:

  • 用户: 写入消息的User对象。这是一个外键字段,因为它指定了一个多对一的关系:一个用户可以发送多条消息,但每条消息都是由单个用户发送的。通过为on_delete参数使用PROTECT,如果存在相关消息,则无法删除User对象。

  • 课程: 与Course对象的关系。每条消息都属于一个课程聊天室。通过为on_delete参数使用PROTECT,如果存在相关消息,则无法删除Course对象。

  • 内容: 用于存储消息内容的TextField

  • 发送时间: 用于存储消息对象首次保存的日期和时间的DateTimeField

在 shell 提示符中运行以下命令以生成chat应用的数据库迁移:

python manage.py makemigrations chat 

你应该得到以下输出:

Migrations for 'chat':
    chat/migrations/0001_initial.py
        - Create model Message 

使用以下命令将新创建的迁移应用到你的数据库中:

python manage.py migrate 

你将得到一个以以下行结束的输出:

Applying chat.0001_initial... OK 

数据库现在与新的模型同步。让我们将Message模型添加到管理网站。

将消息模型添加到管理网站

编辑chat应用的admin.py文件,并将Message模型注册到管理网站,如下所示。新代码加粗:

from django.contrib import admin
**from** **chat.models** **import** **Message**
**@admin.register(****Message****)**
**class****MessageAdmin****(admin.ModelAdmin):**
**list_display = [****'sent_on'****,** **'user'****,** **'course'****,** **'content'****]**
**list_filter = [****'sent_on'****,** **'course'****]**
**search_fields = [****'content'****]**
**raw_id_fields = [****'user'****,** **'content'****]** 

运行开发服务器,并在浏览器中打开http://127.0.0.1:8000/admin/。你应该在管理网站上看到聊天块和消息部分:

图片

图 16.9:管理网站上的聊天应用和消息部分

我们将继续通过用户发送消息时将消息保存到数据库。

在数据库中存储消息

我们将修改 WebSocket 消费者以持久化通过 WebSocket 接收到的每条消息。编辑chat应用的consumers.py文件,并添加以下加粗的代码:

import json
from channels.generic.websocket import AsyncWebsocketConsumer
from django.utils import timezone
**from** **chat.models** **import** **Message**
class ChatConsumer(AsyncWebsocketConsumer):
    # ...
 **async****def****persist_message****(****self, message****):**
**# send message to WebSocket**
**await** **Message.objects.acreate(**
 **user=self.user, course_id=self.id, content=message**
 **)**
# receive message from WebSocket
async def receive(self, text_data):
        text_data_json = json.loads(text_data)
        message = text_data_json['message']
        now = timezone.now()
        # send message to room group
await self.channel_layer.group_send(
            self.room_group_name,
            {
                'type': 'chat_message',
                'message': message,
                'user': self.user.username,
                'datetime': now.isoformat(),
 },
 )
**# persist message**
**await** **self.persist_message(message)**
    # ... 

在此代码中,我们将异步的persist_message()方法添加到ChatConsumer类中。此方法接受一个message参数,并在数据库中创建一个包含给定消息、相关认证的user和属于聊天室组的Course对象idMessage对象。由于ChatConsumer是完全异步的,我们使用acreate() QuerySet 方法,这是create()的异步版本。你可以在docs.djangoproject.com/en/5.0/topics/db/queries/#asynchronous-queries了解更多关于如何使用 Django ORM 编写异步查询的信息。

我们在消费者通过 WebSocket 接收消息时执行的receive()方法中异步调用persist_message()方法。

运行开发服务器,并在浏览器中打开http://127.0.0.1:8000/chat/room/1/,将1替换为数据库中现有课程的id。使用已登录并注册该课程的用户,写一条消息并发送。

然后,在隐身模式下打开第二个浏览器窗口,以防止使用相同的会话。使用不同的用户登录,该用户也注册了相同的课程,并发送一些消息。

图 16.10 展示了两个不同用户发送的消息示例:

图片

图 16.10:显示两个不同用户发送的消息的聊天室示例

在您的浏览器中打开http://127.0.0.1:8000/admin/chat/message/。发送的消息应出现在管理网站上,如图 16.11 所示:

图片

图 16.11:数据库中存储的消息的行政列表显示视图

所有消息现在都已持久化存储在数据库中。

注意,消息可能包含恶意代码,例如 JavaScript 片段。在我们的模板中,我们不将消息标记为安全,以提供对恶意内容的初步保护。然而,为了进一步增强安全性,考虑在将消息存储到数据库之前对其进行清理。清理内容的一个可靠选项是nh3包。您可以在nh3.readthedocs.io/en/latest/了解更多关于nh3的信息。此外,django-nh3是一个 Django 集成,它提供了自定义的nh3模型字段和表单字段。更多信息可在github.com/marksweb/django-nh3找到。

现在您已经将完整的聊天历史存储在数据库中,让我们学习如何在用户加入聊天室时向他们展示聊天历史中的最新消息。

显示聊天历史

当用户加入课程聊天室时,我们将显示聊天历史的最新五条消息。这将确保用户能够立即获得正在进行对话的上下文。

编辑chat应用程序的views.py文件,并在course_chat_room视图中添加以下加粗的代码:

@login_required
def course_chat_room(request, course_id):
    try:
        # retrieve course with given id joined by the current user
        course = request.user.courses_joined.get(id=course_id)
    except Course.DoesNotExist:
        # user is not a student of the course or course does not exist
return HttpResponseForbidden()
**# retrieve chat history**
 **latest_messages = course.chat_messages.select_related(**
**'user'**
**).order_by(****'-id'****)[:****5****]**
 **latest_messages =** **reversed****(latest_messages)**
return render(
        request,
        'chat/room.html',
        {'course': course**,** **'latest_messages'****: latest_messages**}
    ) 

我们检索与课程相关的聊天消息,并使用select_related()在同一查询中获取相关用户。这将防止在访问用户名以显示在每个消息旁边时生成额外的 SQL 查询。Django 的 ORM 不支持负索引,所以我们以倒序检索前五条消息,并利用reversed()函数将它们重新排序成时间顺序。

现在,我们将聊天历史添加到聊天室模板中。编辑chat/room.html模板,并添加以下加粗的行:

# ...
{% block content %}
  <div id="chat">
 **{% for message in latest_messages %}**
**<****div****class****=****"****message {% if message.user == request.user %}me{% else %}other{% endif %}"****>**
**<****strong****>****{{ message.user.username }}****</****strong****>**
**<****span****class****=****"date"****>**
 **{{ message.sent_on|date:"Y.m.d H:i A" }}**
**</****span****>**
**<****br****>**
 **{{ message.content }}**
**</****div****>**
 **{% endfor %}**
</div>
<div id="chat-input">
<input id="chat-message-input" type="text">
<input id="chat-message-submit" type="submit" value="Send">
</div>
{% endblock %}
# ... 

在您的浏览器中打开http://127.0.0.1:8000/chat/room/1/,将1替换为数据库中现有课程的id。您现在应该看到最新消息,如图 16.12 所示:

图片

图 16.12:聊天室最初显示最新消息

用户现在可以在加入聊天室后看到最新的消息。接下来,我们将在菜单中添加一个链接,以便用户可以进入课程聊天室。

将聊天应用程序集成到现有视图中

聊天服务器现已完全实现,注册课程的学生可以相互交流。让我们为每个课程添加一个学生加入聊天室的超链接。

编辑 students/course/detail.html 模板中的 students 应用程序,并在 <div class="contents"> 元素的底部添加以下 <h3> HTML 元素代码:

<div class="contents">
  ...
**<****h3****>**
**<****a****href****=****"{% url "****chat:course_chat_room****"** **object.id** **%}">**
 **Course chat room**
**</****a****>**
**</****h3****>**
</div> 

打开浏览器并访问学生已注册的任何课程以查看课程内容。现在侧边栏将包含一个指向课程聊天室的 课程聊天室 链接。如果您点击它,您将进入聊天室:

图形用户界面,应用程序描述自动生成

图 16.13:课程详情页面,包括指向课程聊天室的链接

恭喜!您已成功使用 Django Channels 构建了您的第一个异步应用程序。

摘要

在本章中,您学习了如何使用 Channels 创建聊天服务器。您实现了 WebSocket 消费者和客户端。通过启用通过 Redis 的通道层进行通信,并将消费者修改为完全异步,您提高了应用程序的响应性和可扩展性。此外,您实现了聊天消息持久化,提供了稳健且用户友好的体验,并随着时间的推移维护用户的聊天历史。您在本章中学到的技能将帮助您在未来的任何异步实时功能实现中。

下一章将教您如何使用 NGINX、uWSGI 和 Daphne 以及 Docker Compose 为您的 Django 项目构建生产环境。您还将学习如何实现跨整个应用程序的请求/响应处理的自定义中间件,以及如何开发自定义管理命令,这些命令使您能够自动化任务并通过命令行执行它们。

其他资源

以下资源提供了与本章涵盖主题相关的额外信息:

第十七章:上线

在上一章中,您使用 Django Channels 为学生们构建了一个实时聊天服务器。现在,您已经创建了一个功能齐全的在线学习平台,您需要设置一个生产环境,以便它可以通过互联网访问。到目前为止,您一直在开发环境中工作,使用 Django 开发服务器运行您的网站。在本章中,您将学习如何设置一个能够以安全高效的方式服务 Django 项目的生产环境。

本章将涵盖以下主题:

  • 为多个环境配置 Django 设置

  • 使用 Docker Compose 运行多个服务

  • 使用 uWSGI 和 Django 设置 Web 服务器

  • 使用 Docker Compose 服务 PostgreSQL 和 Redis

  • 使用 Django 系统检查框架

  • 使用 Docker 服务 NGINX

  • 通过 NGINX 服务静态资源

  • 通过传输层安全性TLS)/ 安全套接字层SSL)来保护连接

  • 使用 Daphne 异步服务器网关接口ASGI)服务器为 Django Channels 服务

  • 创建自定义 Django 中间件

  • 实现自定义 Django 管理命令

在前面的章节中,开头的图表代表了视图、模板和端到端功能。然而,本章的重点转向了设置生产环境。相反,您将在本章中找到特定的图表来展示环境设置。

本章的源代码可以在github.com/PacktPublishing/Django-5-by-example/tree/main/Chapter17找到。

本章中使用的所有 Python 模块都包含在本章源代码中的requirements.txt文件中。您可以根据以下说明安装每个 Python 模块,或者可以使用python -m pip install -r requirements.txt命令一次性安装所有依赖。

创建生产环境

是时候在生产环境中部署您的 Django 项目了。您将首先为多个环境配置 Django 设置,然后设置一个生产环境。

管理多个环境的设置

在现实世界的项目中,您将不得不处理多个环境。您通常至少需要一个用于开发的本地环境和一个用于服务应用程序的生产环境。您还可以有其他环境,例如测试或预发布环境。

一些项目设置将适用于所有环境,但其他设置将针对每个环境特定。通常,您将使用一个定义通用设置的基文件,以及每个环境的设置文件,该文件覆盖必要的设置并定义额外的设置。

我们将管理以下环境:

  • local:在您的机器上运行项目的本地环境

  • prod:将您的项目部署到生产服务器的环境

educa项目的settings.py文件旁边创建一个settings/目录。将settings.py文件重命名为base.py并将其移动到新的settings/目录中。

settings/文件夹内创建以下附加文件,以便新的目录看起来如下:

settings/
    __init__.py
    base.py
    local.py
    prod.py 

这些文件如下:

  • base.py:基础设置文件,其中包含常用设置(之前为settings.py

  • local.py:本地环境的自定义设置

  • prod.py:生产环境的自定义设置

您已将设置文件移动到一级以下的目录,因此您需要更新settings/base.py文件中的BASE_DIR设置以指向主项目目录。

在处理多个环境时,创建一个基础设置文件和每个环境的设置文件。环境设置文件应继承常用设置并覆盖特定环境的设置。

编辑settings/base.py文件并替换以下行:

BASE_DIR = Path(__file__).resolve().parent.parent 

将前面的行替换为以下一行:

BASE_DIR = Path(__file__).resolve().parent.parent**.parent** 

您通过在BASE_DIR路径中添加.parent来指向一个目录以上。让我们配置本地环境的设置。

本地环境设置

对于DEBUGDATABASES设置,您将明确为每个环境定义它们,而不是使用默认配置。这些设置将针对特定环境。编辑educa/settings/local.py文件并添加以下行:

from .base import *
DEBUG = True
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        'NAME': BASE_DIR / 'db.sqlite3',
    }
} 

这是您本地环境的设置文件。在此文件中,您导入在base.py文件中定义的所有设置,并为该环境定义DEBUGDATABASES设置。DEBUGDATABASES设置与您用于开发的设置相同。

现在,从base.py设置文件中删除DATABASESDEBUG设置。

Django 管理命令不会自动检测要使用的设置文件,因为项目设置文件不是默认的settings.py文件。在运行管理命令时,您需要通过添加--settings选项来指明您想要使用的设置模块,如下所示:

python manage.py runserver --settings=educa.settings.local 

接下来,我们将验证项目和本地环境配置。

运行本地环境

让我们使用新的设置结构运行本地环境。请确保 Redis 正在运行或使用以下命令在 shell 中启动 Redis Docker 容器:

docker run -it --rm --name redis -p 6379:6379 redis:7.2.4 

在另一个 shell 中,从项目目录运行以下管理命令:

python manage.py runserver --settings=educa.settings.local 

在您的浏览器中打开http://127.0.0.1:8000/并检查网站是否正确加载。您现在正在使用local环境的设置来提供您的网站。

如果您不想每次运行管理命令时都传递--settings选项,您可以定义DJANGO_SETTINGS_MODULE环境变量。Django 将使用它来识别要使用的设置模块。如果您使用 Linux 或 macOS,您可以在 shell 中执行以下命令来定义环境变量:

export DJANGO_SETTINGS_MODULE=educa.settings.local 

如果你使用的是 Windows,你可以在 shell 中执行以下命令:

set DJANGO_SETTINGS_MODULE=educa.settings.local 

在此之后执行的任何管理命令都将使用在 DJANGO_SETTINGS_MODULE 环境变量中定义的设置。

通过按 Ctrl + C 键从 shell 中停止 Django 开发服务器,并按同样方式按 Ctrl + C 键停止 Redis Docker 容器。

本地环境运行良好。让我们为生产环境准备设置。

生产环境设置

让我们从为生产环境添加初始设置开始。编辑 educa/settings/prod.py 文件,使其看起来如下所示:

from .base import *
DEBUG = False
ADMINS = [
    ('Antonio M', 'email@mydomain.com'),
]
ALLOWED_HOSTS = ['*']
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.sqlite3',
        NAME: BASE_DIR / 'db.sqlite3',
    }
} 

这些是生产环境的设置:

  • DEBUG: 对于任何生产环境,将 DEBUG 设置为 False 是必要的。未能这样做会导致跟踪信息以及敏感的配置数据暴露给每个人。

  • ADMINS: 当 DEBUGFalse 并且一个视图引发异常时,所有信息将通过电子邮件发送到 ADMINS 设置中列出的人员。请确保用你自己的信息替换名称/电子邮件元组。

  • ALLOWED_HOSTS: 由于安全原因,Django 将只允许包含在此列表中的主机来服务项目。目前,你通过使用星号符号 * 允许所有主机。你将在稍后限制可用于服务项目的宿主机的数量。

  • DATABASES: 你保留 default 数据库设置,指向本地环境的 SQLite 数据库。你将在稍后配置生产数据库。

在本章接下来的部分,你将完成生产环境的设置文件。

你已经成功组织了处理多个环境的设置。现在,你将通过使用 Docker 设置不同的服务来构建一个完整的生产环境。

使用 Docker Compose

你最初在 第三章扩展你的博客应用程序 中使用了 Docker,并且你一直在使用 Docker 在这本书中运行不同服务的容器,例如 PostgreSQL、Redis 和 RabbitMQ。

每个 Docker 容器将应用程序源代码与运行应用程序所需的操作系统库和依赖项结合起来。通过使用应用程序容器,你可以提高应用程序的可移植性。对于生产环境,我们将使用 Docker Compose 来构建和运行多个 Docker 容器。

Docker Compose 是一个用于定义和运行多容器应用程序的工具。你可以创建一个配置文件来定义不同的服务,并使用单个命令从你的配置中启动所有服务。你可以在 docs.docker.com/compose/ 找到有关 Docker Compose 的信息。

对于生产环境,你将创建一个在多个 Docker 容器中运行的分发应用程序。每个 Docker 容器将运行不同的服务。你将最初定义以下三个服务,你将在下一节中添加更多服务:

  • Web 服务:用于服务 Django 项目的 Web 服务器

  • 数据库服务:运行 PostgreSQL 的数据库服务

  • 缓存服务:运行 Redis 的服务

让我们从安装 Docker Compose 开始。

通过 Docker Desktop 安装 Docker Compose

你可以在 macOS、64 位 Linux 和 Windows 上运行 Docker Compose。安装 Docker Compose 最快的方式是通过安装 Docker Desktop。安装包括 Docker Engine、命令行界面和 Docker Compose。

按照以下docs.docker.com/compose/install/compose-desktop/中的说明安装 Docker Desktop。

打开 Docker Desktop 应用程序,点击容器。它看起来如下:

图 17.1:Docker Desktop 界面

安装 Docker Compose 后,你需要为你的 Django 项目创建一个 Docker 镜像。

创建 Dockerfile

你需要创建一个 Docker 镜像来运行 Django 项目。Dockerfile是一个包含 Docker 构建镜像命令的文本文件。你将准备一个包含构建 Django 项目 Docker 镜像命令的Dockerfile

educa项目目录旁边,创建一个新文件并命名为Dockerfile。将以下代码添加到新文件中:

# Pull official base Python Docker image
FROM python:3.12.3
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Set work directory
WORKDIR /code
# Install dependencies
RUN pip install --upgrade pip
COPY requirements.txt .
RUN pip install -r requirements.txt
# Copy the Django project
COPY . . 

此代码执行以下任务:

  1. 使用 Python 3.12.3父级 Docker 镜像。你可以在hub.docker.com/_/python找到官方 Python Docker 镜像。

  2. 以下环境变量已设置:

    1. PYTHONDONTWRITEBYTECODE:这防止 Python 写入pyc文件。

    2. PYTHONUNBUFFERED:这确保 Python 的stdoutstderr流直接发送到终端,而无需先进行缓冲。

  3. 使用WORKDIR命令来定义镜像的工作目录。

  4. 镜像中的pip包已升级。

  5. requirements.txt文件复制到父级 Python 镜像的工作目录(.)。

  6. 使用pip在镜像中安装requirements.txt中的 Python 包。

  7. 将 Django 项目源代码从本地目录复制到镜像的工作目录(.)。

通过这个Dockerfile,你已经定义了将用于服务 Django 的 Docker 镜像的构建方式。你可以在docs.docker.com/reference/dockerfile/找到Dockerfile的参考。

添加 Python 需求

在你创建的Dockerfile中使用requirements.txt文件来安装项目所需的所有 Python 包。

educa 项目目录旁边,创建一个新文件并命名为 requirements.txt。你可能已经创建了此文件,并从 github.com/PacktPublishing/Django-5-by-example/blob/main/Chapter17/requirements.txt 复制了 requirements.txt 文件的内容。如果没有这样做,请将以下行添加到新创建的 requirements.txt 文件中:

asgiref==3.8.1
Django~=5.0.4
Pillow==10.3.0
sqlparse==0.5.0
django-braces==1.15.0
django-embed-video==1.4.9
pymemcache==4.0.0
django-debug-toolbar==4.3.0
redis==5.0.4
django-redisboard==8.4.0
djangorestframework==3.15.1
requests==2.31.0
channels[daphne]==4.1.0
channels-redis==4.2.0
psycopg==3.1.18
uwsgi==2.0.25.1
python-decouple==3.8 

除了你在前几章中安装的 Python 包之外,requirements.txt 文件还包括以下包:

  • psycopg: 这是 PostgreSQL 适配器。你将在生产环境中使用 PostgreSQL。

  • uwsgi: 一个 WSGI 网络服务器。你将在稍后配置这个网络服务器以在生产环境中提供 Django 服务。

  • python-decouple: 一个用于轻松加载环境变量的包。

让我们从设置 Docker Compose 中的 Docker 应用程序开始。我们将创建一个 Docker Compose 文件,其中包含网络服务器、数据库和 Redis 服务的定义。

创建 Docker Compose 文件

为了定义将在不同的 Docker 容器中运行的服务,我们将使用 Docker Compose 文件。Compose 文件是一个 YAML 格式的文本文件,定义了 Docker 应用程序的服务、网络和数据卷。YAML 是一种人类可读的数据序列化语言。你可以在 yaml.org/ 看到一个 YAML 文件的示例。

educa 项目目录旁边,创建一个新文件并命名为 docker-compose.yml。向其中添加以下代码:

services:
  web:
    build: .
    command: python /code/educa/manage.py runserver 0.0.0.0:8000
    restart: always
    volumes:
      - .:/code
    ports:
      - "8000:8000"
    environment:
      - DJANGO_SETTINGS_MODULE=educa.settings.prod 

在此文件中,你定义了一个 web 服务。定义此服务的部分如下:

  • build: 这定义了服务容器镜像的构建要求。这可以是一个定义上下文路径的单个字符串,或者一个详细的构建定义。你提供一个以单个点 (.) 为相对路径,指向与 Compose 文件相同的目录。Docker Compose 将在此位置查找 Dockerfile。你可以在 docs.docker.com/compose/compose-file/build/ 了解更多关于 build 部分的信息。

  • command: 这将覆盖容器的默认命令。你可以使用 runserver 管理命令来运行 Django 开发服务器。项目在主机 0.0.0.0 上提供服务,这是默认的 Docker IP,端口为 8000

  • restart: 这定义了容器的重启策略。使用 always,如果容器停止,它将始终重启。这在需要最小化停机时间的生产环境中非常有用。你可以在 docs.docker.com/config/containers/start-containers-automatically/ 了解更多关于重启策略的信息。

  • :Docker 容器中的数据不是永久的。每个 Docker 容器都有一个虚拟文件系统,其中包含图像的文件,并在容器停止时被销毁。卷是持久化 Docker 容器生成和使用的数据的首选方法。在本节中,您将本地.目录挂载到图像的/code目录。您可以在docs.docker.com/storage/volumes/了解更多关于 Docker 卷的信息。

  • 端口:这公开了容器端口。主机端口8000映射到容器端口8000,Django 开发服务器正在该端口上运行。

  • 环境变量:这定义了环境变量。您将DJANGO_SETTINGS_MODULE环境变量设置为使用生产 Django 设置文件educa.settings.prod

注意,在 Docker Compose 文件定义中,您正在使用 Django 开发服务器来提供应用程序。Django 开发服务器不适合生产使用,因此您稍后将用 WSGI Python 网络服务器替换它。

您可以在docs.docker.com/compose/compose-file/找到有关 Docker Compose 规范的信息。

在这一点上,假设您的父目录名为Chapter17,文件结构应如下所示:

Chapter17/
    Dockerfile
    docker-compose.yml
    educa/
        manage.py
        ...
    requirements.txt 

在父目录中打开一个 shell,其中包含docker-compose.yml文件,并运行以下命令:

docker compose up 

这将启动 Docker Compose 文件中定义的 Docker 应用程序。您将看到包括以下行的输出:

chapter17-web-1  | Performing system checks...
chapter17-web-1  |
chapter17-web-1  | System check identified no issues (0 silenced).
chapter17-web-1  | March 10, 2024 - 12:03:28
chapter17-web-1  | Django version 5.0.4, using settings 'educa.settings.prod'
chapter17-web-1  | Starting ASGI/Daphne version 4.1.0 development server at http://0.0.0.0:8000/
chapter17-web-1  | Quit the server with CONTROL-C. 

您的 Django 项目容器正在运行!

使用您的浏览器打开http://0.0.0.0:8000/admin/。您应该看到 Django 管理网站的登录表单。它应该看起来像图 17.2

图 17.2:未应用 CSS 样式的 Django 管理网站登录表单

CSS 样式未加载。您正在使用DEBUG=False,因此 URL 模式未包含在项目的默认urls.py文件中。请记住,Django 开发服务器不适合提供静态文件。您将在本章稍后配置一个用于提供静态文件的服务器。

如果您访问您站点的任何其他 URL,您可能会遇到 HTTP 500错误,因为您尚未为生产环境配置数据库。

查看 Docker Desktop 应用程序。您将看到以下容器:

图 17.3:Docker Desktop 中的 chapter17 应用程序和 web-1 容器

chapter17 Docker 应用程序正在运行,并且它有一个名为web-1的单个容器,该容器正在端口8000上运行。Docker 应用程序的名称是动态生成的,使用 Docker Compose 文件所在的目录名称,在本例中为chapter17

图像部分,您将看到为web服务构建的图像,如图图 17.4所示:

图 17.4:Docker Desktop 中的 chapter17 应用程序和 web-1 容器

chapter17-web 镜像是使用您之前定义的 Dockerfile 构建的,并由 web-1 容器使用。

接下来,您将向您的 Docker 应用程序添加一个 PostgreSQL 服务和一个 Redis 服务。

配置 PostgreSQL 服务

在整本书中,您主要使用了 SQLite 数据库。SQLite 简单且快速设置,但对于生产环境,您将需要一个更强大的数据库,例如 PostgreSQL、MySQL 或 Oracle。您在 第三章扩展您的博客应用程序 中使用了 Docker 安装 PostgreSQL。您可以在 hub.docker.com/_/postgres 找到有关官方 PostgreSQL Docker 镜像的信息。

编辑 docker-compose.yml 文件,并添加以下加粗的行:

services:
 **db:**
 **image: postgres:****16.2**
 **restart: always**
 **volumes:**
 **- ./data/db:/var/lib/postgresql/data**
 **environment:**
 **- POSTGRES_DB=postgres**
 **- POSTGRES_USER=postgres**
 **- POSTGRES_PASSWORD=postgres**
  web:
    build: .
    command: python /code/educa/manage.py runserver 0.0.0.0:8000
    restart: always
    volumes:
      - .:/code
    ports:
      - "8000:8000"
    environment:
      - DJANGO_SETTINGS_MODULE=educa.settings.prod
 **- POSTGRES_DB=postgres**
 **- POSTGRES_USER=postgres**
 **- POSTGRES_PASSWORD=postgres**
 **depends_on:**
 **- db** 

通过这些更改,您定义了一个名为 db 的服务,以下是其子部分:

  • image: 服务使用基于 postgres 的 Docker 镜像。

  • restart: 重启策略设置为 always

  • volumes: 您将 ./data/db 目录挂载到镜像目录 /var/lib/postgresql/data,以持久化数据库,这样在 Docker 应用程序停止后,存储在数据库中的数据将得到保留。这将创建本地的 data/db/ 路径。

  • environment: 您使用具有默认值的 POSTGRES_DB(数据库名称)、POSTGRES_USERPOSTGRES_PASSWORD 变量。

现在对于 web 服务的定义包括了 Django 的 PostgreSQL 环境变量。您使用 depends_on 创建服务依赖,以便在 db 服务启动后启动 web 服务。这将保证容器初始化的顺序,但不会保证在 Django 网络服务器启动之前 PostgreSQL 已经完全初始化。为了解决这个问题,您需要使用一个脚本等待数据库主机及其 TCP 端口的可用性。Docker 推荐您使用 wait-for-it 工具来控制容器初始化。

github.com/vishnubob/wait-for-it/blob/master/wait-for-it.sh 下载 wait-for-it.sh bash 脚本,并将其保存到 docker-compose.yml 文件旁边。然后,编辑 docker-compose.yml 文件,并按如下方式修改 web 服务定义。新的代码已加粗:

web:
  build: .
  command: **[****"****./wait-for-it.sh"****,** **"db:5432"****,** **"--"****,**
**"python"****,** **"/code/educa/manage.py"****,** **"runserver"****,**
**"0.0.0.0:8000"****]**
  restart: always
  volumes:
      - .:/code
    environment:
      - DJANGO_SETTINGS_MODULE=educa.settings.prod
      - POSTGRES_DB=postgres
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    depends_on:
      - db 

在这个服务定义中,您使用 wait-for-it.sh bash 脚本等待 db 主机准备好并接受端口 5432 的连接,这是 PostgreSQL 的默认端口,然后再启动 Django 开发服务器。您可以在 docs.docker.com/compose/startup-order/ 中了解更多关于服务启动顺序的信息。

让我们编辑 Django 设置。编辑 educa/settings/prod.py 文件,并添加以下加粗的代码:

**from** **decouple** **import** **config**
from .base import *	
DEBUG = False
ADMINS = [
    ('Antonio M', 'email@mydomain.com'),
]
ALLOWED_HOSTS = ['*']
DATABASES = {
    'default': {
        'ENGINE': **'django.db.backends.postgresql'****,**
'NAME': **config(****'POSTGRES_DB'****),**
**'USER'****: config(****'POSTGRES_USER'****),**
**'PASSWORD'****: config(****'POSTGRES_PASSWORD'****),**
**'HOST'****:** **'db'****,**
**'PORT'****:** **5432****,**
    }
} 

在生产设置文件中,您使用以下设置:

  • ENGINE: 你使用 Django 数据库后端用于 PostgreSQL。

  • NAME, USER, 和 PASSWORD: 你使用 python-decoupleconfig() 函数检索 POSTGRES_DB(数据库名称)、POSTGRES_USERPOSTGRES_PASSWORD 环境变量。你已在 Docker Compose 文件中设置了这些环境变量。

  • HOST: 你使用 db,这是 Docker Compose 文件中定义的数据库服务的容器主机名。容器主机名默认为 Docker 中的容器 ID。这就是为什么你使用 db 主机名。

  • PORT: 你使用 5432 的值,这是 PostgreSQL 的默认端口。

通过按 Ctrl + C 键或在 Docker Desktop 应用中点击停止按钮从 shell 中停止 Docker 应用。然后,使用以下命令再次启动 Compose:

docker compose up 

在将 db 服务添加到 Docker Compose 文件后的第一次执行将花费更长的时间,因为 PostgreSQL 需要初始化数据库。输出将包含以下两行:

db-1   | ... database system is ready to accept connections
...
web-1  | Starting ASGI/Daphne version 4.1.0 development server at http://0.0.0.0:8000/ 

PostgreSQL 数据库和 Django 应用都已就绪。生产数据库为空,因此你需要应用数据库迁移。

应用数据库迁移并创建超级用户

在父目录中打开另一个 shell,其中包含 docker-compose.yml 文件,并运行以下命令:

docker compose exec web python /code/educa/manage.py migrate 

docker compose exec 命令允许你在容器中执行命令。你使用此命令在 web Docker 容器中执行 migrate 管理命令。

最后,使用以下命令创建超级用户:

docker compose exec web python /code/educa/manage.py createsuperuser 

数据库已应用迁移,并且你已创建了一个超级用户。你可以使用超级用户凭据访问 http://localhost:8000/admin/。CSS 样式仍然无法加载,因为你还没有配置静态文件的提供。

你已经定义了使用 Docker Compose 服务的 Django 和 PostgreSQL。接下来,你将在生产环境中添加一个服务来提供 Redis。

配置 Redis 服务

让我们向 Docker Compose 文件中添加一个 Redis 服务。为此,你将使用官方的 Redis Docker 镜像。你可以在 hub.docker.com/_/redis 找到有关官方 Redis Docker 镜像的信息。

编辑 docker-compose.yml 文件并添加以下加粗的行:

services:
  db:
    # ...
 **cache:**
 **image: redis:****7.2.4**
 **restart: always**
 **volumes:**
 **- ./data/cache:/data**
  web:
    # ...
    depends_on:
      - db
 **- cache** 

在前面的代码中,你使用以下子部分定义了 cache 服务:

  • image: 该服务使用基于 redis 的 Docker 镜像。

  • restart: 重启策略设置为 always

  • volumes: 你将 ./data/cache 目录挂载到 /data 镜像目录,任何 Redis 写入都将持久化在这里。这将创建本地的 data/cache/ 路径。

web 服务定义中,你添加了 cache 服务作为依赖项,这样 web 服务将在 cache 服务启动后启动。Redis 服务器初始化速度快,因此在这种情况下不需要使用 wait-for-it 工具。

编辑 educa/settings/prod.py 文件并添加以下行:

REDIS_URL = 'redis://cache:6379'
CACHES['default']['LOCATION'] = REDIS_URL
CHANNEL_LAYERS['default']['CONFIG']['hosts'] = [REDIS_URL] 

在这些设置中,你使用 Docker Compose 自动生成的 cache 主机名,该主机名使用 cache 服务的名称和 Redis 使用的端口 6379。你修改 Django 的 CACHE 设置和 Channels 使用的 CHANNEL_LAYERS 设置,以使用生产 Redis URL。

通过按 Ctrl + C 键或在 Docker Desktop 应用程序中使用停止按钮从 shell 中停止 Docker 应用程序。然后,使用以下命令再次启动 Compose:

docker compose up
cache-1   | ... Ready to accept connections tcp 

打开 Docker Desktop 应用程序。你现在应该能看到 chapter17 Docker 应用程序正在运行,每个服务定义在 Docker Compose 文件中:dbcacheweb,如 图 17.4 所示:

图 17.5:Docker Desktop 中的 chapter17 应用程序,包含 db-1、web-1 和 cache-1 容器

你仍然在使用 Django 开发服务器来服务 Django,正如你所知,它是为开发而设计的,并不针对生产使用进行优化。让我们用 WSGI Python Web 服务器来替换它。

通过 WSGI 和 NGINX 服务 Django

Django 的主要部署平台是 WSGI。WSGI 代表 Web Server Gateway Interface,它是用于在网络上服务 Python 应用程序的标准。

当你使用 startproject 命令生成一个新的项目时,Django 在你的项目目录中创建一个 wsgi.py 文件。这个文件包含一个可调用的 WSGI 应用程序,这是你应用程序的访问点。

WSGI 用于在 Django 开发服务器上运行项目以及在生产环境中使用你选择的任何服务器部署你的应用程序。你可以在 wsgi.readthedocs.io/en/latest/ 上了解更多关于 WSGI 的信息。

在接下来的章节中,我们将使用 uWSGI,这是一个开源的 Web 服务器,实现了 WSGI 规范。

使用 uWSGI

在整本书中,你一直在使用 Django 开发服务器在你的本地环境中运行项目。然而,开发服务器并不是为生产使用而设计的,并且在生产环境中部署你的应用程序将需要一个标准的 Web 服务器。

uWSGI 是一个极快的 Python 应用程序服务器。它使用 WSGI 规范与你的 Python 应用程序通信。uWSGI 将 Web 请求转换为你的 Django 项目可以处理的形式。

让我们配置 uWSGI 以服务 Django 项目。你已经在项目的 requirements.txt 文件中添加了 uwsgi==2.0.20,所以 uWSGI 已经在 web 服务的 Docker 镜像中安装。

编辑 docker-compose.yml 文件并修改 web 服务定义如下。新的代码以粗体显示:

web:
    build: .
    command: **[****"./wait-for-it.sh"****,** **"db:5432"****,** **"--"****,**
**"uwsgi"****,** **"--ini"****,** **"/code/config/uwsgi/uwsgi.ini"****]**
    restart: always
    volumes:
      - .:/code
    environment:
      - DJANGO_SETTINGS_MODULE=educa.settings.prod
      - POSTGRES_DB=postgres
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    depends_on:
      - db
      - cache 

确保删除 ports 部分。uWSGI 将通过套接字可访问,因此你不需要在容器中暴露端口。

新的镜像 command 运行 uwsgi 并将 /code/config/uwsgi/uwsgi.ini 配置文件传递给它。让我们为 uWSGI 创建配置文件。

配置 uWSGI

uWSGI 允许你在.ini文件中定义自定义配置。在docker-compose.yml文件旁边,创建config/uwsgi/uwsgi.ini文件路径。假设你的父目录名为Chapter17,文件结构应如下所示:

Chapter17/
    config/
        uwsgi/
            uwsgi.ini
    Dockerfile
    docker-compose.yml
    educa/
        manage.py
        ...
    requirements.txt 

编辑config/uwsgi/uwsgi.ini文件,并向其中添加以下代码:

[uwsgi]
socket=/code/educa/uwsgi_app.sock
chdir = /code/educa/
module=educa.wsgi:application
master=true
chmod-socket=666
uid=www-data
gid=www-data
vacuum=true 

uwsgi.ini文件中,你定义以下选项:

  • socket:这是绑定服务器的 Unix/TCP 套接字。

  • chdir:这是你的项目目录的路径,这样 uWSGI 在加载 Python 应用程序之前会切换到该目录。

  • module:这是要使用的 WSGI 模块。你将此设置为你的项目wsgi模块中包含的application可调用对象。

  • master:这启用了主进程。

  • chmod-socket:这是应用于套接字文件的文件权限。在这种情况下,你使用666以便 NGINX 可以读写套接字。

  • uid:这是进程启动后的用户 ID。

  • gid:这是进程启动后的组 ID。

  • vacuum:使用true指示 uWSGI 清理它创建的任何临时文件或 UNIX 套接字。

socket选项旨在与某种第三方路由器(如 NGINX)进行通信。你将使用套接字运行 uWSGI,并将配置 NGINX 作为你的 Web 服务器,它将通过套接字与 uWSGI 通信。

你可以在uwsgi-docs.readthedocs.io/en/latest/Options.html找到可用的 uWSGI 选项列表。

由于它通过套接字运行,你现在无法从浏览器访问你的 uWSGI 实例。为了完成环境,我们将使用 NGINX 在 uWSGI 前面,以管理 HTTP 请求并通过套接字将应用程序请求传递给 uWSGI。让我们完成生产环境。

使用 NGINX

当你托管一个网站时,你必须提供动态内容,但你还需要提供静态文件,例如 CSS 样式表、JavaScript 文件和图像。虽然 uWSGI 能够提供静态文件,但它会给 HTTP 请求增加不必要的开销,因此建议在它前面设置一个 Web 服务器,例如 NGINX。

NGINX 是一个专注于高并发、性能和低内存使用的 Web 服务器。NGINX 还充当反向代理,接收 HTTP 和 WebSocket 请求并将它们路由到不同的后端。

通常,你会在 uWSGI 前面使用 Web 服务器,如 NGINX,以有效地提供静态文件,并将动态请求转发到 uWSGI 工作进程。通过使用 NGINX,你还可以应用不同的规则并利用其反向代理功能。

我们将使用官方 NGINX Docker 镜像将 NGINX 服务添加到 Docker Compose 文件中。你可以在hub.docker.com/_/nginx找到有关官方 NGINX Docker 镜像的信息。

编辑docker-compose.yml文件,并添加以下加粗的行:

services:
  db:
    # ...
  cache:
    # ...
  web:
    # ...
 **nginx:**
 **image: nginx:****1.25.5**
 **restart: always**
 **volumes:**
 **- ./config/nginx:/etc/nginx/templates**
 **- .:/code**
 **ports:**
 **-** **"80:80"** 

您已使用以下子部分添加了 nginx 服务的定义:

  • image:服务使用基于 nginx 的 Docker 镜像。

  • restart:重启策略设置为 always

  • volumes:您将 ./config/nginx 卷挂载到 Docker 镜像的 /etc/nginx/templates 目录。这是 NGINX 将查找默认配置模板的位置。您还将本地目录 . 挂载到镜像的 /code 目录,以便 NGINX 可以访问静态文件。

  • ports:您公开端口 80,该端口映射到容器端口 80。这是 HTTP 的默认端口。

让我们配置 NGINX 网络服务器。

配置 NGINX

config/ 目录下创建以下加粗的文件路径:

config/
    uwsgi/
      uwsgi.ini
    **nginx/**
**default.conf.template** 

编辑 nginx/default.conf.template 文件,并向其中添加以下代码:

# upstream for uWSGI
upstream uwsgi_app {
    server unix:/code/educa/uwsgi_app.sock;
}
server {
    listen       80;
    server_name  www.educaproject.com educaproject.com;
    error_log    stderr warn;
    access_log   /dev/stdout main;
    location / {
        include      /etc/nginx/uwsgi_params;
        uwsgi_pass   uwsgi_app;
    }
} 

这是 NGINX 的基本配置。在这个配置中,您设置了一个名为 uwsgi_appupstream 组件,它指向 uWSGI 创建的套接字。您使用以下配置的 server 块:

  • 您告诉 NGINX 监听端口 80

  • 您将服务器名称设置为 www.educaproject.comeducaproject.com。NGINX 将为这两个域名提供传入请求。

  • 您使用 stderrerror_log 指令,以便将错误日志写入标准错误文件。第二个参数确定日志级别。您使用 warn 来获取更高严重性的警告和错误。

  • 您将 access_log 指向标准输出 /dev/stdout

  • 您指定任何在 / 路径下的请求都必须通过 uwsgi_app 套接字路由到 uWSGI。

  • 您包括 NGINX 中的默认 uWSGI 配置参数。这些位于 /etc/nginx/uwsgi_params

NGINX 现已配置。您可以在 nginx.org/en/docs/ 找到 NGINX 文档。

通过按 Ctrl + C 键或在 Docker Desktop 应用程序中使用停止按钮从 shell 中停止 Docker 应用程序。然后,使用以下命令再次启动 Compose:

docker compose up 

在您的浏览器中打开 http://localhost/ URL。不需要在 URL 中添加端口,因为您是通过标准 HTTP 端口 80 访问主机的。您应该看到没有 CSS 样式的课程列表页面,就像 图 17.6 一样:

图片

图 17.6:使用 NGINX 和 uWSGI 服务的课程列表页面

以下图表显示了您已设置的生成环境的请求/响应周期:

图片

图 17.7:生产环境请求/响应周期

当客户端浏览器发送 HTTP 请求时,以下情况会发生:

  1. NGINX 接收 HTTP 请求。

  2. NGINX 通过套接字将请求委派给 uWSGI。

  3. uWSGI 将请求传递给 Django 进行处理。

  4. Django 返回一个 HTTP 响应,该响应被传递回 NGINX,然后 NGINX 将其传递回客户端浏览器。

如果您检查 Docker Desktop 应用程序,应该会看到有四个容器正在运行:

  • db 服务正在运行 PostgreSQL

  • cache 服务正在运行 Redis

  • web 服务正在运行 uWSGI 和 Django

  • nginx 服务正在运行 NGINX

让我们继续生产环境设置。我们不会使用 localhost 访问我们的项目,而是将项目配置为使用 educaproject.com 主机名。

使用主机名

你将使用 educaproject.com 主机名来访问你的网站。由于你使用的是示例域名,你需要将其重定向到你的本地主机。

如果你使用的是 Linux 或 macOS,编辑 /etc/hosts 文件并在其中添加以下行:

127.0.0.1 educaproject.com www.educaproject.com 

如果你使用的是 Windows,编辑 C:\Windows\System32\drivers\etc 文件并添加相同的行。

通过这样做,你将 educaproject.comwww.educaproject.com 主机名路由到你的本地服务器。在生产服务器上,你不需要这样做,因为你将有一个固定的 IP 地址,你将在你域名的 DNS 配置中将主机名指向你的服务器。

在你的浏览器中打开 http://educaproject.com/。你应该能看到你的网站,仍然没有加载任何静态资产。你的生产环境几乎准备好了。

现在,你可以限制可以为你提供 Django 项目的宿主。编辑你的项目的生产设置文件 educa/settings/prod.py 并更改 ALLOWED_HOSTS 设置,如下所示:

ALLOWED_HOSTS = [**'educaproject.com'****,** **'www.educaproject.com'**] 

Django 只会在以下主机名之一运行时提供你的应用程序。你可以在 docs.djangoproject.com/en/5.0/ref/settings/#allowed-hosts 阅读更多关于 ALLOWED_HOSTS 设置的信息。

生产环境几乎准备好了。让我们继续配置 NGINX 来提供静态文件。

提供静态和媒体资产

uWSGI 能够完美地提供静态文件,但它不如 NGINX 快速和有效。为了最佳性能,你将在生产环境中使用 NGINX 来提供静态文件。你将设置 NGINX 来提供你的应用程序的静态文件(CSS 样式表、JavaScript 文件和图像)以及由讲师上传的课程内容的媒体文件。

编辑 settings/base.py 文件并在 STATIC_URL 设置下方添加以下行:

STATIC_ROOT = BASE_DIR / 'static' 

这是项目所有静态文件的主目录。接下来,你将从不同的 Django 应用程序中收集静态文件到公共目录中。

收集静态文件

你的 Django 项目中的每个应用程序可能都包含在 static/ 目录中的静态文件。Django 提供了一个命令来从所有应用程序中收集静态文件到一个单一的位置。这简化了在生产环境中提供静态文件的设置。collectstatic 命令从项目的所有应用程序中收集静态文件到由 STATIC_ROOT 设置定义的路径。

通过按Ctrl + C键或在 Docker Desktop 应用中的停止按钮来从 shell 停止 Docker 应用。然后,使用以下命令再次启动 Compose:

docker compose up 

在父目录中打开另一个 shell,其中包含docker-compose.yml文件,并运行以下命令:

docker compose exec web python /code/educa/manage.py collectstatic 

注意,你还可以在 shell 中从educa/项目目录运行以下命令:

python manage.py collectstatic --settings=educa.settings.local 

由于基本本地目录已挂载到 Docker 镜像中,这两个命令将产生相同的效果。Django 将询问你是否要覆盖根目录中现有的任何文件。键入yes并按Enter键。你将看到以下输出:

171 static files copied to '/code/educa/static'. 

位于INSTALLED_APPS设置中每个应用程序的static/目录下的文件已复制到全局/educa/static/项目目录。

使用 NGINX 提供静态文件

编辑config/nginx/default.conf.template文件,并在server块中添加以下加粗的行:

server {
    # ...
    location / {
        include      /etc/nginx/uwsgi_params;
        uwsgi_pass   uwsgi_app;
    }
 **location /static/ {**
 **alias /code/educa/static/;**
 **}**
 **location /media/ {**
 **alias /code/educa/media/;**
 **}**
} 

这些指令告诉 NGINX 直接提供位于/static//media/路径下的静态文件。这些路径如下:

  • /static/:这对应于STATIC_URL设置的路径。目标路径对应于STATIC_ROOT设置的值。你使用它来从挂载到 NGINX Docker 镜像的目录中提供你应用程序的静态文件。

  • /media/:这对应于MEDIA_URL设置的路径,其目标路径对应于MEDIA_ROOT设置的值。你使用它来从挂载到 NGINX Docker 镜像的目录中提供课程内容上传的媒体文件。

图 17.8 展示了生产环境的当前设置:

图 17.8:生产环境请求/响应周期,包括静态文件

/static//media/路径下的文件现在直接由 NGINX 提供服务,而不是转发到 uWSGI。对任何其他路径的请求仍然通过 UNIX 套接字由 NGINX 转发到 uWSGI。

通过按Ctrl + C键或在 Docker Desktop 应用中的停止按钮来从 shell 停止 Docker 应用。然后,使用以下命令再次启动 Compose:

docker compose up 

在你的浏览器中打开http://educaproject.com/。你应该看到以下屏幕:

图 17.9:使用 NGINX 和 uWSGI 提供课程列表页面

静态资源,例如 CSS 样式表和图像,现在被正确加载。对于静态文件的 HTTP 请求现在直接由 NGINX 提供服务,而不是转发到 uWSGI。

你已成功配置 NGINX 以提供静态文件。接下来,你将对你的 Django 项目进行一些检查,以验证其在生产环境中的有效性,并且你将在 HTTPS 下提供你的网站。

使用 SSL/TLS 保护你的网站

TLS协议是通过安全连接提供网站的标准。TLS 的前身是 SSL。尽管 SSL 现在已弃用,但在多个库和在线文档中,您将找到对 TLS 和 SSL 两个术语的引用。强烈建议您通过 HTTPS 提供网站。

在本节中,您将检查您的 Django 项目是否存在任何问题,并验证其用于生产部署。您还将准备项目以通过 HTTPS 提供服务。然后,您将配置 NGINX 中的 SSL/TLS 证书以安全地提供您的网站。

检查您的项目以用于生产

Django 包含一个系统检查框架,用于在任何时候验证您的项目。检查框架检查您的 Django 项目中安装的应用程序并检测常见问题。当您运行runservermigrate等管理命令时,检查会隐式触发。然而,您可以使用check管理命令显式触发检查。

您可以在docs.djangoproject.com/en/5.0/topics/checks/上了解更多关于 Django 系统检查框架的信息。

让我们确认检查框架不会为您的项目引发任何问题。在educa项目目录中打开 shell 并运行以下命令以检查您的项目:

python manage.py check --settings=educa.settings.prod 

您将看到以下输出:

System check identified no issues (0 silenced). 

系统检查框架没有识别出任何问题。如果您使用--deploy选项,系统检查框架将执行与生产部署相关的附加检查。

educa项目目录运行以下命令:

python manage.py check --deploy --settings=educa.settings.prod 

您将看到以下输出:

System check identified some issues:
WARNINGS:
(security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting. ...
(security.W008) Your SECURE_SSL_REDIRECT setting is not set to True...
(security.W009) Your SECRET_KEY has less than 50 characters, less than 5 unique characters, or it's prefixed with 'django-insecure-'...
(security.W012) SESSION_COOKIE_SECURE is not set to True. ...
(security.W016) You have 'django.middleware.csrf.CsrfViewMiddleware' in your MIDDLEWARE, but you have not set CSRF_COOKIE_SECURE ...
System check identified 5 issues (0 silenced). 

检查框架已识别出五个问题(零个错误和五个警告)。所有警告都与安全相关设置有关。

让我们解决security.W009问题。编辑educa/settings/base.py文件并修改SECRET_KEY设置,通过移除django-insecure-前缀并添加额外的随机字符来生成至少 50 个字符的字符串。

再次运行check命令并验证security.W009问题不再出现。其余的警告与 SSL/TLS 配置有关。我们将在下一节中解决它们。

配置您的 Django 项目以使用 SSL/TLS

Django 为 SSL/TLS 支持提供了特定的设置。您将编辑生产设置以通过 HTTPS 提供您的网站。

编辑educa/settings/prod.py设置文件并向其中添加以下设置:

# Security
CSRF_COOKIE_SECURE = True
SESSION_COOKIE_SECURE = True
SECURE_SSL_REDIRECT = True 

这些设置如下:

  • CSRF_COOKIE_SECURE: 使用安全的 cookie 来保护跨站请求伪造CSRF)。当设置为True时,浏览器将仅在 HTTPS 上传输 cookie。

  • SESSION_COOKIE_SECURE: 使用安全的会话 cookie。当设置为True时,浏览器将仅在 HTTPS 上传输 cookie。

  • SECURE_SSL_REDIRECT: 这表示是否必须将 HTTP 请求重定向到 HTTPS。

Django 现在将重定向 HTTP 请求到 HTTPS;会话和 CSRF cookies 只会在 HTTPS 上发送。

从您项目的根目录运行以下命令:

python manage.py check --deploy --settings=educa.settings.prod 

只剩下一条警告,security.W004

(security.W004) You have not set a value for the SECURE_HSTS_SECONDS setting. ... 

此警告与HTTP 严格传输安全HSTS)策略相关。HSTS 策略阻止用户绕过警告并连接到具有过期、自签名或无效 SSL 证书的网站。在下一节中,我们将为我们自己的网站使用自签名证书,因此我们将忽略此警告。

当您拥有真实域名时,您可以申请一个受信任的证书颁发机构CA)为其颁发 SSL/TLS 证书,以便浏览器可以验证其身份。在这种情况下,您可以为SECURE_HSTS_SECONDS赋予一个大于0的值,这是默认值。您可以在docs.djangoproject.com/en/5.0/ref/middleware/#http-strict-transport-security上了解更多关于 HSTS 策略的信息。

您已成功修复检查框架提出的其余问题。您可以在docs.djangoproject.com/en/5.0/howto/deployment/checklist/上了解更多关于 Django 部署清单的信息。

创建 SSL/TLS 证书

educa项目目录内创建一个新的目录,并将其命名为ssl。然后,使用以下命令从命令行生成 SSL/TLS 证书:

openssl req -x509 -newkey rsa:2048 -sha256 -days 3650 -nodes \
  -keyout ssl/educa.key -out ssl/educa.crt \
  -subj '/CN=*.educaproject.com' \
  -addext 'subjectAltName=DNS:*.educaproject.com' 

这将生成一个私钥和一个有效的 10 年的 2048 位 SSL/TLS 证书。此证书是为*.educaproject.com主机名签发的。这是一个通配符证书;通过在域名中使用*通配符字符,该证书可以用于educaproject.com的任何子域名,例如www.educaproject.comdjango.educaproject.com。生成证书后,educa/ssl/目录将包含两个文件:educa.key(私钥)和educa.crt(证书)。

您至少需要 OpenSSL 1.1.1 或 LibreSSL 3.1.0 才能使用-addext选项。您可以使用which openssl命令检查您机器上的 OpenSSL 位置,并使用openssl version命令检查版本。

或者,您可以使用本章源代码中提供的 SSL/TLS 证书。您可以在github.com/PacktPublishing/Django-5-by-example/blob/main/Chapter17/educa/ssl/找到证书。请注意,您应该生成一个私钥,不要在生产中使用此证书。

配置 NGINX 以使用 SSL/TLS

编辑docker-compose.yml文件,并添加以下加粗的行:

services:
  # ...
  nginx:
    #...
    ports:
      - "80:80"
 **-** **"443:443"** 

NGINX 容器宿主将通过端口80(HTTP)和端口443(HTTPS)访问。宿主端口443映射到容器端口443

编辑educa项目的config/nginx/default.conf.template文件,并编辑server块以包含 SSL/TLS,如下所示:

server {
   listen               80;
 **listen** **443** **ssl;**
 **ssl_certificate      /code/educa/ssl/educa.crt;**
 **ssl_certificate_key  /code/educa/ssl/educa.key;**
   server_name          www.educaproject.com educaproject.com;
   # ...
} 

使用前面的代码,NGINX 现在同时监听 HTTP 端口80和 HTTPS 端口443。您使用ssl_certificate指示 SSL/TLS 证书的路径,并使用ssl_certificate_key指示证书密钥。

通过按Ctrl + C键或在 Docker Desktop 应用中点击停止按钮从 shell 停止 Docker 应用。然后,使用以下命令再次启动 Compose:

docker compose up 

使用您的浏览器打开https://educaproject.com/。您应该看到类似于以下的一个警告信息:

图片

图 17.10:无效证书警告

这个屏幕可能因您的浏览器而异。它会提醒您您的网站没有使用受信任或有效的证书;浏览器无法验证您网站的身份。这是因为您签发了自己的证书而不是从受信任的 CA 获取证书。当您拥有真实域名时,您可以申请受信任的 CA 为其颁发 SSL/TLS 证书,以便浏览器可以验证其身份。如果您想为真实域名获取受信任的证书,可以参考由 Linux 基金会创建的 Let’s Encrypt 项目。它是一个非营利性 CA,简化了免费获取和更新受信任 SSL/TLS 证书的过程。更多信息请访问letsencrypt.org

点击提供额外信息的链接或按钮,并选择忽略警告访问网站。浏览器可能会要求您为该证书添加例外或验证您是否信任它。如果您使用 Chrome,您可能看不到继续访问网站的选择。如果是这种情况,请在 Chrome 的警告页面上直接输入thisisunsafe并按Enter。Chrome 将随后加载网站。请注意,您这样做时使用的是您自己的颁发的证书;不要信任任何未知的证书或绕过其他域的浏览器 SSL/TLS 证书检查。

当您访问网站时,浏览器将在 URL 旁边显示一个锁形图标,如图 17.11

图片

图 17.11:浏览器地址栏,包括安全连接锁形图标

其他浏览器可能会显示一个警告,表明证书不受信任,如图 17.12

图片

图 17.12:浏览器地址栏,包括警告信息

您的浏览器可能会将证书标记为不安全,但您仅用于测试目的。现在您正在通过 HTTPS 安全地提供您的网站。

将 HTTP 流量重定向到 HTTPS

您正在使用 Django 的SECURE_SSL_REDIRECT设置将 HTTP 请求重定向到 HTTPS。任何使用http://的请求都会被重定向到使用https://的相同 URL。然而,这可以通过使用 NGINX 以更高效的方式处理。

编辑config/nginx/default.conf.template文件,并添加以下加粗的行:

# upstream for uWSGI
upstream uwsgi_app {
    server unix:/code/educa/uwsgi_app.sock;
}
server {
    listen      80;
    **server_name www.educaproject.com educaproject.com;**
**return****301** **https://$host$request_uri;**
**}**
**server {**
    listen               443 ssl;
    ssl_certificate      /code/educa/ssl/educa.crt;
    ssl_certificate_key  /code/educa/ssl/educa.key;
    server_name   www.educaproject.com educaproject.com;
    # ...
} 

在此代码中,您从原始的server块中删除了指令listen 80;,因此平台现在仅通过 HTTPS(端口443)可用。在原始的server块之上,您添加了一个额外的server块,该块仅在端口80上监听,并将所有 HTTP 请求重定向到 HTTPS。为此,您返回 HTTP 响应代码301(永久重定向),使用$host$request_uri变量将请求重定向到https://版本的请求 URL。

在包含docker-compose.yml文件的父目录中打开一个 shell,并运行以下命令以重新加载 NGINX:

docker compose exec nginx nginx -s reload 

这将在nginx容器中运行nginx -s reload命令。您现在正使用 NGINX 将所有 HTTP 流量重定向到 HTTPS。

您的环境现在已通过 TLS/SSL 进行安全保护。为了完成生产环境的设置,唯一剩下的步骤是将 Daphne 集成以处理异步请求,并使我们的课程聊天室在生产环境中运行。

配置 Daphne 以用于 Django Channels

在第十六章构建聊天服务器中,您使用了 Django Channels 来构建一个使用 WebSocket 的聊天服务器,并且您使用 Daphne 通过替换标准的 Django runserver命令来服务异步请求。我们将把 Daphne 添加到我们的生产环境中。

让我们在 Docker Compose 文件中创建一个新的服务来运行 Daphne 网络服务器。

编辑docker-compose.yml文件,并在services块内添加以下行:

daphne:
    build: .
    working_dir: /code/educa/
    command: ["../wait-for-it.sh", "db:5432", "--",
              "daphne", "-b", "0.0.0.0", "-p", "9001",
              "educa.asgi:application"]
    restart: always
    volumes:
      - .:/code
    environment:
      - DJANGO_SETTINGS_MODULE=educa.settings.prod
      - POSTGRES_DB=postgres
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
    depends_on:
      - db
      - cache 

daphne服务定义与web服务非常相似。daphne服务的镜像也是使用您之前为web服务创建的Dockerfile构建的。主要区别如下:

  • working_dir将镜像的工作目录更改为/code/educa/

  • command运行在educa/asgi.py文件中定义的educa.asgi:application应用程序,使用daphne0.0.0.0主机名和端口9001上运行。它还使用wait-for-itbash 脚本来等待 PostgreSQL 数据库准备好,然后再初始化网络服务器。

由于您在生产环境中运行 Django,Django 在接收 HTTP 请求时检查ALLOWED_HOSTS。我们将为 WebSocket 连接实现相同的验证。

编辑您项目的educa/asgi.py文件,并添加以下加粗的行:

import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
**from** **channels.security.websocket** **import** **AllowedHostsOriginValidator**
from channels.auth import AuthMiddlewareStack
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'educa.settings')
django_asgi_app = get_asgi_application()
from chat.routing import websocket_urlpatterns
application = ProtocolTypeRouter({
    'http': django_asgi_app,
    'websocket': **AllowedHostsOriginValidator(**
        AuthMiddlewareStack(
            URLRouter(websocket_urlpatterns)
        )
    **)**,
}) 

Channels 配置现在已准备好投入生产。

使用安全的 WebSocket 连接

您已配置 NGINX 使用 SSL/TLS 进行安全连接。您现在需要将ws(WebSocket)连接更改为使用wss(WebSocket Secure)协议,就像 HTTP 连接现在正通过 HTTPS 提供服务一样。

编辑chat/room.html模板中的chat应用程序,并在domready块中找到以下行:

const url = 'ws://' + window.location.host + 

将该行替换为以下一行:

const url = 'ws**s**://' + window.location.host + 

通过使用wss://而不是ws://,您明确地连接到一个安全的 WebSocket。

在 NGINX 配置中包含 Daphne

在您的生产设置中,您将在 UNIX 套接字上运行 Daphne,并在其前面使用 NGINX。NGINX 将根据请求的路径将请求传递给 Daphne。您将通过 UNIX 套接字接口将 Daphne 暴露给 NGINX,就像 uWSGI 设置一样。

编辑 config/nginx/default.conf.template 文件,使其看起来如下:

# upstream for uWSGI
upstream uwsgi_app {
    server unix:/code/educa/uwsgi_app.sock;
}
**# upstream for Daphne**
**upstream daphne {**
 **server daphne:****9001****;**
**}**
server {
    listen       80;
    server_name www.educaproject.com educaproject.com;
    return 301 https://$host$request_uri;
}
server {
    listen               443 ssl;
    ssl_certificate      /code/educa/ssl/educa.crt;
    ssl_certificate_key  /code/educa/ssl/educa.key;
    server_name  www.educaproject.com educaproject.com;
    error_log    stderr warn;
    access_log   /dev/stdout main;
    location / {
        include      /etc/nginx/uwsgi_params;
        uwsgi_pass   uwsgi_app;
    }
 **location /ws/ {**
 **proxy_pass          http://daphne;**
 **proxy_http_version** **1.1****;**
 **proxy_set_header    Upgrade $http_upgrade;**
 **proxy_set_header    Connection** **"upgrade"****;**
 **proxy_redirect      off;**
 **}**
    location /static/ {
        alias /code/educa/static/;
    }
    location /media/ {
        alias /code/educa/media/;
    }
} 

在此配置中,您设置了一个名为 daphne 的新上游,它指向 daphne 主机和端口 9001。在 server 块中,您配置了 /ws/ 位置以将请求转发到 Daphne。您使用 proxy_pass 指令将请求传递到 Daphne,并包含了一些额外的代理指令。

在此配置中,NGINX 将将任何以 /ws/ 前缀开始的 URL 请求传递给 Daphne,其余的请求传递给 uWSGI,除了 /static//media/ 路径下的文件,这些文件将由 NGINX 直接服务。

图 17.13 展示了最终的最终生产设置,包括 Daphne 服务器:

图 17.13:包括 Daphne 的生产环境请求/响应周期

NGINX 作为反向代理服务器运行在 uWSGI 和 Daphne 之前。NGINX 面向 Web,根据它们的路径前缀将请求传递给应用程序服务器(uWSGI 或 Daphne)。除此之外,NGINX 还负责服务静态文件并将非安全请求重定向到安全请求。这种设置减少了停机时间,消耗更少的服务器资源,并提供了更高的性能和安全性。

通过按 Ctrl + C 键或在 Docker Desktop 应用中点击停止按钮来从 shell 中停止 Docker 应用程序。然后,使用以下命令再次启动 Compose:

docker compose up 

使用您的浏览器创建一个带有讲师用户的示例课程,用注册课程的用户登录,并在浏览器中打开 https://educaproject.com/chat/room/1/。您应该能够发送和接收如下示例的消息:

图 17.14:使用 NGINX 和 Daphne 服务的课程聊天室消息

Daphne 正在正常工作,NGINX 正在将其 WebSocket 请求传递给它。所有连接都通过 SSL/TLS 加密。

恭喜!您已使用 NGINX、uWSGI 和 Daphne 构建了一个自定义的生产就绪堆栈。您可以通过在 NGINX、uWSGI 和 Daphne 中的配置设置进行进一步优化,以实现额外的性能提升和安全性增强。然而,这个生产设置是一个很好的起点!

您已使用 Docker Compose 在多个容器中定义和运行服务。请注意,您可以在本地开发环境和生产环境中都使用 Docker Compose。您可以在 docs.docker.com/compose/production/ 找到有关在生产环境中使用 Docker Compose 的更多信息。

对于更高级的生产环境,您需要在多台机器上动态分配容器。为此,您将需要像 Docker Swarm 模式或 Kubernetes 这样的编排器。您可以在 docs.docker.com/engine/swarm/ 找到有关 Docker Swarm 模式的信息,以及 kubernetes.io/docs/home/ 找到有关 Kubernetes 的信息。

注意,管理和云基础设施需要配置、优化和安全方面的专业知识。为了确保安全高效的生产环境,考虑聘请系统/DevOps 专家或增强自己在这些领域的专业知识。

现在我们已经拥有了一个能够高效处理 HTTP 请求的完整环境,现在是时候深入了解跨我们应用程序的请求/响应处理中间件了。

创建自定义中间件

您已经了解了 MIDDLEWARE 设置,它包含您项目的中间件。您可以将其视为一个低级插件系统,允许您实现请求/响应过程中执行的钩子。每个中间件都负责执行一些特定操作,这些操作将针对所有 HTTP 请求或响应执行。

由于中间件在每次请求中都会执行,因此您应该避免在中间件中添加昂贵的处理。

图 17.15 展示了 Django 中的中间件执行:

图 17.15:Django 中的中间件执行

当接收到 HTTP 请求时,中间件将按照 MIDDLEWARE 设置中出现的顺序执行。当 Django 生成 HTTP 响应后,响应将通过所有中间件,并按相反的顺序返回。

图 17.16 展示了使用 startproject 管理命令创建项目时,MIDDLEWARE 设置中包含的中间件组件的执行顺序:

图 17.16:默认中间件组件的执行顺序

中间件可以编写为一个函数,如下所示:

def my_middleware(get_response):
    def middleware(request):
        # Code executed for each request before
# the view (and later middleware) are called.
        response = get_response(request)
        # Code executed for each request/response after
# the view is called.
return response
    return middleware 

中间件工厂是一个可调用对象,它接受一个 get_response 可调用对象并返回中间件。middleware 可调用对象接受一个请求并返回一个响应,就像视图一样。get_response 可调用对象可能是链中的下一个中间件,或者是在最后列出的中间件的情况下的实际视图。

如果任何中间件在未调用其 get_response 可调用之前返回响应,则会短路该过程;没有其他中间件会执行(视图也不会执行),响应将通过请求通过的相同层返回。

MIDDLEWARE 设置中中间件组件的顺序非常重要,因为每个组件可能依赖于先前执行的中间件组件中的请求数据集。

当向 MIDDLEWARE 设置添加新的中间件时,请确保将其放置在正确的位置。

您可以在docs.djangoproject.com/en/5.0/topics/http/middleware/找到有关中间件的更多信息。

创建子域名中间件

您将创建自定义中间件,以允许通过自定义子域名访问课程。每个课程详情 URL,看起来像https://educaproject.com/course/django/,也将可以通过使用课程 slug 的子域名访问,例如https://django.educaproject.com/。用户可以使用子域名作为访问课程详情的快捷方式。任何对子域名的请求都将重定向到相应的课程详情 URL。

中间件可以位于您的项目中的任何位置。然而,建议您在应用程序目录中创建一个middleware.py文件。

courses应用程序目录中创建一个新文件,并将其命名为middleware.py。向其中添加以下代码:

from django.urls import reverse
from django.shortcuts import get_object_or_404, redirect
from .models import Course
def subdomain_course_middleware(get_response):
    """
    Subdomains for courses
    """
def middleware(request):
        host_parts = request.get_host().split('.')
        if len(host_parts) > 2 and host_parts[0] != 'www':
            # get course for the given subdomain
            course = get_object_or_404(Course, slug=host_parts[0])
            course_url = reverse('course_detail', args=[course.slug])
            # redirect current request to the course_detail view
            url = '{}://{}{}'.format(
                request.scheme, '.'.join(host_parts[1:]), course_url
            )
            return redirect(url)
        response = get_response(request)
        return response
    return middleware 

当接收到 HTTP 请求时,您将执行以下任务:

  1. 您将获取请求中正在使用的域名,并将其分割成部分。例如,如果用户正在访问mycourse.educaproject.com,您将生成['mycourse', 'educaproject', 'com']列表。

  2. 您通过检查分割生成的元素是否超过两个来检查主机名是否包含子域名。如果主机名包含子域名,并且这不是www,您将尝试获取子域名中提供的 slug 对应的课程。

  3. 如果找不到课程,您将引发 HTTP 404异常。否则,您将浏览器重定向到课程详情 URL。

编辑项目的settings/base.py文件,并在MIDDLEWARE列表的底部添加'courses.middleware.subdomain_course_middleware',如下所示:

MIDDLEWARE = [
    # ...
**'courses.middleware.subdomain_course_middleware'****,**
] 

中间件现在将在每个请求中执行。

记住允许为您的 Django 项目提供服务的域名是在ALLOWED_HOSTS设置中指定的。让我们更改此设置,以便允许educaproject.com的任何可能的子域名提供您的应用程序。

编辑educa/settings/prod.py文件,并修改ALLOWED_HOSTS设置,如下所示:

ALLOWED_HOSTS = [**'****.educaproject.com'**] 

以点开头的值用作子域名通配符;'.educaproject.com'将匹配educaproject.com及其任何子域名,例如,course.educaproject.comdjango.educaproject.com

使用 NGINX 提供多个子域名服务

您需要 NGINX 才能使用任何可能的子域名来提供您的网站服务。编辑config/nginx/default.conf.template文件,在这些两个出现的地方进行修改:

server_name  www.educaproject.com educaproject.com; 

将上一行的出现替换为以下一行:

server_name  *****.educaproject.com educaproject.com; 

通过使用星号,此规则适用于educaproject.com的所有子域名。为了在本地测试您的中间件,您需要将您想要测试的任何子域名添加到/etc/hosts文件中。为了使用具有 slug djangoCourse对象测试中间件,请将以下行添加到您的/etc/hosts文件中:

127.0.0.1  django.educaproject.com 

通过按 Ctrl + C 键或在 Docker Desktop 应用程序中使用停止按钮从 shell 中停止 Docker 应用程序。然后,使用以下命令再次启动 Compose:

docker compose up 

然后,在您的浏览器中打开 https://django.educaproject.com/。中间件将通过子域名找到课程,并将您的浏览器重定向到 https://educaproject.com/course/django/

您的自定义子域名中间件正在工作!

现在,我们将深入探讨一个对项目极其有用的最终主题:自动化任务并将它们作为命令提供。

实现自定义管理命令

Django 允许您的应用程序为 manage.py 工具注册自定义管理命令。例如,您在 第十一章将国际化添加到您的商店 中使用了 makemessagescompilemessages 管理命令来创建和编译翻译文件。

管理命令由一个包含继承自 django.core.management.base.BaseCommand 或其子类的 Command 类的 Python 模块组成。您可以创建简单的命令或使它们接受位置参数和可选参数作为输入。

Django 在 INSTALLED_APPS 设置中每个活动的应用程序的 management/commands/ 目录中查找管理命令。每个找到的模块都注册为以它命名的管理命令。

您可以在 docs.djangoproject.com/en/5.0/howto/custom-management-commands/ 了解更多关于自定义管理命令的信息。

您将创建一个自定义管理命令来提醒学生至少报名一个课程。该命令将向注册时间超过指定期限且尚未报名任何课程的用户发送电子邮件提醒。

students 应用程序目录内创建以下文件结构:

management/
    __init__.py
    commands/
        __init__.py
        enroll_reminder.py 

编辑 enroll_reminder.py 文件,并向其中添加以下代码:

import datetime
from django.conf import settings
from django.contrib.auth.models import User
from django.core.mail import send_mass_mail
from django.core.management.base import BaseCommand
from django.db.models import Count
from django.utils import timezone
class Command(BaseCommand):
    help = 'Sends an e-mail reminder to users registered more' \
           'than N days that are not enrolled into any courses yet'
def add_arguments(self, parser):
        parser.add_argument('--days', dest='days', type=int)
    def handle(self, *args, **options):
        emails = []
        subject = 'Enroll in a course'
        date_joined = timezone.now().today() - datetime.timedelta(
            days=options['days'] or 0
        )
        users = User.objects.annotate(
            course_count=Count('courses_joined')
        ).filter(course_count=0, date_joined__date__lte=date_joined)
        for user in users:
            message = f"""Dear {user.first_name},
            We noticed that you didn't enroll in any courses yet.
            What are you waiting for?"""
            emails.append(
                (
                    subject,
                    message,
                    settings.DEFAULT_FROM_EMAIL,
                    [user.email]
                )
            )
        send_mass_mail(emails)
        self.stdout.write(f'Sent {len(emails)} reminders') 

这就是您的 enroll_reminder 命令。前面的代码如下:

  • Command 类继承自 BaseCommand

  • 您包含一个 help 属性。此属性提供了当您运行 python manage.py help enroll_reminder 命令时打印的命令的简短描述。

  • 您使用 add_arguments() 方法添加名为 --days 的命名参数。此参数用于指定用户必须注册的最少天数,且未报名任何课程,以便接收提醒。

  • handle() 命令包含实际命令。你从命令行解析出 days 属性。如果没有设置,你使用 0,这样就会向所有尚未报名课程的用户发送提醒,无论他们何时注册。你使用 Django 提供的 timezone 工具通过 timezone.now().date() 获取当前的时区感知日期。(你可以通过 TIME_ZONE 设置为你的项目设置时区。)你检索出注册天数超过指定天数且尚未报名任何课程的用户。你通过为每个用户的已报名课程总数注释 QuerySet 来实现这一点。你为每个用户生成提醒邮件并将其附加到 emails 列表中。最后,你使用 send_mass_mail() 函数发送邮件,该函数优化为打开单个 SMTP 连接以发送所有邮件,而不是为每封邮件打开一个连接。

你已经创建了你的第一个管理命令。打开 shell 并运行你的命令:

docker compose exec web python /code/educa/manage.py \
  enroll_reminder --days=20 --settings=educa.settings.prod 

如果你没有运行本地的 SMTP 服务器,你可以查看第二章通过高级功能增强你的博客,在那里你为你的第一个 Django 项目配置了 SMTP 设置。或者,你可以将以下设置添加到 base.py 文件中,以便在开发期间 Django 将电子邮件输出到标准输出:

EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 

Django 还包括一个用于使用 Python 调用管理命令的实用工具。你可以如下从你的代码中运行管理命令:

from django.core import management
management.call_command('enroll_reminder', days=20) 

恭喜!你现在可以为你的应用程序创建自定义管理命令了。

Django 管理命令可以使用 cron 或 Celery Beat 等工具自动安排运行。cron 是类 Unix 操作系统中基于时间的作业调度器,它允许用户安排脚本或命令在指定的时间和间隔运行。你可以在en.wikipedia.org/wiki/Cron上了解更多关于 cron 的信息。另一方面,Celery Beat 是与 Celery 一起工作的调度器,可以在指定的时间间隔运行函数。你可以在docs.celeryq.dev/en/stable/userguide/periodic-tasks.html上了解更多关于 Celery Beat 的信息。通过使用 cron 或 Celery Beat,你可以确保你的任务定期执行,无需手动干预。

摘要

在本章中,你使用 Docker Compose 创建了一个生产环境。你配置了 NGINX、uWSGI 和 Daphne 以在生产环境中提供你的应用程序服务。你使用 SSL/TLS 保护了你的环境。你还实现了自定义中间件,并学习了如何创建自定义管理命令。

你已经到达了这本书的结尾。恭喜!你已经学会了使用 Django 构建成功 Web 应用所需的技能。这本书已经引导你通过开发真实项目并将 Django 与其他技术集成的过程。现在,你准备好创建自己的 Django 项目了,无论是简单的原型还是大型 Web 应用。

祝你在下一个 Django 冒险中好运!

使用 AI 扩展你的项目

在本节中,你将面临一个扩展项目的任务,并附有 ChatGPT 的示例提示以协助你。要参与 ChatGPT,请访问 chat.openai.com/。如果你是第一次与 ChatGPT 互动,你可以回顾第三章,扩展你的博客应用中的使用 AI 扩展你的项目部分。

我们已经开发了一个全面的在线学习平台。然而,当学生注册了多个课程,每个课程包含多个模块时,他们可能难以记住上次停止的地方。为了解决这个问题,让我们结合使用 ChatGPT 和 Redis 来存储和检索每个学生在课程中的进度。有关指导,请参考提供的提示 github.com/PacktPublishing/Django-5-by-example/blob/main/Chapter17/prompts/task.md

当你在优化你的 Python 代码时,ChatGPT 可以帮助你探索不同的重构策略。讨论你的当前方法,ChatGPT 可以就如何使你的代码更 Pythonic,利用如不要重复自己(DRY)和模块化设计等原则提供建议。

其他资源

以下资源提供了与本章涵盖主题相关的额外信息:

加入我们吧,在 Discord 上!

与其他用户、Django 开发专家以及作者本人一起阅读这本书。提问,为其他读者提供解决方案,通过 Ask Me Anything 会话与作者聊天,等等。扫描二维码或访问链接加入社区。

packt.link/Django5ByExample

posted @ 2025-09-18 12:47  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报