Django-示例-全-

Django 示例(全)

译者:夜夜月

来源:https://www.jianshu.com/p/05810d38f93a

第一章:创建一个博客应用

在本书中,你将学习如何创建完整的,可用于生产环境的 Django 项目。如果你还没有安装 Django,你将在本章的第一部分学习如何安装。本章将会涉及如何使用 Django 创建一个简单的博客应用。本章的目的是对框架如何工作有一个基本概念,理解不同组件之间如何交互,并教你使用基本功能创建 Django 项目。本章会引导你创建一个完整项目,但不会阐述所有细节。不同框架组件的细节会在本书接下来的章节中介绍。

本章将会涉及以下知识点:

  • 安装 Django,并创建第一个项目
  • 设计模型(model),并生成模型迁移(model migration)
  • 为模型创建一个管理站点
  • 使用QuerySet和管理器(manager)
  • 创建视图(view),模板(template)和 URL
  • 为列表视图中添加分页
  • 使用基于类的视图

1.1 安装 Django

如果你已经安装了 Django,可以略过本节,直接跳到创建第一个项目。Django 是一个 Python 包,可以在任何 Python 环境中安装。如果你还没有安装 Django,这是为本地开发安装 Django 的快速指南。

Django 可以在 Python 2.7 或 3 版本中工作。本书的例子中,我们使用 Python 3。如果你使用 Linux 或 Mac OS X,你可能已经安装了 Python。如果不确定是否安装,你可以在终端里输入python。如果看到类似下面的输出,表示已经安装了 Python:

Python 3.5.2 (v3.5.2:4def2a2901a5, Jun 26 2016, 10:47:25)
[GCC 4.2.1 (Apple Inc. build 5666) (dot 3)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>>

如果你安装的 Python 版本低于 3,或者没有安装,请从官方网站下载并安装。

译者注:如果你的电脑上同时安装了 Python 2 和 Python 3,需要输入python3,而不是python

因为将会使用 Python 3,所以不需要安装数据库。该 Python 版本内置了 SQLite 数据库。SQLite 是一个轻量级数据库,可以在 Django 开发中使用。如果计划在生产环境部署应用,应该使用更高级的数据库,比如 PostgreSQL,MySQL,或者 Oracle。在这里获取更多关于如何在 Django 中使用数据库的信息。

1.1.1 创建独立的 Python 环境

推荐你使用virtualenv创建独立的 Python 环境,这样就可以为不同项目使用不同的包版本,比在系统范围内安装 Python 包更实用。使用virtualenv的另一个好处是,安装 Python 包时不需要管理员权限。在终端运行下面的命令来安装virtualenv

pip install virtualenv

译者注:如果电脑上同时安装了 Python 2 和 Python 3,需要使用pip3

安装virtualenv后,使用下面的命令创建一个独立的 Python 环境:

virtualenv my_env

这将会创建一个包括 Python 环境的my_env/目录。当虚拟环境激活时,安装的所有 Python 库都会在my_env/lib/python3.5/site-packages目录中。

如果电脑上同时安装了 Python 2 和 Python 3,你需要告诉virtualenv使用后者。使用以下命令定位 Python 3 的安装路径,然后使用该路径创建虚拟环境:

zenx$ which python3
/Library/Frameworks/Python.framework/Versions/3.5/bin/python3
zenx$ virtualenv my_env -p
/Library/Frameworks/Python.framework/Versions/3.5/bin/python3

运行下面的命令激活虚拟环境:

source my_env/bin/activate

终端的提示中,括号内是激活的虚拟环境的名称,比如:

(my_env)laptop:~ zenx$

你可以使用deactive命令随时停用虚拟环境。

你可以在这里找到更多关于virtualenv的信息。

virtualenv之上,你可以使用virtualenvwrapper。该工具进行了一些封装,更容易创建和管理虚拟环境。你可以在这里下载。

1.1.2 使用 pip 安装 Django

推荐使用pip安装 Django。Python 3.5 中已经安装了pip。在终端运行以下命令安装 Django:

pip install Django

Django 将会安装在虚拟环境的site-packages目录中。

检查一下 Django 是否安装成功。在终端中运行python,然后导入 Django,检查版本:

>>> import django
>>> django.VERSION
(1, 11, 0, 'final', 1)

如果得到类似以上的输出,表示 Django 已经安装成功。

有多种方式可以安装 Django,访问这里查看完成的安装指南。

1.2 创建第一个项目

我们的第一个 Django 项目是一个完整的博客网站。Django 提供了一个命令,可以很容易创建一个初始的项目文件结构。在终端运行以下命令:

django-admin startproject mysite

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

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

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

以下是这些文件的基本介绍:

  • manage.py:用于与项目交互的命令行工具。它对django-admin.py工具进行了简单的封装。你不需要编辑该文件。
  • mysite/:你的项目目录,由以下文件组成:
  • __init__.py:一个空文件,告诉 Python,把mysite目录当做一个 Python 模块。
  • settings.py:用于设置和配置你的项目。包括初始的默认设置。
  • urls.py:放置 URL 模式(pattern)的地方。这里定义的每个 URL 对应一个视图。
  • wsgi.py:配置你的项目,让它作为一个 WSGI 应用运行。

生成的settings.py文件中包括:使用 SQLite 数据库的基本配置,以及默认添加到项目中的 Django 应用。我们需要为这些初始应用在数据库中创建表。

打开终端,运行以下命令:

cd mysite
python manage.py migrate

你会看到以类似这样结尾的输出:

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 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 sessions.0001_initial... OK

初始应用的数据库表已经创建成功。一会你会学习migrate管理命令。

1.2.1 运行开发服务器

Django 自带一个轻量级的 web 服务器,可以快速运行你的代码,不需要花时间配置生产服务器。当你运行 Django 的开发服务器时,它会一直监测代码的变化,自动重新载入,不需要修改代码后,手动重启服务器。但是,有些操作它可能无法监测,比如在项目中添加新文件,这种情况下,你需要手动重启服务器。

从项目的根目录下输入以下命令,启动开发服务器:

python manage.py runserver

你会看到类似这样的输出:

Performing system checks...

System check identified no issues (0 silenced).
April 21, 2017 - 08:01:00
Django version 1.11, using settings 'kaoshao.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

在浏览器中打开http://127.0.0.1:8000/。你应该可以看到一个页面,告诉你项目已经成功运行,如下图所示:

你可以让 Django 在自定义的 host 和端口运行开发服务器,或者载入另一个配置文件来运行项目。例如,你可以这样运行manage.py命令:

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

这可以用来处理多个环境需要不同的配置。记住,该服务器只能用于开发,不适合生产环境使用。要在生产环境发布 Django,你需要使用真正的 web 服务器(比如 Apache,Gunicorn,或者 uWSGI)作为 Web Server Gateway Interface(WSGI)。你可以在这里找到更多关于如何使用不同的 web 服务器发布 Django 的信息。

1.2.2 项目设置

让我们打开settings.py文件,看下项目的配置。该文件中有很多 Django 的配置,但这只是所有 Django 配置中的一部分。你可以在这里查看所有配置和它们的默认值。

以下配置非常值得一看:

  • DEBUG:一个布尔值,用于开启或关闭项目的调试模式。如果设置为True,当应用抛出一个未捕获的异常时,Django 会显示详细的错误页面。当你部署到生产环境时,记得设置为False。在生产环境部署站点时,永远不要启用DEBUG,否则会暴露项目的敏感数据。
  • ALLOWED_HOSTS:当开启调试模式,或者运行测试时,它不会起作用。一旦你准备把站点部署到生产环境,并设置DEBUGFalse,就需要将你的域或 host 添加到该设置中,以便它可以提供该 Django 站点。
  • INSTALLED_APPS:你需要在所有项目中编辑该设置。该设置告诉 Django,该站点激活了哪些应用。默认情况下,Django 包括以下应用:
  • django.contrib.admin:管理站点。
  • django.contrib.auth:权限框架。
  • django.contrib.contenttypes:内容类型的框架。
  • django.contrib.sessions:会话框架。
  • django.contrib.messages:消息框架。
  • django.contrib.staticfiles:管理静态文件的框架。
  • MIDDLEWARE:一个包括被执行的中间件的元组。
  • ROOT_URLCONF:指明哪个 Python 模块定义了应用的根 URL 模式。
  • DATABASES:一个字典,其中包括了所有在项目中使用的数据库的设置。必须有一个default数据库。默认使用 SQLite3 数据库。
  • LANGUAGE_CODE:定义该 Django 站点的默认语言编码。

译者注:Django 1.9 和之前的版本是MIDDLEWARE,之后的版本修改为MIDDLEWARE

不用担心现在不能理解这些配置的意思。在以后的章节中你会逐渐熟悉 Django 配置。

1.2.3 项目和应用

在本书中,你会一次次看到术语项目(project)和应用(application)。在 Django 中,一个项目认为是一个具有一些设置的 Django 安装;一个应用是一组模型,视图,模板和 URLs。应用与框架交互,提供一些特定的功能,而且可能在多个项目中复用。你可以认为项目是你的站点,其中包括多个应用,比如博客,wiki,或者论坛,它们可以在其它项目中使用。

1.2.4 创建一个应用

现在,让我们创建第一个 Django 应用。我们会从头开始创建一个博客应用。在项目的根目录下,执行以下命令:

python manage.py startapp blog

这将会创建应用的基本架构,如下所示:

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

以下是这些文件:

  • admin.py:用于注册模型,把它们包括进 Django 管理站点。是否使用 Django 管理站点是可选的。
  • apps.py:用于放置应用配置(application configuration),可以配置应用的某些属性。
  • migrations:该目录会包含应用的数据库迁移。迁移允许 Django 追踪模型的变化,并同步数据库。
  • models.py:应用的数据模型。所有 Django 应用必须有一个models.py文件,但该文件可以为空。
  • tests.py:用于添加应用的测试。
  • views.py:用于存放应用的逻辑。每个视图接收一个 HTTP 请求,然后处理请求,并返回响应。

译者注:从 Django 1.9 开始,startapp命令会创建apps.py文件。

1.3 设计博客的数据架构

我们将会开始定义博客的初始数据模型。一个模型是一个 Python 类,并继承自django.db.models.Model,其中每个属性表示数据库的一个字段。Django 会为models.py中定义的每个模型创建一张数据库表。创建模型后,Django 会提供一个实用的 API 进行数据库查询。

首先,我们定义一个Post模型,在blog应用的models.py文件中添加以下代码:

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

class Post(models.Model):
    STATUS_CHOICES = (
        ('draft', 'Draft'),
        ('published', 'Published'),
    )

    title = models.CharField(max_length=250)
    slug = models.SlugField(max_length=250, 
                            unique_for_date='publish')
    author = models.ForeignKey(User, 
                               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=10,
                              choices=STATUS_CHOICES,
                              default='draft')

    class Meta:
        ordering = ('-publish',)

    def __str__(self):
        return self.title

这是博客帖子的基础模型。让我们看看该模型定义的字段:

  • title:这个字段是帖子的标题。该字段的类型是CharField,在 SQL 数据库中会转换为VARCHAR
  • slug:这个字段会在 URLs 中使用。一个别名(slug)是一个短标签,只包括字母,数字,下划线或连字符。我们将使用slug字段为 blog 的帖子创建漂亮的,搜索引擎友好的 URLs。我们为该字段添加了unique_for_date参数,所以我们可以使用帖子的日期和别名来构造帖子的 URLs。Django 不允许多个帖子有相同的别名和日期。
  • author:这个字段是一个ForeignKey。该字段定义了多对一的关系。我们告诉 Django,一篇帖子由一个用户编写,一个用户可以编写多篇帖子。对于该字段,Django 使用关联模型的主键,在数据库中创建一个外键。在这里,我们关联了 Django 权限系统的User模型。我们使用related_name属性,指定了从UserPost的反向关系名。之后我们会学习更多这方面的内容。
  • body:帖子的正文。该字段是TextField,在 SQL 数据中会转换为TEXT
    publish:帖子发布的时间。 我们使用 Django 中timezonenow方法作为默认值。这只是一个时区感知的datetime.now。(译者注:根据不同时区,返回该时区的当前时间)
  • created:帖子创建的时间。我们使用auto_now_add,因此创建对象时,时间会自动保存。
  • updated:帖子最后被修改的时间。我们使用auto_now,因此保存对象时,时间会自动更新。
  • status:该字段表示帖子的状态。我们使用choices参数,因此该字段的值只能是给定选项中的一个。

正如你所看到的,Django 内置了很多不同类型的字段,可以用来定义你的模型。你可以在这里找到所有字段类型。

模型中的Meta类包含元数据。我们告诉 Django,查询数据库时,默认排序是publish字段的降序排列。我们使用负号前缀表示降序排列。

__str__()方法是对象的默认可读表示。Django 会在很多地方(比如管理站点)使用它。

如果你是从 Python 2.X 中迁移过来的,请注意在 Python 3 中所有字符串天生就是 Unicode 编码,因此我们只使用__str__()方法。__unicode__()方法被废弃了。

因为我们要处理时间,所以将会安装pytz模块。该模块为 Python 提供了时区定义,同时 SQLite 也需要它操作时间。打开终端,使用以下命令安装pytz

pip install pytz

Django 内置支持时区感知。在项目的settings.py文件中,通过USE_TZ设置,启用或禁用时区支持。使用startproject管理命令创建新项目时,该设置为True

1.3.1 激活应用

为了让 Django 保持追踪应用,并且可以为它的模型创建数据库,我们需要激活应用。编辑settings.py文件,在INSTALLED_APPS设置中添加blog。如下所示:

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

现在,Django 知道在该项目中,我们的应用已经激活,并且可以自省(instrospect)它的模型。

1.3.2 创建并应用数据库迁移

让我们在数据库中创建模型的数据库表。Django 自带一个迁移系统,可以追踪模型变化,并同步到数据库中。migrate命令会应用迁移到INSTALLED_APPS中列出的所有应用;它根据当前模型和迁移来同步数据库。

首先,我们需要为刚创建的新模型创建一个数据库迁移。在项目的根目录下输入以下命令:

python manage.py makemigrations blog

你会得到类似以下的输出:

Migrations for 'blog':
  0001_initial.py:
    - Create model Post

Django 在blog应用的migrations目录中创建了0001_initial.py文件。你可以打开该文件,查看数据库迁移生成的内容。

让我们看看 SQL 代码,Django 会在数据库中执行它们,为我们的模型创建数据库表。sqlmigrate命令接收一个数据库迁移名称,并返回 SQL 语句,但不会执行。运行以下命令检查数据:

python manage.py sqlmigrate blog 0001

输入看起来是这样:

BEGIN;
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"));
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 通过组合应用名和模型名的小写字母生成表名(blog_post),但你可以在模型的Meta类中使用db_table属性指定表明。Django 会自动为每个模型创建一个主键,但你同样可以在某个模型字段中指定primary_key=True,来指定主键。

让我们使用新模型同步数据库。运行以下命令,应用已经存在的数据库迁移:

python manage.py migrate

你会得到以下面这行结尾的输出:

  Applying blog.0001_initial... OK

我们刚才为INSTALLED_APPS中列出的所有应用(包括blog应用)进行了数据库迁移。应用了迁移后,数据库会反应模型的当前状态。

如果添加,移除或修改了已存在模型的字段,或者添加了新模型,你需要使用makemigrations命令创建一个新的数据库迁移。数据库迁移将会允许 Django 保持追踪模型的变化。然后,你需要使用migrate命令应用该迁移,保持数据库与模型同步。

1.4 为模型创建管理站点

现在,我们已经定义了Post模型,我们将会创建一个简单的管理站点,来管理博客帖子。Django 内置了管理界面,非常适合编辑内容。Django 管理站点通过读取模型的元数据,并为编辑内容提供可用于生产环境的界面,进行动态构建。你可以开箱即用,或者配置如何显示模型。

记住,django.contrib.admin已经包括在我们项目的INSTALLED_APPS设置中,所以我们不需要再添加。

1.4.1 创建超级用户

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

python manage.py createsuperuser

你会看到以下输出。输入你的用户名,e-mail 和密码:

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

1.4.2 Django 管理站点

现在,使用python manage.py runserver命令启动开发服务器,并在浏览器中打开http://127.0.0.1:8000/admin/。你会看到如下所示的管理员登录界面:

使用上一步创建的超级用户登录。你会看到管理站点的首页,如下图所示:

这里的GroupUser模型是 Django 权限框架的一部分,位于django.contrib.auth中。如果你点击Users,会看到你之前创建的用户。你的blog应用的Post模型与User模型关联在一起。记住,这种关系由author字段定义。

1.4.3 添加模型到管理站点

让我们添加 blog 模型到管理站点。编辑blog应用的admin.py文件,如下所示:

from django.contrib import admin
from .models import Post

admin.site.register(Post)

现在,在浏览器中重新载入管理站点。你会看到Post模型,如下图所示:

这很容易吧?当你在 Django 管理站点注册模型时,你会得到一个用户友好的界面,该界面通过内省你的模型产生,允许你非常方便的排列,编辑,创建和删除对象。

点击Post右边的Add链接来添加一篇新的帖子。你会看到 Django 为模型动态生成的表单,如下图所示:

Django 为每种字段类型使用不同的表单控件。即使是复杂的字段(比如DateTimeField),也会使用类似JavaScript的日期选择器显示一个简单的界面。

填写表单后,点击Save按钮。你会被重定向到帖子列表页面,其中显示一条成功消息和刚刚创建的帖子,如下图所示:

1.4.4 自定义模型显示方式

现在,我们看下如何自定义管理站点。编辑blog应用的admin.py文件,修改为:

from django.contrib import admin
from .models import Post

class PostAdmin(admin.ModelAdmin):
    list_display = ('title', 'slug', 'author', 'publish', 'status')

admin.site.register(Post, PostAdmin)

我们告诉 Django 管理站点,使用从ModelAdmin继承的自定义类注册模型到管理站点。在这个类中,我们可以包括如何在管理站点中显示模型的信息,以及如何与它们交互。list_display属性允许你设置想在管理对象列表页中显示的模型字段。

让我们使用更多选项自定义管理模型,如下所示:

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']

回到浏览器,重新载入帖子类别页,如下图所示:

你可以看到,帖子列表页中显示的字段就是在list_display属性中指定的字段。现在,帖子列表页包括一个右边栏,可以通过list_filter属性中包括的字段来过滤结果。页面上出现了一个搜索栏。这是因为我们使用search_fields属性定义了可搜索的字段列表。在搜索栏下面,有一个通过日期进行快速导航的栏。这是通过定义date_hierarchy属性定义的。你还可以看到,帖子默认按StatusPublish列排序。这是因为使用ordering属性指定了默认排序。

现在点击Add post链接,你会看到有些不同了。当你为新帖子输入标题时,会自动填写slug字段。我们通过prepopulated_fields属性已经告诉了 Django,用title字段的输入预填充slug字段。同样,author字段显示为搜索控件,当你有成千上万的用户时,比下拉框更人性化,如下图所示:

通过几行代码,我们已经在管理站点中自定义了模型的显示方式。还有很多自定义和扩展 Django 管理站点的方式。本书后面的章节会涉及这个特性。

1.5 使用 QuerySet 和管理器

现在,你已经有了一个功能完整的管理站点来管理博客的内容,是时候学习如何从数据库中检索对象,并与之交互了。Django 自带一个强大的数据库抽象 API,可以很容易的创建,检索,更新和删除对象。Django 的 ORM(Object-relational Mapper)兼容 MySQL,PostgreSQL,SQLite 和 Oracle。记住,你可以在项目的settings.py文件中编辑DATABASES设置,来定义项目的数据库。Django 可以同时使用多个数据库,你可以用任何你喜欢的方式,甚至编写数据库路由来处理数据。

一旦创建了数据模型,Django 就提供了一个自由的 API 来与之交互。你可以在这里找到数据模型的官方文档。

1.5.1 创建对象

打开终端,运行以下命令来打开 Python 终端:

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='One more post',
                slug='one-more-post',
                body='Post body.',
                author=user)
>>> post.save()

译者注:书中的代码不是创建一个Post实例,而是直接使用create()在数据库中创建对象。这个地方应该是作者的笔误。

让我们分析这段代码做了什么。首先,我们检索usernameadmin的用户对象:

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

get()方法允许你冲数据库中检索单个对象。注意,该方法期望一个匹配查询的结果。如果数据库没有返回结果,该方法会抛出DoesNotExist异常;如果数据库返回多个结果,将会抛出MultipleObjectsReturned异常。这两个异常都是执行查询的模型类的属性。

然后,我们使用titleslugbody创建了一个Post实例,并设置之前返回的user作为帖子的作者:

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

该对象在内存中,而不会存储在数据库中。

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

post.save()

这个操作会在底层执行一个INSERT语句。我们已经知道如何先在内存创建一个对象,然后存储到数据库中,但也可以使用create()方法直接在数据库中创建对象:

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

1.5.2 更新对象

现在,修改帖子的标题,并再次保存对象:

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

此时,save()方法会执行UPDATE语句。

直到调用save()方法,你对对象的修改才会存到数据库中。

1.5.3 检索对象

Django 的 ORM 是基于QuerySet的。一个QuerySet是来自数据库的对象集合,它可以有数个过滤器来限制结果。你已经知道如何使用get()方法从数据库检索单个对象。正如你所看到的,我们使用Post.objects.get()访问该方法。每个 Django 模型最少有一个管理器(manager),默认管理器叫做objects。你通过使用模型管理器获得一个QuerySet对象。要从表中检索所有对象,只需要在默认的objects管理器上使用all()方法,比如:

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

这是如何创建一个返回数据库中所有对象的QuerySet。注意,该QuerySet还没有执行。Django 的QuerySet是懒惰的;只有当强制它们执行时才会执行。这种行为让QuerySet变得很高效。如果没有没有把QuerySet赋值给变量,而是直接在 Python 终端输写,QuerySet的 SQL 语句会执行,因为我们强制它输出结果:

>>> Post.objects.all()

1.5.3.1 使用filter()方法

你可以使用管理器的filter()方法过滤一个QuerySet。例如,我们使用下面的QuerySet检索所有 2015 年发布的帖子:

Post.objects.filter(publish__year=2015)

你也可以过滤多个字段。例如,我们可以检索 2015 年发布的,作者的usernameamdin的帖子:

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

这等价于链接多个过滤器,来创建QuerySet

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

通过两个下划线(publish__year),我们使用字段查找方法构造了查询,但我们也可以使用两个下划线访问相关模型的字段(author__username)。

1.5.3.2 使用exclude()

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

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

1.5.3.3 使用order_by()

你可以使用管理器的order_by()方法对不同字段进行排序。例如,你可以检索所有对象,根据它们的标题排序:

Post.objects.order_by('title')

默认是升序排列。通过负号前缀指定降序排列,比如:

Post.objects.order_by('-title')

1.5.4 删除对象

如果想要删除对象,可以这样操作:

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

注意,删除对象会删除所有依赖关系。

1.5.5 什么时候执行 QuerySet

你可以连接任意多个过滤器到QuerySet,在QuerySet执行之前,不会涉及到数据库。QuerySet只在以下几种情况被执行:

  • 你第一次迭代它们
  • 当你对它们进行切片操作。比如:Post.objects.all()[:3]
  • 当你对它们进行pickle或缓存
  • 当你对它们调用repr()len()
  • 当你显示对它们调用list()
  • 当你在语句中测试,比如bool()orand或者if

1.5.6 创建模型管理器

正如我们之前提到的,objects是每个模型的默认管理器,它检索数据库中的所有对象。但我们也可以为模型自定义管理器。接下来,我们会创建一个自定义管理器,用于检索所有状态为published的帖子。

为模型添加管理器有两种方式:添加额外的管理器方法或者修改初始的管理器QuerySet。前者类似Post.objects.my_manager(),后者类似Post.my_manager.all()。我们的管理器允许我们使用Post.published来检索帖子。

编辑blog应用中的models.py文件,添加自定义管理器:

class PublishedManager(models.Manager):
    def get_queryset(self):
        return super().get_queryset()\
                      .filter(status='published')


class Post(models.Model):
    # ...
    objects = models.Manager()
    published = PublishedManager()

get_queryset()是返回被执行的QuerySet的方法。我们使用它在最终的QuerySet中包含了自定义的过滤器。我们已经自定义了管理器,并添加到Post模型中;现在可以用它来执行查询。例如,我们可以检索所有标题以Who开头,并且已经发布的帖子:

Post.published.filter(title__startswith='Who')

译者注:这里修改了models.py文件,因此需要在终端再次导入Postfrom blog.models import Post

1.6 构建列表和详情视图

现在,你已经了解了如何使用 ORM,可以随时构建博客应用的视图了。一个 Django 视图就是一个 Python 函数,它接收一个 web 请求,并返回一个 web 响应。视图中的所有逻辑返回期望的响应。

首先,我们会创建应用视图,然后定义每个视图的 URL 模式,最后创建 HTML 模板渲染视图产生的数据。每个视图渲染一个的模板,同时把变量传递给模板,并返回一个具有渲染输出的 HTTP 响应。

1.6.1 创建列表和详情视图

让我们从创建显示所有帖子的列表视图开始。编辑blog应用的views.py文件,如下所示:

from django.shortcuts import render, get_object_or_404
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对象作为参数,通过模板路径和变量来渲染指定的模板。它返回一个带有渲染后文本(通常是 HTML 代码)的HttpResponse对象。render()快捷方法考虑了请求上下文,因此由模板上下文处理器(template context processor)设置的任何变量都可以由给定的模板访问。模板上下文处理器是可调用的,它们把变量设置到上下文中。你将会在第三章中学习如何使用它们。

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

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

这是帖子的详情视图。该视图接收yearmonthdaypost作为参数,用于检索指定别名和日期的已发布的帖子。注意,当我们创建Post模型时,添加了unique_for_date参数到slug字段。这就确保了指定日期和别名时,只会检索到一个帖子。在详情视图中,我们使用get_object_or_404()快捷方法检索期望的帖子。该函数检索匹配给定参数的对象,如果没有找到对象,就会引发 HTTP 404(Not found)异常。最后,我们使用模板,调用render()快捷方法渲染检索出来的帖子。

1.6.2 为视图添加 URL 模式

一个 URL 模式由一个 Python 正则表达式,一个视图和一个项目范围内的名字组成。Django 遍历每个 URL 模式,并在匹配到第一个请求的 URL 时停止。然后,Django 导入匹配 URL 模式的视图,传递HttpRequest类实例和关键字或位置参数,并执行视图。

如果你以前没有使用过正则表达式,可以在这里了解。

blog应用的目录下新建一个urls.py文件,添加以下代码:

from django.conf.urls import url
from . import views

urlpatterns = [
    url(r'^$', views.post_list, name='post_list'),
    url(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/'\
        r'(?P<post>[-\w]+)/$',
        views.post_detail,
        name='post_detail'),
]

第一个 URL 模式不带任何参数,映射到post_list视图。第二个模式带以下四个参数,映射到post_detail视图。让我们看看 URL 模式的正则表达式:

  • year:需要四个数字
  • month:需要两个数字,在前面补零。
  • day:需要两个数字,在前面补零。
  • post:可以由单词和连字符组成。

最好为每个应用创建一个urls.py文件,这可以让应用在其它项目中复用。

现在你需要在项目的主 URL 模式中包含blog应用的 URL 模式。编辑项目目录中的urls.py文件,如下所示:

from django.conf.urls import url, include
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^blog/', include('blog.urls',
                           namespace='blog',
                           app_name='blog'))
]

这样你就可以让 Django 包括 URL 模式,该模式在blog/路径下的urls.py文件中定义。你指定它们的命名空间为blog,这样你可以很容易的引用该 URLs 组。

1.6.3 模型的标准 URLs

你可以使用上一节定义的post_detail URL,为Post对象构建标准的 URL。Django 的惯例是在模型中添加get_absolute_url()方法,该方法返回对象的标准 URL。对于这个方法,我们会使用reverse()方法,它允许你通过它们的名字,以及传递参数构造 URLs。编辑models.py文件,添加以下代码:

from django.core.urlresolvers import reverse
   Class Post(models.Model):
       # ...
       def get_absolute_url(self):
           return reverse('blog:post_detail',
                          args=[self.publish.year,
                                self.publish.strftime('%m'),
                                self.publish.strftime('%d'),
                                self.slug])

注意,我们使用strftime()函数构造使用零开头的月份和日期。我们将会在模板中使用get_absolute_url()方法。

1.7 为视图创建模板

我们已经为应用创建了视图和 URL 模式。现在该添加模板来显示用户界面友好的帖子了。

在你的blog应用目录中创建以下目录和文件:

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

这就是模板的文件结构。base.html文件将会包括网站的主 HTML 结构,它把内容分为主内容区域和一个侧边栏。list.htmldetail.html文件继承自base.html文件,分别用于渲染帖子列表视图和详情视图。

Django 有一个强大的模板语言,允许你指定如何显示数据。它基于模板标签——{% tag %},模板变量——{{ variable }},和可作用于变量的模板过滤器——{{ variable|filter }}。你可以在这里查看所有内置模板标签和过滤器。

让我们编辑base.html文件,添加以下代码:

{% load staticfiles %}
<!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 staticfiles %}告诉 Django 加载staticfiles模板标签,它是django.contrib.staticfiles应用提供的。加载之后,你可以在该模板中使用{% static %}模板过滤器。通过该模板过滤器,你可以包括静态文件(比如blog.css,在 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="{{ 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 %}

使用{% extends %}模板标签告诉 Django 从blog/base.html模板继承。接着,我们填充基类模板的titlecontent块。我们迭代帖子,并显示它们的标题,日期,作者和正文,其中包括一个标题链接到帖子的标准 URL。在帖子的正文中,我们使用了两个模板过滤器:truncatewords从内容中截取指定的单词数,linebreaks把输出转换为 HTML 换行符。你可以连接任意多个模板过滤器;每个过滤器作用于上一个过滤器产生的输出。

打开终端,执行python manage.py runserver启动开发服务器。在浏览器打开http://127.0.0.1:8000/blog/,就能看到运行结果。注意,你需要一些状态为Published的帖子才能看到。如下图所示:

接着,让我们编辑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 %}

返回浏览器,点击某条帖子的标题跳转到详情视图,如下图所示:

观察一下 URL,类似这样:/blog/2017/04/25/who-was-django-reinhardt/。我们为帖子创建了一个对搜索引擎友好的 URL。

1.8 添加分页

当你开始往博客中添加内容,你会发现需要把帖子分页。Django 内置了一个分页类,可以很容易的管理分页内容。

编辑blog应用的views.py文件,导入分页类,并修改post_list视图:

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

分页是这样工作的:

  1. 用每页想要显示的对象数量初始化Paginator类。
  2. 获得GET中的page参数,表示当前页码。
  3. 调用Paginator类的page()方法,获得想要显示页的对象。
  4. 如果page参数不是整数,则检索第一页的结果。如果这个参数大于最大页码,则检索最后一页。
  5. 把页码和检索出的对象传递给模板。

现在,我们需要创建显示页码的模板,让它可以在任何使用分页的模板中使用。在blog应用的templates目录中,创建pagination.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模板,将pagination.html模板包括在{% content %}块的底部,如下所示:

{% block content %}
	...
	{% include "pagination.html" with page=posts %}
{% endblock %}

因为我们传递给模板的Page对象叫做posts,所以我们把分页模板包含在帖子列表模板中,并指定参数进行正确的渲染。通过这种方法,你可以在不同模型的分页视图中重用分页模板。

在浏览器中打开http://127.0.0.1:8000/blog/,你会在帖子列表底部看到分页,并且可以通过页码导航:

1.9 使用基于类的视图

视图接收一个 web 请求,返回一个 web 响应,并且可以被调用,所以可以把视图定义为类方法。Django 为此提供了基础视图类。它们都是继承自View类,可以处理 HTTP 方法调度和其它功能。这是创建视图的一个替代方法。

我们使用 Django 提供的通用ListView,把post_list视图修改为基于类的视图。这个基础视图允许你列出任何类型的对象。

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

from django.views.generic import ListView

class PostListView(ListView):
	queryset = Post.published.all()
	context_object_name = 'posts'
	paginate_by = 3
	template_name = 'blog/post/list.html'

这个基于类的视图与之前的post_list视图类似,它做了以下操作:

  • 使用特定的QuerySet代替检索所有对象。我们可以指定model=Post,然后 Django 会为我们创建通用的Post.objects.all()这个QuerySet,来代替定义一个queryset属性。
  • 为查询结果使用上下文变量posts。如果不指定context_object_name,默认变量是object_list
  • 对结果进行分页,每页显示三个对象。
  • 使用自定义模板渲染页面。如果没有设置默认模板,ListView会使用blog/post_list.html

打开blog应用的urls.py文件,注释之前的post_list URL 模式,使用PostListView类添加新的 URL 模式:

urlpatterns = [
	# post views
	# url(r'^$', views.post_list, name='post_list'),
	url(r'^$', views.PostListView.as_view(), name='post_list'),
	url(r'^(?P<year>\d{4})/(?P<month>\d{2})/(?P<day>\d{2})/'\
	    r'(?P<post>[-\w]+)/$',
	    views.post_detail,
	    name='post_detail'),
]

为了保证分页正常工作,我们需要传递正确的page对象给模板。Django 的ListView使用page_obj变量传递选中页,因此你需要编辑list.html模板,使用正确的变量包括页码:

{% include "pagination.html" with page=page_obj %}

在浏览器打开http://127.0.0.1:8000/blog/,检查是不是跟之前使用的post_list视图一致。这是一个基于类视图的简单示例,使用了 Django 提供的通过类。你会在第十章和后续章节学习更多基于类的视图。

1.10 总结

在这章中,通过创建一个基本的博客应用,我们学习了 Django 框架的基础知识。你设计了数据模型,并进行了数据库迁移。你创建了视图,模板和博客的 URLs,以及对象分页。

在下一章,你将会学习如何完善博客应用,包括评论系统,标签功能,并且允许用户通过 e-mail 分享帖子。

第二章:为博客添加高级功能

上一章中,你创建了一个基础的博客应用。现在,利用一些高级特性,你要把它打造成一个功能完整的博客,比如通过邮件分享帖子,添加评论,为帖子打上标签,以及通过相似度检索帖子。在这一章中,你会学习以下主题:

  • 使用 Django 发送邮件
  • 在视图中创建和处理表单
  • 通过模型创建表单
  • 集成第三方应用
  • 构造复杂的QuerySet

2.1 通过邮件分享帖子

首先,我们将会允许用户通过邮件分享帖子。花一点时间想想,通过上一章学到的知识,你会如何使用视图,URL 和模板来完成这个功能。现在核对一下,允许用户通过邮件发送帖子需要完成哪些操作:

  • 为用户创建一个填写名字,邮箱,收件人和评论(可选的)的表单
  • views.py中创建一个视图,用于处理post数据和发送邮件
  • blog应用的urls.py文件中,为新视图添加 URL 模式
  • 创建一个显示表单的模板

2.1.1 使用 Django 创建表单

让我们从创建分享帖子的表单开始。Django 有一个内置的表单框架,让你很容易的创建表单。表单框架允许你定义表单的字段,指定它们的显示方式,以及如何验证输入的数据。Django 的表单框架还提供了一种灵活的方式,来渲染表单和处理数据。

Django 有两个创建表单的基础类:

  • Form:允许你创建标准的表单
  • ModelForm:允许你通过创建表单来创建或更新模型实例

首先,在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 表单。这段代码通过继承基类Form创建了一个表单。我们使用不同的字段类型,Django 可以相应的验证字段。

表单可以放在 Django 项目的任何地方,但惯例是放在每个应用的forms.py文件中。

name字段是一个CharField。这种字段的类型渲染为<input type="text"> HTML 元素。每种字段类型都有一个默认组件,决定了该字段如何在 HTML 中显示。可以使用widget属性覆盖默认组件。在comments字段中,我们使用Textarea组件显示为<textarea> HTML 元素,而不是默认的<input>元素。

字段的验证也依赖于字段类型。例如,emailto字段是EmailField。这两个字段都要求一个有效的邮箱地址,否则字段验证会抛出forms.ValidationError异常,导致表单无效。表单验证时,还会考虑其它参数:我们定义name字段的最大长度为 25 个字符,并使用required=Falsecomments字段是可选的。字段验证时,这些所有因素都会考虑进去。这个表单中使用的字段类型只是 Django 表单字段的一部分。在这里查看所有可用的表单字段列表。

2.1.2 在视图中处理表单

你需要创建一个新视图,用于处理表单,以及提交成功后发送一封邮件。编辑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='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作为参数。
  • 我们通过 ID,使用get_object_or_404()快捷方法检索状态为published的帖子。
  • 我们使用同一个视图=显示初始表单和处理提交的数据。根据request.method区分表单是否提交。我们将使用POST提交表单。如果我们获得一个GET请求,需要显示一个空的表单;如果获得一个POST请求,表单会被提交,并且需要处理它。因此,我们使用request.method == 'POST'来区分这两种场景。

以下是显示和处理表单的过程:

  1. 当使用GET请求初始加载视图时,我们创建了一个新的表单实例,用于在模板中显示空表单。

form = EmailPostForm()

  1. 用户填写表单,并通过POST提交。接着,我们使用提交的数据创建一个表单实例,提交的数据包括在request.POST中:
if request.POST == 'POST':
    # Form was submitted
    form = EmailPostForm(request.POST)
  1. 接着,我们使用表单的is_valid()方法验证提交的数据。该方法会验证表单中的数据,如果所有字段都是有效数据,则返回True。如果任何字段包含无效数据,则返回False。你可以访问form.errors查看验证错误列表。
  2. 如果表单无效,我们使用提交的数据在模板中再次渲染表单。我们将会在模板中显示验证错误。
  3. 如果表单有效,我们访问form.cleaned_data获得有效的数据。该属性是表单字段和值的字典。

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

现在,你需要学习如何使用 Django 发送邮件,把所有功能串起来。

2.1.3 使用 Django 发送邮件

使用 Django 发送邮件非常简单。首先,你需要一个本地 SMTP 服务,或者在项目的settings.py文件中添加以下设置,定义一个外部 SMTP 服务的配置:

  • EMAIL_HOST:SMTP 服务器地址。默认是localhost
  • EMAIL_PORT:SMTP 服务器端口,默认 25。
  • EMAIL_HOST_USER:SMTP 服务器的用户名。
  • EMAIL_HOST_PASSWORD:SMTP 服务器的密码。
  • EMAIL_USE_TLS:是否使用 TLS 加密连接。
  • EMAIL_USE_SSL:是否使用隐式 TLS 加密连接。

如果你没有本地 SMTP 服务,可以使用你的邮箱提供商的 SMTP 服务。下面这个例子中的配置使用 Google 账户发送邮件:

EMAIL_HOST = 'smtp.gmail.com'
EMAIL_HOST_USER = 'your_account@gmail.com'
EMAIL_HOST_PASSWORD = 'your_password'
EMAIL_PORT = 587
EMAIL_USE_TLS = True

运行python manage.py shell命令打开 Python 终端,如下发送邮件:

>>> 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,则表示邮件发送成功。如果你使用前面配置的 Gmail 发送邮件,你可能需要在这里启用低安全级别应用访问权限。

现在,我们把它添加到视图中。编辑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='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 = '{} ({}) recommends you reading "{}"'.format(cd['name'], cd['email'], post.title)
			message = 'Read "{}" at {}\n\n{}\'s comments: {}'.format(post.title, post_url, cd['name'], cd['comments'])
			send_mail(subject, message, 'admin@blog.com', [cd['to']])
			sent = True
	else:
		form = EmailPostForm()
	return render(request, 
		           'blog/post/share.html', 
		           {'post': post, 'form': form, 'sent': sent}) 

注意,我们声明了一个sent变量,当帖子发送后,设置为True。当表单提交成功后,我们用该变量在模板中显示一条成功的消息。因为我们需要在邮件中包含帖子的链接,所以使用了get_absolute_url()方法检索帖子的绝对路径。我们把这个路径作为request.build_absolute_uri()的输入,构造一个包括 HTTP 模式(schema)和主机名的完整 URL。我们使用验证后的表单数据构造邮件的主题和内容,最后发送邮件到表单to字段中的邮件地址。

现在,视图的开发工作已经完成,记得为它添加新的 URL 模式。打开blog应用的urls.py文件,添加post_share的 URL 模式:

urlpatterns = [
	# ...
	url(r'^(?P<post_id>\d+)/share/$', views.post_share, name='post_share'),
]

2.1.4 在模板中渲染表单

完成创建表单,编写视图和添加 URL 模式后,我们只缺少该视图的模板了。在blog/templates/blog/post/目录中创建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 {{ cd.to }}.
		</p>
	{% else %}
		<h1>Share "{{ post.title }}" by e-mail</h1>
		<form action="." method="post">
			{{ form.as_p }}
			{% csrf_token %}
			<input type="submit" value="Send e-mail">
		</form>
	{% endif %}
{% endblock %}

这个模板用于显示表单,或者表单发送后的一条成功消息。正如你所看到的,我们创建了一个 HTML 表单元素,指定它需要使用POST方法提交:

<form action="." method="post">

然后,我们包括了实际的表单实例。我们告诉 Django 使用as_p方法,在 HTML 的<p>元素中渲染表单的字段。我们也可以使用as_ul把表单渲染为一个无序列表,或者使用as_table渲染为 HTML 表格。如果你想渲染每一个字段,我们可以这样迭代字段:

{% for field in form %}
	<div>
		{{ field.errors }}
		{{ field.label_tag }} {{ field }}
	</div>
{% endfor %}

模板标签{% csrf_token %}使用自动生成的令牌引入一个隐藏字段,以避免跨站点请求伪造(CSRF)的攻击。这些攻击包含恶意网站或程序,对你网站上的用户执行恶意操作。你可以在这里找到更多相关的信息。

上述标签生成一个类似这样的隐藏字段:

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

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

编辑blog/post/detail.html模板,在{{ post.body|linebreaks }}变量之后添加链接,用于分享帖子的 URL:

<p>
	<a href="{% url "blog:post_share" post.id %}">
		Share this post
	</a>
</p>

记住,我们使用 Django 提供的{% url %}模板标签,动态生成 URL。我们使用名为blog命名空间和名为post_share的 URL,并传递帖子 ID 作为参数来构造绝对路径的 URL。

现在,使用python manage.py runserver命令启动开发服务器,并在浏览器中打开http://127.0.0.1:8000/blog/。点击任何一篇帖子的标题,打开详情页面。在帖子正文下面,你会看到我们刚添加的链接,如下图所示:

点击Share this post,你会看到一个包含表单的页面,该页面可以通过邮件分享帖子。如下图所示:

该表单的 CSS 样式在static/css/blog.css文件中。当你点击Send e-mail按钮时,该表单会被提交和验证。如果所有字段都是有效数据,你会看到一条成功消息,如下图所示:

如果你输入了无效数据,会再次渲染表单,其中包括了所有验证错误:

译者注:不知道是因为浏览器不同,还是 Django 的版本不同,这里显示的验证错误跟原书中不一样。我用的是 Chrome 浏览器。

2.2 创建评论系统

现在,我们开始为博客构建评论系统,让用户可以评论帖子。要构建评论系统,你需要完成以下工作:

  • 创建一个保存评论的模型
  • 创建一个提交表单和验证输入数据的表单
  • 添加一个视图,处理表单和保存新评论到数据库中
  • 编辑帖子详情模板,显示评论列表和添加新评论的表单

首先,我们创建一个模型存储评论。打开blog应用的models.py文件,添加以下代码:

class Comment(models.Model):
	post = models.ForeignKey(Post, 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', )
		
	def __str__(self):
		return 'Comment by {} on {}'.format(self.name, self.post)

这就是我们的Comment模型。它包含一个外键,把评论与单篇帖子关联在一起。这个多对一的关系在Comment模型中定义,因为每条评论对应一篇帖子,而每篇帖子可能有多条评论。从关联对象反向到该对象的关系由related_name属性命名。定义这个属性后,我们可以使用comment.post检索评论对象的帖子,使用post.comments.all()检索帖子的所有评论。如果你没有定义related_name属性,Django 会使用模型名加_set(即comment_set)命名关联对象反向到该对象的管理器。

你可以在这里学习更多关于多对一的关系。

我们使用了active布尔字段,用于手动禁用不合适的评论。我们使用created字段排序评论,默认按时间排序。

刚创建的Comment模型还没有同步到数据库。运行以下命令,生成一个新的数据库迁移,反射创建的新模型:

python manage.py makemigrations blog

你会看到以下输出:

Migrations for 'blog'
  0002_comment.py:
    - Create model Comment

Django 在blog应用的migrations/目录中生成了0002_comment.py文件。现在,你需要创建一个相关的数据库架构,并把这些改变应用到数据库中。运行以下命令,让已存在的数据库迁移生效:

python manage.py migrate

你会得到一个包括下面这一行的输出:

Apply blog.0002_comment... OK

我们刚创建的数据库迁移已经生效,数据库中已经存在一张新的blog_comment表。

现在我们可以添加新的模型到管理站点,以便通过简单的界面管理评论。打开blog应用的admin.py文件,导入Comment模型,并增加CommentAdmin类:

from .models import Post, Comment

class CommentAdmin(admin.ModelAdmin):
	list_display = ('name', 'email', 'post', 'created', 'active')
	list_filter = ('active', 'created', 'updated')
	search_fields = ('name', 'email', 'body')
admin.site.register(Comment, CommentAdmin)

使用python manage.py runserver命令启动开发服务器,并在浏览器中打开http://127.0.0.1:8000/admin/。你会在Blog中看到新的模型,如下图所示:

我们的模型已经在管理站点注册,并且可以使用简单的界面管理Comment实例。

2.2.1 通过模型创建表单

我们仍然需要创建一个表单,让用户可以评论博客的帖子。记住,Django 有两个基础类用来创建表单:FormModelForm。之前你使用了第一个,让用户可以通过邮件分享帖子。在这里,你需要使用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列表明确告诉框架,你想在表单中包含哪些字段,或者使用exclude列表定义你想排除哪些字段。对应CommentForm,我们只使用nameemail,和body字段,因为用户只可能填写这些字段。

2.2.2 在视图中处理 ModelForm

为了简单,我们将会使用帖子详情页面实例化表单,并处理它。编辑views.py文件,导入Comment模型和CommentForm表单,并修改post_detail视图,如下所示:

译者注:原书中是编辑models.py文件,应该是作者的笔误。

from .models import Post, Comment
from .forms import EmailPostForm, CommentForm

def post_detail(request, year, month, day, post):
	post = get_object_or_404(Post, slug=post,
										 status='published',
										 publish__year=year,
										 publish__month=month,
										 publish__day=day)
	# List of active comments for this post
	comments = post.comments.filter(active=True)
	new_comment = None
	
	if request.method == 'POST':
		# A comment was posted
		comment_form = CommentForm(data=request.POST)
		if comment_form.is_valid():
			# Create Comment object but don't save to database yet
			new_comment = comment_form.save(commit=False)
			# Assign the current post to comment
			new_comment.post = post
			# Save the comment to the database
			new_comment.save()
	else:
		comment_form = CommentForm()
	return render(request, 
					 'blog/post/detail.html',
					 {'post': post,
					  'comments': comments,
					  'new_comment': new_comment,
					  'comment_form': comment_form})

让我们回顾一下,我们往视图里添加了什么。我们使用post_detail视图显示帖子和它的评论。我们添加了一个QuerySet,用于检索该帖子所有有效的评论:

comments = post.comments.filter(active=True)

我们从post对象开始创建这个QuerySet。我们在Comment模型中使用related_name属性,定义了关联对象的管理器为comments。这里使用了这个管理器。

同时,我们使用同一个视图让用户添加新评论。因此,如果视图通过GET调用,我们使用comment_form = CommentForm()创建一个表单实例。如果是POST请求,我们使用提交的数据实例化表单,并使用is_valid()方法验证。如果表单无效,我们渲染带有验证错误的模板。如果表单有效,我们完成以下操作:

  1. 通过调用表单的save()方法,我们创建一个新的Comment对象:

new_comment = comment_form.save(commit=False)

save()方法创建了一个链接到表单模型的实例,并把它存到数据库中。如果使用commit=False调用,则只会创建模型实例,而不会存到数据库中。当你想在存储之前修改对象的时候,会非常方便,之后我们就是这么做的。save()只对ModelForm实例有效,对Form实例无效,因为它们没有链接到任何模型。

  1. 我们把当前的帖子赋值给刚创建的评论:

new_comment.post = post

通过这个步骤,我们指定新评论属于给定的帖子。

  1. 最后,使用下面的代码,把新评论存到数据库中:

new_comment.save()

现在,我们的视图已经准备好了,可以显示和处理新评论了。

2.2.3 在帖子详情模板中添加评论

我们已经为帖子创建了管理评论的功能。现在我们需要修改blog/post/detail.html模板,完成以下工作:

  • 为帖子显示评论总数
  • 显示评论列表
  • 显示一个表单,用户增加评论

首先,我们会添加总评论数。打开detail.html模板,在content块中添加以下代码:

{% with comments.count as total_comments %}
	<h2>
		{{ total_comments }} comment{{ total_comments|pluralize }}
	</h2>
{% endwith %}

我们在模板中使用 Django ORM 执行comments.count()这个QuerySet。注意,Django 模板语言调用方法时不带括号。{% with %}标签允许我们把值赋给一个变量,我们可以在{% endwith %}标签之前一直使用它。

{% with %}模板标签非常有用,它可以避免直接操作数据库,或者多次调用昂贵的方法。

我们使用了pluralize模板过滤器,根据total_comments的值决定是否显示单词comment的复数形式。模板过滤器把它们起作用变量的值作为输入,并返回一个计算后的值。我们会在第三章讨论模板过滤器。

如果值不是 1,pluralize模板过滤器会显示一个“s”。上面的文本会渲染为0 comments1 comment,或者N comments。Django 包括大量的模板标签和过滤器,可以帮助你以希望的方式显示信息。

现在,让我们添加评论列表。在上面代码后面添加以下代码:

{% 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 %}

我们使用{% for %}模板标签循环所有评论。如果comments列表为空,显示一个默认消息,告诉用户该帖子还没有评论。我们使用{{ forloop.counter }}变量枚举评论,它包括每次迭代中循环的次数。然后我们显示提交评论的用户名,日期和评论的内容。

最后,当表单成功提交后,我们需要渲染表单,或者显示一条成功消息。在上面的代码之后添加以下代码:

{% if new_comment %}
	<h2>Your comment has been added.</h2>
{% else %}
	<h2>Add a new comment</h2>
	<form action="." method="post">
		{{ comment_form.as_p }}
		{% csrf_token %}
		<p><input type="submit" value="Add comment"></p>
	</form>
{% endif %}

代码非常简单:如果new_comment对象存在,则显示一条成功消息,因为已经创建评论成功。否则渲染表单,每个字段使用一个<p>元素,以及POST请求必需的 CSRF 令牌。在浏览器中打开http://127.0.0.1:8000/blog/,点击一条帖子标题,打开详情页面,如下图所示:

使用表单添加两条评论,它们会按时间顺序显示在帖子下方,如下图所示:

在浏览器中打开http://127.0.0.1:8000/admin/blog/comment/,你会看到带有刚创建的评论列表的管理页面。点击某一条编辑,不选中Active选择框,然后点击Save按钮。你会再次被重定向到评论列表,该评论的Active列会显示一个禁用图标。类似下图的第一条评论:

如果你回到帖子详情页面,会发现被删除的评论没有显示;同时也没有算在评论总数中。多亏了active字段,你可以禁用不合适的评论,避免它们在帖子中显示。

2.3 增加标签功能

实现评论系统之后,我们准备为帖子添加标签。我们通过在项目中集成一个第三方的 Django 标签应用,来实现这个功能。django-taggit是一个可复用的应用,主要提供了一个Tag模型和一个管理器,可以很容易的为任何模型添加标签。你可以在这里查看它的源码。

首先,你需要通过pip安装django-taggit,运行以下命令:

pip install django-taggit

然后打开mysite项目的settings.py文件,添加taggitINSTALLED_APPS设置中:

INSTALLED_APPS = (
	# ...
	'blog',
	'taggit',
)

打开blog应用的models.py文件,添加django-taggit提供的TaggableManager管理器到Post模型:

from taggit.managers import TaggableManager

class Post(models.Model):
	# ...
	tags = TaggableManager()

tags管理器允许你从Post对象中添加,检索和移除标签。

运行以下命令,为模型改变创建一个数据库迁移:

python manage.py makemigrations blog

你会看下以下输出:

Migrations for 'blog'
  0003_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 blog.0003_post_tags... OK

你的数据库已经为使用django-taggit模型做好准备了。使用python manage.py shell打开终端,学习如何使用tags管理器。

首先,我检索其中一个帖子(ID 为 3 的帖子):

>>> from blog.models import Post
>>> post = Post.objects.get(id=3)

接着给它添加标签,并检索它的标签,检查是否添加成功:

>>> post.tags.add('music', 'jazz', 'django')
>>> post.tags.all()
[<Tag: jazz>, <Tag: django>, <Tag: music>]

最后,移除一个标签,并再次检查标签列表:

>>> post.tags.remove('django')
>>> post.tags.all()
[<Tag: jazz>, <Tag: music>]

这很容易,对吧?运行python manage.py runserver,再次启动开发服务器,并在浏览器中打开http://127.0.0.1:8000/admin/taggit/tag/。你会看到taggit应用管理站点,其中包括Tag对象的列表:

导航到http://127.0.0.1:8000/admin/blog/post/,点击一条帖子编辑。你会看到,现在帖子包括一个新的Tags字段,如下图所示,你可以很方便的编辑标签:

现在,我们将会编辑博客帖子,来显示标签。打开blog/post/list.html模板,在帖子标题下面添加以下代码:

<p class="tags">Tags: {{ post.tags.all|join:", " }}</p>

模板过滤器join与 Python 字符串的join()方法类似,用指定的字符串连接元素。在浏览器中打开http://127.0.0.1:8000/blog/。你会看到每篇帖子标题下方有标签列表:

现在,我们将要编辑post_list视图,为用户列出具有指定标签的所有帖子。打开blog应用的views.py文件,从django-taggit导入Tag模型,并修改post_list视图,可选的通过标签过滤帖子:

from taggit.models import Tag

def post_list(request, tag_slug=None):
	object_list = Post.published.all()
	tag = None
	
	if tag_slug:
		tag = get_object_or_404(Tag, slug=tag_slug)
		object_list = object_list.filter(tags__in=[tag])
		# ...

该视图是这样工作的:

  1. 该视图接收一个默认值为None的可选参数tag_slug。该参数会在 URL 中。
  2. 在视图中,我们创建了初始的QuerySet,检索所有已发布的帖子,如果给定了标签别名,我们使用get_object_or_404()快捷方法获得给定别名的Tag对象。
  3. 然后,我们过滤包括给定标签的帖子列表。因为这是一个多对多的关系,所以我们需要把过滤的标签放在指定列表中,在这个例子中只包含一个元素。

记住,QeurySet是懒惰的。这个QuerySet只有在渲染模板时,循环帖子列表时才会计算。

最后,修改视图底部的render()函数,传递tag变量到模板中。视图最终是这样的:

def post_list(request, tag_slug=None):
	object_list = Post.published.all()
	tag = None
	
	if tag_slug:
		tag = get_object_or_404(Tag, slug=tag_slug)
		object_list = object_list.filter(tags__in=[tag])
		
	paginator = Paginator(object_list, 3)
	page = request.GET.get('page')
	try:
		posts = paginator.page(page)
	except PageNotAnInteger:
		posts = paginator.page(1)
	excpet EmptyPage:
		posts = paginator.page(paginator.num_pages)
	return render(request,
					 'blog/post/list.html',
					 {'page': page,
					  'posts': posts,
					  'tag': tag})

打开blog应用的urls.py文件,注释掉基于类PostListView的 URL 模式,取消post_list视图的注释:

url(r'^$', views.post_list, name='post_list'),
# url(r'^$', views.PostListView.as_view(), name='post_list'),

添加以下 URL 模式,通过标签列出帖子:

url(r'^tag/(?P<tag_slug>[-\w]+)/$', views.post_list,
    name='post_list_by_tag'),

正如你所看到的,两个模式指向同一个视图,但是名称不一样。第一个模式不带任何可选参数调用post_list视图,第二个模式使用tag_slug参数调用视图。

因为我们使用的是post_list视图,所以需要编辑blog/post/list.hmlt模板,修改pagination使用posts参数:

{% include "pagination.html" with page=posts %}

{% for %}循环上面添加以下代码:

{% if tag %}
	<h2>Posts tagged with "{{ tag.name }}"</h2>
{% endif %}

如果用户正在访问博客,他会看到所有帖子列表。如果他通过指定标签过滤帖子,就会看到这个信息。现在,修改标签的显示方式:

<p class="tag">
	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>

现在,我们循环一篇帖子的所有标签,显示一个自定义链接到 URL,以便使用该便签过滤帖子。我们用{% url "blog:post_list_by_tag" tag.slug %}构造 URL,把 URL 名和标签的别名作为参数。我们用逗号分隔标签。

在浏览器中打开http://127.0.0.1:8000/blog/,点击某一个标签链接。你会看到由该标签过滤的帖子列表:

2.4 通过相似度检索帖子

现在,我们已经为博客帖子添加了标签,我们还可以用标签做更多有趣的事。通过便签,我们可以很好的把帖子分类。主题类似的帖子会有几个共同的标签。我们准备增加一个功能:通过帖子共享的标签数量来显示类似的帖子。在这种情况下,当用户阅读一篇帖子的时候,我们可以建议他阅读其它相关帖子。

为某个帖子检索相似的帖子,我们需要:

  • 检索当前帖子的所有标签。
  • 获得所有带这些便签中任何一个的帖子。
  • 从列表中排除当前帖子,避免推荐同一篇帖子。
  • 通过和当前帖子共享的标签数量来排序结果。
  • 如果两篇或以上的帖子有相同的标签数量,推荐最近发布的帖子。
  • 限制我们想要推荐的帖子数量。

这些步骤转换为一个复杂的QuerySet,我们需要在post_detail视图中包含它。打开blog应用的views.py文件,在顶部添加以下导入:

from django.db.models import Count

这是 Django ORM 的Count汇总函数。此函数允许我们执行汇总计数。然后在post_detail视图的render()函数之前添加以下代码:

# 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]

这段代码完成以下操作:

  1. 我们获得一个包含当前帖子所有标签的 ID 列表。values_list()这个QuerySet返回指定字段值的元组。我们传递flat=True给它,获得一个[1, 2, 3, ...]的列表。
  2. 我们获得包含这些标签中任何一个的所有帖子,除了当前帖子本身。
  3. 我们使用Count汇总函数生成一个计算后的字段same_tags,它包含与所有查询标签共享的标签数量。
  4. 我们通过共享的标签数量排序结果(降序),共享的标签数量相等时,用publish优先显示最近发布的帖子。我们对结果进行切片,只获取前四篇帖子。

render()函数添加similar_posts对象到上下文字典中:

return render(request,
              'blog/post/detail.html',
              {'post': post,
               'comments': comments,
               'new_comment':new_comment,
               'comment_form': comment_form,
               'similar_posts': similar_posts})

现在,编辑blog/post/detail.html模板,在帖子的评论列表前添加以下代码:

<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 post yet.
{% endfor %}

推荐你在帖子详情模板中也添加标签列表,就跟我们在帖子列表模板中所做的那样。现在,你的帖子详情页面应该看起来是这样的:

译者注:需要给其它帖子添加标签,才能看到上图所示的相似的帖子。

你已经成功的推荐了相似的帖子给用户。django-taggit也包含一个similar_objects()管理器,可以用来检索共享的标签。你可以在这里查看所有django-taggit管理器。

2.5 总结

在这一章中,你学习了如何使用 Django 表单和模型表单。你创建了一个可以通过邮件分享网站内容的系统,还为博客创建了评论系统。你为帖子添加了标签,集成了一个可复用的应用,并创建了一个复杂的QuerySet,通过相似度检索对象。

下一章中,你会学习如何创建自定义模板标签和过滤器。你还会构建一个自定义的站点地图和帖子的 RSS 源,并在应用中集成一个高级的搜索引擎。

第三章:扩展你的博客应用

上一章介绍了标签的基础知识,你学会了如何在项目中集成第三方应用。本章将会涉及以下知识点:

  • 创建自定义模板标签和过滤器
  • 添加站点地图和帖子订阅
  • 使用 Solr 和 Haystack 构建搜索引擎

3.1 创建自定义模板标签和过滤器

Django 提供了大量内置的模板标签,比如{% if %}{% block %}。你已经在模板中使用过几个了。你可以在这里找到所有内置的模板标签和过滤器。

当然,Django 也允许你创建自定义模板标签来执行操作。当你需要在模板中添加功能,而 Django 模板标签不满足需求时,自定义模板标签会非常方便。

3.1.1 创建自定义模板标签

Django 提供了以下帮助函数,让你很容易的创建自定义模板标签:

  • simple_tag:处理数据,并返回一个字符串。
  • inclusion_tag:处理数据,并返回一个渲染后的模板。
  • assignment_tag:处理数据,并在上下文中设置一个变量。

模板标签必须存在 Django 应用中。

blog应用目录中,创建templatetags目录,并在其中添加一个空的__init__.py文件和一个blog_tags.py文件。博客应用的目录看起来是这样的:

blog/
	__init__.py
	models.py
	...
	templatetags/
		__init__.py
		blog_tags.py

文件名非常重要。你将会在模板中使用该模块名来加载你的标签。

我们从创建一个simple_tag标签开始,该标签检索博客中已发布的帖子总数。编辑刚创建的blog_tags.py文件,添加以下代码:

from django import template

register = template.Library()

from ..models import Post

@register.simple_tag
def total_posts():
	return Post.published.count()

我们创建了一个简单的模板标签,它返回已发布的帖子数量。每一个模板标签模块想要作为一个有效的标签库,都需要包含一个名为register的变量。该变量是一个template.Library的实例,用于注册你自己的模板标签和过滤器。然后我们使用 Python 函数定义了一个名为total_posts的标签,并使用@register.simple_tag定义该函数为一个simple_tag,并注册它。Django 将会使用函数名作为标签名。如果你想注册为另外一个名字,可以通过name属性指定,比如@register.simple_tag(name='my_tag')

添加新模板标签模块之后,你需要重启开发服务器,才能使用新的模板标签和过滤器。

使用自定义模板标签之前,你必须使用{% load %}标签让它们在模板中生效。像之前提到的,你需要使用包含模板标签和过滤器的 Python 模块名。打开blog/base.html模板,在顶部添加{% load blog_tags %},来加载你的模板标签模块。然后使用创建的标签显示帖子总数。只需要在模板中添加{% total_posts %}。最终,该模板看起来是这样的:

{% load blog_tags %}
{% load staticfiles %}
<!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>

因为在项目中添加了新文件,所以需要重启开发服务器。运行python manage.py runserver启动开发服务器。在浏览器中打开http://127.0.0.1:8000/blog/。你会在侧边栏中看到帖子的总数量,如下图所示:

自定义模板标签的强大之处在于,你可以处理任意数据,并把它添加到任意模板中,不用管视图如何执行。你可以执行QuerySet或处理任意数据,并在模板中显示结果。

现在,我们准备创建另一个标签,在博客侧边栏中显示最近发布的帖子。这次我们使用inclusion_tag标签。使用该标签,可以利用你的模板标签返回的上下文变量渲染模板。编辑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模板。我们的模板标签接收一个默认值为 5 的可选参数count,用于指定想要显示的帖子数量。我们用该变量限制Post.published.order_by('-publish')[:count]查询返回的结果。注意,该函数返回一个字典变量,而不是一个简单的值。Inclusion标签必须返回一个字典值作为上下文变量,来渲染指定的模板。Inclusion标签返回一个字典。我们刚创建的模板标签可以传递一个显示帖子数量的可选参数,比如{% show_latest_posts 3 %}

现在,在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模板,添加新的模板标签,显示最近 3 篇帖子,如下所示:

<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>

通过传递显示的帖子数量调用模板标签,并用给定的上下文在原地渲染模板。

现在回到浏览器,并刷新页面。侧边栏现在看起来是这样的:

最后,我们准备创建一个assignment标签。Assignment标签跟simple标签很像,它们把结果存在指定的变量中。我们会创建一个assignment标签,用于显示评论最多的帖子。编辑blog_tags.py文件,添加以下导入和模板标签:

from django.db.models import Count

@register.assignment_tag
def get_most_commented_posts(count=5):
	return Post.published.annotate(total_comments=Count('comments')).order_by('-total_comments')[:count]

这个QuerySet使用了annotate()函数,调用Count汇总函数进行汇总查询。我们构造了一个QuerySet,在totaol_comments字段中汇总每篇帖子的评论数,并用该字段对QeurySet排序。我们还提供了一个可选变量count,限制返回的对象数量。

除了Count,Django 还提供了AvgMaxMinSum汇总函数。你可以在这里阅读更多关于汇总函数的信息。

编辑blog/base.html模板,在侧边栏的<div>元素中添加以下代码:

<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>

Assignment模板标签的语法是{% template_tag as variable %}。对于我们这个模板标签,我们使用{% get_most_commented_posts as most_commented_posts %}。这样,我们就在名为most_commented_posts的变量中存储了模板标签的结果。接着,我们用无序列表显示返回的帖子。

现在,打开浏览器,并刷新页面查看最终的结果,如下所示:

你可以在这里阅读更多关于自定义模板标签的信息。

3.1.2 创建自定义模板过滤器

Django 内置了各种模板过滤器,可以在模板中修改变量。过滤器就是接收一个或两个参数的 Python 函数——一个是它要应用的变量的值,以及一个可选参数。它们返回的值可用于显示,或者被另一个过滤器处理。一个过滤器看起来是这样的:{{ variable|my_filter }},或者传递一个参数:{{ variable|my_filter:"foo" }}。你可以在一个变量上应用任意多个过滤器:{{ variable|filter1|filter2 }},每个过滤器作用于前一个过滤器产生的输出。

我们将创建一个自定义过滤器,可以在博客帖子中使用markdown语法,然后在模板中把帖子内容转换为 HTML。Markdown是一种纯文本格式化语法,使用起来非常简单,并且可以转换为 HTML。你可以在这里学习该格式的基本语法。

首先,使用下面的命令安装 Python 的markdown模块:

pip install Markdown

接着,编辑blog_tags.py文件,添加以下代码:

from django.utils.safestring import mark_safe
import markdown

@register.filter(name='markdown')
def markdown_format(text):
	return mark_safe(markdown.markdown(text))

我们用与模板标签同样的方式注册模板过滤器。为了避免函数名和markdown模块名的冲突,我们将函数命名为markdown_format,把过滤器命名为markdown,在模板中这样使用:{{ variable|markdown }}。Django 会把过滤器生成的 HTML 代码转义。我们使用 Django 提供的mark_safe函数,把要在模板中渲染的结果标记为安全的 HTML 代码。默认情况下,Django 不会信任任何 HTML 代码,并且会在输出结果之前进行转义。唯一的例外是标记为安全的转义变量。这种行为可以阻止 Django 输出有潜在危险的 HTML 代码;同时,当你知道返回的是安全的 HTML 代码时,允许这种例外情况发生。

现在,在帖子列表和详情模板中加载你的模板标签。在post/list.htmlpost/detail.html模板的{% extends %}标签之后添加下面这行代码:

{% load blog_tags %}

post/detail.html模板中,把这一行:

{{ post.body|linebreaks }}

替换为:

{{ post.body|markdown }}

接着,在post/list.html模板中,把这一行:

{{ post.body|truncatewords:30|linebreaks }}

替换为:

{{ post.body|markdown|truncatewords_html:30 }}

过滤器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.2 为站点添加站点地图

Django 自带一个站点地图框架,可以为站点动态生成站点地图。站点地图是一个 XML 文件,告诉搜索引擎你的网站有哪些页面,它们之间的关联性,以及更新频率。使用站点地图,可以帮助网络爬虫索引网站的内容。

Django 的站点地图框架依赖django.contrib.sites,它允许你将对象关联到在项目中运行的指定网站。当你用单个 Django 项目运行多个站点时,会变得非常方便。要安装站点地图框架,我们需要在项目中启用sitessitemap两个应用。编辑项目的settings.py文件,并在INSTALLED_APPS设置中添加django.contrib.sitesdjango.contrib.sitemaps。同时为站点 ID 定义一个新的设置,如下所示:

SITE_ID = 1

INSTALLED_APPS = [
	# ...
	'django.contrib.sites',
	'django.contrib.sitemaps',
]

现在,运行以下命令,在数据库中创建sites应用的数据库表:

python manage.py migrate

你会看到包含这一行的输出:

Applying sites.0001_initial... 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.publish

我们创建了一个自定义的站点地图,它继承自sitemaps模块的Sitemap类。changefreqpriority属性表示帖子页面的更新频率和它们在网站中的关联性(最大值为 1)。items()方法返回这个站点地图中包括的对象的QuerySet。默认情况下,Django 调用每个对象的get_absolute_url()方法获得它的 URL。记住,我们在第一章创建了该方法,用于获得帖子的标准 URL。如果你希望为每个对象指定 URL,可以在站点地图类中添加location方法。lastmod方法接收items()返回的每个对象,并返回该对象的最后修改时间。changefreqpriority既可以是方法,也可以是属性。你可以在官方文档中查看完整的站点地图参考。

最后,我们只需要添加站点地图的 URL。编辑项目的urls.py文件,添加站点地图:

from django.conf.urls import include, url
from django.contrib import admin
from django.contrib.sitemaps.views import sitemap 
from blog.sitemaps import PostSitemap

sitemaps = {
	'posts': PostSitemap,
}

urlpatterns = [
	url(r'^admin/', include(admin.site.urls)),
	url(r'^blog/', 
		include('blog.urls'namespace='blog', app_name='blog')),
	url(r'^sitemap\.xml$', sitemap, {'sitemaps': sitemaps},
		name='django.contrib.sitemaps.views.sitemap'),
]

我们在这里包括了必需的导入,并定义了一个站点地图的字典。我们定义了一个匹配sitemap.xml的 URL 模式,并使用sitemap视图。把sitemaps字典传递给sitemap视图。在浏览器中打开http://127.0.0.1:8000/sitemap.xml,你会看到类似这样的 XML 代码:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
	<url>
		<loc>http://example.com/blog/2017/04/28/markdown-post/</loc>
		<lastmod>2017-04-28</lastmod>
		<changefreq>weekly</changefreq>
		<priority>0.9</priority>
	</url>
	<url>
		<loc>http://example.com/blog/2017/04/25/one-more-again/</loc>
		<lastmod>2017-04-25</lastmod>
		<changefreq>weekly</changefreq>
		<priority>0.9</priority>
	</url>
	...
</urlset>

通过调用get_absolute_url()方法,为每篇帖子构造了 URL。我们在站点地图中指定了,lastmod属性对应帖子的publish字段,changefreqpriority属性也是从PostSitemap中带过来的。你可以看到,用于构造 URL 的域名是example.com。该域名来自数据库中的Site对象。这个默认对象是在我们同步sites框架数据库时创建的。在浏览器中打开http://127.0.0.1/8000/admin/sites/site/,如下图所示:

这是sites框架的管理视图显示的列表。你可以在这里设置sites框架使用的域名或主机,以及依赖它的应用。为了生成存在本机环境中的 URL,需要把域名修改为127.0.0.1:8000,如下图所示:

为了方便开发,我们指向了本机。在生产环境中,你需要为sites框架使用自己的域名。

3.3 为博客帖子创建订阅

Django 内置一个聚合订阅(syndication feed)框架,可以用来动态生成 RSS 或 Atom 订阅,与用sites框架创建站点地图的方式类似。

blog应用目录下创建一个feeds.py文件,添加以下代码:

from django.contrib.syndication.views import Feed
from django.template.defaultfilters import truncatewords
from .models import Post

class LatestPostsFeed(Feed):
	title = 'My blog'
	link = '/blog/'
	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(item.body, 30)

首先,我们从syndication框架的Feed类继承。titlelinkdescription属性分别对应 RSS 的<title><link><description>元素。

items()方法获得包括在订阅中的对象。我们只检索最近发布的五篇帖子。item_title()item_description()方法接收items()返回的每一个对象,并返回每一项的标题和描述。我们用内置的truncatewords模板过滤器截取前 30 个单词,用于构造博客帖子的描述。

现在编辑blog应用的urls.py文件,导入刚创建的LatestPostsFeed,并在新的 URL 模式中实例化:

from .feeds import LatestPostsFeed

urlpatterns = [
	# ...
	url(r'^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://127.0.0.1:8000/blog/</link>
		<description>New posts of my blog</description>
		<atom:link href="http://127.0.0.1:8000/blog/feed/" rel="self"></atom:link>
		<language>en-us</language>
		<lastBuildDate>Fri, 28 Apr 2017 05:44:43 +0000</lastBuildDate>
		<item>
			<title>One More Again</title>
			<link>http://127.0.0.1:8000/blog/2017/04/25/one-more-again/</link>
			<description>Post body.</description>
			<guid>http://127.0.0.1:8000/blog/2017/04/25/one-more-again/</guid>
		</item>
		<item>
			<title>Another Post More</title>
			<link>http://127.0.0.1:8000/blog/2017/04/25/another-post-more/</link>
			<description>Post body.</description>
			<guid>http://127.0.0.1:8000/blog/2017/04/25/another-post-more/</guid>
		</item>
		...
	</channel>
</rss>

如果你在 RSS 客户端中打开这个 URL,你会看到一个用户界面友好的订阅。

最后一步是在博客的侧边栏添加一个订阅链接。打开blog/base.html模板,在侧边栏<div>的帖子总数后面添加这一行代码:

<p><a href="{% url "blog:post_feed" %}">Subscribe to my RSS feed</a></p>

现在,在浏览器中打开http://127.0.0.1:8000/blog/,你会看到如下图所示的侧边栏:

3.4 使用 Solr 和 Haystack 添加搜索引擎

译者注:暂时跳过这一节的翻译,对于一般的博客,实在是用不上搜索引擎。

3.5 总结

在这一章中,你学习了如何创建自定义 Django 模板标签和过滤器,为模板提供自定义的功能。你还创建了站点地图,便于搜索引擎爬取你的网站,以及一个 RSS 订阅,便于用户订阅。同时,你在项目中使用 Haystack 集成了 Solr,为博客构建了一个搜索引擎。

在下一章,你会学习如何使用 Django 的authentication构建社交网站,创建自定义的用户资料,以及社交认证。

第四章:创建一个社交网站

在上一章中,你学习了如何创建站点地图和订阅,并且为博客应用构建了一个搜索引擎。在这一章中,你会开发一个社交应用。你会为用户创建登录,登出,编辑和修改密码的功能。你会学习如何为用户创建自定义的个人资料,并在网站中添加社交认证。

本章会涉及以下知识点:

  • 使用认证框架
  • 创建用户注册视图
  • 用自定义个人资料模型扩展User模型
  • python-social-auth添加社交认证

让我们从创建新项目开始。

4.1 创建一个社交网站项目

我们将会创建一个社交应用,让用户可以分享他们在 Internet 上发现的图片。我们需要为该项目构建以下元素:

  • 一个认证系统,用于用户注册,登录,编辑个人资料,修改或重置密码
  • 一个关注系统,允许用户互相关注
  • 显示分享的图片,并实现一个书签工具,让用户可以分享任何网站的图片
  • 每个用户的活动信息,让用户可以看到他关注的用户上传的内容

本章讨论第一点。

4.1.1 启动社交网站项目

打开终端,使用以下命令为项目创建一个虚拟环境,并激活:

mkdir env
virtualenv env/bookmarks
source env/bookmarks/bin/activate

终端会如下显示你激活的虚拟环境:

(bookmarks)laptop:~ zenx$

使用以下命令,在虚拟环境中安装 Django:

pip install Django

执行以下命令创建一个新项目:

django-admin startproject bookmarks

创建初始项目结构之后,使用以下命令进入项目目录,并创建一个account的新应用:

cd bookmarks/
django-admin startapp account

通过把该应用添加到settings.py文件的INSTALLED_APPS中,来激活它。把它放在INSTALLED_APPS列表的最前面:

INSTALLED_APPS = (
	'account',
	# ...
)

执行下面的命令,同步INSTALLED_APPS设置中默认应用的模型到数据库中:

python manage.py migrate

接下来,我们用authentication框架在项目中构建一个认证系统。

4.2 使用 Django 认证框架

Django 内置一个认证框架,可以处理用户认证,会话,权限和用户组。该认证系统包括常见的用户操作视图,比如登录,登出,修改密码和重置密码。

认证框架位于django.contrib.auth中,并且被其它 Django contrib包使用。记住,你已经在第一章中使用过认证框架,为博客应用创建了一个超级用户,以便访问管理站点。

当你使用startproject命令创建新 Django 项目时,认证框架已经包括在项目的默认设置中。它由django.contrib.auth应用和以下两个中间件(middleware)类组成(这两个中间类位于项目的MIDDLEWARE_CLASSES设置中):

  • AuthenticationMiddleware:使用会话管理用户和请求
  • SessionMiddleware:跨请求处理当前会话

一个中间件是一个带有方法的类,在解析请求或响应时,这些方法在全局中执行。你会在本书的好几个地方使用中间件类。你会在第十三章学习如何创建自定义的中间件。

该认证框架还包括以下模块:

  • User:一个有基础字典的用户模型;主要字段有:usernamepasswordemailfirst_namelast_nameis_active
  • Group:一个用于对用户分类的组模型。
  • Permission:执行特定操作的标识。

该框架还包括默认的认证视图和表单,我们之后会学习。

4.2.1 创建登录视图

我们从使用 Django 认证框架允许用户登录网站开始。我们的视图要执行以下操作来登录用户:

  1. 通过提交表单获得用户名和密码。
  2. 对比数据库中的数据,来验证用户。
  3. 检查用户是否激活。
  4. 用户登录,并开始一个认证的会话(authenticated session)。

首先,我们将创建一个登录表单。在account应用目录中创建forms.py文件,添加以下代码:

from django import forms

class LoginForm(forms.Form):
    username = forms.CharField()
    password = forms.CharField(widget=forms.PasswordInput)

该表单用于在数据库用验证用户。注意,我们使用PasswordInput组件来渲染包括type="password"属性的 HTML input元素。编辑account应用的views.py文件,添加以下代码:

from django.shortcuts import render
from django.http import HttpResponse
from django.contrib.auth import authenticate, login
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(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提交表单时,我们执行以下操作:

  1. 使用form = LoginForm(request.POST)实例化带有提交的数据的表单。
  2. 检查表单是否有效。如果无效,则在模板中显示表单错误(例如,用户没有填写某个字段)。
  3. 如果提交的数据有效,我们使用authenticate()方法,在数据库中验证用户。该方法接收usernamepassword参数,如果用户验证成功,则返回User对象,否则返回None。如果用户没有通过验证,我们返回一个原始的HttpResponse,显示一条消息。
  4. 如果用户验证成功,我们通过is_active属性检查用户是否激活。这是 Django User模型的属性。如果用户没有激活,我们返回一个HttpResponse显示信息。
  5. 如果是激活的用户,我们在网站登录用户。我们调用login()方法,把用户设置在 session 中,并返回一条成功消息。

注意authenticatelogin之间的区别:authenticate()方法检查用户的认证信息,如果正确,则返回User对象;login()在当前 session 中设置用户。

现在,你需要为该视图创建 URL 模式。在account应用目录中创建urls.py文件,并添加以下代码:

from django.conf.urls import url
from . import views

urlpatterns = [
    # post views
    url(r'^login/$', views.user_login, name='login'),
]

编辑bookmarks项目目录中的urls.py文件,在其中包括account应用的 URL 模式:

from django.conf.urls import url, include
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^account/', include('account.urls')),
]

现在可以通过 URL 访问登录视图了。是时候为该视图创建一个模板了。因为该项目还没有模板,所以你可以创建一个基础模板,在登录模板中扩展它。在account应用目录中创建以下文件和目录:

templates/
	account/
		login.html
	base.html

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

{% load staticfiles %}
<!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 样式。该基础模板定义了titlecontent区域,可以被从它扩展的模板填充内容。

让我们为登录表单创建模板。打开account/login.html模板,添加以下代码:

{% extends "base.html" %}

{% block title %}Log-in{% endblock  %}

{% block content %}
    <h1>Log-in</h1>
    <p>Please, user the following form to log-in</p>
    <form action="." method="post">
        {{ form.as_p }}
        {% csrf_token %}
        <p><input type="submit" value="Log-in"></p>
    </form>
{% endblock  %}

该模板包括了在视图中实例化的表单。因为我们的表单会通过POST提交,所以我们使用{% csrf_token %}模板标签进行 CSRF 保护。你在第二章学习了 CSRF 保护。

现在数据库中还没有用户。首先,你需要创建一个超级用户,访问管理站点来管理其他用户。打开命令行,执行python manage.py createsuperuser。填写必需的用户名,邮箱和密码。然后使用python manage.py runserver启动开发服务器,并在浏览器中打开http://127.0.0.1:8000/admin/。使用你刚创建的用户登录管理站点。你会看到 Django 管理站点中包括了 Django 认证框架的UserGroup模型,如下图所示:

通过管理站点创建一个新用户,并在浏览器中打开http://127.0.0.1:8000/account/login/。你会看到包括登录表单的模板:

现在,提交表单时不填其中一个字段。这时,你会看到表单是无效的,并显示错误信息,如下图所示:

如果你输入一个不存在的用户,或者错误的密码,你会看到一条Invalid login消息。

如果你输入有效的认证信息,会看到一条Authenticated successfully消息,如下图所示:

4.2.2 使用 Django 认证视图

Django 在认证框架中包括了几个表单和视图,你可以直接使用。你已经创建的登录视图对于理解 Django 中的用户认证过程是一个很好的练习。然而,你在绝大部分情况下可以使用默认的 Django 认证视图。

Django 提供了以下视图处理认证:

  • login:操作一个登录表单,并登录用户
  • logout:登出一个用户
  • logout_then_login:登出一个用户,并重定向用户到登录页面

Django 提供以下视图处理修改密码:

  • password_change:操作一个修改用户密码的表单
  • password_change_done:修改密码后,显示成功页面

Django 还提供以下视图用于重置密码:

  • password_reset:允许用户重置密码。它生成一个带令牌的一次性链接,并发送到用户的电子邮箱中。
  • password_reset_done:告诉用户,重置密码的邮件已经发送到他的邮箱中。
  • password_reset_confirm:让用户设置新密码。
  • password_reset_complete:用户重置密码后,显示成功页面。

创建一个带用户账户的网站时,这里列出的视图会节省你很多时间。你可以覆盖这些视图使用的默认值,比如需要渲染的模板的位置,或者视图使用的表单。

你可以在这里获得更多关于内置的认证视图的信息。

4.2.3 登录和登出视图

编辑account应用的urls.py文件,如下所示:

from django.conf.urls import url
from django.contrib.auth.views import login, logout, logout_then_login
from . import views

urlpatterns = [
    # previous login view
    # url(r'^login/$', views.user_login, name='login'),

    # login / logout urls
    url(r'^login/$', login, name='login'),
    url(r'^logout/$', logout, name='logout'),
    url(r'^logout-then-login/$', logout_then_login, name='logout_then_login'),
]

译者注:Django 新版本中,URL 模式使用方式跟旧版本不一样。

我们注释了之前为user_login视图创建的 URL 模式,使用了 Django 认证框架的login视图。

account应用的templates目录中创建一个registration目录。这是 Django 认证视图的默认路径,它期望你的认证模板在这个路径下。在新创建的目录中创建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, user 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  %}

这个login模板跟我们之前创建那个很像。Django 默认使用django.contrib.auth.forms中的AuthenticationForm。该表单尝试验证用户,如果登录不成功,则抛出一个验证错误。这种情况下,如果认证信息出错,我们可以在模板中使用{% if form.errors %}查找错误。注意,我们添加了一个隐藏的 HTML <input>元素,用于提交名为next的变量的值。当你在请求中传递一个next参数时(比如,http://127.0.0.1:8000/account/login/?next=/account/),这个变量首次被登录视图设置。

next参数必须是一个 URL。如果指定了这个参数,Django 登录视图会在用户登录后,重定义到给定的 URL。

现在,在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 认证视图登录了。

注意,我们在urlconf中包含的logout_then_login视图不需要任何模板,因为它重定义到了登录视图。

现在我们开始创建一个新的视图,当用户登录账号时,用于显示用户的仪表盘。打开account应用的views.py文件,添加以下代码:

from django.contrib.auth.decorators import login_required

@login_required
def dashboard(request):
    return render(request,
                  'account/dashboard.html',
                  {'section': 'dashboard'})

我们用认证框架的login_required装饰器装饰视图。该装饰器检查当前用户是否认证。如果是认证用户,它会执行被装饰的视图。如果不是认证用户,它会重定向用户到登录 URL,并在登录 URL 中带上一个名为nextGET参数,该参数是用户试图访问的 URL。通过这样的做法,当用户成功登录后,登录视图会重定向用户到用户登录之前试图访问的页面。记住,我们在登录模板的表单中添加了一个隐藏的<input>元素就是为了这个目的。

我们还定义了一个section变量。我们用这个变量跟踪用户正在查看网站的哪一部分(section)。多个视图可能对应相同的部分。这是定义每个视图对应的 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 = [
	# ...
	url(r'^$', views.dashboard, name='dashboard'),
 ]

编辑项目的settings.py文件,添加以下代码:

from django.core.urlresolvers import reverse_lazy

LOGIN_REDIRECT_URL = reverse_lazy('dashboard')
LOGIN_URL = reverse_lazy('login')
LOGOUT_URL = reverse_lazy('logout')

这些设置是:

  • LOGIN_REDIRECT_URL:告诉 Django,如果contrib.auth.views.login视图没有获得next参数时,登录后重定向到哪个 URL

  • LOGIN_URL:重定向用户登录的 URL(比如使用login_required装饰器)

  • LOGOUT_URL:重定向用户登出的 URL

    我们使用reverse_lazy(),通过 URL 的名字动态创建 URL。reverse_lazy()函数跟reverse()函数一样逆向 URL。当你需要在项目 URL 配置加载之前逆向 URL 时,可以使用reverse_lazy()

让我们总结一下,到现在为止,我们做了哪些工作:

  • 你在项目中添加了内置的 Django 认证登录和登出视图
  • 你为这两个视图创建了自定义模板,并定义了一个简单的视图,让用户登录后重定向到这个视图
  • 最后,你配置了设置,让 Django 默认使用这些 URL

现在,我们需要把登录和登出链接到基础模板中,把所有功能串起来。

要做到这点,我们需要确定,无论当前用户是否登录,都能显示适当的链接。通过认证中间件,当前用户被设置在HttpRequest对象中。你可以通过request.user访问。即使用户没有认证,你也可以找到一个用户对象。一个未认证的用户在request中是一个AnonymousUser的实例。调用request.user.is_authenticated()是检测当前用户是否认证最好的方式。

编辑base.html文件,修改 ID 为header<div>,如下所示:

<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 }},
			<a href="{% url "logout" %}">Logout</a>
		{% else %}
			<a href="{% url "login" %}">Log-in</a>
		{% endif %}
	</span>
</div>

正如你所看到的,我们只为认证的用户显示网站的菜单。我们还检查当前的 section,通过 CSS 为相应的<li>项添加selected类属性来高亮显示菜单中的当前 section。我们还显示用户的姓,如果是认证过的用户,还显示一个登出链接,否则显示登录链接。

现在,在浏览器中打开http://127.0.0.1:8000/account/login。你会看到登录页面。输入有效的用户名和密码,点击Log-in按钮,你会看到这样的页面:

因为My dashboardselected属性,所以你会看到它是高亮显示的。因为是认证过的用户,所以用户的姓显示在头部的右边。点击Logout链接,你会看到下面的页面:

在这个页面中,用户已经登出,所以你不能再看到网站的菜单。现在头部右边显示Log-in链接。

如果你看到的是 Django 管理站点的登出页面,而不是你自己的登出页面,检查项目的INSTALLED_APPS设置,确保django.contrib.adminaccount应用之后。这两个模板位于同样的相对路径中,Django 目录加载器会使用第一个。

4.2.4 修改密码视图

用户登录我们的网站后,我们需要用户可以修改他们的密码。我们通过集成 Django 认证视图来修改密码。打开account应用的urls.py文件,添加以下 URL 模式:

from django.contrib.auth.views import password_change
from django.contrib.auth.views import password_change_done

# change password urls
urlpatterns = [
    url(r'^password-change/$', password_change, name='password_change'),
    url(r'^password_change/done/$', password_change_done, name='password_change_done'),
]

password_change视图会处理修改密码表单,password_change_done会在用户成功修改密码后显示一条成功消息。让我们为每个视图创建一个模板。

account应用的templates/registration/目录中创建password_change_form.html文件,添加以下代码:

{% extends "base.html" %}

{% block title %}Change you password{% endblock  %}

{% block content %}
    <h1>Change you password</h1>
    <p>Use the form below to change your password.</p>
    <form action="." method="post">
        {{ form.as_p }}
        <p><input type="submit" value="Change"></p>
        {% csrf_token %}
    </form>
{% endblock %}

该模板包括修改密码的表单。在同一个目录下创建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 %}

该模板只包括一条用户成功修改密码后显示的成功消息。

在浏览器中打开http://127.0.0.1:8000/account/password-change/。如果用户没有登录,浏览器会重定向到登录页面。当你认证成功后,你会看到下面的修改密码页面:

在表单中填写当前密码和新密码,点击Change按钮。你会看到以下成功页面:

登出后,使用新密码再次登录,确定所有功能都能正常工作。

4.2.5 重置密码视图

account应用的urls.py文件中,为重置密码添加以下 URL 模式:

from django.contrib.auth.views import password_reset
from django.contrib.auth.views import password_reset_done
from django.contrib.auth.views import password_rest_confirm
from django.contrib.auth.views import password_reset_complete

# restore password urls
url(r'^password-reset/$', password_reset, name='password_reset'),
url(r'^password-reset/done/$', password_reset_done, name='password_reset_done'),
url(r'^password-reset/confirm/(?P<uidb64>[-\w]+)/(?P<token>[-\w]+)/$', password_reset_confirm, name='password_reset_confirm'),
url(r'^password-reset/complete/$', password_reset_complete, name='password_reset_complete'),

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 action="." method="post">
        {{ form.as_p }}
        <p><input type="submit" value="Send e-mail"></p>
        {% csrf_token %}
    </form>
{% endblock %}

在同一个目录下创建password_reset_email.html文件,添加以下代码:

Someon asked for password reset for email {{ email }}. Fllow the link below:
{{ protocol }}://{{ domain }}/{% url "password_reset_form" uidb64=uid token=token %}
Your usernmae, in case you've forgotten: {{ user.get_username }}

这个模板用于渲染发送给用户重置密码的邮件。

在同一个目录下创建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 action="." method="post">
            {{ formo.as_p }}
            {% csrf_token %}
            <p><input type="submit" value="Change my password" /></p>
        </form>
    {% else %}
        <p>The password reset link was invalid, possible because it has already been used. 
            Please request a new password reset.</p>
    {% endif %}
{% endblock  %}

我们检查提供的链接是否有效。Django 重置页面视图设置该变量,并把它放在这个模板的上下文中。如果链接有效,我们显示重置密码表单。

创建另一个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模板,在<form>元素后面添加以下代码:

<p><a href="{% url "password_reset" %}">Forgotten your password?</a></p>

现在,在浏览器中打开htth://127.0.0.1:8000/account/login/,点击Forgotten your password?链接,你会看到以下链接:

此时,你需要在项目的settings.py文件中添加SMTP配置,让 Django 可以发送邮件。我们已经在第二章学习了如何添加邮件设置。但是在开发期间,你可以让 Django 在标准输出中写邮件,代替通过 SMTP 服务发送邮件。Django 提供了一个邮件后台,可以把邮件输出到控制台。编辑项目的settings.py文件,添加下面这一行代码:

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

EMAIL_BACKEND设置指定用于发送邮件的类。

回到浏览器,输入已有用户的邮箱地址,点击Send a e-mail按钮。你会看到以下页面:

看一眼正在运行开发服务器的控制台,你会看到生成的邮件:

MIME-Version: 1.0
Content-Transfer-Encoding: 7bit
Subject: Password reset on 127.0.0.1:8000
From: webmaster@localhost
To: lakerszhy@gmail.com
Date: Tue, 02 May 2017 03:50:20 -0000
Message-ID: <20170502035020.7440.93778@bogon>

Someon asked for password reset for email lakerszhy@gmail.com. Fllow the link below:
http://127.0.0.1:8000/account/password-reset/confirm/Mg/4lp-4b14906c833231658e9f/
Your usernmae, in case you've forgotten: antonio

邮件使用我们之间创建的password_reset_email.html模板渲染。重置密码的 URL 包括一个 Django 动态生成的令牌。在浏览器中打开连接,会看到以下页面:

设置新密码的页面对应password_reset_confirm.html模板。填写新密码并点击Change my password按钮。Django 会创建一个新的加密密码,并保存到数据库中。你会看到一个成功页面:

现在你可以使用新密码再次登录。每个用于设置新密码的令牌只能使用一次。如果你再次打开收到的链接,会看到一条令牌无效的消息。

你已经在项目中集成了 Django 认证框架的视图。这些视图适用于大部分场景。如果需要不同的行为,你可以创建自己的视图。

4.3 用户注册和用户资料

现在,已存在的用户可以登录,登出和修改密码,如果用户忘记密码,可以重置密码。现在,我们需要创建视图,用于游客创建账户。

4.3.1 用户注册

让我们创建一个简单的视图,允许用户在我们的网站注册。首先,我们必须创建一个表单,让用户输入用户名,姓名和密码。编辑account应用中的forms.py文件,添加以下代码:

from django.contrib.auth.models import User

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']

我们为User模型创建了一个模型表单。在表单中,我们只包括了模型的usernamefirst_nameemail字段。这些字段会根据相应的模型字段验证。例如,如果用户选择了一个已存在的用户名,会得到一个验证错误。我们添加了两个额外字段:passwordpassword2,用来设置密码和确认密码。我们定义了clean_password2()方法,检查两次输入的密码是否一致,如果不一致,则让表单无效。当我们调用表单的is_valid()方法验证时,这个检查会执行。你可以为任何表单字段提供clean_<fieldname>()方法,清理特定字段的值或抛出表单验证错误。表单还包括一个通用的clean()方法验证整个表单,验证相互依赖的字段时非常有用。

Django 还在django.contrib.auth.forms中提供了UserCreationForm表单供你使用,这个表单跟我们刚创建的表单类似。

编辑account应用中的views.py文件,添加以下代码:

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})

这个创建用户账户的视图非常简单。为了安全,我们使用User模型的set_password()方法处理加密保存,来代替保存用户输入的原始密码。

现在编辑account应用的urls.py文件,添加以下 URL 模式:

url(r'^register/$', views.register, name='register')

最后,我们在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 action="." 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/,你会看到刚创建的注册页面:

为新用户填写信息,点击Create my account按钮。如果所有字段都有效,则会创建用户,你会看到下面的成功消息:

点击log in链接,输入你的用户名和密码验证能否访问你的账户。

现在,你还可以在登录模板中添加注册链接。编辑registration/login.html模板,把这行代码:

<p>Please, user the following form to log-in</p>

替换为:

<p>Please, user the following form to log-in. 
If you don't have an account <a href="{% url "register" %}">register here</a></p>

我们可以通过登录页面访问注册页面了。

4.3.2 扩展 User 模型

当你必须处理用户账户时,你会发现 Django 认证框架的User模型适用于常见情况。但是User模型有非常基础的字段。你可能希望扩展User模型包含额外的数据。最好的方式是创建一个包括所有额外字段的个人资料模型,并且与 Django 的User模型是一对一的关系。

编辑account应用的models.py文件,添加以下代码:

from django.db import models
from django.conf import settings

class Profile(models.Model):
    user = models.OneToOneField(settings.AUTH_USER_MODEL)
    date_of_birth = models.DateField(blank=True, null=True)
    photo = models.ImageField(upload_to='users/%Y/%m/%d', blank=True)

    def __str__(self):
        return 'Pofile for User {}'.format(self.user.username)

为了让代码保持通用性,请使用get_user_model()方法检索用户模型。同时,定义模型和用户模型之间的关系时,使用AUTH_USER_MODEL设置引用用户模型,而不是直接引用该用户模型。

一对一的user字段允许我们用用户关联个人资料。photo字段是一个ImageField字段。你需要安装 PIL(Python Imaging Library)或 Pillow(PIL 的一个分支)Python 包来管理图片。在终端中执行以下命令安装 Pillow:

pip install Pillow

为了在 Django 开发服务器中提供多媒体文件上传功能,需要在项目的settings.py文件中添加以下设置:

MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media/')

MEDIA_URL是用户上传的多媒体文件的基 URL,MEDIA_ROOT是多媒体文件的本地路径。我们根据项目路径动态构建该路径,让代码更通用。

现在,编辑bookmarks项目的主urls.py文件,如下所示修改代码:

from django.conf import settings
from django.conf.urls.static import static

if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

这样,Django 开发服务器将在开发过程中负责多媒体文件服务。

static()帮助函数只适用于开发环境,不适合生产环境。永远不要在生产环境使用 Django 为静态文件提供服务。

打开终端执行以下命令,为新模型创建数据库迁移:

python manage.py makemigrations

你会得到这样的输出:

Migrations for 'account':
  account/migrations/0001_initial.py
    - Create model Profile

接着使用以下命令同步数据库:

python manage.py migrate

你会看到包括下面这一样的输出:

Applying account.0001_initial... OK

编辑account应用的admin.py文件,在管理站点注册Profile模型,如下所示:

from .models import Profile

class ProfileAdmin(admin.ModelAdmin):
    list_display = ('user', 'date_of_birth', 'photo')

admin.site.register(Profile, ProfileAdmin)

使用python manage.py runserver命令运行开发服务器。现在,你会在项目的管理站点看到Profile模型,如下图所示:

现在,我们将让用户在网站上编辑个人资料。在account应用的forms.py文件中添加以下模型表单:

from .models import Profile

class UserEditForm(forms.ModelForm):
    class Meta:
        model = User
        fields = ('first_name', 'last_name', 'email')

class ProfileEditForm(forms.ModelForm):
    class Meta:
        model = Profile
        fields = ('date_of_birth', 'photo')

这些表单的作用是:

  • UserEditForm:允许用户编辑存在内置的User模型中的姓,名和邮箱。
  • ProfileEditForm:允许用户编辑存在自定义的Profile模型中的额外数据。用户可以编辑出生日期,并上传一张图片。

编辑account应用的views.py文件,导入Profile模型:

from .models import Profile

register视图的new_user.save()下面添加以下代码:

# Create the user profile
profile = Profile.objects.create(user=new_user)

当用户在我们网站注册时,我们会创建一个空的个人资料关联到用户。你需要使用管理站点手动为之前创建的用户创建Profile对象。

现在我们让用户可以编辑个人资料。添加以下代码到同一个文件中:

from .forms import LoginForm, UserRegistrationForm, UserEditForm, ProfileEditForm

@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})

我们使用了login_required装饰器,因为用户必须认证后才能编辑个人资料。在这里,我们使用了两个模型表单:UserEditForm存储内置的User模型数据,ProfileEditForm存储额外的个人数据。我们检查两个表单的is_valid()方法返回 True 来验证提交的数据。在这里,我们保持两个表单,用来更新数据库中相应的对象。

account应用的urls.py文件中添加以下 URL 模式:

url(r'^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 action="." 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",来启用文件上传。我们使用一个 HTML 表单提交user_formprofile_form两个表单。

注册一个新用户,并在浏览器中打开http://127.0.0.1:8000/account/edit/,你会看到以下界面:

现在你可以编辑仪表盘页面,来包括编辑个人资料和修改密码的页面链接。打开account/dashboard.html模板,把这一行代码:

<p>Welcome to your dashboard.</p>

替换为:

<p>
	Welcome to your dashboard. 
	You can <a href="{% url "edit" %}">edit your profiles</a> 
	or <a href="{% url "password_change" %}">change your password</a>. 
</p>

用户现在可以通过仪表盘访问编辑个人资料的表单。

4.3.2.1 使用自定义 User 模型

Django 还提供了方式,可以用自定义模型代替整个User模型。你的用户类应从 Django 的AbstractUser类继承,它作为一个抽象模型,提供了默认用户的完整实现。你可以在这里阅读更多关于这个模型的信息。

使用自定义用户模型会有更多的灵活性,但它也可能给一些需要与User模型交互的可插拔应用应用的集成带来一定的困难。

4.3.3 使用消息框架

处理用户动作时,你可能想要通知用户动作的结果。Django 内置一个消息框架,允许你显示一次性提示。该消息框架位于django.contrib.message中,当你用python manage.py startproject创建新项目时,它默认包括在settings.pyINSTALLED_APPS列表中。你注意到,设置文件的MIDDLEWARE_CLASSES设置列表中,包括一个名为django.contrib.message.middleware.MessageMiddleware的中间件。该消息框架提供了一种简单的方式来给用户添加消息。消息存储在数据库中,并会在用户下次请求时显示。你可以通过导入消息模块,使用简单的快捷方式添加新消息,来在视图中使用消息框架,如下所示:

from django.contrib import message
message.error(request, 'Something went wrong')

你可以使用add_message()方法,或者以下任何一个快捷方法创建新消息:

  • success():动作执行成功后显示成功消息
  • info():信息消息
  • waring():还没有失败,但很可能马上失败
  • error():一个不成功的操作,或某些事情失败
  • debug():调试信息,会在生产环境移除或忽略

让我们显示消息给用户。因为消息框架对项目来说是全局的,所以我们可以在基础模板中显示消息给用户。打开base.html模板,在 id 为 header 和 content 的<div>元素之间添加以下代码:

{% if messages %}
	<ul class="messages">
		{% for message in messages %}
			<li class="{{ message.tags }}">
				{{ message|safe }}
				<a href="#" class="close">✖</a>
			</li>
		{% endfor %}
	</ul>
{% endif %}

消息框架包括一个上下文处理器(context processor),它会添加messages变量到请求上下文中。因此,你可以在模板使用该变量显示当前消息。

现在,让我们修改edit视图来使用消息框架。编辑account应用的views.py文件,如下修改edit视图:

from django.contrib import messages

@login_required
def edit(request):
    if request.method == 'POST':
    # ...
        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)
        # ...

当用户成功更新个人资料后,我们添加一条成功消息。如果任何一个表单无效,我们添加一条错误消息。

在浏览器中打开http://127.0.0.1:8000/account/edit/,并编辑你的个人资料。当个人资料更新成功后,你会看到以下消息:

当表单无效时,你会看到以下消息:

4.4 创建自定义认证后台

Django 允许你针对不同来源进行身份验证。AUTHENTICATION_BACKENDS设置包括了项目的认证后台列表。默认情况下,该设置为:

('django.contrib.auth.backends.ModelBackend',)

默认的ModelBackend使用django.contrib.authUser模型,验证数据库中的用户。这适用于大部分项目。但是你可以创建自定义的后台,来验证其它来源的用户,比如一个 LDAP 目录或者其它系统。

你可以在这里阅读更多关于自定义认证的信息。

一旦你使用django.contrib.auth中的authenticate()函数,Django 会一个接一个尝试AUTHENTICATION_BACKENDS中定义的每一个后台来验证用户,直到其中一个验证成功。只有所有后台都验证失败,才不会在站点中验证通过。

Django 提供了一种简单的方式来定义自己的认证后台。一个认证后台是提供了以下两个方法的类:

  • authenticate():接收用户信息作为参数,如果用户认证成功,则返回 True,否则返回 False。
  • get_user():接收用户 ID 作为参数,并返回一个User对象。

创建一个自定义认证后台跟编写一个实现这两个方法的 Python 类一样简单。我们会创建一个认证后台,让用户使用邮箱地址代替用户名验证。

account应用目录中创建一个authentication.py文件,添加以下代码:

from django.contrib.auth.models import User

class EmailAuthBackend:
    """
    Authenticates using e-mail account.
    """
    def authenticate(self, username=None, password=None):
        try:
            user = User.objects.get(email=username)
            if user.check_password(password):
                return user
            return None
        except User.DoesNotExist:
            retur None

    def get_user(self, user_id):
        try:
            return User.objects.get(pk=user_id)
        except User.DoesNotExist:
            return None

这是一个很简单的认证后台。authenticate()方法接收usernamepassword作为可选参数。我们可以使用不同的参数,但我们使用usernamepassword确保后台可以立即在认证框架中工作。上面的代码完成以下工作:

  • authenticate():我们尝试使用给定的邮箱地址检索用户,并用User模型内置的check_password()方法检查密码。该方法会处理密码哈希化,并比较给定的密码和数据库中存储的密码。
  • get_user():我们通过user_id参数获得一个用户。在用户会话期间,Django 使用认证用户的后台来检索User对象。

编辑项目的settings.py,添加以下设置:

AUTHENTICATION_BACKENDS = (
    'django.contrib.auth.backends.ModelBackend',
    'account.authentication.EmailAuthBackend',
)

我们保留了默认的ModelBackend,使用用户名和密码认证,并包括了自己的基于邮箱地址的认证后台。现在,在浏览器中打开http://127.0.0.1/8000/account/login/。记住,Django 会试图使用每一个后台验证用户,所以你现在可以使用用户名或邮箱账号登录。

AUTHENTICATION_ BACKENDS设置中列出的后端顺序很重要。如果同样的信息对于多个后台都有效,Django 会在第一个成功验证用户的后台停止。

4.5 为网站添加社交认证

你可能还想为网站添加社交认证,比如使用 Facebook,Twitter 或 Google 服务认证。Python-socail-auth是一个 Python 模块,可以简化添加社交认证过程。通过这个模块,你可以让用户使用其他服务的账户登录你的网站。

译者注:从 2016 年 12 月 3 日开始,这个模块迁移到了Python Social Auth。原书的内容已经过时,所以就不翻译了。

4.6 总结

在本章中,你学习了如何在网站中构建认证系统和创建自定义用户资料。你还在网站中添加了社交认证。

下一章中,你会学习如何创建一个图片书签系统,生成图片的缩略图,以及创建 AJAX 视图。

第五章:分享内容到你的网站

上一章中,你在网站中构建了用户注册和认证。你学会了如何为用户创建自定义的个人资料模型,并添加了主流社交网站的社交认证。

在这一章中,你会学习如何创建 JavaScript 书签工具,来从其它网站分享内容到你的网站,你还会使用 jQuery 和 Django 实现 AJAX 特性。

本章会覆盖以下知识点:

  • 创建多对多的关系
  • 定制表单行为
  • 在 Django 中使用 jQuery
  • 构建 jQuery 书签工具
  • 使用 sorl-thumbnail 生成图片缩略图
  • 实现 AJAX 视图,并与 jQuery 集成
  • 为视图创建自定义装饰器
  • 构建 AJAX 分页

5.1 创建图片标记网站

我们将允许用户在其他网站上标记和分享他们发现的图片,并将其分享到我们的网站。为了实现这个功能,我们需要完成以下任务:

  1. 定义一个存储图片和图片信息的模型。
  2. 创建处理图片上传的表单和视图。
  3. 为用户构建一个系统,让用户可以上传在其它网站找到的图片。

首先在bookmarks项目目录中,使用以下命令创建一个新的应用:

django-admin startapp images

settings.py文件的INSTALLED_APPS设置中添加images

INSTALLED_APPS = (
	# ...
	'images',
)

现在 Django 知道新应用已经激活了。

5.1.1 创建图片模型

编辑images应用的models.py文件,添加以下代码:

from django.db import models
from django.conf import settings

class Image(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name='images_created')
    title = models.CharField(max_length=200)
    slug = models.CharField(max_length=200, blank=True)
    url = models.URLField()
    image = models.ImageField(upload_to='/images/%Y/%m/%d')
    description = models.TextField(blank=True)
    created = models.DateField(auto_now_add=True, db_index=True)

    def __str__(self):
        return self.title

我们将使用这个模型存储来自不同网站的被标记的图片。让我们看看这个模型中的字段:

  • user:标记这张图片的User对象。这是一个ForeignKey字段,它指定了一对多的关系:一个用户可以上传多张图片,但一张图片只能由一个用户上传。
  • title:图片的标题。
  • slug:只包括字母,数据,下划线或连字符的短标签,用于构建搜索引擎友好的 URL。
  • url:图片的原始 URL。
  • image:图片文件。
  • description:一个可选的图片描述。
  • created:在数据库中创建对象的时间。因为我们使用了auto_now_add,所以创建对象时会自动设置时间。我们使用了db_index=True,所以 Django 会在数据库中为该字段创建一个索引。

数据库索引会提高查询效率。考虑为经常使用filter()exclude()order_by()查询的字段设置db_index=TrueForeignKey字段或带unique=True的字段隐式的创建了索引。你也可以使用Meta.index_together为多个字段创建索引。

我们会覆写Image模型的save()方法,根据title字段的值自动生成slug字段。在Image模型中导入slugify()函数,并添加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)

没有提供别名(slug)时,我们根据给定的标题,使用 Django 提供的slufigy()函数自动生成图片的slug字段。然后保存对象。我们为图片自动生成别名,所以用户不需要为每张图片输入slug字段。

5.1.2 创建多对多的关系

我们将会在Image模型中添加另一个字段,用于存储喜欢这张图片的用户。这种情况下,我们需要一个多对多的关系,因为一个用户可能喜欢多张图片,每张图片也可能被多个用户喜欢。

添加以下代码到Image模型中:

users_like = models.ManyToManyField(settings.AUTH_USER_MODEL,
                                    related_name='images_liked',
                                    blank=True)

当你定义一个ManyToManyFeild时,Django 会使用两个模型的主键创建一张中介连接表。ManyToManyFeild可以在两个关联模型的任何一个中定义。

ForeignKey字段一样,ManyToManyFeild允许我们命名从关联对象到这个对象的逆向关系。ManyToManyFeild字段提供了一个多对多管理器,允许我们检索关联的对象,比如:image.users_like.all(),或者从user对象检索:user.images_liked.all()

打开命令行,执行以下命令创建初始数据库迁移:

python manage.py makemigrations images

你会看到以下输出:

Migrations for 'images':
  images/migrations/0001_initial.py
    - Create model Image

现在运行这条命令,让迁移生效:

python manage.py migrate images

你会看到包括这一行的输出:

Applying images.0001_initial... OK

现在Image模型已经同步到数据库中。

5.1.3 在管理站点注册图片模型

编辑images应用的admin.py文件,在管理站点注册Image模型,如下所示:

from django.contrib import admin
from .models import Image

class ImageAdmin(admin.ModelAdmin):
    list_display = ('title', 'slug', 'image', 'created')
    list_filter = ['created']

admin.site.register(Image, ImageAdmin)

执行python manage.py runserver命令启动开发服务器。在浏览器中打开http://127.0.0.1:8000/amdin/,可以看到Image模型已经在管理站点注册,如下图所示:

5.2 从其它网站上传内容

我们将允许用户标记从其它网站找到的图片。用户将提供图片的 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 作为参数。我们用HiddenInput组件覆盖了url字段的默认组件。这个组件渲染为带有type="hidden"属性的 HTML 输入元素。使用这个组件是因为我们不想用户看见这个字段。

5.2.1 清理表单字段

为了确认提供的图片 URL 是有效的,我们会检查文件名是否以.jpg.jpeg扩展名结尾,只允许 JPG 文件。Django 允许你通过形如clean_<filedname>()的方法,定义表单方法来清理指定字段。如果存在这个方法,它会在调用表单实例的is_valid()方法时执行。在清理方法中,你可以修改字段的值,或者需要时,为这个字段抛出任何验证错误。在ImageCreateForm中添加以下方法:

def clean_url(self):
    url = self.cleaned_data['url']
    valid_extensions = ['jpg', 'jpeg']
    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 是否提供了有效的图片。

除了验证给定的 URL,我们还需要下载并保存图片。比如,我们可以用处理这个表单的视图来下载图片文件。不过我们会使用更通用的方式:覆写模型表单的save()方法,在每次保存表单时执行这个任务。

5.2.2 覆写 ModelForm 的 save()方法

你知道,ModelForm提供了save()方法,用于把当前模型的实例保存到数据库中,并返回该对象。这个方法接收一个commit布尔参数,允许你指定是否把该对象存储到数据库中。如果commitFalsesave()方法会返回模型的实例,但不会保存到数据库中。我们会覆写表单的save()方法下载指定的图片,然后保存。

forms.py文件顶部添加以下导入:

from urllib import request
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']
    image_name = '{}.{}'.format(slugify(image.title), image_url.rsplit('.', 1)[1].lower())

    #download image from the given URL
    response = request.urlopen(image_url)
    image.image.save(image_name, ContentFile(response.read()), save=False)

    if commit:
        image.save()
    return image

我们覆写了save()方法,保留了ModelForm必需的参数。这段代码完成以下操作:

  1. 我们用commit=False调用表单的save()方法,创建了一个新的image实例。
  2. 我们从表单的cleaned_data字典中获得 URL。
  3. 我们用image的标题别名和原始文件扩展名的组合生成图片名。
  4. 我们使用urllib模块下载图片,然后调用image字段的save()方法,并传递一个ContentFile对象,这个对象由下载的文件内容实例化。这样就把文件保存到项目的media目录中了。我们还传递了save=False参数,避免把对象保存到数据库中。
  5. 为了与被我们覆写的save()方法保持一致的行为,只有在commit参数为True时,才把表单保存到数据库中。

现在我们需要一个处理表单的视图。编辑images应用的views.py文件,添加以下代码:

from django.shortcuts import render, redirect
from django.contrib.auth.decorators import login_required
from django.contrib import message
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_item = form.save(commit=False)

            # assign current user to the item
            new_item.user = request.user
            new_item.save()
            message.success(request, 'Image added successfully')

            # redirect to new created item detail view
            return redirect(new_item.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请求获得创建表单实例的初始数据。数据由其它网站的图片urltitle属性组成,这个数据由我们之后会创建的 JavaScript 工具提供。现在我们假设初始的时候有数据。
  2. 如果提交了表单,我们检查表单是否有效。如果有效,我们创建一个新的Image实例,但我们通过传递commit=False来阻止对象保存到数据库中。
  3. 我们把当前对象赋值给新的image对象。这样就知道每张图片是谁上传的。
  4. 我们把图片对象保存到数据库中。
  5. 最后,我们用 Django 消息框架创建一条成功消息,并重定向到新图片的标准 URL。我们还没有实现Image模型的get_absolute_url()方法,我们会马上完成这个工作。

images应用中创建urls.py文件,添加以下代码:

from django.conf.urls import url
from . import views

urlpatterns = [
    url(r'^create/$', views.image_create, name='create'),
]

编辑项目的主urls.py文件,引入我们刚创建的images应用的模式:

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^account/', include('account.urls')),
    url(r'^images/', include('images.urls', namespace='images')),
]

最近,你需要创建模板来渲染表单。在images应用目录中创建以下目录结构:

templates/
	images/
		image/
			create.html

编辑create.hmtl文件,添加以下代码:

{% extends "base.html" %}

{% block title %}Bookmark an image{% endblock %}

{% block content %}
    <h1>Bookmark an image</h1>
    <img src="{{ request.GET.url }}" class="image-preview">
    <form action="." method=POST>
        {{ form.as_p }}
        {% csrf_token %}
        <input type="submit" value="Bookmark it!">
    </form>
{% endblock %}

现在在浏览器中打开http://127.0.0.1:8000/images/create/?title=...&url=...,其中包括titleurl参数,后者是现有的 JPG 图片的 URL。

例如,你可以使用以下 URL:

http://127.0.0.1:8000/images/create/?title=%20Django%20and%20Duke&url=http%3A%2F%2Fmvimg2.meitudata.com%2F56d7967dd02951453.jpg

你会看到带一张预览图片的表单,如下所示:

添加描述并点击Bookmark it!按钮。一个新的Image对象会保存到数据库中。你会得到一个错误,显示Image对象没有get_absolute_url()方法。现在不用担心,我们之后会添加这个方法。在浏览器打开http://127.0.0.1:8000/admin/images/image/,确认新的图片对象已经保存了。

5.2.3 用 jQuery 构建书签工具

书签工具是保存在 web 浏览器中的书签,其中包括 JavaScript 代码,可以扩展浏览器的功能。当你点击书签,JavaScript 代码会在浏览器正在显示的网页中执行。这对构建与其它网站交互的工具非常有用。

某些在线服务(比如 Pinterest)实现了自己的书签工具,让用户可以在自己的平台分享其它网站的内容。我们会创建一个书签工具,让用户以类似的方式在我们的网站中分享其它网站的图片。

我们将使用 jQuery 构建书签工具。jQuery 是一个非常流行的 JavaScript 框架,可以快速开发客户端的功能。你可以在官方网站进一步了解 jQuery。

以下是用户如何在浏览器中添加书签工具,并使用它:

  1. 用户从你的网站中拖拽一个链接到浏览器的书签中。该链接在href属性中包含 JavaScript 代码。这段代码会存储在书签中。
  2. 用户导航到任意网站,并点击该书签。该书签的 JavaScript 代码会执行。

因为 JavaScript 代码会以书签的形式存储,所以之后你不能更新它。这是一个显著的缺点,但你可以实现一个简单的启动脚本解决这个问题。该脚本从 URL 中加载实际的 JavaScript 书签工具。用户会以书签的形式保存启动脚本,这样你就可以在任何时候更新书签工具的代码了。我们将采用这种方式构建书签工具。让我们开始吧。

images/templates/中创建一个bookmarklet_launcher.js模板。这是启动脚本,并添加以下代码:

(function() {
    if (window.myBookmarklet !== underfined) {
        myBookmarklet();
    }
    else {
        document.body.appendChild(document.createElement('script'))
            .src='http://127.0.0.1:8000/static/js/bookmarklet.js?r='+
            Math.floor(Math.random()*99999999999999999999);
    }
})();

这个脚本检查是否定义myBookmarklet变量,来判断书签工具是否加载。这样我们就避免了用户重复点击书签时多次加载它。如果没有定义myBookmarklet,我们通过在文档中添加<script>元素,来加载另一个 JavaScript 文件。这个scrip标签加载bookmarklet.js脚本,并用一个随机参数作为变量,防止从浏览器缓存中加载文件。

真正的书签工具代码位于静态文件bookmarklet.js中。这样就能更新我们的书签工具代码,而不用要求用户更新之前在浏览器中添加的书签。让我们把书签启动器添加到仪表盘页面,这样用户就可以拷贝到他们的书签中。

编辑account应用中的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 %}

仪表盘显示用户现在添加书签的图片总数。我们使用{% with %}模板标签设置当前用户添加书签的图片总数为一个变量。我们还包括了一个带href属性的链接,该属性指向书签工具启动脚本。我们从bookmarklet_launcher.js模板中引入这段 JavaScript 代码。

在浏览器中打开http://127.0.0.1:8000/account/,你会看下如下所示的页面:

拖拽Bookmark it!链接到浏览器的书签工具栏中。

现在,在images应用目录中创建以下目录和文件:

static/
	js/
		bookmarklet.js

你在本章示例代码的images应用目录下可以找到static/css目录。拷贝css/目录到你代码的static/目录中。css/bookmarklet.css文件为我们的 JavaScript 书签工具提供了样式。

编辑bookmarklet.js静态文件,添加以下 JavaScript 代码:

(function() {
    var jquery_version = '2.1.4';
    var site_url = 'http://127.0.0.1:8000/';
    var static_url = site_url + 'static/';
    var min_width = 100;
    var min_height = 100;

    function bookmarklet(msg) {
        // Here goes our bookmarklet code
    };

    // Check if jQuery is loaded
    if(typeof window.jQuery != 'undefined') {
        bookmarklet();
    } else {
        // Check for conflicts
        var conflict = typeof window.$ != 'undefined';
        // Create the script and point to Google API
        var script = document.createElement('script');
        script.setAttribute('src', 
            'http://ajax.googleapis.com/ajax/libs/jquery/' + 
            jquery_version + '/jquery.min.js');
        // Add the script to the 'head' for processing
        document.getElementsByTagName('head')[0].appendChild(script);
        // Create a way to wait until script loading
        var attempts = 15;
        (function(){
            // Check again if jQuery is undefined
            if (typeof window.jQuery == 'undefined') {
                if(--attempts > 0) {
                    // Calls himself in a few milliseconds
                    window.setTimeout(arguments.callee, 250);
                } else {
                    // Too much attempts to load, send error
                    alert('An error ocurred while loading jQuery')
                }
            } else {
                bookmarklet();
            }
        })();
    }
})()

这是主要的 jQuery 加载脚本。如果当前网站已经加载了 jQuery,那么它会使用 jQuery;否则会从 Google CDN 中加载 jQuery。加载 jQuery 后,它会执行包含书签工具代码的bookmarklet()函数。我们还在文件顶部设置了几个变量:

  • jquery_version:要加载的 jQuery 版本
  • site_urlstatic_url:我们网站的主 URL 和各个静态文件的主 URL
  • min_widthmin_height:我们的书签工具在网站中查找的图片的最小宽度和高度(单位是像素)

现在让我们实现bookmarklet()函数,如下所示:

function bookmarklet(msg) {
    // load CSS
    var css = jQuery('<link>');
    css.attr({
        rel: 'stylesheet',
        type: 'text/css',
        href: static_url + 'css/bookmarklet.css?r=' + Math.floor(Math.random()*99999999999999999999)
    });
    jQuery('head').append(css);

    // load HTML
    box_html = '<div id="bookmarklet"><a href="#" id="close">&times;</a><h1>Select an image to bookmark:</h1><div class="images"></div></div>';
    jQuery('body').append(box_html);

    // close event
    jQuery('#bookmarklet #close').click(function() {
        jQuery('#bookmarklet').remove();
    });
};

这段代码是这样工作的:

  1. 为了避免浏览器缓存,我们使用一个随机数作为参数,来加载bookmarklet.css样式表。
  2. 我们添加了定制的 HTML 到当前网站的<body>元素中。它由一个<div>元素组成,里面会包括在当前网页中找到的图片。
  3. 我们添加了一个事件。当用户点击我们的 HTML 块中的关闭链接时,我们会从文档中移除我们的 HTML。我们使用#bookmarklet #close选择器查找 ID 为close,父元素 ID 为bookmarklet的 HTML 元素。一个 jQuery 选择器允许你查找多个 HTML 元素。一个 jQuery 选择器返回指定 CSS 选择器找到的所有元素。你可以在这里找到 jQuery 选择器列表。

加载 CSS 样式和书签工具需要的 HTML 代码后,我们需要找到网站中的图片。在bookmarklet()函数底部添加以下 JavaScript 代码:

// find images and display them
jQuery.each(jQuery('img[src$="jpg"]'), function(index, image) {
    if (jQuery(image).width() >= min_width && jQuery(image).height() >= min_height) {
        image_url = jQuery(image).attr('src');
        jQuery('#bookmarklet .images').append('<a href="#"><img src="' + image_url + '" /></a>');
    }
});

这段代码使用img[src$="jpg"]选择器查找所有src属性以jpg结尾的<img>元素。这意味着我们查找当前网页中显示的所有 JPG 图片。我们使用 jQuery 的each()方法迭代结果。我们把尺寸大于min_widthmin_height变量的图片添加到<div class="images">容器中。

现在,HTML 容器中包括可以添加标签图片。我们希望用户点击需要的图片,并为它添加标签。在bookmarklet()函数底部添加以下代码:

// when an image is selected open URL with it
jQuery('#bookmarklet .images a').click(function(e) {
    selected_image = jQuery(this).children('img').attr('src');
    // hide bookmarklet
    jQuery('#bookmarklet').hide();
    // open new window to submit the image
    window.open(site_url + 'images/create/?url=' 
        + encodeURIComponent(selected_image)
        + '&title='
        + encodeURIComponent(jQuery('title').text()),
        '_blank');
});

这段代码完成以下工作:

  1. 我们绑定一个click()事件到图片的链接元素。
  2. 当用户点击一张图片时,我们设置一个新变量——selected_image,其中包含了选中图片的 URL。
  3. 我们隐藏书签工具,并在我们网站中打开一个新的浏览器窗口为新图片添加标签。传递网站的<title>元素和选中的图片 URL 作为GET参数。

在浏览器中随便打开一个网址(比如http://z.cn),并点击你的书签工具。你会看到一个新的白色框出现在网页上,其中显示所有尺寸大于 100*100px 的 JPG 图片,如下图所示:

因为我们使用的是 Django 开发服务器,它通过 HTTP 提供页面,所以出于浏览器安全限制,书签工具不能在 HTTPS 网站上工作。

如果你点击一张图片,会重定向到图片创建页面,并传递网站标题和选中图片的 URL 作为GET参数:

恭喜你!这是你的第一个 JavaScript 书签工具,并且完全集成到你的 Django 项目中了。

5.3 为图片创建详情视图

我们将创建一个简单的详情视图,用于显示一张保存在我们网站的图片。打开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 模式:

url(r'^detail/(?P<id>\d+)/(?P<slug>[-\w]+)/$', views.image_detail, name='detail'),

编辑images应用的models.py文件,在Image模型中添加get_absolute_url()方法,如下所示:

from django.core.urlresolvers import reverse

class Image(models.Model):
	# ...
	def get_absolute_url(self):
		return reverse('image:detail', args=[self.id, self.slug])

记住,为对象提供标准 URL 的通用方式是在模型中添加get_absolute_url()方法。

最后,在images应用的/images/image/模板目录中创建detail.html模板,添加以下代码:

{% extends "base.html" %}

{% block title %}{{ image.title }}{% endblock %}

{% block content %}
    <h1>{{ image.title }}</h1>
    <img src="{{ 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>
                    <img src="{{ user.profile.photo.url }}">
                    <p>{{ user.first.name }}</p>
                </div>
            {% empty %}
                Nobody likes this image yet.
            {% endfor %}
        </div>
    {% endwith %}
{% endblock  %}

这是显示一张添加了标签的图片的详情模板。我们使用{% with %}标签存储QuerySet的结果,这个QuerySettotal_likes变量中统计所有喜欢这张图片的用户。这样就能避免计算同一个QuerySet两次。我们还包括了图片的描述,并迭代image.users_like.all()来显示所有喜欢这张图片的用户。

使用{% width %}模板标签可以有效地阻止 Django 多次计算QuerySet

现在用书签工具标记一张新图片。当你上传图片后,会重定向到图片详情页面。该页面会包括一条成功消息,如下图所示:

5.4 使用 sorl-thumbnail 创建缩略图

现在,我们在详情页面显示原图,但是不同图片的尺寸各不相同。同时,有些图片的源文件可能很大,需要很长时间才能加载。用统一的方式显示优化图像的最好方法是生成缩略图。因此我们将使用一个名为sorl-thumbnail的 Django 应用。

打开终端,执行以下命令安装sorl-thumbnail

pip install sorl-thumbnail

编辑bookmarks项目的settings.py文件,把sorl.thumbnail添加到INSTALLED_APPS设置中:

接着执行以下命令同步应用和数据库:

python manage.py makemigrations thumbnail
python manage.py migrate

sorl-thumbnail应用提供了多种定义图片缩略图的方式。它提供了{% thumbnail %}模板标签,可以在模板中生成缩略图;如果你想在模型中定义缩略图,还提供自定义的ImageField。我们将使用模板标签的方式。编辑images/image/detail.html模板,把这一行代码:

<img src="{{ image.image.url }}" class="image-detail">

替换为:

{% load thumbnail %}
{% thumbnail image.image "300" as im %}
    <a href="{{ image.image.url }}">
        <img src="{{ im.url }}" class="image-detail">
    </a>
{% endthumbnail %}

我们在这里定义了一张固定宽度为 300 像素的缩略图。用户第一次加载这个页面时,会创建一张缩略图。之后的请求会使用生成的缩略图。用python manage.py runserver启动开发服务器后,访问一张已存在的图片详情页。此时会生成一张缩略图并显示。

sorl-thumbmail应用提供了一些选项来定制缩略图,包括裁剪算法和不同的效果。如果你在生成缩略图时遇到问题,可以在设置中添加THUMBNAIL_DEBUG=True,就能查看调试信息。你可以在这里阅读sorl-thumbnail应用的完整文档。

5.5 使用 JQuery 添加 AJAX 操作

现在我们将向应用中添加 AJAX 操作。AJAX 是Asynchronous JavaScript and XML的缩写。这个术语包括一组异步 HTTP 请求技术。它包括从服务器异步发送和接收数据,而不用加载整个页面。尽管名字中有XML,但它不是必需的。你可以使用其它格式发送或接收数据,比如 JSON,HTML 或者普通文本。

我们将会在图片详情页面添加一个链接,用户点击链接表示喜欢这张图片。我们会用 AJAX 执行这个操作,避免加载整个页面。首先,我们需要创建一个视图,让用户喜欢或不喜欢图片。编辑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:
            pass
    return JsonResponse({'status': 'ko'})

我们在这个视图上使用了两个装饰器。login_required装饰阻止没有登录的用户访问这个视图;如果 HTTP 请求不是通过POST完成,required_ POST装饰器返回一个HttpResponseNotAllowed对象(状态码为 405)。这样就只允许POST请求访问这个视图。Django 还提供了required_GET装饰器,只允许GET请求,以及required_http_methods装饰器,你可以把允许的方法列表作为参数传递。

我们在这个视图中使用了两个POST参数:

  • image_id:用户执行操作的图片对象的 ID。
  • action:用户希望执行的操作,我们假设为likeunlike字符串。

我们使用 Django 为Image模型的users_like多对多字段提供的管理器的add()remove()方法从关系中添加或移除对象。调用add()方法时,如果传递一个已经存在关联对象集中的对象,不会重复添加这个对象;同样,调用remove()方法时,如果传递一个不存在关联对象集中的对象,不会执行任何操作。另一个多对多管理器方法是clear(),会从关联对象集中移除所有对象。

最后,我们使用 Django 提供的JsonResponse类返回一个 HTTP 响应,其中内容类型为application/json,它会把给定对象转换为 JSON 输出。

编辑images应用的urls.py文件,添加以下 URL 模式:

url(r'^like/$', views.image_like, name='like'),

5.5.1 加载 jQuery

我们需要添加 AJAX 功能到图片详情模板中。为了在模板中使用 jQuery,首先在项目的base.html模板中引入它。编辑account应用的base.html模板,在</body>标签之前添加以下代码:

<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
<script>
    $(document).ready(function() {
        {% block domready %}
        {% endblock %}
    });
</script>

我们从 Google 加载 jQuery 框架,Google 在高速内容分发网络中托管了流行的 JavaScript 框架。你也可以从http://jquery.com/下载 jQuery,然后把它添加到应用的static目录中。

我们添加一个<script>标签来包括 JavaScript 代码。$(document).ready()是一个 jQuery 函数,参数是一个处理函数,当 DOM 层次构造完成后,会执行这个处理函数。DOM 是Document Object Model的缩写。DOM 是网页加载时浏览器创建的一个树对象。在这个函数中包括我们的代码,可以确保我们要交互的 HTML 元素都已经在 DOM 中加载完成。我们的代码只有在 DOM 准备就绪后才执行。

在文档准备就绪后的处理函数中,我们包括了一个名为domready的 Django 模板块,在扩展了基础模板的模板中可以包括特定的 JavaScript。

不要将 JavaScript 代码和 Django 模板标签搞混了。Django 模板语言在服务端渲染,输出为最终的 HTML 文档;JavaScript 在客户端执行。某些情况下,使用 Django 动态生成 JavaScript 非常有用。

本章的示例中,我们在 Django 模板中引入了 JavaScript 代码。引入 JavaScript 代码更好的方式是加载作为静态文件的.js文件,尤其当它们是代码量很大的脚本时。

5.5.2 AJAX 请求的跨站请求伪造

你已经在第二章中学习了跨站点请求伪造。在激活了 CSRF 保护的情况下,Django 会检查所有 POST 请求的 CSRF 令牌。当你提交表单时,可以使用{% csrf_token %}模板标签发送带令牌的表单。但是,对于每个 POST 请求,AJAX 请求都将 CSRF 令牌作为 POST 数据传递是不方便的。因此,Django 允许你在 AJAX 请求中,用 CSRF 令牌的值设置一个自定义的X-CSRFToken头。这允许你用 jQuery 或其它任何 JavaScript 库,在每次请求中自动设置X-CSRFToken头。

要在所有请求中包括令牌,你需要:

  1. csrftoken cookie 中检索 CSRF 令牌,如果激活了 CSRF,它就会被设置。
  2. 在 AJAX 请求中,使用X-CSRFToken头发送令牌。

你可以在这里找到更多关于 CSRF 保护和 AJAX 的信息。

编辑你在base.html中最后引入的代码,修改为以下代码:

<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
<script src="http://cdn.jsdelivr.net/jquery.cookie/1.4.1/jquery.cookie.min.js"></script>
<script>
    var csrftoken = $.cookie('csrftoken');
    function csrfSafeMethod(method) {
        // these HTTP methods do not required CSRF protection
        return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
    }
    $.ajaxSetup({
        beforeSend: function(xhr, settings) {
            if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
                xhr.setRequestHeader("X-CSRFToken", csrftoken);
            }
        }
    });
    $(document).ready(function() {
        {% block domready %}
        {% endblock %}
    });
</script>

这段代码完成以下工作:

  1. 我们从一个公有 CDN 加载 jQuery Cookie 插件,因此我们可以与 cookies 交互。
  2. 我们读取csrftoken cookie 中的值。
  3. 我们定义csrfSafeMethod()函数,检查 HTTP 方法是否安全。安全的方法不需要 CSRF 保护,包括GETHEADOPTIONSTRACE
  4. 我们使用$.ajaxSetup()设置 jQuery AJAX 请求。每个 AJAX 请求执行之前,我们检查请求方法是否安全,以及当前请求是否跨域。如果请求不安全,我们用从 cookie 中获取的值设置X-CSRFToken头。这个设置会应用到 jQuery 执行的所有 AJAX 请求。

CSRF 令牌会在所有使用不安全的 HTTP 方法的 AJAX 请求中引入,比如POSTPUT

5.5.3 使用 jQuery 执行 AJAX 请求

编辑images应用的images/image/detail.htmlt模板,把这一行代码:

{% with total_likes=image.users_like.count %}

替换为下面这行:

{% with total_likes=image.users_like.count users_like=image.users_like.all %}

然后修改classimage-info<div>元素,如下所示:

<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>

首先,我们添加了另一个变量到{% with %}模板标签中,用于存储image.users_like.all的查询结果,避免执行两次查询。我们显示喜欢这张图片的用户总数,以及一个like/unlike链接:我们检查用户是否在users_like关联对象集中,根据当前用户跟这样图片的关系显示likeunlike。我们在<a>元素中添加了以下属性:

  • data-id:显示的图片的 ID。
  • data-action:用户点击链接时执行的操作。可能是likeunlike

我们将会在 AJAX 请求发送这两个属性的值给image_like视图。当用户点击like/unlike链接时,我们需要在客户端执行以下操作:

  1. 调用 AJAX 视图,并传递图片 ID 和 action 参数。
  2. 如果 AJAX 请求成功,用相反操作(like/unlike)更新<a>元素的data-action属性,并相应的修改显示文本。
  3. 更新显示的喜欢总数。

images/image/detail.html模板底部添加包括以下代码的domready块:

{% block domready %}
    $('a.like').click(function(e) {
        e.preventDefault();
        $.post('{% url "images:like" %}', 
            {
                id: $(this).data('id'),
                action: $(this).data('action')
            },
            function(data) {
                if (data['status'] == 'ok') {
                    var previous_action = $('a.like').data('action');

                    // toggle data-action
                    $('a.like').data('action', previous_action == 'like' ? 'unlike' : 'like');
                    // toggle link text
                    $('a.like').text(previous_action == 'like' ? 'Unlike' : 'Like');
                    // update total likes
                    var previous_likes = parseInt($('span.count .total').text());
                    $('span.count .total').text(previous_action == 'like' ? previous_likes+1 : previous_likes-1);
                }
            }
        );
    });
{% endblock %}

这段代码是这样工作的:

  1. 我们使用$('a.like') jQuery 选择器查找 HTML 文档中classlike<a>元素。
  2. 我们为click事件定义了一个处理函数。用户每次点击like/unlike链接时,会执行这个函数。
  3. 在处理函数内部,我们使用e.preventDefault()阻止<a>元素的默认行为。这会阻止链接调转到其它地方。
  4. 我们使用$.post()向服务器执行一个异步请求。jQuery 还提供了执行GET请求的$.get()方法,以及一个底层的$.ajax()方法。
  5. 我们使用 Django 的{% url %}模板标签为 AJAX 请求构建 URL。
  6. 我们构建在请求中发送的POST参数字典。我们的 Django 视图需要idaction参数。我们从<a>元素的<data-id><data-action>属性中获得这两个值。
  7. 我们定义了回调函数,当收到 HTTP 响应时,会执行这个函数。它的data属性包括响应的内容。
  8. 我们访问收到的datastatus属性,检查它是否等于ok。如果返回的data是期望的那样,我们切换链接的data-action属性和文本。这样就允许用户取消这个操作。
  9. 根据执行的操作,我们在喜欢的总数上加 1 或减 1。

在浏览器中打开之前上传的图片详情页面。你会看到以下初始的喜欢总数和LIKE按钮:

点击LIKE按钮。你会看到喜欢总数加 1,并且按钮的文本变为UNLIKE

当你点击UNLIKE按钮时,会执行这个操作,按钮的文本变回LIKE,总数也会相应的改变。

编写 JavaScript 代码时,尤其是执行 AJAX 请求时,推荐使用 Firebug 等调试工具。Firebug 是一个 Firefox 插件,允许你调试 JavaScript 代码,并监控 CSS 和 HTML 的变化。你可以从这里下载 Firebug。其它浏览器,比如 Chrome 或 Safari,也有调试 JavaScript 的开发者工具。在这些浏览器中,右键网页中的任何一个地方,然后点击Inspect element访问开发者工具。

5.6 为视图创建自定义装饰器

我们将限制 AJAX 视图只允许由 AJAX 发起的请求。Django 的Request对象提供一个is_ajax()方法,用于检查请求是否带有XMLHttpRequest,也就是说是否是一个 AJAX 请求。这个值在 HTTP 头的HTTP_X_REQUESTED_WITH中设置,绝大部分 JavaScript 库都会在 AJAX 请求中包括它。

我们将创建一个装饰器,用于在视图中检查HTTP_X_REQUESTED_WITH头。装饰器是一个接收另一个函数为参数的函数,并且不需要显式修改作为参数的函数,就能扩展它的行为。如果你忘了装饰器的概念,你可能需要先阅读这里

因为这是一个通用的装饰器,可以作用于任何视图,所以我们会在项目中创建一个common包。在bookmarks项目目录中创建以下目录和文件:

common/
	__init__.py
	decrorators.py

编辑decrorators.py文件,添加以下代码:

from django.http import HttpResponseBadRequest

def ajax_required(f):
    def wrap(request, *args, **kwargs):
        if not request.is_ajax():
            return HttpResponseBadRequest()
        return f(request, *args, **kwargs)
    wrap.__doc__ = f.__doc__
    wrap.__name__ = f.__name__
    return wrap

这是我们自定义的ajax_required装饰器。它定义了一个wrap函数,如果不是 AJAX 请求,则返回HttpResponseBadRequest对象(HTTP 400)。否则返回被装饰的函数。

现在编辑images应用的views.py文件,添加这个装饰器到image_like视图中:

from common.decrorators import ajax_required

@ajax_required
@login_required
@require_POST
def image_like(request):
	# ...

如果你直接在浏览器中访问http://127.0.0.1:8000/images/like/,你会得到一个 HTTP 400 的响应。

如果你在多个视图中重复同样的验证,则可以为视图构建自定义装饰器。

5.7 为列表视图创建 AJAX 分页

我们需要在网站中列出所有标记过的图片。我们将使用 AJAX 分页构建一个无限滚动功能。当用户滚动到页面底部时,通过自动加载下一页的结果实现无限滚动。

我们将实现一个图片列表视图,同时处理标准浏览器请求和包括分页的 AJAX 请求。当用户首次加载图片列表页面,我们显示第一页的图片。当用户滚动到页面底部,我们通过 AJAX 加载下一页的项,并添加到主页面的底部。

我们用同一个视图处理标准和 AJAX 分页。编辑images应用的views.py文件,添加以下代码:

from django.http import HttpResponse
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger

@login_required
def image_list(request):
    images = Image.objects.all()
    paginator = Paginator(images, 8)
    page = request.GET.get('page')
    try:
        images = paginator.page(page)
    except PageNotAnInteger:
        # If page is not an integer deliver first page
        images = paginator.page(1)
    except EmptyPage:
        if request.is_ajax():
            # If the request is AJAX an the page is out of range
            # return an empty page
            return HttpResponse('')
        # If page is out of range deliver last page of results
        images = paginator.page(paginator.num_pages)

    if request.is_ajax():
        return render(request, 'images/image/list_ajax.html', {'section': 'images', 'images': images})
    
    return render(request, 'images/image/list.html', {'section': 'images', 'images': images})

我们在这个视图中创建了一个返回数据库中所有图片的QuerySet。然后我们构造了一个Paginator对象来分页查询结果,每页显示八张图片。如果请求的页数超出范围,则抛出EmptyPage异常。这种情况下,如果是通过 AJAX 发送请求,则返回一个空的HttpResponse对象,帮助我们在客户端停止 AJAX 分页。我们把结果渲染到两个不同的模板中:

  • 对于 AJAX 请求,我们渲染list_ajax.html模板。该模板只包括请求页的图片。
  • 对于标准请求,我们渲染list.html模板。该模板继承自base.html模板,并显示整个页面,同时还包括list_ajax.html模板,用来引入图片列表。

编辑images应用的urls.py文件,添加以下 URL 模式:

url(r'^$', views.image_list, name='list'),

最后,我们需要创建上面提到的模板。在images/image/模板目录中创建list_ajax.html模板,添加以下代码:

{% load thumbnail %}

{% for image in images %}
    <div class="image">
        <a href="{{ image.get_absolute_url }}">
            {% thumbnail image.image "300*300" crop="100%" as im %}
                <a href="{{ image.get_absolute url }}">
                    <img src="{{ im.url }}">
                </a>
            {% endthumbnail %}
        </a>
        <div class="info">
            <a href="{{ image.get_absolute_url }}" class="title">
                {{ image.title }}
            </a>
        </div>
    </div>s
{% endfor %}

这个模板显示图片的列表。我们将用它返回 AJAX 请求的结果。在同一个目录下创建list.html模板,添加以下代码:

{% extends "base.html" %}

{% block title %}Images bookmarked{% endblock %}

{% block content %}
    <h1>Images bookmarked</h1>
    <div id="image-list">
        {% include "images/image/list_ajax.html" %}
    </div>
{% endblock %}

列表模板继承自base.html模板。为了避免重复代码,我们引入了list_ajax.html模板来显示图片。list.html模板会包括 JavaScript 代码,当用户滚动到页面底部时,负责加载额外的页面。

list.html模板中添加以下代码:

{% block domready %}
    var page = 1;
    var empty_page = false;
    var block_request = false;

    $(window).scroll(function() {
        var margin = $(document).height() - $(window).height() - 200;
        if ($(window).scrollTop() > margin && empty_page == false && block_request == false) {
            block_request = true;
            page += 1;
            $.get('?page=' + page, function(data) {
                if (data == '') {
                    empty_page = true;
                } else {
                    block_request = false;
                    $('#image-list').append(data);
                }
            });
        }
    });
{% endblock %}

这段代码提供了无限滚动功能。我们在base.html模板中定义的domready块中引入了 JavaScript 代码。这段代码是这样工作的:

  1. 我们定义了以下变量:
  • page:存储当前页码。
  • empty_page:让我们知道是否到了最后一页,如果是则接收一个空的页面。只要我们得到一个空的页面,就会停止发送额外的 AJAX 请求,因为我们假设此时没有结果了。
  • block_request:正在处理 AJAX 请求时,阻止发送另一个请求。
  1. 我们使用$(window).scroll()捕获滚动事件,并为它定义一个处理函数。
  2. 我们计算边距变量来获得文档总高度和窗口高度之间的差值,这是用户滚动的剩余内容的高度。我们从结果中减去 200,因此,当用户距离页面底部小于 200 像素时,我们会加载下一页。
  3. 如果没有执行其它 AJAX 请求(block_request必须为false),并且用户没有获得最后一页的结果时(empty_page也为false),我们才发送 AJAX 请求。
  4. 我们设置block_requesttrue,避免滚动事件触发额外的 AJAX 请求,同时给page计算器加 1 来获取下一页。
  5. 我们使用$.get()执行 AJAX GET 请求,然后在名为data的变量中接收 HTML 响应。这里有两种情况:
  • 响应不包括内容:我们已经到了结果的末尾,没有更多页面需要加载。我们设置empty_pagetrue阻止额外的 AJAX 请求。
  • 响应包括内容:我们把数据添加到 id 为image-list的 HTML 元素中。当用户接近页面底部时,页面内容会垂直扩展附加的结果。

在浏览器中打开http://127.0.0.1:8000/images/,你会看到目前已经标记过的图片列表,如下图所示:

滚动到页面底部来加载下一页。确保你用书签工具标记了八张以上图片,因为我们每页显示八张图片。记住,你可以使用 Firebug 或类似工具追踪 AJAX 请求和调试 JavaScript 代码。

最后,编辑account应用的base.html模板,为主菜单的Images项添加 URL:

<li {% if section == "images" %}class="selected"{% endif %}>
	<a href="{% url "images:list" %}">Images</a>
</li>

现在你可以从主菜单中访问图片列表。

5.8 总结

在本章中,我们构建了一个 JavaScript 书签工具,可以分享其它网站的图片到我们的网站中。你用 jQuery 实现了 AJAX 视图,并添加了 AJAX 分页。

下一章会教你如何构建关注系统和活动流。你会使用通用关系(generic relations),信号(signals)和反规范化(denormalization)。你还会学习如何在 Django 中使用 Redis。

第六章:跟踪用户动作

在上一章中,你用 jQuery 实现了 AJAX 视图,并构建了一个分享其它网站内容的 JavaScript 书签工具。

本章中,你将学习如何构建关注系统和用户活动流。你会了解 Django 的信号(signals)如何工作,并在项目中集成 Redis 快速 I/O 存储,用于存储项视图。

本章将会覆盖以下知识点:

  • 用中介模型创建多对多关系
  • 构建 AJAX 视图
  • 创建活动流应用
  • 为模型添加通用关系
  • 优化关联对象的QuerySet
  • 使用信号进行反规范化计数
  • 在 Redis 中存储项的浏览次数

6.1 构建关注系统

我们将在项目中构建关注系统。用户可以相互关注,并跟踪其他用户在平台分享的内容。用户之间是多对多的关系,一个用户可以关注多个用户,也可以被多个用户关注。

6.1.1 用中介模型创建多对多关系

在上一章中,通过在一个关联模型中添加ManyToManyField,我们创建了多对多的关系,并让 Django 为这种关系创建了一张数据库表。这种方式适用于大部分情况,但有时候你需要为这种关系创建一个中介模型。当你希望存储这种关系的额外信息(比如关系创建的时间,或者描述关系类型的字段)时,你需要创建中介模型。

我们将创建一个中介模型用于构建用户之间的关系。我们使用中介模型有两个原因:

  • 我们使用的是 Django 提供的User模型,不想修改它。
  • 我们想要存储关系创建的时间。

编辑account应用的models.py文件,添加以下代码:

from django.contrib.auth.models import User

class Contact(models.Model):
    user_from = models.ForeignKey(User, related_name='rel_from_set')
    user_to = models.ForeignKey(User, related_name='rel_to_set')
    created = models.DateTimeField(auto_now_add=True, db_index=True)

    class Meta:
        ordering = ('-created', )

    def __str__(self):
        return '{} follows {}'.format(self.user_from, self.user_to)

我们将把Contact模型用于用户关系。它包括以下字段:

  • user_from:指向创建关系的用户的ForeignKey
  • user_to:指向被关注用户的ForeignKey
  • created:带auto_new_add=TrueDateTimeField字段,存储创建关系的时间

数据库会自动在ForeignKey字段上创建索引。我们在created字段上用db_index=True创建数据库索引。当用这个字段排序QuerySet时,可以提高查询效率。

通过 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的自定义模型。在其中一个关联模型中添加ForeignKey,并通过through参数指向中介模型,让 Django 使用该中介模型。

如果User模型属于我们的应用,我们就可以把上面这个字段添加到模型中。但是我们不能直接修改它,因为它属于django.contrib.auth应用。我们将采用略微不同的方法:动态的添加该字段到User模型中。编辑account应用的models.py文件,添加以下代码:

User.add_to_class('following', 
    models.ManyToManyField('self', 
    	through=Contact, 
    	related_name='followers', 
    	symmetrical=False))

在这段代码中,我们使用 Django 模型的add_to_class()方法添加monkey-patchUser模型中。不推荐使用add_to_class()为模型添加字段。但是,我们在这里使用这种方法有以下几个原因:

  • 通过 Django ORM 的user.followers.all()user.following.all(),可以简化检索关联对象。我们使用Contact中介模型,避免涉及数据库连接(join)的复杂查询。如果我们在Profile模型中定义关系,则需要使用复杂查询。
  • 这个多对多关系的数据库表会使用Contact模型创建。因此,动态添加的ManyToManyField不会对 Django 的User模型数据库做任何修改。
  • 我们避免创建自定义的用户模型,充分利用 Django 内置的User模型。

记住,在大部分情况下都推荐使用添加字段到我们之前创建的Profile模型,而不是添加monkey-patchUser模型。Django 也允许你使用自定义的用户模型。如果你想使用自定义的用户模型,请参考文档

你可以看到,关系中包括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模型创建列表和详情视图。

6.1.2 为用户资料创建列表和详情视图

打开account应用的views.py文件,添加以下代码:

from django.shortcuts import get_object_or_404
from django.contrib.auth.models import User

@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对象简单的列表和详情视图。user_list视图获得所有激活的用户。Django 的User模型包括一个is_active标记,表示用户账户是否激活。我们通过is_active=True过滤查询,只返回激活的用户。这个视图返回了所有结果,你可以跟image_list视图那样,为它添加分页。

user_detail视图使用get_object_or_404()快捷方法,检索指定用户名的激活用户。如果没有找到指定用户名的激活用户,该视图返回 HTTP 404 响应。

编辑account应用的urls.py文件,为每个视图添加 URL 模式,如下所示:

urlpatterns= [
	# ...
    url(r'^users/$', views.user_list, name='user_list'),
    url(r'^users/(?P<username>[-\w]+)/$', views.user_detail, name='user_detail'),
]

我们将使用user_detail URL 模式为用户生成标准 URL。你已经在模型中定义过get_absolute_url()方法,为每个对象返回标准 URL。另一种方式是在项目中添加ABSOLUTE_URL_OVERRIDES设置。

编辑项目的settings.py文件,添加以下代码:

ABSOLUTE_URL_OVERRIDES = {
    'auth.user': lambda u: reverse_lazy('user_detail', args=[u.username])
}

Django 为ABSOLUTE_URL_OVERRIDES设置中的所有模型动态添加get_absolute_url()方法。这个方法返回给定模型的对应 URL。我们为给定用户返回user_detail URL。现在你可以在User实例上调用get_absolute_url()方法获得相应的 URL。用python manage.py shell打开 Python 终端,执行以下命令测试:

>>> from django.contrib.auth.models import User
>>> user = User.objects.latest('id')
>>> str(user.get_absolute_url())
'/account/users/Antonio/'

返回的结果是期望的 URL。我们需要为刚创建的视图创建模板。在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 }}">
                    {% thumbnail user.profile.photo "180x180" crop="100%" as im %}
                        <img src="{{ im.url }}">
                    {% endthumbnail %}
                </a>
                <div class="info">
                    <a href="{{ user.get_absolute_url }}" class="title">
                        {{ user.get_full_name }}
                    </a>
                </div>
            </div>
        {% endfor %}
    </div>
{% endblock %}

该模板列出网站中所有激活的用户。我们迭代给定的用户,使用sorl-thumbnail{% thumbnail %}模板标签生成个人资料的图片缩略图。

打开项目的base.html文件,在以下菜单项的href属性中包括user_list URL:

<li {% if section == "people" %}class="selected"{% endif %}>
	<a href="{% url "user_list" %}">People</a>
</li>

执行python manage.py runserver命令启动开发服务器,然后在浏览器中打开http://127.0.0.1/8000/account/users/。你会看到用户列表,如下图所示:

编辑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">
        {% thumbnail user.profile.photo "180x180" crop="100%" as im %}
            <img src="{{ im.url }}" class="user-detail">
        {% endthumbnail %}
    </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_ajax.html" with images=user.images_created.all %}
        </div>
    {% endwith %}
{% endblock %}

我们在详情模板中显示用户个人资料,并使用{% thumbnail %}模板标签显示个人资料图片。我们显示关注者总数和一个用于follow/unfollow的链接。如果用户正在查看自己的个人资料,我们会隐藏该链接,防止用户关注自己。我们将执行 AJAX 请求来follow/unfollow指定用户。我们在<a>元素中添加data-iddata-action属性,其中分别包括用户 ID 和点击链接时执行的操作(关注或取消关注),这取决于请求该页面的用户是否已经关注了这个用户。我们用list_ajax.html模板显示这个用户标记过的图片。

再次打开浏览器,点击标记过一些图片的用户。你会看到个人资料详情,如下图所示:

6.1.3 构建关注用户的 AJAX 视图

我们将使用 AJAX 创建一个简单视图,用于关注或取消关注用户。编辑account用于的views.py文件,添加以下代码:

from django.http import JsonResponse
from django.views.decorators.http import require_POST
from common.decrorators import ajax_required
from .models import Contact

@ajax_required
@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': 'ko'})
    
    return JsonResponse({'status': 'ko'})

user_follow视图跟我们之前创建的image_like视图很像。因为我们为用户的多对多关系使用了自定义的中介模型,所以ManyToManyField自动生成的管理器的默认add()remove()方法不可用了。我们使用Contact中介模型创建或删除用户关系。

account应用的urls.py文件中导入你刚创建的视图,然后添加以下 URL 模式:

url(r'^users/follow/$', views.user_follow, name='user_follow'),

确保你把这个模式放在user_detail模式之前。否则任何到/users/follow/的请求都会匹配user_detail模式的正则表达式,然后执行user_detail视图。记住,每一个 HTTP 请求时,Django 会按每个模式出现的先后顺序匹配请求的 URL,并在第一次匹配成功后停止。

编辑account应用的user/detail.html模板,添加以下代码:

{% block domready %}
    $('a.follow').click(function(e){
        e.preventDefault();
        $.post('{% url "user_follow" %}', {
            id: $(this).data('id'),
            action: $(this).data('action')
        },
        function(data){
            if (data['status'] == 'ok') {
                var previous_action = $('a.follow').data('action');

                // toggle data-action
                $('a.follow').data('action', previous_action == 'follow' ? 'unfollow' : 'follow');
                // toggle link text
                $('a.follow').text(previous_action == 'follow' ? 'Unfollow' : 'Follow');

                // update total followers
                var previous_followers = parseInt($('span.count .total').text())
                $('span.count .total').text(previous_action == 'follow' ? previous_followers+1 : previous_followers - 1);
            }
        });
    });
{% endblock %}

这段 JavaScript 代码执行关注或取消关注指定用户的 AJAX 请求,同时切换follow/unfollow链接。我们用 jQuery 执行 AJAX 请求,并根据之前的值设置data-action属性和<a>元素的文本。AJAX 操作执行完成后,我们更新页面显示的关注总数。打开一个已存在用户的详情页面,点击FOLLOW链接,测试我们刚添加的功能。

6.2 构建通用的活动流应用

很多社交网站都会给用户显示活动流,让用户可以跟踪其他用户在平台上做了什么。活动流是一个或一组用户最近执行的活动列表。比如,Facebook 的 News Feed 就是一个活动流。又或者用户 X 标记了图片 Y,或者用户 X 不再关注用户 Y。我们将构造一个活动流应用,让每个用户都可以看到他关注的用户最近的操作。要实现这个功能,我们需要一个模型,存储用户在网站中执行的操作,并提供简单的添加操作的方式。

用以下命令在项目中创建actions应用:

django-admin startapp actions

在项目的settings.py文件的INSTALLED_APPS中添加actions,让 Django 知道新应用已经激活:

INSTALLED_APPS = (
	# ...
	'actions',
)

编辑actions应用的models.py文件,添加以下代码:

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

class Action(models.Model):
    user = models.ForeignKey(User, related_name='actions', db_index=True)
    verb = models.CharField(max_length=255)
    created = models.DateTimeField(auto_now_add=True, db_index=True)

    class Meta:
        ordering = ('-created', )

这是Actioin模型,用于存储用户活动。该模型的字段有:

  • user:执行这个操作的用户。这是一个指向 Django 的User模型的ForeignKey
  • verb:描述用户执行的操作。
  • created:该操作创建的日期和时间。我们使用auto_now_add=True自动设置为对象第一次在数据库中保存的时间。

通过这个基础模型,我们只能存储类似用户 X 做了某些事情的操作。我们需要一个额外的ForeignKey字段,存储涉及目标对象的操作,比如用户 X 标记了图片 Y,或者用户 X 关注了用户 Y。你已经知道,一个普通的ForeignKey字段只能指向另一个模型。但是我们需要一种方式,让操作的目标对象可以是任何一个已经存在的模型的实例。这就是 Django 的contenttypes框架的作用。

6.2.1 使用 contenttypes 框架

Django 的contenttypes框架位于django.contrib.contenttypes中。这个应用可以跟踪项目中安装的所有模型,并提供一个通用的接口与模型交互。

当你使用startproject命令创建新项目时,django.contrib.contenttypes已经包括在INSTALLED_APPS设置中。它被其它contrib包(比如authentication框架和admin应用)使用。

contenttypes应用包括一个ContentType模型。这个模型的实例代表你的应用中的真实模型,当你的项目中安装了一个新模型时,会自动创建一个新的ContentType实例。ContentType模型包括以下字段:

  • app_label:模型所属应用的名字。它会自动从模型Meta选项的app_label属性中获得。例如,我们的Image模型属于images应用。
  • model:模型的类名。
  • name:模型的人性化名字。它自动从模型Meta选项的verbose_name属性中获得。

让我们看下如何与ContentType对象交互。使用python manage.py shell命令打开 Python 终端。通过执行带label_namemodel属性的查询,你可以获得指定模型对应的ContentType对象,比如:

>>> from django.contrib.contenttypes.models import ContentType
>>> image_type = ContentType.objects.get(app_label='images',model='image')
>>> image_type
<ContentType: image>

你也可以通过调用ContentType对象的model_class()方法,反向查询模型类:

>>> image_type.model_class()
<class 'images.models.Image'>

从指定的模型类获得ContentType对象操作也很常见:

>>> from images.models import Image
>>> ContentType.objects.get_for_model(Image)
<ContentType: image>

这些只是使用contenttypes的几个示例。Django 提供了更多使用它们的方式。你可以在官方文档学习contenttypes框架。

6.2.2 在模型中添加通用关系

在通用关系中,ContentType对象指向关系中使用的模型。在模型中设置通用关系,你需要三个字段:

  • 一个ForeignKey字段指向ContentType。这会告诉我们关系中的模型。
  • 一个存储关联对象主键的字段。通常这是一个PositiveIntegerField,来匹配 Django 自动生成的主键字段。
  • 一个使用上面两个字段定义和管理通用关系的字段。contenttypes框架为此定义了GenericForeignKey字段。

编辑actions应用的models.py文件,如下所示:

from django.db import models
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey

class Action(models.Model):
    user = models.ForeignKey(User, related_name='actions', db_index=True)
    verb = models.CharField(max_length=255)

    target_ct = models.ForeignKey(ContentType, blank=True, null=True, related_name='target_obj')
    target_id = models.PositiveIntegerField(null=True, blank=True, db_index=True)
    target = GenericForeignKey('target_ct', 'target_id')
    created = models.DateTimeField(auto_now_add=True, db_index=True)

    class Meta:
        ordering = ('-created', )

我们在Action模型中添加了以下字段:

  • target_ct:一个指向ContentType模型的ForeignKey字段。
  • target_id:一个用于存储关联对象主键的PositiveIntegerField
  • target:一个指向由前两个字段组合的关联对象的GenericForeignKey字段。

Django 不会在数据库中为GenericForeignKey字段创建任何字段。只有target_cttarget_id字段会映射到数据库的字段。因为这两个字段都有blank=Truenull=True属性,所以保存Action对象时target对象不是必需的。

使用通用关系有意义的时候,你可以使用它代替外键,让应用更灵活。

执行以下命令为这个应用创建初始的数据库迁移:

python manage.py makemigrations actions

你会看到以下输出:

Migrations for 'actions':
  actions/migrations/0001_initial.py
    - Create model Action

接着执行以下命令同步应用和数据库:

python manage.py migrate

这个命令的输入表示新的数据库迁移已经生效:

Applying actions.0001_initial... OK

当我们把Action模型添加到管理站点。编辑actions应用的admin.py文件,添加以下代码:

from django.contrib import admin
from .models import Action

class ActionAdmin(admin.ModelAdmin):
    list_display = ('user', 'verb', 'target', 'created')
    list_filter = ('created', )
    search_fields = ('verb', )

admin.site.register(Action, ActionAdmin)

你刚刚在管理站点注册了Action模型。执行python manage.py runserver命令启动开服务器,然后在浏览器中打开http://127.0.0.1:8000/actions/action/add/。你会看到创建一个新的Action对象的页面,如下图所示:

正如你所看到的,只有target_cttarget_id字段映射到实际的数据库字段,而GenericForeignKey没有在这里出现。target_ct允许你选择在 Django 项目中注册的任何模型。使用target_ct字段的limit_choices_to属性,可以让contenttypes从一个限制的模型集合中选择:limit_choices_to属性允许你限制ForeignKey字段的内容为一组指定的值。

actions应用目录中创建一个utils.py文件。我们将定义一些快捷方法,快速创建Action对象。编辑这个新文件,添加以下代码:

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()方法允许我们创建Action对象,其中包括一个可选的target对象。我们可以在任何地方使用这个函数添加新操作到活动流中。

6.2.3 避免活动流中的重复操作

有时候用户可能执行一个操作多次。他们可能在很短的时间内多次点击like/unlike按钮,或者执行同一个操作多次。最终会让你存储和显示重复操作。为了避免这种情况,我们会完善create_acion()函数,避免大部分重复操作。

编辑actions应用的utils.py文件,如下所示:

import datetime
from django.utils import timezone
from django.contrib.contenttypes.models import ContentType
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, targt_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()函数,避免保存重复操作,并返回一个布尔值,表示操作是否保存。我们是这样避免重复的:

  • 首先使用 Django 提供的timezone.now()方法获得当前时间。这个函数的作用与datetime.datetime.now()相同,但它返回一个timezone-aware对象。Django 提供了一个USE_TZ设置,用于启用或禁止时区支持。使用startproject命令创建的默认settings.py文件中,包括USE_TZ=True
  • 我们使用last_minute变量存储一分钟之前的时间,然后我们检索用户从那之后执行的所有相同操作。
  • 如果最后一分钟没有相同的操作,则创建一个Action对象。如果创建了Action对象,则返回True,否则返回False

6.2.4 添加用户操作到活动流中

是时候为用户添加一些操作到视图中,来创建活动流了。我们将为以下几种交互存储操作:

  • 用户标记图片
  • 用户喜欢或不喜欢一张图片
  • 用户创建账户
  • 用户关注或取消关注其它用户

编辑images应用的views.py文件,添加以下导入:

from actions.utils import create_action

image_create视图中,在保存图片之后添加create_action()

new_item.save()
create_action(request.user, 'bookmarked image', new_item)

image_like视图中,在添加用户到users_like关系之后添加create_action()

image.users_like.add(request.user)
create_action(request.user, 'likes', image)

现在编辑account应用的views.py文件,添加以下导入:

from actions.utils import create_action

register视图中,在创建Profile对象之后添加create_action()

new_user.save()
profile = Profile.objects.create(user=new_user)
create_action(new_user, 'has created an account')

user_follow视图中,添加create_action()

Contact.objects.get_or_create(user_from=request.user, user_to=user)
create_action(request.user, 'is following', user)

正如你所看到的,多亏了Action模型和帮助函数,让我们很容易的在活动流中保存新操作。

6.2.5 显示活动流

最后,我们需要为每个用户显示活动流。我们将把它包括在用户的仪表盘中。编辑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()进行排序,因为我们使用Action模型的Meta选项提供的默认排序。因为我们在Action模型中设置了ordering = ('-created',),所以会先返回最新的操作。

6.2.6 优化涉及关联对象的 QuerySet

每次检索一个Action对象时,你可能需要访问与它关联的User对象,以及该用户关联的Profile对象。Django ORM 提供了一种方式,可以一次检索关联对象,避免额外的数据库查询。

Django 提供了一个select_related方法,允许你检索一对多关系的关联对象。它会转换为单个更复杂的QuerySet,但是访问关联对象时,可以避免额外的查询。select_related方法用于ForeignKeyOneToOne字段。它在SELECT语句中执行SQL JOIN,并且包括了关联对象的字段。

要使用select_related(),需要编辑之前代码的这一行:

actions = actions.filter(user_id__in=following_ids)

并在你会使用的字段上添加select_related

actions = actions.filter(user_id__in=following_ids)\
	.select_related('user', 'user__profile')

我们用user__profile在单条 SQL 查询中连接了Profile表。如果调用select_related()时没有传递参数,那么它会从所有ForeignKey关系中检索对象。总是将之后会访问的关系限制为select_related()

仔细使用select_related()可以大大减少执行时间。

正如你所看到的,在一对多关系中检索关联对象时,select_related()会提高执行效率。但是select_related()不能用于多对多或者多对一关系。Django 提供了一个名为prefetch_relatedQuerySet方法,除了select_related()支持的关系之外,还可以用于多对多和多对一关系。prefetch_related()方法为每个关系执行独立的查询,然后用 Python 连接结果。该方法还支持GenericRelationGenericForeignKey的预读取。

GenericForeignKey字段target添加prefetch_related(),完成这个查询:

actions = actions.filter(user_id__in=following_ids)\
	.select_related('user', 'user__profile')\
	.prefetch_related('target')

现在查询已经优化,用于检索包括关联对象的用户操作。

6.2.7 为操作创建模板

我们将创建模板用于显示特定的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="{{ im.url }}" alt="{{ user.get_full_nam }}" class="item-img">
                    </a>
                {% endthumbnail %}
            {% 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="{{ im.url }}" class="item-img">
                            </a>
                        {% endthumbnail %}
                    {% endif %}
                {% endwith %}
            {% endif %}
        </div>
        <div class="info">
            <p>
                <span class="date">{{ action.created|timesince }} age</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 %}模板标签检索执行操作的用户和他们的个人资料。接着,如果Action对象有关联的target对象,则显示target对象的图片。最后,我们显示执行操作的用户链接,描述,以及target对象(如果有的话)。

现在编辑account/dashboard.html模板,在content块底部添加以下代码:

<h2>What's happening</h2>
<div id="action-list">
	{% for action in actions %}
		{% include "actions/action/detail.html" %}
	{% endfor %}
</div>

在浏览器中打开http://127.0.0.1:8000/account/。用已存在的用户登录,并执行一些操作存储在数据库中。接着用另一个用户登录,并关注之前那个用户,然后在仪表盘页面查看生成的活动流,如下图所示:

我们为用户创建了一个完整的活动流,并且能很容易的添加新的用户操作。你还可以通过 AJAX 分页,在活动流中添加无限滚动,就像我们在image_list视图中那样。

6.3 使用信号进行反规范化计数

某些情况下你希望对数据进行反规范化处理。反规范化(denormalization)是在一定程度上制造一些冗余数据,从而优化读取性能。你必须小心使用反规范化,只有当你真的需要的时候才使用。反规范化最大的问题是很难保持数据的更新。

我们将通过一个例子解释如何通过反规范化计数来改善查询。缺点是我们必须保持冗余数据的更新。我们将在Image模型中使用反规范数据,并使用 Django 的信号来保持数据的更新。

6.3.1 使用信号

Django 自带一个信号调度程序,当特定动作发生时,允许接收函数获取通知。当某些事情发生时,你的代码需要完成某些工作,信号非常有用。你也可以创建自己的信号,当事件发生时,其他人可以获得通知。

Django 在django.db.models.signals中为模型提供了几种信号,其中包括:

  • pre_savepost_save:调用模型的save()方法之前或之后发送
  • pre_deletepost_delete:调用模型或QuerySetdelete()方法之前或之后发送
  • m2m_changed:当模型的ManyToManyField改变时发送

这只是 Django 提供了部分信号。你可以在这里查看 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文件,为Image模型添加以下字段:

total_likes = models.PositiveIntegerField(db_index=True, default=0)

total_likes字段允许我们存储每张图片被用户喜欢的数量。当你希望过滤或者排序QuerySet时,反规范计数非常有用。

在使用反规范字段之前,你必须考虑其它提升性能的方式。比如数据库索引,查询优化和缓存。

执行以下命令为新添加的字段创建数据库迁移:

python manage.py makemigrations images

你会看到以下输出:

Migrations for 'images':
  images/migrations/0002_image_total_likes.py
    - Add field total_likes to image

接着执行以下命令让迁移生效:

python manage.py migrate images

输出中会包括这一行:

Applying images.0002_image_total_likes... OK

我们将会为m2m_changed信号附加一个receiver函数。在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函数为receiver()函数,并把它附加给m2m_changed信号。我们把函数连接到Image.users_like.throuth,只有这个发送者发起m2m_changed信号时,这个方法才会被调用。还可以使用Signal对象的connect()方法来注册receiver()函数。

Django 信号是同步和阻塞的。不要用异步任务导致信号混乱。但是,当你的代码从信号中获得通知时,你可以组合两者来启动异步任务。

你必须把接收器函数连接到一个信号,这样每次发送信号时,接收器函数才会调用。注册信号的推荐方式是在应用配置类的ready()函数中导入它们。Django 提供了一个应用注册表,用于配置和内省应用。

6.3.2 定义应用配置类

Django 允许你为应用指定配置类。要为应用提供一个自定义配置,你需要创建一个自定义类,它继承自位于django.apps中的AppConfig类。应用配置类允许为应用存储元数据和配置,并提供内省。

你可以在这里阅读更多关于应用配置的信息。

为了注册你的信号接收函数,当你使用receiver()装饰器时,你只需要在AppConfig类的ready()方法中导入应用的信号模块。一旦应用注册表完全填充,就会调用这个方法。这个方法中应该包括应用的所有初始化工作。

images应用目录下创建apps.py文件,并添加以下代码:

from django.apps import AppConfig


class ImagesConfig(AppConfig):
    name = 'images'
    verbose_name = 'Image bookmarks'

    def ready(self):
        # import signal handlers
        import images.signals

译者注:Django 1.11 版本中,默认已经生成了apps.py文件,只需要在其中添加ready()方法。

其中,name属性定义应用的完整 Python 路径;verbose_name属性设置应用的可读名字。它会在管理站点中显示。我们在ready()方法中导入该应用的信号。

现在我们需要告诉 Django 应用配置的位置。编辑images应用目录的__init__.py文件,添加这一行代码:

default_app_config = 'images.apps.ImagesConfig'

在浏览器中查看图片详情页面,并点击like按钮。然后回到管理站点查看total_likes属性。你会看到total_likes已经更新,如下图所示:

现在你可以使用total_likes属性按热门排序图片,或者在任何地方显示它,避免了用复杂的查询来计算。以下按图片被喜欢的总数量排序的查询:

images_by_popularity = Image.objects.annotate(likes=Count('users_like')).order_by('-likes')

可以变为这样:

images_by_popularity = Image.objects.order_by('-total_likes')

通过更快的 SQL 查询就返回了这个结果。这只是使用 Django 信号的一个示例。

小心使用信号,因为它会让控制流更难理解。如果你知道会通知哪个接收器,很多情况下就能避免使用信号。

你需要设置初始计数,来匹配数据库的当前状态。使用python manage.py shell命令打开终端,执行以下命令:

from images.models import Image
for image in Image.objects.all():
	image.total_likes = image.users_like.count()
	image.save()

现在每张图片被喜欢的总数量已经更新了。

6.4 用 Redis 存储项视图

Redis 是一个高级的键值对数据库,允许你存储不同类型的数据,并且能进行非常快速的 I/O 操作。Redis 在内存中存储所有数据,但数据集可以一次性持久化到硬盘中,或者添加每条命令到日志中。与其它键值对存储相比,Redis 更通用:它提供了一组功能强大的命令,并支持各种各样的数据结构,比如stringshasheslistssetsordered sets,甚至bitmapsHyperLogLogs

SQL 最适合于模式定义的持久化数据存储,而当处理快速变化的数据,短暂的存储,或者快速缓存时,Redis 有更多的优势。让我们看看如何使用 Redis 为我们的项目添加新功能。

6.4.1 安装 Redis

这里下载最新的 Redis 版本。解压tar.gz文件,进入redis目录,使用make命令编译 Redis:

cd redis-3.2.8
make

安装完成后,使用以下命令初始化 Redis 服务器:

src/redis-server

你会看到结尾的输出为:

19608:M 08 May 17:04:38.217 # Server started, Redis version 3.2.8
19608:M 08 May 17:04:38.217 * The server is now ready to accept connections on port 6379

默认情况下,Redis 在 6379 端口运行,但你可以使用--port之指定自定义端口,比如:redis-server --port 6655。服务器就绪后,使用以下命令在另一个终端打开 Redis 客户端:

src/redis-cli

你会看到 Redis 客户端终端:

127.0.0.1:6379>

你可以直接在 Redis 客户端执行 Redis 命令。让我们尝试一下。在 Redis 终端输入SET命令,在键中存储一个值:

127.0.0.1:6379> SET name "Peter"
OK

以上命令在 Redis 数据库中创建了一个字符串值为Petername键。输出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> EXPIRE name 2
(integer) 1

等待 2 秒,再次获取同样的键:

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 为每种数据类型(比如stringshasheslistssetsordered sets等等)提供了大量的命令。你可以在这里查看所有 Redis 命令,在这里查看所有 Redis 数据类型。

6.4.2 在 Python 中使用 Redis

我们需要为 Redis 绑定 Python。通过pip安装redis-py

pip install redis

你可以在这里查看redis-py的文档。

redis-py提供了两个类用于与 Redis 交互:StricRedisRedis。两个类提供了相同的功能。StricRedis类视图遵守官方 Redis 命令语法。Redis类继承自StricRedis,覆写了一些方法,提供向后的兼容性。我们将使用StrictRedis类,因为它遵循 Redis 命令语法。打开 Python 终端,执行以下命令:

>>> import redis
>>> r = redis.StrictRedis(host='localhost', port=6379, db=0)

这段代码创建了一个 Redis 数据连接。在 Redis 中,数据由整数索引区分,而不是数据库名。默认情况下,客户端连接到数据库 0。Redis 数据库有效的数字到 16,但你可以在redis.conf文件中修改这个值。

现在使用 Python 终端设置一个键:

>>> r.set('foo', 'bar')
True

命令返回True表示键创建成功。现在你可以使用get()命令查询键:

>>> r.get('foo')
b'bar'

正如你锁看到的,StrictRedis方法遵循 Redis 命令语法。

让我们在项目中集成 Redis。编辑bookmarks项目的settings.py文件,添加以下设置:

REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 0

以上设置了 Redis 服务器和我们在项目中使用的数据库。

6.4.3 在 Redis 中存储项的浏览次数

让我们存储一张图片被查看的总次数。如果我们使用 Django ORM,则每次显示图片后,都会涉及UPDATE语句。如果使用 Redis,我们只需要增加内存中的计数,从而获得更好的性能。

编辑images应用的views.py文件,添加以下代码:

import redis
from django.conf import settings

# connect to redis
r = redis.StrictRedis(host=settings.REDIS_HOST,
                      port=settings.REDIS_PORT,
                      db=settings.REDIS_DB)

我们建立了 Redis 连接,以便在视图中使用。修改image_detail视图,如下所示:

def image_detail(request, id, slug):
    image = get_object_or_404(Image, id=id, slug=slug)
    # increament total image views by 1
    total_views = r.incr('image:{}:views'.format(image.id))
    return render(request, 
                  'images/image/detail.html', 
                  {'section': 'images', 'image': image, 'total_views': total_views})

在这个视图中,我们使用INCR命令把一个键的值加 1,如果键不存在,则在执行操作之前设置值为 0。incr()方法返回执行操作之后键的值,我们把它存在total_views变量中。我们用object-type:id:field(比如image:33:id:views)构建 Redis 键。

Redis 键的命名惯例是使用冒号分割,来创建带命名空间的键。这样键名会很详细,并且相关的键共享部分相同的模式。

编辑image/detail.html模板,在<span class="count">元素之后添加以下代码:

<span class="count">
	<span class="total">{{ total_views }}</span>
	view{{ total_views|pluralize }}
</span>

现在在浏览器中打开图片详情页面,加载多次。你会看到每次执行视图,显示的浏览总数都会加 1,如下图所示:

你已经成功的在项目集成了 Redis,来存储项的浏览次数。

6.4.4 在 Redis 中存储排名

让我们用 Redis 构建更多功能。我们将创建浏览次数最多的图片排名。我们将使用 Redis 的sorted set来构建排名。一个sorted set是一个不重复的字符串集合,每个成员关联一个分数。项通过它们的分数存储。

编辑images应用的views.py文件,修改image_detail视图,如下所示:

def image_detail(request, id, slug):
    image = get_object_or_404(Image, id=id, slug=slug)
    # increament total image views by 1
    total_views = r.incr('image:{}:views'.format(image.id))
    # increament image ranking by 1
    r.zincrby('image_ranking', image.id, 1)
    return render(request, 
                  'images/image/detail.html', 
                  {'section': 'images', 'image': image, 'total_views': total_views})

我们用zincrby()命令在sorted set中存储图片浏览次数,其中键为image_ranking。我们存储图片 id,分数 1 会被加到sorted set中这个元素的总分上。这样就可以全局追踪所有图片的浏览次数,并且有一个按浏览次数排序的sorted set

现在创建一个新视图,用于显示浏览次数最多的图片排名。在views.py文件中添加以下代码:

@login_required
def image_ranking(request):
    # get image ranking dictinary
    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视图。我们用zrange()命令获得sorted set中的元素。这个命令通过最低和最高分指定自定义范围。通过 0 作为最低,-1 作为最高分,我们告诉 Redis 返回sorted set中的所有元素。我们还指定desc=True,按分数的降序排列返回元素。最后,我们用[:10]切片操作返回分数最高的前 10 个元素。我们构建了一个返回的图片 ID 列表,并作为整数列表存在image_ranking_ids变量中。我们迭代这些 ID 的Image对象,并使用list()函数强制执行查询。强制QuerySet执行很重要,因为之后我们要调用列表的sort()方法(此时我们需要一组对象,而不是一个QuerySet)。我们通过Image对象在图片排名中的索引进行排序。现在我们可以在模板中使用most_viewed列表显示浏览次数最多的前 10 张图片。

创建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(r'^/ranking/$', views.image_ranking, name='ranking')

在浏览器中打开http://127.0.0.1:8000/images/ranking/,你会看到图片排名,如下图所示:

6.4.5 Redis 的后续功能

Redis 不是 SQL 数据库的替代者,而是更适用于特定任务的快速的内存存储。当你真的需要时可以使用它。Redis 非常适合以下场景:

  • 计数:正如你所看到的,使用 Redis 管理计算非常简单。你可以使用incr()incrby()计数。
  • 存储最近的项:你可以使用lpush()rpush()在列表开头或结尾添加项。使用lpop()rpop()移除并返回第一或最后一项。你可以使用ltrim()截断列表长度。
  • 队列:除了pushpop命令,Redis 还提供了阻塞队列的命令。
  • 缓存:使用expire()expireat()允许你把 Redis 当做缓存。你还可以找到 Django 的第三方 Redis 缓存后台。
  • 订阅/发布:Redis 还为订阅/取消订阅,以及发送消息给频道提供了命令。
  • 排名和排行榜:Redis 的sorted set可以很容易创建排行榜。
  • 实时跟踪:Redis 的快速 I/O 非常适合实时场景。

6.5 总结

这一章中,你构建了关注系统和用户活动流。你学习了 Django 信号是如何工作的,并在项目中集成了 Redis。

下一章中,你会学习如何构建一个在线商店。你将创建一个产品目录,并使用会话构建购物车。你还讲学习如何使用 Celery 启动异步任务。

第七章:构建在线商店

在上一章中,你创建了关注系统和用户活动流。你还学习了 Django 信号是如何工作的,并在项目中集成了 Redis,用于计算图片的浏览次数。在这一章中,你会学习如何构建一个基本的在线商店。你会创建商品目录(catalog),并用 Django 会话(session)实现购物车。你还会学习如果创建自定义上下文管理器,以及用 Celery 启动异步任务。

在这一章中,你会学习:

  • 创建商品目录
  • 使用 Django 会话创建购物车
  • 管理客户订单
  • 用 Celery 给客户发送异步通知

7.1 创建在线商店项目

我们将创建一个新的 Django 项目来构建在线商店。用户可以通过商品目录浏览,并把商品添加到购物车中。最后,客户结账并下单。本章将会覆盖在线商店以下几个功能:

  • 创建商品目录模型,把它们添加到管理站点,并创建一个基础视图,用于显示目录
  • 使用 Django 会话构建购物车系统,允许用户浏览网站时保留选定的商品
  • 创建用于下单的表单和功能
  • 用户下单后,发送一封异步确认邮件给用户

首先,我们为新项目创建虚机环境,并用以下命令激活虚拟环境:

mkdiv env
virtualenv env/myshop
source env/myshop/bin/activate

使用以下命令在虚拟环境中安装 Django:

pip install Django

打开终端,执行以下命令,创建myshop项目,以及shop应用:

django-admin startproject myshop
cd myshop
django-admin startapp shop

编辑项目的settings.py文件,在INSTALLED_APPS设置中添加shop应用:

INSTALLED_APPS = (
	# ...
	'shop',
)

现在项目中的应用已经激活。让我们为商品目录定义模型。

7.1.1 创建商品目录模型

商店的目录由属于不同类别的商品组成。每个商品有名字,可选的描述,可选的图片,价格,以及有效的库存。编辑你刚创建的shop应用的models.py文件,添加以下代码:

from django.db import models

class Category(models.Model):
    name = models.CharField(max_length=200, db_index=True)
    slug = models.SlugField(max_length=200, db_index=True, unique=True)

    class Meta:
        ordering = ('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')
    name = models.CharField(max_length=200, db_index=True)
    slug = models.SlugField(max_length=200, db_index=True)
    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)
    stock = models.PositiveIntegerField()
    available = models.BooleanField(default=True)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ('name', )
        index_together = (('id', 'slug'), )

    def __str__(self):
        return self.name

这是我们的CategoryProduct模型。Category模型由name字段和唯一的slug字段组成。Product模型包括以下字段:

  • category:这是指向Catetory模型的ForeignKey。这是一个多对一的关系:一个商品属于一个目录,而一个目录包括多个商品。
  • name:这是商品的名称。
  • slug:这是商品的别名,用于构建友好的 URL。
  • image:这是一张可选的商品图片。
  • description:这是商品的可选描述。
  • price:这是DecimalField。这个字段用 Python 的decimal.Decimal类型存储固定精度的十进制数。使用max_digits属性设置最大的位数(包括小数位),使用decimal_places属性设置小数位。
  • stock:这个PositiveIntegerField存储商品的库存。
  • available:这个布尔值表示商品是否有效。这允许我们在目录中启用或禁用商品。
  • created:对象创建时存储该字段。
  • updated:对象最后更新时存储该字段。

对于price字段,我们使用DecimalField代替FloatField,来避免四舍五入的问题。

总是使用DecimalField存储货币值。在 Python 内部,FloatField使用float类型,而DecimalField使用Decimal类型。使用Decimal类型可以避免float的四舍五入问题。

Product模型的Meta类中,我们用index_together元选项为idslug字段指定共同索引。这是因为我们计划通过idslug来查询商品。两个字段共同索引可以提升用这两个字段查询的性能。

因为我们要在模型中处理图片,打开终端,用以下命令安装Pillow

pip install Pillow

现在,执行以下命令,创建项目的初始数据库迁移:

python manage.py makemigrations

你会看到以下输出:

Migrations for 'shop':
  shop/migrations/0001_initial.py
    - Create model Category
    - Create model Product
    - Alter index_together for product (1 constraint(s))

执行以下命令同步数据:

python manage.py migrate

你会看到包括这一行的输出:

Applying shop.0001_initial... OK

现在数据库与模型已经同步了。

7.1.2 在管理站点注册目录模型

让我们把模型添加到管理站点,从而可以方便的管理目录和商品。编辑shop应用的admin.py文件,添加以下代码:

from django.contrib import admin
from .models import Category, Product

class CategoryAdmin(admin.ModelAdmin):
    list_display = ('name', 'slug')
    prepopulated_fields = {'slug': ('name', )}
admin.site.register(Category, CategoryAdmin)

class ProductAdmin(admin.ModelAdmin):
    list_display = ('name', 'slug', 'price', 'stock', 'available', 'created', 'updated')
    list_filter = ('available', 'created', 'updated')
    list_editable = ('price', 'stock', 'available')
    prepopulated_fields = {'slug': ('name', )}
admin.site.register(Product, ProductAdmin)

记住,我们使用prepopulated_fields属性指定用其它字段的值自动填充的字段。正如你前面看到的,这样可以很容易的生成别名。我们在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/,然后用刚创建的用户登录。使用管理界面添加一个新的目录和商品。管理页面的商品修改列表页面看起来是这样的:

7.1.3 构建目录视图

为了显示商品目录,我们需要创建一个视图列出所有商品,或者通过制定的目录过滤商品。编辑shop应用的views.py文件,添加以下代码:

from django.shortcuts import render, get_object_or_404
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 获得该实例,因为 ID 是唯一性的属性。但是我们会在 URL 中包括别名,为商品构建搜索引擎友好的 URL。

创建商品列表和详情视图后,我们需要为它们定义 URL 模式。在shop应用目录中创建urls.py文件,添加以下代码:

from django.conf.urls import url
from . import views

urlpatterns = [
    url(r'^$', views.product_list, name='product_list'),
    url(r'^(?P<category_slug>[-\w]+)/$', views.product_list, name='product_list_by_category'),
    url(r'^(?P<id>\d+)/(?P<slug>[-\w]+)/$', 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.conf.urls import url, include
from django.contrib import admin

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^', include('shop.urls', namespace='shop')),
]

我们在项目的主 URL 模式中引入了shop应用的 URL,并指定命名空间为shop

现在编辑shop应用的models.py文件,导入reverse()函数,并为CategoryProduct模型添加get_absolute_url()方法,如下所示:

from django.core.urlresolvers 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 模式。

7.1.4 创建目录模板

现在我们需要为商品列表和详情视图创建模板。在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/目录。把它们拷贝到你的项目中的相同位置。

编辑shop/product/list.html模板,添加以下代码:

{% extends "shop/base.html" %}
{% load static %}

{% block title %}
    {% if category %}{{ category.name }}{% else %}Products{% endif %}
{% endblock title %}

{% 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 catetory %}{{ category.name }}{% else %}Products{% endif %}</h1>
        {% for product in products %}
            <div class="item">
                <a href="{{ product.get_absolute_url }}">
                    <img src="{% 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 content %}

这是商品列表目录。它继承自shop/base.html目录,用categories上下文变量在侧边栏显示所有目录,用products显示当前页商品。用同一个模板列出所有有效商品和通过目录过滤的所有商品。因为Product模型的image字段可以为空,所以如果商品没有图片时,我们需要提供一张默认图片。图片位于静态文件目录,相对路径为img/no_image.png

因为我们用ImageField存储商品图片,所以需要开发服务器管理上传的图片文件。编辑myshopsettings.py文件,添加以下设置:

MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(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

urlpatterns = [
    # ...
]
if settings.DEBUG:
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

记住,我们只在开发阶段这么做。在生产环境,你不应该用 Django 管理静态文件。

使用管理站点添加一些商品,然后在浏览器中打开http://127.0.0.1:8000/。你会看到商品列表页面,如下图所示:

如果你用管理站点创建了一个商品,但是没有上传图片,则会显示默认图片:

让我们编辑商品详情模板。编辑shop/product/detail.html模板,添加以下代码:

{% extends "shop/base.html" %}
{% load static %}

{% block titie %}
    {% if category %}{{ category.title }}{% else %}Products{% endif %}
{% endblock titie %}

{% 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 content %}

我们在关联的目录对象上调用get_absolute_url()方法,来显示属于同一个目录的有效商品。现在在浏览器中打开http://127.0.0.1/8000/,点击某个商品查看详情页面,如下图所示:

我们现在已经创建了一个基本的商品目录。

7.2 构建购物车

创建商品目录之后,下一步是创建购物车,让用户选择他们希望购买的商品。当用户浏览网站时,购物车允许用户选择并暂时存储他们想要的商品,直到最后下单。购物车存储在会话中,所以在用户访问期间可以保存购物车里的商品。

我们将使用 Django 的会话框架保存购物车。购物车会一直保存在会话中,直到完成购物或者用户结账离开。我们还需要为购物车和它的商品创建额外的 Django 模型。

7.2.1 使用 Django 会话

Django 提供了一个会话框架,支持匿名和用户会话。会话框架允许你为每个访问者存储任意数据。会话数据保存在服务端,cookies 包括会话 ID,除非你使用基于 cookie 的会话引擎。会话中间件负责发送和接收 cookies。默认的会话引擎在数据库中存储会话数据,但是接下来你会看到,可以选择不同的会话引擎。要使用会话,你必须确保项目的MIDDLEWARE_CLASSES设置中包括django.contrib.sessions.middleware.SessionMiddleware。这个中间件负责管理会话,当你用startproject命令创建新项目时,会默认添加这个中间件。

会话中间件让当前会话在request对象中生效。你可以使用request.session访问当前会话,与使用 Python 字典类似的存储和检索会话数据。会话字典默认接收所有可以序列化为 JSON 的 Python 对象。你可以这样在会话中设置变量:

request.session['foo'] = 'bar'

查询一个会话的键:

request.session.get('foo')

删除存储在会话中的键:

del request.session['foo']

正如你所看到的,我们把request.session当做标准的 Python 字典。

当用户登录到网站时,他们的匿名会话丢失,并未认证用户创建新的会话。如果你在匿名会话中存储了数据,并想在用户登录后保留,你需要旧的会话数据拷贝到新的会话中。

7.2.2 会话设置

你可以使用几种设置为项目配置会话。其中最重要的是SESSION_ENGINE。该设置允许你设置会话存储的位置。默认情况下,Django 使用django.contrib.sessions应用的Session模型,把会话存储在数据库中。

Django 为存储会话数据提供了以下选项:

  • Database sessions:会话数据存储在数据库中。这是默认的会话引擎。
  • File-based sessions:会话数据存储在文件系统中。
  • Cached sessions:会话数据存储在缓存后台。你可以使用CACHES设置指定婚车后台。在缓存系统中存储会话数据的性能最好。
  • Cached database sessions:会话数据存储在连续写入的缓存(write-through cache)和数据库中。只有在缓存中没有数据时才读取数据库。
  • Cookie-based sessions:会话数据存储于发送到浏览器的 cookies。

使用cache-based会话引擎有更好的性能。Django 支持 Memcached,以及其它支持 Redis 的第三方缓存后台和缓存系统。

你可以只是用其它设置自定义会话。以下是一些重要的会话相关设置:

  • SESSION_COOKIE_AGE:这是会话 cookies 的持续时间(单位是秒)。默认值是 1209600(两周)。
  • SESSION_COOKIE_DOMAIN:会话 cookies 使用的域。设置为.mydomain.com可以启用跨域 cookies。
  • SESSION_EXPIRE_AT_BROWSER_CLOSE:当浏览器关闭后,表示会话是否过期的一个布尔值。
  • SESSION_SAVE_EVERY_REQUEST:如果这个布尔值为True,则会在每次请求时把会话保存到数据库中。会话的过期时间也会每次更新。

你可以在这里查看所有会话设置。

7.2.3 会话过期

你可以使用SESSTION_EXPIRE_AT_BROWSER_CLOSE设置选择browser-length会话或者持久会话。默认值为False,强制把会话的有效期设置为SESSION_COOKIE_AGE的值。如果设置SESSTION_EXPIRE_AT_BROWSER_CLOSETrue,当用户关闭浏览器后,会话会过期,而SESSION_COOKIE_AGE不会起任何作用。

你可以使用request.sessionset_expiry()方法覆写当前会话的有效期。

7.2.4 在会话中存储购物车

我们需要创建一个简单的可以序列号为 JSON 的结构体,在会话中存储购物车商品。购物车的每一件商品必须包括以下数据:

  • Product实例的id
  • 选择该商品的数量
  • 该商品的单价

因为商品价格可能变化,所以当商品添加到购物车时,我们把商品的价格和商品本身同事存入购物车。这样的话,即使之后商品的价格发生变化,用户看到的还是添加到购物车时的价格。

现在你需要创建购物车,并与会话关联起来。购物车必须这样工作:

  • 需要购物车时,我们检查是否设置了自定义会话键。如果会话中没有设置购物车,则创建一个新的购物车,并保存在购物车会话键中。
  • 对于连续的请求,我们执行相同的检查,并从购物车会话键中取出购物车的商品。我们从会话中检索购物车商品,并从数据库中检索它们关联的Product对象。

编辑项目settings.py文件,添加以下设置:

CART_SESSION_ID = 'cart'

我们在用户会话用这个键存储购物车。因为每个访客的 Django 会话是独立的,所以我们可以为所有会话使用同一个购物车会话键。

让我们创建一个管理购物车的应用。打开终端,执行以下命令创建一个新应用:

python manage.py startapp cart

然后编辑项目的settings.py文件,把cart添加到INSTALLED_APPS

INSTALLED_APPS = (
	# ...
	'cart',
)

cart应用目录中创建cart.py文件,并添加以下代码:

from decimal import Decimal
from django.conf import settings
from shop.models import Product

class Cart:
    def __init__(self, request):
        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()方法:

def add(self, product, quantity=1, update_quantity=False):
    product_id = str(product.id)
    if product_id not in self.cart:
        self.cart[product_id] = {
            'quantity': 0,
            'price': str(product.price)
        }
    if update_quantity:
        self.cart[product_id]['quantity'] = quantity
    else:
        self.cart[product_id]['quantity'] += quantity
    self.save()

def save(self):
    # update the session cart
    self.session[settings.CART_SESSION_ID] = self.cart
    # mark the sessions as "modified" to make sure it is saved
    self.session.modified = True

add()方法接收以下参数:

  • product:在购物车中添加或更新的Product实例。
  • quantity:可选的商品数量。默认为 1.
  • update_quantity:一个布尔值,表示使用给定的数量更新数量(True),或者把新数量加到已有的数量上(False)。

我们用商品id作为购物车内容字典的键。因为 Django 使用 JSON 序列号会话数据,而 JSON 只允许字符串类型的键名,所以我们把商品id转换为字符串。商品id是键,保存的值是带商品quantityprice的字典。为了序列号,我们把商品价格转换为字符串。最后,我们调用save()方法在会话中保存购物车。

save()方法在会话中保存购物车的所有修改,并使用session.modified = True标记会话已修改。这告诉 Django,会话已经修改,需要保存。

我们还需要一个方法从购物车中移除商品。在Cart类中添加以下方法:

def remove(self, product):
    product_id = str(product.id)
    if product_id in self.cart:
        del self.cart[product_id]
        self.save()

remove()方法从购物车字典中移除指定商品,并调用save()方法更新会话中的购物车。

我们将需要迭代购物车中的商品,并访问关联的Product实例。因为需要在类中定义__iter__()方法。在Cart类中添加以下方法:

def __iter__(self):
    product_ids = self.cart.keys()
    # get the product objects and add them to the cart
    products = Product.objects.filter(id__in=product_ids)
    for product in products:
        self.cart[str(product.id)]['product'] = product

    for item in self.cart.values():
        item['price'] = Decimal(item['price'])
        item['total_price'] = item['price'] * item['quantity']
        yield item

__iter__()方法中,我们检索购物车中的Product实例,并把它们包括在购物车商品中。最后,我们迭代购物车商品,把price转换回Decimal类型,并为每一项添加total_price属性。现在我们可以在购物车中方便的迭代商品。

我们还需要返回购物车中商品总数量。当在一个对象上调用len()函数时,Python 会调用__len__()方法返回对象的长度。我们定义一个__len__()方法,返回购物车中所有商品的总数量。在Cart类中添加__len__()方法:

def __len__(self):
    return sum(item['quantity'] for item in self.cart.values())

我们返回购物车中所有商品数量的总和。

添加以下方法,计算购物车中所有商品的总价:

def get_total_price(self):
	return sum(Decimal(item['price']) * item['quantity'] for item in self.cart.values())

最后,添加一个清空购物车会话的方法:

def clear(self):
    del self.session[settings.CART_SESSION_ID]
    self.session.modified = True

我们的Cart类已经可以管理购物车了。

7.2.5 创建购物车视图

现在我们已经创建了Cart类来管理购物车,我们需要创建添加,更新和移除购物车商品的视图。我们需要创建以下视图:

  • 一个添加或更新购物车商品的视图,可以处理当前和新的数量
  • 一个从购物车中移除商品的视图
  • 一个显示购物车商品和总数的视图

7.2.5.1 添加商品到购物车

要添加商品到购物车中,我们需要一个用户可以选择数量的表单。在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)
    update = forms.BooleanField(required=False, initial=False, widget=forms.HiddenInput)

我们用这个表单向购物车中添加商品。CartAddProductForm类包括以下两个字段:

  • quantity:允许用户选择 1-20 之间的数量。我们使用带coerce=intTypedChoiceField字段把输入的值转换为整数。
  • update:允许你指定把数量累加到购物车中已存在的商品数量上(False),还是用给定的数量更新已存在商品数量(True)。我们为该字段使用HiddenInput组件,因为我们不想让用户看见它。

让我们创建向购物车添加商品的视图。编辑cart应用的views.py文件,并添加以下代码:

from django.shortcuts import render, redirect, get_object_or_404
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'], update_quantity=cd['update'])
    return redirect('cart:cart_detail')

这个视图用于向购物车中添加商品或者更新已有商品的数量。因为这个视图会修改数据,所以我们只允许 POST 请求。视图接收商品 ID 作为参数。我们用给定的商品 ID 检索Product实例,并验证CartAddProductForm。如果表单有效,则添加或更新购物车中的商品。该视图重定向到cart_detail URL,它会显示购物车中的内容。之后我们会创建cart_detail视图。

我们还需要一个从购物车中移除商品的视图。在cart应用的views.py文件中添加以下代码:

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 作为参数。我们用给定的商品 ID 检索Product实例,并从购物车中移除该商品。接着我们重定向到cart_detail URL。

最后,我们需要一个显示购物车和其中的商品的视图。在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.conf.urls import url
from . import views

urlpatterns = [
    url(r'^$', views.cart_detail, name='cart_detail'),
    url(r'^add/(?P<product_id>\d+)/$', views.cart_add, name='cart_add'),
    url(r'^remove/(?P<product_id>\d+)/$', views.cart_remove, name='cart_remove'),
]

最后,编辑myshop项目的主urls.py文件,引入cart的 URL 模式:

urlpatterns = [
    url(r'^admin/', admin.site.urls),
    url(r'^cart/', include('cart.urls', namespace='cart')),
    url(r'^', include('shop.urls', namespace='shop')),
]

确保在shop.urls模式之前引入这个 URL 模式,因为它比前者更有限定性。

7.2.5.2 构建显示购物车的模板

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 title %}

{% 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="{{ prouct.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><a href="{% url "cart:cart_remove" product.id %}">Remove</a></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 content %}

这个模板用于显示购物车的内容。它包括一个当前购物车中商品的表格。用户通过提交表单到cart_add视图,来修改选中商品的数量。我们为每个商品提供了Remove链接,用户可以从购物车移除商品。

7.2.5.3 添加商品到购物车

现在我们需要在商品详情页面添加Add to cart按钮。编辑shop应用的views.py文件,修改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应用的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>

使用python manage.py runserver命令启动开发服务器。在浏览器中打开127.0.0.1/8000/,然后导航到商品详情页面。它现在包括一个选择数量的表单,如下图所示:

选择数量,然后点击Add to cart按钮。表单通过 POST 提交到cart_add视图。该视图把商品添加到会话中的购物车,包括当前价格和选择的数量。然后重定义到购物车详情页面,如下图所示:

7.2.5.4 在购物车中更新商品数量

当用户查看购物车时,他们可能希望在下单前修改商品数量。我们接下来实现在购物车详情页面修改数量。

编辑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'], 'update': True})
    return render(request, 'cart/detail.html', {'cart': cart})

我们为购物车中的每个商品创建了一个CartAddProductForm实例,允许用户修改商品数量。我们用当前商品数量初始化表单,并设置update字段为True。因此,当我们把表单提交到cart_add视图时,会用新数量了代替当前数量。

现在编辑cart应用的cart/detail.html模板,找到这一行代码:

<td>{{ item.quantity }}</td>

把这行代码替换为:

<td>
	<form action="{% url "cart:cart_add" product.id %}" method="post">
	    {{ item.update_quantity_form.quantity }}
	    {{ item.update_quantity_form.update }}
	    <input type="submit" value="Update">
	    {% csrf_token %}
	</form>
</td>

在浏览器中打开http://127.0.0.1:8000/cart/。你会看到购物车中每个商品都有一个修改数量的表单,如下图所示:

修改商品数量,然后点击Update按钮,测试一下新功能。

7.2.6 为当前购物车创建上下文处理器

你可能已经注意到了,我们的网站头部还是显示Your cart is emtpy。当我们开始向购物车中添加商品,我们将看到它替换成购物车中商品的总数量和总价钱。因为这是需要在所有页面显示,所以我们将创建一个上下文处理器(context processor),将当前购物车包含在请求上下文中,而不管已经处理的视图。

7.2.6.1 上下文处理器

上下文处理器是一个 Python 函数,它将request对象作为参数,并返回一个添加到请求上下文中的字典。当你需要让某些东西在所有模板都可用时,它会派上用场。

默认情况下,当你使用startproject命令创建新项目时,项目中会包括以下模板上下文处理器,它们位于TEMPLATES设置的context_processors选项中:

  • django.template.context_processors.debug:在上下文中设置debug布尔值和sql_queries变量,表示请求中执行的 SQL 查询列表
  • django.template.context_processors.request:在上下文中设置request变量
  • django.contrib.auth.context_processors.auth:在请求中设置user变量
  • django.contrib.messages.context_processors.messages:在上下文中设置message变量,其中包括所有已经用消息框架发送的消息。

Django 还启用了django.template.context_processors.csrf来避免跨站点请求伪造攻击。这个上下文处理器不在设置中,但它总是启用的,并且为了安全不能关闭。

你可以在这里查看所有内置的上下文处理器列表。

7.2.6.2 在请求上下文中设置购物车

让我们创建一个上下文处理器,把当前购物车添加到模板的请求上下文中。我们可以在所有模板中访问购物车。

cart应用目录中创建context_processors.py文件。上下文处理器可以位于代码的任何地方,但是在这里创建他们将保持代码组织良好。在文件中添加以下代码:

from .cart import Cart

def cart(request):
    return {'cart': Cart(request)}

正如你所看到的,上下文处理器是一个函数,它将request对象作为参数,并返回一个对象的字典,这些对象可用于所有使用RequestContext渲染的模板。在我们的上下文处理器中,我们用request对象实例化购物车,模板可以通过cart变量名访问它。

编辑项目的settings.py文件,在TEMPLATES设置的context_processors选项中添加cart.context_processors.cart,如下所示:

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',
            ],
        },
    },
]

每次使用RequestContext渲染模板时,会执行你的上下文处理器。cart变量会设置在模板的上下文中。

上下文处理器会在所有使用RequestContext的请求中执行。如果你想访问数据库的话,可能希望创建一个自定义模板标签来代替上下文处理器。

现在编辑shop应用的shop/base.html模板,找到以下代码:

<div class="cart">
	Your cart is empty.
</div>

用下面的代码替换上面的代码:

<div class="cart">
	{% with total_items=cart|length %}
	    {% if cart|length > 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/,并添加一些商品到购物车中。在网站头部,你会看到当前购物车总数量和总价钱,如下所示:

7.3 注册用户订单

当购物车结账后,你需要在数据库中保存订单。订单包括用户信息和他们购买的商品。

使用以下命令创建一个新应用,来管理用户订单:

python manage.py startapp orders

编辑项目的settings.py文件,在INSTALLED_APPS设置中添加orders

INSTALLED_APPS = [
	# ...
	'orders',
]

你已经激活了新应用。

7.3.1 创建订单模型

你需要创建一个模型存储订单详情,以及一个模型存储购买的商品,包括价格和数量。编辑orders应用的models.py文件,添加以下代码:

from django.db import models
from shop.models import Product

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', )

    def __str__(self):
        return 'Order {}'.format(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')
    product = models.ForeignKey(Product, related_name='order_items')
    price = models.DecimalField(max_digits=10, decimal_places=2)
    quantity = models.PositiveIntegerField(default=1)

    def __str__(self):
        return '{}'.format(self.id)

    def get_cost(self):
        return self.price * self.quantity

Order模型包括几个用户信息字段和一个默认值为Falsepaid布尔字段。之后,我们将用这个字段区分已支付和未支付的订单。我们还定义了get_total_cost()方法,获得这个订单中购买商品的总价钱。

OrderItem模型允许我们存储商品,数量和每个商品的支付价格。我们用get_cost()返回商品价钱。

运行以下命令,为orders应用创建初始数据库迁移:

python manage.py makemigrations

你会看到以下输出:

Migrations for 'orders':
  orders/migrations/0001_initial.py
    - Create model Order
    - Create model OrderItem

运行以下命令让新的迁移生效:

python manage.py migrate

现在你的订单模型已经同步到数据库中。

7.3.2 在管理站点引入订单模型

让我们在管理站点添加订单模型。编辑orders应用的admin.py文件,添加以下代码:

from django.contrib import admin
from .models import Order, OrderItem

class OrderItemInline(admin.TabularInline):
    model = OrderItem
    raw_id_fields = ['product']

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]

admin.site.register(Order, OrderAdmin)

我们为OrderItem模型使用ModeInline,把它作为内联模型引入OrderAdmin类。内联可以包含一个模型,与父模型在同一个编辑页面显示。

使用python manage.py runserver命令启动开发服务器,然后在浏览器中打开http:127.0.0.1/8000/admin/order/add/。你会看到以下界面:

7.3.3 创建用户订单

当用户最终下单时,我们需要使用刚创建的订单模型来保存购物车中的商品。创建一个新订单的工作流程是这样的:

  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 django.shortcuts import render
from .models import OrderItem
from .forms import OrderCreateForm
from cart.cart import Cart

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()创建一个新的Order实例。然后我们会将它保存到数据库中,并存储在order变量中。创建order之后,我们会迭代购物车中的商品,并为每个商品创建OrderItem。最后,我们会清空购物车的内容。

现在,在orders应用目录中创建urls.py文件,并添加以下代码:

from django.conf.urls import url
from .import views

urlpatterns = [
    url(r'^create/$', views.order_create, name='order_create'),
]

这是order_create视图的 URL 模式。编辑myshop项目的urls.py文件,并引入以下模式。记住,把它放在shop.urls模式之前:

url(r'^orders/', include('orders.urls', namespace='orders')),

编辑cart应用的cart/detail.html模板,找到这行代码:

<a href="#" class="button">Checkout</a>

把这样代码替换为以下代码:

<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 title %}

{% 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 action="." method="post" class="order-form">
        {{ form.as_p }}
        <p><input type="submit" value="Place order"></p>
        {% csrf_token %}
    </form>
{% endblock content %}

这个模板显示购物车中的商品,包括总数量和下单的表单。

编辑orders/order/created.html模板,并添加以下代码:

{% extends "shop/base.html" %}

{% block title %}
    Thank you
{% endblock title %}

{% block content %}
    <h1>Thank you</h1>
    <p>Your order has been successfully completed. 
        Your order number is <stong>{{ order.id }}</stong>
    </p>
{% endblock content %}

成功创建订单后,我们渲染这个模板。启动开发服务器,并在浏览器中打开http://127.0.0.1:8000/。在购物车中添加一些商品,然后跳转到结账界面。如下图所示:

用有效的数据填写表单,然后点击Place order按钮。订单会被创建,你将看到成功页面,如下图所示:

7.4 使用 Celery 启动异步任务

你在视图中执行的所有操作都会影响响应时间。在很多场景中,你可能希望尽快给用户返回响应,并让服务器执行一些异步处理。对于费时处理,或者失败后可能需要重试策略的处理尤其重要。例如,一个视频分享平台允许用户上传视频,但转码上传的视频需要很长的时间。网站可能给用户返回一个响应,告诉用户马上开始转码,然后开始异步转码。另一个例子是给用户发送邮件。如果网站在视图中发送邮件通知,SMTP 连接可能失败,或者减慢响应时间。启动异步任务避免阻塞操作是必不可少的。

Celery 是一个可以处理大量消息的分布式任务队列。它既可以实时处理,也支持任务调度。使用 Celery 不仅可以很容易的创建异步任务,还可以尽快执行任务,但也可以在一个指定时间执行任务。

你可以在这里查看 Celery 文档。

7.4.1 安装 Celery

让我们安装 Celery,并在项目中集成它。使用以下pip命令安装 Celery:

pip install celery

Celery 必须有一个消息代理(message broker)处理外部请求。代理负责发送消息给 Celery 的workerworker收到消息后处理任务。让我们安装一个消息代理。

7.4.2 安装 RabbitMQ

Celery 有几个消息代理可供选择,包括键值对存储(比如 Redis),或者一个实际的消息系统(比如 RabbitMQ)。我们将用 RabbitMQ 配置 Celery,因为它是 Celery 的推荐消息 worker。

如果你使用的是 Linux,可以在终端执行以下命令安装 RabbitMQ:

apt-get install rabbitmq

如果你需要在 Max OS X 或者 Windows 上安装 RabbitMQ,你可以在这里找到独立的版本。

安装后,在终端执行以下命令启动 RabbitMQ:

rabbitmq-server

你会看到以这一行结尾的输出:

Starting broker... completed with 10 plugins.

7.4.3 在项目中添加 Celery

你需要为 Celery 实例提供一个配置。在myshop中创建celery.py文件,该文件会包括项目的 Celery 配置,并添加以下代码:

import os
from celery import Celery
from django.conf import settings

# 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')
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS)

在这段代码中,我们为 Celery 命令行程序设置DJANGO_SETINGS_MODULE变量。然后用app = Celery('myshop.)创建了一个应用实例。我们用config_from_object()方法从项目设置中加载所有自定义设置。最后我们告诉 Celery,为INSTALLED_APPS设置中列出的应用自动查找异步任务。Celery 会在每个应用目录中查找tasks.py文件,并加载其中定义的异步任务。

你需要在项目的__init__.py文件中导入celery模块,确保 Django 启动时会加载 Celery。编辑myshop/__init__.py文件,并添加以下代码:

from .celery import app as celery_app

现在你可以开始为应用编写异步任务了。

CELERY_ALWAYS_EAGER设置允许你以同步方式在本地执行任务,而不是将其发送到队列。这对于运行单元测试,或者在不运行 Celery 的情况下,运行本地环境中的项目时非常有用。

7.4.4 在应用中添加异步任务

当用户下单后,我们将创建一个异步任务,给用户发送一封邮件通知。

一般的做法是在应用目录的tasks模块中包括应用的异步任务。在orders应用目录中创建tasks.py文件。Celery 会在这里查找异步任务。在其中添加以下代码:

from celery import task
from django.core.mail import send_mail
from .models import Order

@task
def order_created(order_id):
    order = Order.objects.get(id=order_id)
    subject = 'Order nr. {}'.format(order.id)
    message = 'Dear {},\n\nYou have successfully placed an order.\
    	Your order id is {}.'.format(order.first_name, order.id)
    mail_sent = send_mail(subject, message, 'admin@myshop.com', [order.email])
    return mail_sent

我们使用task装饰器定义了order_created任务。正如你所看到的,Celery 任务就是一个用task装饰的 Python 函数。我们的task函数接收order_id作为参数。推荐只传递 ID 给任务函数,并在任务执行时查询对象。我们用 Django 提供的send_mail()函数,当用户下单后发送邮件通知。如果你不想配置邮件选项,你可以在项目的settings.py文件中添加以下设置,让 Django 在控制台输出邮件:

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

异步任务不仅可以用于费时的操作,还可以用于可能失败的操作,这些操作不会执行很长时间,但它们可能会连接失败,或者需要重试策略。

现在我们需要在order_create视图中添加任务。打开orders应用的views.py文件,并导入任务:

from .tasks import order_created

然后在清空购物车之后调用order_created异步任务:

# clear the cart
cart.clear()
# launch asynchronous task
order_created.delay(order.id)

我们调用任务的delay()方法异步执行任务。任务会被添加到队列中,worker会尽快执行。

打开另一个终端,并使用以下命令启动 Celery 的worker

celery -A myshop worker -l info

译者注:必须在myshop项目目录下执行上面的命令。

现在 Celery 的worker已经运行,准备好处理任务了。确保 Django 开发服务器也在运行。在浏览器中打开http://127.0.0.1/8000/,添加一些商品到购物车中,然后完成订单。在终端,你已经启动了 Celery worker,你会看到类似这样的输出:

[2017-05-11 06:40:27,416: INFO/MainProcess] Received task: orders.tasks.order_created[4d6f667b-7cc7-4310-82fc-8323810fae54]
[2017-05-11 06:40:27,825: INFO/PoolWorker-3] Task orders.tasks.order_created[4d6f667b-7cc7-4310-82fc-8323810fae54] succeeded in 0.12212000600266038s: 1

任务已经执行,你会收到一封订单的邮件通知。

7.4.5 监控 Celery

你可能希望监控已经执行的异步任务。Flower 是一个基于网页的监控 Celery 工具。你可以使用pip install flower安装 Flower。

安装后,你可以在项目目录下执行以下命令启动 Flower:

celery -A myshop flower

在浏览器中打开http://127.0.0.1:5555/dashboard。你会看到活动的 Celery worker和异步任务统计:

你可以在这里查看 Flower 的文档。

7.5 总结

在这章中,你创建了一个基础的在线商店应用。你创建了商品目录,并用会话构建了购物车。你实现了自定义上下文处理器,让模板可以访问购物车,并创建了下单的表单。你还学习了如何使用 Celery 启动异步任务。

在下一章中,你会学习在商店中集成支付网关(payment gateway),在管理站点添加用户操作,导出 CVS 格式数据,以及动态生成 PDF 文件。

第八章:管理支付和订单

在上一章中,你创建了一个包括商品目录和订单系统的在线商店。你还学习了如何用 Celery 启动异步任务。在这一章中,你会学习如何在网站中集成支付网关。你还会扩展管理站点,用于管理订单和导出不同格式的订单。

我们会在本章覆盖以下知识点:

  • 在项目中集成支付网关
  • 管理支付通知
  • 导出订单到 CSV 文件中
  • 为管理站点创建自定义视图
  • 动态生成 PDF 单据

8.1 集成支付网关

支付网关允许你在线处理支付。你可以使用支付网关管理用户订单,以及通过可靠的,安全的第三方代理处理支付。这意味着你不用考虑在自己的系统中存储信用卡。

有很多支付网关可供选择。我们将集成 PayPal,它是最流行的支付网关之一。

PayPal 提供了几种方法在网站中集成它的网关。标准集成包括一个Buy now按钮,你可能在其它网站见过。这个按钮把顾客重定向到 PayPal 来处理支付。我们将在网站中集成包括一个自定义Buy now按钮的PayPal Payments Standard。PayPal 会处理支付,并发送一条支付状态的信息到我们的服务器。

8.1.1 创建 PayPal 账户

你需要一个 PayPal 商家账户,才能在网站中集成支付网关。如果你还没有 PayPal 账户,在这里注册。确保你选择了商家账户。

在注册表单填写详细信息完成注册。PayPal 会给你发送一封邮件确认账户。

8.1.2 安装 django-paypal

django-paypal是一个第三方 Django 应用,可以简化在 Django 项目中集成 PayPal。我们将用它在我们的商店中集成PayPal Payments Standard。你可以在这里查看 django-paypal 的文档。

在终端使用以下命令安装 django-paypal:

pip install django-paypal

编辑项目的settings.py文件,在INSTALLED_APPS设置中添加paypal.standard.ipn

INSTALLED_APPS = [
	# ...
	'paypal.standard.ipn',
]

这个应用是 django-paypal 提供的,通过Instant Payment Notification(IPN)集成PayPal Payments Standard。我们之后会处理支付通知。

myshopsettings.py文件添加以下设置来配置 django-paypal:

# django-paypal settings
PAYPAL_RECEIVER_EMAIL = 'mypaypalemail@myshop.com'
PAYPAL_TEST = True

这些设置分别是:

  • PAYPAL_RECEIVER_EMAIL:你 PayPal 账户的邮箱地址。用你创建 PayPal 账户的邮箱替换mypaypalemail@myshop.com
  • PAYPAL_TEST:一个布尔值,表示是否用 PayPal 的 Sandbox 环境处理支付。在迁移到生产环境之前,你可以用 Sandbox 测试 PayPal 集成。

打开终端执行以下命令,同步 django-paypal 的模型到数据库中:

python manage.py migrate

你会看到类似这样结尾的输出:

Running migrations:
  Applying ipn.0001_initial... OK
  Applying ipn.0002_paypalipn_mp_id... OK
  Applying ipn.0003_auto_20141117_1647... OK
  Applying ipn.0004_auto_20150612_1826... OK
  Applying ipn.0005_auto_20151217_0948... OK
  Applying ipn.0006_auto_20160108_1112... OK
  Applying ipn.0007_auto_20160219_1135... OK

现在 django-paypal 的模型已经同步到数据库中。你还需要添加 django-paypal 的 URL 模式到项目中。编辑myshop项目的主urls.py文件,并添加以下 URL 模式。记住,把它放在shop.urls模式之前,避免错误的模式匹配:

url(r'^paypal/', include('paypal.standard.ipn.urls')),

让我们把支付网关添加到结账过程中。

8.1.3 添加支付网关

结账流程是这样的:

  1. 用户添加商品到购物车中。
  2. 用户结账购物车。
  3. 重定向用户到 PayPal 进行支付。
  4. PayPal 发送支付通知到我们的服务器。
  5. PayPal 重定向用户返回我们的网站。

使用以下命令在项目中创建一个新应用:

python manage.py startapp payment

我们将使用这个应用管理结账流程和用户支付。

编辑项目的settings.py文件,在INSTALLED_APP设置中添加payment

INSTALLED_APPS = [
	# ...
	'paypal.standard.ipn',
	'payment',
]

现在payment应用已经在项目中激活了。编辑orders应用的views.py文件,添加以下导入:

from django.shortcuts import render, redirect
from django.core.urlresolvers import reverse

找到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)
request.session['order_id'] = order.id
return redirect(reverse('payment:process'))

创建订单成功之后,我们用order_id会话键在当前会话中设置订单 ID。然后我们把用户重定向到接下来会创建的payment:process URL。

编辑payment应用的views.py文件,并添加以下代码:

from decimal import Decimal
from django.conf import settings
from django.core.urlresolvers import reverse
from django.shortcuts import render, get_object_or_404
from paypal.standard.forms import PayPalPaymentsForm
from orders.models import Order

def payment_process(request):
    order_id = request.session.get('order_id')
    order = get_object_or_404(Order, id=order_id)
    host = request.get_host()

    paypal_dict = {
        'business': settings.PAYPAL_RECEIVER_EMAIL,
        'amount': '%.2f' % order.get_total_cost().quantize(Decimal('.01')),
        'item_name': 'Order {}'.format(order.id),
        'invoice': str(order.id),
        'currency_code': 'USD',
        'notify_url': 'http://{}{}'.format(host, reverse('paypal-ipn')),
        'return_url': 'http://{}{}'.format(host, reverse('payment:done')),
        'cancel_return': 'http://{}{}'.format(host, reverse('payment:canceled')),
    }
    form = PayPalPaymentsForm(initial=paypal_dict)
    return render(request, 'payment/process.html', {'order': order, 'form': form})

payment_process视图中,我们生成了一个自定义 PayPal 的Buy now按钮用于支付。首先我们从order_id会话键中获得当前订单,这个键值之前在order_create视图中设置过。我们获得指定 ID 的Order对象,并创建了包括以下字段的PayPalPaymentForm

  • business:处理支付的 PayPal 商家账户。在这里我们使用PAYPAL_RECEIVER_EMAIL设置中定义的邮箱账户。
  • amount:向顾客收取的总价。
  • item_name:出售的商品名。我们使用商品 ID,因为订单里可能包括多个商品。
  • invoice:单据 ID。每次支付对应的这个 ID 应用是唯一的。我们使用订单 ID。
  • currency_code:这次支付的货币。我们设置为USD使用美元。使用与 PayPal 账户中设置的相同货币(EUR对应欧元)。
  • notify_url:PayPal 发送 IPN 请求到这个 URL。我们使用 django-paypal 提供的paypal-ipn URL。这个 URL 关联的视图处理负责支付通知和在数据库中保存支付通知。
  • return_url:支付成功后重定向用户到这个 URL。我们使用之后会创建的payment:done URL。
  • cancel_return:如果支付取消,或者遇到其它问题,重定向用户到这个 URL。我们使用之后会创建的payment:canceled URL。

PayPalPaymentForm会被渲染为带隐藏字典的标准表单,用户只能看到Buy now按钮。点用户点击这个按钮,表单会通过 POST 提交到 PayPal。

让我们创建一个简单的视图,当支付完成,或者因为某些原因取消支付,让 PayPal 重定向用户。在同一个views.py文件中添加以下代码:

from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def payment_done(request):
    return render(request, 'payment/done.html')

@csrf_exempt
def payment_canceled(request):
    return render(request, 'payment/canceled.html')

因为 PayPal 可以通过 POST 重定向用户到这些视图的任何一个,所以我们用csrf_exempt装饰器避免 Django 期望的 CSRF 令牌。在payment应用目录中创建urls.py文件,并添加以下代码:

from django.conf.urls import url
from . import views

urlpatterns = [
    url(r'^process/$', views.payment_process, name='process'),
    url(r'^done/$', views.payment_done, name='done'),
    url(r'^canceled/$', views.payment_canceled, name='canceled'),
]

这些是支付流程的 URL。我们包括了以下 URL 模式:

  • process:用于生成带Buy now按钮的 PayPal 表单的视图
  • done:当支付成功后,用于 PayPal 重定向用户
  • canceled:当支付取消后,用于 PayPal 重定向用户

编辑myshop项目的主urls.py文件,引入payment应用的 URL 模式:

url(r'^payment/', include('payment.urls', namespace='payment')),

记住把它放在shop.urls模式之前,避免错误的模式匹配。

payment应用目录中创建以下文件结构:

templates/
	payment/
		process.html
		done.html
		canceled.html

编辑payment/process.html模板,添加以下代码:

{% extends "shop/base.html" %}

{% block title %}Pay using PayPal{% endblock title %}

{% block content %}
    <h1>Pay using PayPal</h1>
    {{ form.render }}
{% endblock content %}

这个模板用于渲染PayPalPaymentForm和显示Buy now按钮。

编辑payment/done.html模板,添加以下代码:

{% extends "shop/base.html" %}

{% block content %}
    <h1>Your payment was successful</h1>
    <p>Your payment has been successfully received.</p>
{% endblock content %}

用户支付成功后,会重定向到这个模板页面。

编辑payment/canceled.html模板,并添加以下代码:

{% extends "shop/base.html" %}

{% block content %}
    <h1>Your payment has not been processed</h1>
    <p>There was a problem processing your payment.</p>
{% endblock content %}

处理支付遇到问题,或者用户取消支付时,会重定向到这个模板页面。

让我们尝试完整的支付流程。

8.1.4 使用 PayPal 的 Sandbox

在浏览器中打开http://developer.paypal.com,并用你的 PayPal 商家账户登录。点击Dashboard菜单项,然后点击Sandbox下的Accounts选项。你会看到你的 sandbox 测试账户列表,如下图所示:

最初,你会看到一个商家账户和一个 PayPal 自动生成的个人测试账户。你可以点击Create Account按钮创建新的 sandbox 测试账户。

点击列表中TypePERSONAL的账户,然后点击Pofile链接。你会看到测试账户的信息,包括邮箱地址和个人资料信息,如下图所示:

Funding标签页中,你会看到银行账户,信用卡数据,以及 PayPal 贷方余额。

当你的网站使用 sandbox 环境时,测试账户可以用来处理支付。导航到Profile标签页,然后点击修改Change password链接。为这个测试账户创建一个自定义密码。

在终端执行python manage.py runserver命令启动开发服务器。在浏览器中打开http://127.0.0.1:8000/,添加一些商品到购物车中,然后填写结账表单。当你点击Place order按钮时,订单会存储到数据库中,订单 ID 会保存在当前会话中,然后会重定向到支付处理页面。这个页面从会话中获得订单,并渲染带Buy now按钮的 PayPal 表单,如下图所示:

译者注:启动开发服务器后,还需要启动 RabbitMQ 和 Celery,因为我们要用它们异步发送邮件,否则会抛出异常。

你可以看一眼 HTML 源码,查看生成的表单字段。

点击Buy now按钮。你会被重定向到 PayPal,如下图所示:

输入顾客测试账号的邮箱地址和密码,然后点击登录按钮。你会被重定向到以下页面:

译者注:即之前修改过密码的个人账户。

现在点击立即付款按钮。最后,你会看到一个包括交易 ID 的确认页面,如下图所示:

点击返回商家按钮。你会被重定向到PayPalPaymentFormreturn_url字段指定的 URL。这是payment_done视图的 URL,如下图所示:

支付成功!但是因为我们在本地运行项目,127.0.0.1 不是一个公网 IP,所以 PayPal 不能给我们的应用发送支付状态通知。我们接下来学习如何让我们的网站可以从 Internet 访问,从而接收 IPN 通知。

8.1.5 获得支付通知

IPN 是大部分支付网关都会提供的方法,用于实时跟踪购买。当网关处理完一个支付后,会立即给你的服务器发送一个通知。该通知包括所有支付细节,包括状态和用于确认通知来源的支付签名。这个通知作为独立的 HTTP 请求发送到你的服务器。出现问题的时候,PayPal 会多次尝试发送通知。

django-payapl自带两个不同的 IPN 信号,分别是:

  • valid_ipn_received:当从 PayPal 接收的 IPN 消息是正确的,并且不会与数据库中现在消息重复时触发
  • invalid_ipn_received:当从 PayPal 接收的消息包括无效数据或者格式不对时触发

我们将创建一个自定义接收函数,并把它连接到valid_ipn_received信号来确认支付。

payment应用目录中创建signals.py文件,并添加以下代码:

from django.shortcuts import get_object_or_404
from paypal.standard.models import ST_PP_COMPLETED
from paypal.standard.ipn.signals import valid_ipn_received
from orders.models import Order

def payment_notification(sender, **kwargs):
    ipn_obj = sender
    if ipn_obj.payment_status == ST_PP_COMPLETED:
        # payment was successful
        order = get_object_or_404(Order, id=ipn_obj.invoice)
        # mark the order as paid
        order.paid = True
        order.save()

valid_ipn_received.connect(payment_notification)

我们把payment_notification接收函数连接到 django-paypal 提供的valid_ipn_received信号。接收函数是这样工作的:

  1. 我们接收sender对象,它是在paypal.standard.ipn.models中定义的PayPalPN模型的一个实例。
  2. 我们检查paypal_status属性,确保它等于 django-paypal 的完成状态。这个状态表示支付处理成功。
  3. 接着我们用get_object_or_404快捷函数获得订单,这个订单的 ID 必须匹配我们提供给 PayPal 的invoice参数。
  4. 我们设置订单的paid属性为True,标记订单状态为已支付,并把Order对象保存到数据库中。

valid_ipn_received信号触发时,你必须确保信号模块已经加载,这样接收函数才会被调用。最好的方式是在包括它们的应用加载的时候,加载你自己的信号。可以通过定义一个自定义的应用配置来实现,我们会在下一节中讲解。

8.1.6 配置我们的应用

你已经在第六章学习了应用配置。我们将为payment应用定义一个自定义配置,用来加载我们的信号接收函数。

payment应用目录中创建apps.py文件,并添加以下代码:

from django.apps import AppConfig

class PaymentConfig(AppConfig):
    name = 'payment'
    verbose_name = 'Payment'

    def ready(self):
        # improt signal handlers
        import payment.signals

在这段代码中,我们为payment应用定义了一个AppConfig类。name参数是应用的名字,verbose_name是一个可读的名字。我们在ready()方法中导入信号模板,确保应用初始化时会加载信号模块。

编辑payment应用的__init__.py文件,并添加这一行代码:

default_app_config = 'payment.apps.PaymentConfig'

这会让 Django 自动加载你的自定义应用配置类。你可以在这里阅读更多关于应用配置的信息。

8.1.7 测试支付通知

因为我们在本地环境开发,所以我们需要让 PayPal 可以访问我们的网站。有几个应用程序可以让开发环境通过 Internet 访问。我们将使用 Ngrok,是最流行的之一。

这里下载你的操作系统版本的 Ngrok,并使用以下命令运行:

./ngrok http 8000

这个命令告诉 Ngrok 在 8000 端口为你的本地主机创建一个链路,并为它分配一个 Internet 可访问的主机名。你可以看到类似这样的输入:

Session Status                online
Account                       lakerszhy (Plan: Free)
Update                        update available (version 2.2.4, Ctrl-U to update)
Version                       2.1.18
Region                        United States (us)
Web Interface                 http://127.0.0.1:4040
Forwarding                    http://c0f17d7c.ngrok.io -> localhost:8000
Forwarding                    https://c0f17d7c.ngrok.io -> localhost:8000

Connections                   ttl     opn     rt1     rt5     p50     p90
                              0       0       0.00    0.00    0.00    0.00

Ngrok 告诉我们,我们网站使用的 Django 开发服务器在本机的 8000 端口运行,现在可以通过http://c0f17d7c.ngrok.iohttps://c0f17d7c.ngrok.io(分别对应 HTTP 和 HTTPS 协议)在 Internet 上访问。Ngrok 还提供了一个网页 URL,这个网页显示发送到这个服务器的信息。在浏览器中打开 Ngrok 提供的 URL,比如http://c0f17d7c.ngrok.io。在购物车中添加一些商品,下单,然后用 PayPal 测试账户支付。此时,PayPal 可以访问payment_process视图中PayPalPaymentFormnotify_url字段生成的 URL。如果你查看渲染的表单,你会看类似这样的 HTML 表单:

<input id="id_notify_url" name="notify_url" type="hidden" value="http://c0f17d7c.ngrok.io/paypal/">

完成支付处理后,在浏览器中打开http://127.0.0.1:8000/admin/ipn/paypalipn/。你会看到一个IPN对象,对应状态是Completed的最新一笔支付。这个对象包括支付的所有信息,它由 PayPal 发送到你提供给 IPN 通知的 URL。

译者注:如果通过http://c0f17d7c.ngrok.io访问在线商店,则需要在项目的settings.py文件的ALLOWED_HOSTS设置中添加c0f17d7c.ngrok.io

译者注:我在后台看到的一直都是Pending状态,一直没有找出原因。哪位朋友知道的话,请给我留言,谢谢。

你也可以在这里使用 PayPal 的模拟器发送 IPN。模拟器允许你指定通知的字段和类型。

除了PayPal Payments Standard,PayPal 还提供了Website Payments Pro,它是一个订购服务,可以在你的网站接收支付,而不用重定向到 PayPal。你可以在这里查看如何集成Website Payments Pro

8.2 导出订单到 CSV 文件

有时你可能希望把模型中的信息导出到文件中,然后把它导入到其它系统中。其中使用最广泛的格式是Comma-Separated Values(CSV)。CSV 文件是一个由若干条记录组成的普通文本文件。通常一行包括一条记录和一些定界符号,一般是逗号,用于分割记录的字段。我们将自定义管理站点,让它可以到处订单到 CSV 文件。

8.2.1 在管理站点你添加自定义操作

Django 提供了大量自定义管理站点的选项。我们将修改对象列表视图,在其中包括一个自定义的管理操作。

一个管理操作是这样工作的:用户在管理站点的对象列表页面用复选框选择对象,然后选择一个在所有选中选项上执行的操作,最后执行操作。下图显示了操作位于管理站点的哪个位置:

创建自定义管理操作允许工作人员一次在多个元素上进行操作。

你可以编写一个常规函数来创建自定义操作,该函数需要接收以下参数:

  • 当前显示的ModelAdmin
  • 当前请求对象——一个HttpRequest实例
  • 一个用户选中对象的QuerySet

当在管理站点触发操作时,会执行这个函数。

我们将创建一个自定义管理操作,来下载一组订单的 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
    response = HttpResponse(content_type='text/csv')
    response['Content-Disposition'] = 'attachment;filename={}.csv'.format(opts.verbose_name)
    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. 我们创建了 CSV 的writer对象,用于向response对象中写入数据。
  3. 我们用模型的_meta选项的get_fields()方法动态获得模型的字段。我们派出了对多对和一对多关系。
  4. 我们用字段名写入标题行。
  5. 我们迭代给定的QuerySet,并为QuerySet返回的每个对象写入一行数据。因为 CSV 的输出值必须为字符串,所以我们格式化datetime对象。
  6. 我们设置函数的short_description属性,指定这个操作在模板中显示的名字。

我们创建了一个通用的管理操作,可以添加到所有ModelAdmin类上。

最后,如下添加export_to_csv管理操作到OrderAdmin类上:

calss OrderAdmin(admin.ModelAdmin):
	# ...
	actions = [export_to_csv]

在浏览器中打开http://127.0.0.1:8000/admin/orders/order/,管理操作如下图所示:

选中几条订单,然后在选择框中选择Export to CSV操作,接着点击Go按钮。你的浏览器会下载生成的order.csv文件。用文本编辑器打开下载的文件。你会看到以下格式的内容,其中包括标题行,以及你选择的每个Order对象行:

ID,first name,last name,email,address,postal code,city,created,updated,paid
1,allen,iverson,lakerszhy@gmail.com,北京市朝阳区,100012,北京市,11/05/2017,11/05/2017,False
2,allen,kobe,lakerszhy@gmail.com,北京市朝阳区,100012,北京市,11/05/2017,11/05/2017,False

正如你所看到的,创建管理操作非常简单。

8.3 用自定义视图扩展管理站点

有时,你可能希望通过配置ModelAdmin,创建管理操作和覆写管理目标来定制管理站点。这种情况下,你需要创建自定义的管理视图。使用自定义视图,可以创建任何你需要的功能。你只需要确保只有工作人员能访问你的视图,以及让你的模板继承自管理模板来维持管理站点的外观。

让我们创建一个自定义视图,显示订单的相关信息。编辑orders应用的views.py文件,并添加以下代码:

from django.contrib.admin.views.decorators import staff_member_required
from django.shortcuts import get_object_or_404
from .models import Order

@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 模式:

url(r'^admin/order/(?P<order_id>\d+)/$', views.admin_order_detail, name='admin_order_detail'),

orders应用的templates目录中创建以下目录结构:

admin/
	orders/
		order/
			detail.html

编辑detail.html模板,添加以下代码:

{% extends "admin/base_site.html" %}
{% load static %}

{% block extrastyle %}
    <link rel="stylesheet" type="text/css" href="{% static "css/admin.css" %}" />
{% endblock extrastyle %}

{% block title %}
    Order {{ order.id }} {{ block.super }}
{% endblock title %}

{% 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 breadcrumbs %}

{% block content %}
    <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>
    </table>

    <div class="module">
        <div class="tabular inline-related last-related">
            <table>
                <h2>Items bought</h2>
                <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>
    </div>
{% endblock content %}

这个模板用于在管理站点显示订单详情。模板扩展自 Django 管理站点的admin/base_site.html模板,其中包括主 HTML 结构和管理站的 CSS 样式。我们加载自定义的静态文件css/admin.css

为了使用静态文件,我们可以从本章的示例代码中获得它们。拷贝orders应用的static/目录中的静态文件,添加到你项目中的相同位置。

我们使用父模板中定义的块引入自己的内容。我们显示订单信息和购买的商品。

当你想要扩展一个管理模板时,你需要了解它的结构,并确定它存在哪些块。你可以在这里查看所有管理模板。

如果需要,你也可以覆盖一个管理模板。把要覆盖的模板拷贝到templates目录中,保留一样的相对路径和文件。Django 的管理站点会使用你自定义的模板代替默认模板。

最后,让我们为管理站点的列表显示页中每个Order对象添加一个链接。编辑orders应用的amdin.py文件,在OrderAdmin类之前添加以下代码:

from django.core.urlresolvers import reverse

def order_detail(obj):
    return '<a href="{}">View</a>'.format(reverse('orders:admin_order_detail', args=[obj.id]))
order_detail.allow_tags = True

这个函数接收一个Order对象作为参数,并返回一个admin_order_detail的 HTML 链接。默认情况下,Django 会转义 HTML 输出。我们必须设置函数的allow_tags属性为True,从而避免自动转义。

在任何Model方法,ModelAdmin方法,或者可调用函数中设置allow_tags属性为True可以避免 HTML 转义。使用allow_tags时,确保转义用户的输入,以避免跨站点脚本。

然后编辑OrderAdmin类来显示链接:

class OrderAdmin(admin.ModelAdmin):
    list_display = [... order_detail]

在浏览器中打开http://127.0.0.1:8000/admin/orders/order/,现在每行都包括一个View链接,如下图所示:

点击任何一个订单的View链接,会加载自定义的订单详情页面,如下图所示:

8.4 动态生成 PDF 单据

我们现在已经有了完成的结账和支付系统,可以为每个订单生成 PDF 单据了。有几个 Python 库可以生成 PDF 文件。一个流行的生成 PDF 文件的 Python 库是 Reportlab。你可以在这里查看如果使用 Reportlab 输出 PDF 文件。

大部分情况下,你必须在 PDF 文件中添加自定义样式和格式。你会发现,让 Python 远离表现层,渲染一个 HTML 模板,然后把它转换为 PDF 文件更加方便。我们将采用这种方法,在 Django 中用模块生成 PDF 文件。我们会使用 WeasyPrint,它是一个 Python 库,可以从 HTML 模板生成 PDF 文件。

8.4.1 安装 WeasyPrint

首先,为你的操作系统安装 WeasyPrint 的依赖,请访问这里

然后用以下命令安装 WeasyPrint:

pip install WeasyPrint

8.4.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>元素。我们还包括一个消息,显示订单是否支付。

8.4.3 渲染 PDF 文件

我们将创建一个视图,在管理站点中生成已存在订单的 PDF 单据。编辑orders应用的views.py文件,并添加以下代码:

from django.conf import settings
from django.http import HttpResponse
from django.template.loader import render_to_string
import weasyprint

@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'] = 'filename="order_{}.pdf"'.format(order.id)
    weasyprint.HTML(string=html).write_pdf(response, 
        stylesheets=[weasyprint.CSS(settings.STATIC_ROOT + '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 样式。我们从STATIC_ROOT设置中的本地路径加载它。最后返回生成的响应。

因为我们需要使用STATIC_ROOT设置,所以需要把它添加到我们项目中。这是项目的静态文件存放的路径。编辑myshop项目的settings.py文件,添加以下设置:

STATIC_ROOT = os.path.join(BASE_DIR, 'static/')

接着执行python manage.py collectstatic命令。你会看到这样结尾的输出:

You have requested to collect static files at the destination
location as specified in your settings:

    /Users/lakerszhy/Documents/GitHub/Django-By-Example/code/Chapter 8/myshop/static

This will overwrite existing files!
Are you sure you want to do this?

输入yes并按下Enter。你会看到一条消息,显示静态文件已经拷贝到STATIC_ROOT目录中。

collectstatic命令拷贝应用中所有静态文件到STATIC_ROOT设置中定义的目录。这样每个应用可以在static/目录中包括静态文件。你还可以在STATICFILES_DIRS设置中提供其它静态文件源。执行collectstatic命令时,STATICFILES_DIRS中列出的所有目录都会被拷贝到STATIC_ROOT目录中。

编辑orders应用中的urls.py文件,添加以下 URL 模式:

url(r'admin/order/(?P<order_id>\d+)/pdf/$', views.admin_order_pdf, name='admin_order_pdf'),

现在,我们可以编辑管理列表显示页面,为Order模型的每条记录添加一个 PDF 文件链接。编辑orders应用的admin.py文件,在OrderAdmin类之前添加以下代码:

def order_pdf(obj):
    return '<a href="{}">PDF</a>'.format(reverse('orders:admin_order_pdf', args=[obj.id]))
order_pdf.allow_tags = True
order_pdf.short_description = 'PDF bill'

order_pdf添加到OrderAdmin类的list_display属性中,如下所示:

class OrderAdmin(admin.ModelAdmin):
    list_display = [..., order_detail, order_pdf]

如果你为可调用对象指定了short_description属性,Django 将把它作为列名。

在浏览器中打开http://127.0.0.1:8000/admin/orders/order。每行都会包括一个 PDF 链接,如下图所示:

点击任意一条订单的 PDF 链接。你会看到生成的 PDF 文件,下图是未支付的订单:

已支付订单如下图所示:

8.4.4 通过邮件发送 PDF 文件

当收到支付时,让我们给顾客发送一封包括 PDF 单据的邮件。编辑payment应用的signals.py文件,并添加以下导入:

from django.template.loader import render_to_string
from django.core.mail import EmailMessage
from django.conf import settings
import weasyprint
from io import BytesIO

然后在order.save()行之后添加以下代码,保持相同的缩进:

# create invoice e-mail
subject = 'My Shop - Invoice no. {}'.format(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(settings.STATIC_ROOT + 'css/pdf.css')]
weasyprint.HTML(string=html).write_pdf(out, stylesheets=stylesheets)
# attach PDF file
email.attach('order_{}.pdf'.format(order.id), out.getvalue(), 'application/pdf')
# send e-mail
email.send()

在这个信号中,我们用 Django 提供的EmailMessage类创建了一个邮件对象。然后把模板渲染到html变量中。我们从渲染的模板中生成 PDF 文件,并把它输出到一个BytesIO实例(内存中的字节缓存)中。接着我们用EmailMessage对象的attach()方法,把生成的 PDF 文件和out缓存中的内容添加到EmailMessage对象中。

记得在项目settings.py文件中设置发送邮件的SMTP设置,你可以参考第二章。

现在打开 Ngrok 提供的应用 URL,完成一笔新的支付,就能在邮件中收到 PDF 单据了。

8.5 总结

在这一章中,你在项目中集成了支付网关。你自定义了 Django 管理站点,并学习了如果动态生成 CSV 和 PDF 文件。

下一章会深入了解 Django 项目的国际化和本地化。你还会创建一个优惠券系统和商品推荐引擎。

第九章:扩展你的商店

上一章中,你学习了如何在商店中集成支付网关。你完成了支付通知,学习了如何生成 CSV 和 PDF 文件。在这一章中,你会在商店中添加优惠券系统。你将学习如何处理国际化和本地化,并构建一个推荐引擎。

本章会覆盖以下知识点:

  • 创建优惠券系统实现折扣
  • 在项目中添加国际化
  • 使用 Rosetta 管理翻译
  • 使用 django-parler 翻译模型
  • 构建一个商品推荐系统

9.1 创建优惠券系统

很多在线商店会给顾客发放优惠券,在购买商品时可以兑换折扣。在线优惠券通常是一组发放给用户的代码,这个代码在某个时间段内有效。这个代码可以兑换一次或多次。

我们将为我们的商品创建一个优惠券系统。顾客在某个时间段内输入我们的优惠券才有效。优惠券没有使用次数限制,可以抵扣购物车的总金额。对于这个功能,我们需要创建一个模型,用于存储优惠券码,有效时间和折扣金额。

使用以下命令在myshop项目中添加一个新应用:

python manage.py startapp coupons

编辑myshopsettings.py文件,把应用添加到INSTALLED_APPS中:

INSTALLED_APPS = (
	# ...
	'coupons',
)

现在新应用已经在我们的 Django 项目中激活了。

9.1.1 构建优惠券模型

让我们从创建Coupon模型开始。编辑coupons应用的models.py文件,并添加以下代码:

from django.db import models
from django.core.validators import MinValueValidator
from django.core.validators import MaxValueValidator

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)])
    active = models.BooleanField()

    def __str__(self):
        return self.code

这是存储优惠券的模型。Coupon模型包括以下字段:

  • code:用户必须输入优惠券码才能使用优惠券。
  • valid_from:优惠券开始生效的时间。
  • valid_to:优惠券过期的时间。
  • discount:折扣率(这是一个百分比,所以范围是 0 到 100)。我们使用验证器限制这个字段的最小值和最大值。
  • active:表示优惠券是否有效的布尔值。

执行以下命令,生成coupon应用的初始数据库迁移:

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

class CouponAdmin(admin.ModelAdmin):
    list_display = ['code', 'valid_from', 'valid_to', 'discount', 'active']
    list_filter = ['active', 'valid_from', 'valid_to']
    search_fields = ['code']
admin.site.register(Coupon, CouponAdmin)

现在Coupon模型已经在管理站点注册。执行python manage.py runserver命令启动开发服务器,然后在浏览器中打开http://127.0.0.1:8000/admin/coupons/coupon/add/。你会看下图中的表单:

填写表单,创建一个当天可用的优惠券,并勾选Active,然后点击Save按钮。

9.1.2 在购物车中使用优惠券

我们可以存储新的优惠券,并且可以查询已存在的优惠券。现在我们需要让顾客可以使用优惠券。考虑一下应该怎么实现这个功能。使用优惠券的流程是这样的:

  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 render, redirect
from django.utils import timezone
from django.views.decorators.http import require_POST
from .models import Coupon
from .forms import CouponApplyForm

@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字典中获得用户输入的优惠券码。我们用给定的优惠券码查询Coupon对象。我们用iexact字段执行大小写不敏感的精确查询。优惠券必须是有效的(active=True),并且在当前时间是有效地。我们用 Django 的timezone.now()函数获得当前时区的时间,我们把它与valid_fromvalid_to字段比较,对这两个字段分别执行lte(小于等于)和gte(大于等于)字段查询。
  3. 我们在用户会话中存储优惠券 ID。
  4. 我们重定向用户到cart_detail URL,显示使用了优惠券的购物车。

我们需要一个coupon_apply视图的 URL 模式。在coupons应用目录中添加urls.py文件,并添加以下代码:

from django.conf.urls import url
from . import views

urlpatterns = [
    url(r'^apply/$', views.coupon_apply, name='apply'),
]

然后编辑myshop项目的主urls.py文件,添加coupons的 URL 模式:

url(r'^coupons/', include('coupons.urls', namespace='coupons')),

记住,把这个模式放到shop.urls模式之前。

现在编辑cart应用的cart.py文件,添加以下导入:

from coupons.models import Coupon

Cart类的__init__()方法最后添加以下代码,从当前会话中初始化优惠券:

# store current applied coupon
self.coupon_id = self.session.get('coupon_id')

这行代码中,我们尝试从当前会话中获得coupon_id会话键,并把它存储到Cart对象中。在Cart对象中添加以下方法:

@property
def coupon(self):
    if self.coupon_id:
        return Coupon.objects.get(id=self.coupon_id)
    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。如果cart中包括coupon_id属性,则返回给定idCoupon对象。
  • get_discount():如果cart包括coupon,则查询它的折扣率,并返回从购物车总金额中扣除的金额。
  • 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'], 'update': 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 }}</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>

这段代码显示一个可选的优惠券和它的折扣率。如果购物车包括一张优惠券,我们在第一行显示购物车总金额为Subtotal。然后在第二行显示购物车使用的当前优惠券。最后,我们调用cart对象的cart.get_total_price_after_discount()方法,显示折扣之后的总金额。

在同一个文件的</table>标签之后添加以下代码:

<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/,添加一个商品到购物车中,然后使用表单中输入的优惠券码。你会看到购物车显示优惠券折扣,如下图所示:

让我们把优惠券添加到购物流程的下一步。编辑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 }}</span>
        </li>
    {% endfor %}
    {% if cart.coupon %}
        <li>
            "{{ cart.coupon.code }}" ({{ cart.coupon.discount }}% off)
            <span>- ${{ 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/。你会看到订单汇总包括了使用的优惠券:

现在用户可以在购物车中使用优惠券了。但是当用户结账时,我们还需要在创建的订单中存储优惠券信息。

9.1.3 在订单中使用优惠券

我们将存储每个订单使用的优惠券。首先,我们需要修改Order模型来存储关联的Coupon对象(如果存在的话)。

编辑orders应用的models.py文件,并添加以下导入:

from decimal import Decimal
from django.core.validators import MinValueValidator
from django.core.validators import MaxValueValidator
from coupons.models import Coupon

然后在Order模型中添加以下字段:

coupon = models.ForeignKey(Coupon, related_name='orders', null=True, blank=True)
discount = models.IntegerField(default=0, 
        validators=[MinValueValidator(0), MaxValueValidator(100)])

这些字段允许我们存储一个可选的订单使用的优惠券和优惠券的折扣。折扣存储在关联的Coupon对象中,但我们在Order模型中包括它,以便优惠券被修改或删除后还能保存。

因为修改了Order模型,所以我们需要创建一个数据库迁移。在命令行中执行以下命令:

python manage.py makemigrations

你会看到类似这样的输出:

Migrations for 'orders':
  orders/migrations/0002_auto_20170515_0731.py
    - Add field coupon to order
    - Add field discount to order

执行以下命令同步数据库迁移:

python manage.py migrate orders

你会看到新的数据库迁移已经生效,现在Order模型的字段修改已经同步到数据库中。

回到models.py文件,修改Order模型的get_total_cost()方法:

def get_total_cost(self):
	total_cost = sum(item.get_cost() for item in self.items.all())
	return total_cost - total_cost * self.discount / Decimal('100')

如果存在优惠券,Order模型的get_total_cost()方法会计算优惠券的折扣。

编辑orders应用的views.py文件,修改其中的order_create视图,当创建新订单时,保存关联的优惠券。找到这一行代码:

order = form.save()

替换为以下代码:

order = form.save(commit=False)
if cart.coupon:
    order.coupon = cart.coupon
    order.discount = cart.coupon.discount
order.save()

在新代码中,我们用OrderCreateForm表单的save()方法创建了一个Order对象,并用commit=False避免保存到数据库中。如果购物车中包括优惠券,则存储使用的关联优惠券和折扣。然后我们把order对象存储到数据库中。

执行python manage.py runserver命令启动开发服务器,并使用./ngrok http 8000命令启动 Ngrok。

在浏览器中打开 Ngrok 提供的 URL,并使用你创建的优惠券完成一次购物。当你成功完成一次购物,你可以访问http://127.0.0.1:8000/admin/orders/order/,检查订单是否包括优惠券和折扣,如下图所示:

你还可以修改管理订单详情模板和订单 PDF 账单,用跟购物车同样的方式显示使用的优惠券。

接下来,我们要为项目添加国际化。

9.2 添加国际化和本地化

Django 提供了完整的国际化和本地化支持。它允许你把应用翻译为多种语言,它会处理特定区域日期,时间,数字和时区。让我们弄清楚国际化和本地化的区别。国际化(通常缩写为 i18n)是让软件适用于潜在的不同语言和地区的过程,让软件不会硬编码为特定语言和地区。本地化(缩写为 l10n)是实际翻译软件和适应特定地区的过程。使用 Django 自己的国际化框架,它本身被翻译为超过 50 中语言。

9.2.1 使用 Django 国际化

国际化框架让你很容易的在 Python 代码和模板中编辑需要翻译的字符串。它依赖 GNU gettext 工具集生成和管理信息文件。一个信息文件是一个代表一种语言的普通文本文件。它包括你应用中的部分或全部需要翻译的字符串,以及相应的单种语言的翻译。信息文件的扩展名是.po

一旦完成翻译,信息文件就会被编译,用来快速访问翻译后的字符串。被编译的翻译文件扩展名是.mo

9.2.1.1 国际化和本地化设置

Django 为国际化提供了一些设置。以下是最相关的设置:

  • USE_I18N:指定 Django 的翻译系统是否可用的布尔值。默认为True
  • USE_L10N:表示本地格式是否可用的布尔值。可用时,用本地格式表示日期和数字。默认为False
  • USE_TZ:指定日期和时间是否时区感知的布尔值。当你用startproject创建项目时,该值设置为True
  • LANGUAGE_CODE:项目的默认语言代码。它使用标准的语言 ID 格式,比如en-us表示美式英语,en-gb表示英式英语。这个设置需要USE_I18N设为True才生效。你可以在这里查看有效地语言 ID 列表。
  • LANGUAGES:一个包括项目可用语言的元组。它由包括语言代码和语言名称的双元组构成。你可以在django.conf.global_settings中查看可用语言列表。当你选择你的网站将使用哪些语言时,你可以设置LANGUAGES为这个列表的一个子集。
  • LOCALE_PATHS:Django 查找项目中包括翻译的信息文件的目录列表。
  • TIME_ZONE:表示项目时区的字符串。当你使用startproject命令创建新项目时,它设置为UTC。你可以设置它为任何时区,比如Europe/Madrid

这是一些可用的国际化和本地化设置。你可以在这里查看完整列表。

9.2.1.2 国际化管理命令

使用manage.py或者django-admin工具管理翻译时,Django 包括以下命令:

  • makemessages:它在源代码树上运行,查找所有标记为需要翻译的字符串,并在locale目录中创建或更新.po信息文件。每种语言创建一个.po文件。
  • compilemessages:编译存在的.po信息文件为.mo文件,用于检索翻译。

你需要gettext工具集创建,更新和编译信息文件。大部分 Linux 发行版都包括了gettext工具集。如果你使用的是 Mac OS X,最简单的方式是用brew install gettext命令安装。你可能还需要用brew link gettext --force强制链接到它。对于 Windows 安装,请参考这里的步骤。

9.2.1.3 如果在 Django 项目中添加翻译

让我们看下国际化我们项目的流程。我们需要完成以下工作:

  1. 我们标记 Python 代码和目录中需要编译的字符串。
  2. 我们运行makemessages命令创建或更新信息文件,其中包括了代码中所有需要翻译的字符串。
  3. 我们翻译信息文件中的字符串,然后用compilemessages管理命令编辑它们。

9.2.1.4 Django 如何决定当前语言

Django 自带一个中间件,它基于请求的数据决定当前语言。位于django.middleware.locale.LocaleMiddlewareLocaleMiddleware中间件执行以下任务:

  1. 如果你使用i18_patterns,也就是你使用翻译后的 URL 模式,它会在被请求的 URL 中查找语言前缀,来决定当前语言。
  2. 如果没有找到语言前缀,它会在当前用户会话中查询LANGUAGE_SESSION_KEY
  3. 如果没有在会话中设置语言,它会查找带当前语言的 cookie。这个自定义的 cookie 名由LANGUAGE_COOKIE_NAME设置提供。默认情况下,该 cookie 名为django-language
  4. 如果没有找到 cookie,它会查询请求的Accept-Language头。
  5. 如果Accept-Language头没有指定语言,Django 会使用LANGUAGE_CODE设置中定义的语言。

默认情况下,Django 会使用LANGUAGE_CODE设置中定义的语言,除非你使用LocaleMiddleware。以上描述的过程只适用于使用这个中间件。

9.2.2 为国际化我们的项目做准备

让我们为我们的项目使用不同语言。我们将创建商店的英语和西拔牙语版本。编辑项目的settings.py文件,在LANGUAGE_CODE设置之后添加LANGUAGES设置:

LANGUAGES = (
    ('en', 'English'),
    ('es', 'Spanish'),
)

LANGUAGES设置中包括两个元组,每个元组包括语言代码和名称。语言代码可以指定地区,比如en-usen-gb,也可以通用,比如en。在这个设置中,我们指定我们的应用只对英语和西班牙可用。如果我们没有定义LANGUAGES设置,则网站对于 Django 的所有翻译语言都可用。

如下修改LANGUAGE_CODE设置:

LANGUAGE_CODE = 'en'

MIDDLEWARE设置中添加django.middleware.locale.LocaleMiddleware。确保这个中间件在SessionMiddleware之后,因为LocaleMiddleware需要使用会话数据。它还需要在CommonMiddleware之前,因为后者需要一个激活的语言解析请求的 URL。MIDDLEWARE设置看起来是这样的:

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.locale.LocaleMiddleware',
    'django.middleware.common.CommonMiddleware',
    # ...
]

中间件的顺序很重要,因为每个中间件都依赖前面其它中间件执行后的数据集。中间件按MIDDLEWARE中出现的顺序应用在请求上,并且反序应用在响应上。

在项目主目录中穿件以下目录结构,与manage.py同级:

locale/
	en/
	es/

locale目录是应用的信息文件存储的目录。再次编辑settings.py文件,在其中添加以下设置:

LOCALE_PATHS = (
    os.path.join(BASE_DIR, 'locale/'),
)

LOCALE_PATHS设置指定了 Django 查找翻译文件的目录。最先出现的路径优先级最高。

当你在项目目录中使用makemessages命令时,信息文件会在我们创建的locale/路径中生成。但是,对于包括locale/目录的应用来说,信息文件会在这个应用的locale/目录中生成。

9.2.3 翻译 Python 代码

要翻译 Python 代码中的字面量,你可以用django.utils.translation中的gettext()函数标记要翻译的字符串。这个函数翻译信息并返回一个字符串。惯例是把这个函数导入为短别名_

你可以在这里查看所有关于翻译的文档。

9.2.3.1 标准翻译

以下代码展示了如何标记一个需要翻译的字符串:

from django.utils.translation import gettext as _
output = _('Text to be translated.')

9.2.3.2 惰性翻译

Django 的所有翻译函数都包括惰性(lazy)版本,它们的后缀都是_lazy()。使用惰性函数时,当值被访问时翻译字符串,而不是惰性函数被调用时翻译(这就是为什么它们被惰性翻译)。当标记为翻译的字符串位于加载模式时执行的路径中,这些惰性翻译函数非常方便。

使用gettext_lazy()代替gettext()时,当值被访问时翻译字符串,而不是翻译函数调用时翻译。Django 为所有翻译函数提供了惰性版本。

9.2.3.3 带变量的翻译

标记为翻译的字符串的字符串可以包括占位符来在翻译中引入变量。以下代码是翻译带占位符的字符串:

from django.utils.translation import gettext as _
month = _('April')
day = '14'
output = _('Today is %(month)s %(day)s') % {'month': month, 'day': day}

通过使用占位符,你可以重新排列文本变量。比如,上个例子中的英文防疫可能是Today is April 14,而西拔牙语翻译时Hoy es 14 de Abiril。当需要翻译的字符串中包括一个以上的参数时,总是使用字符串插值代替位置插值。这样就可以重新排列站位文本。

9.2.3.4 翻译中的复数形式

对于复数形式,你可以使用ngettext()ngettext_lazy()。这些函数根据一个表示对象数量的参数翻译单数和复数形式。下面的代码展示了如何使用它们:

output = ngettext('there is %(count)d product',
					'there are %(count)d products',
					count) % {'count': count}

现在你已经学会了翻译 Python 代码中字面量的基础,是时候翻译我们的项目了。

9.2.3.5 翻译你的代码

编辑项目的settings.py文件,导入gettext_lazy()函数,并如下修改LANGUAGES设置来翻译语言名称:

from django.utils.translation import gettext_lazy as _

LANGUAGES = (
    ('en', _('English')),
    ('es', _('Spanish')),
)

我们在这里使用gettext_lazy()函数代替gettext(),来避免循环导入,所以当语言名称被访问时翻译它们。

打开终端,并在项目目录中执行以下命令:

django-admin makemessages --all

你会看到以下输出:

processing locale en
processing locale es

看一眼locale/目录,你会看这样的文件结构:

en/
	LC_MESSAGES/
		django.po
es/
	LC_MESSAGES/
		django.po

为每种语言创建了一个.po信息文件。用文本编辑器打开es/LC_MESSAGES/django.po文件。在文件结尾,你会看到以下内容:

#: myshop/settings.py:122
msgid "English"
msgstr ""

#: myshop/settings.py:123
msgid "Spanish"
msgstr ""

每个需要翻译的字符串前面都有一条注释,显示它位于的文件和行数。每个翻译包括两个字符串:

  • msgid:源代码中需要翻译的字符串。
  • msgstr:对应语言的翻译,默认为空。你需要在这里输入给定字符串的实际翻译。

为给的msgid字符串填入msgstr翻译:

#: myshop/settings.py:122
msgid "English"
msgstr "Inglés"

#: myshop/settings.py:123
msgid "Spanish"
msgstr "Español"

保存修改的信息文件,打开终端,执行以下命令:

django-admin compilemessages

如果一切顺利,你会看到类似这样的输出:

processing file django.po in /Users/lakerszhy/Documents/GitHub/Django-By-Example/code/Chapter 9/myshop/locale/en/LC_MESSAGES
processing file django.po in /Users/lakerszhy/Documents/GitHub/Django-By-Example/code/Chapter 9/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(_('email'))
    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/目录,这个应用中需要翻译的字符串会存储在这个目录的信息文件中,而不是主信息文件。通过这种方式,你可以为每个应用生成独立的翻译文件。

在项目目录打开终端,执行以下命令:

django-admi makemessages --all

你会看到以下输出:

processing locale en
processing locale es

用文本编辑器开大es/LC_MESSAGES/django.po文件。你会看到Order模型需要翻译的字符串。为给的msgid字符串填入msgstr翻译:

#: orders/models.py:10
msgid "first name"
msgstr "nombre"

#: orders/models.py:11
msgid "last name"
msgstr "apellidos"

#: orders/models.py:12
msgid "email"
msgstr "e-mail"

#: orders/models.py:13
msgid "address"
msgstr "dirección"

#: orders/models.py:14
msgid "postal code"
msgstr "código postal"

#: orders/models.py:15
msgid "city"
msgstr "ciudad"

填完之后保存文件。

除了文本编辑器,你还可以使用 Poedit 编辑翻译。Poedit 是一个编辑翻译的软件,它使用gettext。它有 Linux,Windows 和 Mac OS X 版本。你可以在这里下载。

让我们再翻译项目中的表单。orders应用的OrderCreateForm不需要翻译,因为它是一个ModelForm,它的表单字段标签使用了Order模型字段的verbose_name属性。我们将翻译cartcoupons应用的表单。

编辑cart应用中的forms.py文件,为CartAddProductFormquantity字段添加一个lable属性,然后标记为需要翻译:

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'))
    update = 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字段添加了label属性,并标记为需要翻译。

9.2.4 翻译模板

Django 为翻译模板中的字符串提供了{% trans %}{% blocktrans %}模板标签。要使用翻译模板标签,你必须在模板开头添加{% load i18n %}加载它们。

9.2.4.1 模板标签

{% trans %}模板标签允许你标记需要翻译的字符串,常量或者变量。在内部,Django 在给定的文本上执行gettext()。以下是在模板中标记需要翻译的字符串:

{% trans "Text to be translated" %}

你可以使用as在变量中存储翻译后的内容,然后就能在整个模板中使用这个变量。下面这个例子在greeting变量中存储翻译后的文本:

{% trans "Hello!" as greeting %}
<h1>{{ greeting }}</h1>

{% trans %}标签对简单的翻译字符串很有用,但它不能处理包括变量的翻译内容。

9.2.4.2 模板标签

{% blocktrans %}模板标签允许你标记包括字面量的内容和使用占位符的变量内容。下面这个例子展示了如何使用{% blocktrans %}标签标记一个包括name变量的翻译内容:

{% blocktrans %}Hello {{ name }}!{% endblocktrans %}

你可以使用with引入模板表达式,比如访问对象属性,或者对变量使用模板过滤器。这时,你必须总是使用占位符。你不能在blocktrans块中访问表达式或者对象属性。下面的例子展示了如何使用with,其中引入了一个对象属性,并使用capfirst过滤器:

{% blocktrans with name=user.name|capfirst %}
	Hello {{ name }}!
{% endblocktrans %}

当需要翻译的字符串中包括变量内容时,使用{% blocktrans %}代替{% trans %}

9.2.4.3 翻译商店的模板

编辑shop应用的shop/base.html模板。在模板开头加载i18n标签,并标记需要翻译的字符串:

{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>
        {% block title %}{% trans "My shop" %}{% endblock %}
    </title>
    <link href="{% static "css/base.css" %}" rel="stylesheet">
</head>
<body>
    <div id="header">
        <a href="/" class="logo">{% trans "My shop" %}</a>
    </div>
    <div id="subheader">
        <div class="cart">
            {% with total_items=cart|length %}
                {% if cart|length > 0 %}
                    {% trans "Your cart:" %}
                    <a href="{% url "cart:cart_detail" %}">
                        {% blocktrans with total_items_plural=otal_items|pluralize total_price=cart.get_total_price %}
                            {{ total_items }} item{{ total_items_plural }},
                            ${{ total_price }}
                        {% endblocktrans %}
                    </a>
                {% else %}
                    {% trans "Your cart is empty." %}
                {% endif %}
            {% endwith %}
        </div>
    </div>
    <div id="content">
        {% block content %}
        {% endblock %}
    </div>
</body>
</html>

记住,我们用{% blocktrans %}标签显示购物车汇总。之前购物车汇总是这样的:

{{ total_items }} item{{ total_items|pluralize }},
${{ cart.get_total_price }}

我们利用{% blocktrans with ... %}total_items|pluralize(在这里使用模板标签)和cart.get_total_price(在这里访问对象方法)使用占位符,结果是:

{% blocktrans with total_items_plural=otal_items|pluralize total_price=cart.get_total_price %}
	{{ total_items }} item{{ total_items_plural }},
   ${{ total_price }}
{% endblocktrans %}

接着编辑shop应用的shop/product/detai.html模板,在{% extends %}标签(它必须总是第一个标签)之后加载i18n标签:

{% load i18n %}

然后找到这一行:

<input type="submit" value="Add to cart">

替换为:

<input type="submit" value="{% trans "Add to cart" %}">

现在翻译orders应用的模板。编辑orders应用的orders/order/create.html模板,如下标记需要翻译的文本:

{% extends "shop/base.html" %}
{% load i18n %}

{% block title %}
    {% trans "Checkout" %}
{% endblock title %}

{% block content %}
    <h1>{% trans "Checkout" %}</h1>

    <div class="order-info">
        <h3>{% trans "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>
                    {% blocktrans with code=cart.coupon.code discount=cart.coupon.discount %}
                        "{{ code }}" ({{ discount }}% off)
                    {% endblocktrans %}
                    <span>- ${{ cart.get_discount|floatformat:"2" }}</span>
                </li>
            {% endif %}
        </ul>
        <p>{% trans "Total" %}: ${{ cart.get_total_price_after_discount|floatformat:"2" }}</p>
    </div>

    <form action="." method="post" class="order-form">
        {{ form.as_p }}
        <p><input type="submit" value="{% trans "Place order" %}"></p>
        {% csrf_token %}
    </form>
{% endblock content %}

在本章示例代码中查看以下文件是如何标记需要翻译的字符串:

  • shop应用:shop/product/list.hmtl模板
  • orders应用:orders/order/created.html模板
  • cart应用:cart/detail.html模板

让我们更新信息文件来引入新的翻译字符串。打开终端,执行以下命令:

django-admin makemessages --all

.po翻译文件位于myshop项目的locale目录中,orders应用现在包括了所有我们标记过的翻译字符串。

编辑项目和orders应用的.po翻译文件,并填写西班牙语翻译。你可以参考本章示例代码的.po文件。

从项目目录中打开终端,并执行以下命令:

cd orders/
django-admin compilemessages
cd ../

我们已经编译了orders应用的翻译文件。

执行以下命令,在项目的信息文件中包括没有locale目录的应用的翻译。

django-admin compilemessage

9.2.5 使用 Rosetta 的翻译界面

Rosetta 是一个第三方应用,让你可以用跟 Django 管理站点一样的界面编辑翻译。Rosetta 可以很容易的编辑.po文件,并且它会更新编译后的编译文件。让我们把它添加到项目中。

使用pip命令安装 Rosetta:

pip install django-rosetta

然后把rosetta添加到项目settings.py文件中的INSTALLED_APP设置中:

你需要把 Rosetta 的 URL 添加到主 URL 配置中。编辑项目的urls.py文件,并添加下 URL 模式:

url(r'^rosetta/', include('rosetta.urls')),

确保把它放在shop.urls模式之后,避免错误的匹配。

在浏览器中打开http://127.0.0.1:8000/admin/,并用超级用户登录。然后导航到http://127.0.0.1:8000/rosetta/。你会看到已经存在的语言列表,如下图所示:

点击Filter中的All显示所有可用的信息文件,包括属于orders应用的信息文件。在Spanish中点击Myshop链接来编辑西班牙语翻译。你会看到一个需要翻译的字符串列表,如下图所示:

你可以在Spanish列中输入翻译。Occurrences列显示每个需要翻译的字符串所在的文件和行数。

包括占位符的翻译是这样的:

Rosetta 用不同的颜色显示占位符。当你翻译内容时,确保不要翻译占位符。比如这一行字符串:

%(total_items)s item%(total_items_plural)s, $%(total_price)s

翻译为西班牙语后是这样的:

%(total_items)s producto%(total_items_plural)s, $%(total_price)s

你可以参考本章的示例代码,用同样的西班牙语翻译你的项目。

当你完成翻译后,点击Save and translate next block按钮,把翻译保存到.po文件。保存翻译时,Rosseta 会编译信息文件,所以不需要执行compilemessages命令。但是 Rosetta 需要写入locale目录的权限来写入信息文件。确保这些目录有合理的权利。

如果你希望其他用户也可以编辑翻译,在浏览器中打开http://127.0.0.1:8000/admin/auth/group/add/,并创建一个translators组。然后访问http://127.0.0.1:8000/admin/auth/user/,编辑你要授予翻译权限的用户。编辑用户时,在Premissions中,把translators组添加到每个用户的Chosen Groups中。Resetta 只对超级用户和属于translators组的用户可用。

你可以在这里阅读 Rosetta 的文档。

当你在生产环境添加新翻译时,如果你的 Django 运行在一个真实的 web 服务器上,你必须在执行compilemessages命令或者用 Rosetta 保存翻译之后重启服务器,才能让修改生效。

9.2.6 不明确的翻译

你可能已经注意到了,在 Rosetta 中有一个Fuzzy列。这不是 Rosetta 的特征,而是有gettext提供的。如果启用了翻译的fuzzy标记,那么它就不会包括在编译后的信息文件中。这个标记用于需要翻译者修改的翻译字符串。当用新的翻译字符串更新.po文件中,可能有些翻译字符串自动标记为fuzzy。当gettext发现某些msgid变动不大时,它会匹配为旧的翻译,并标记为fuzzy,以便复核。翻译者应该复核不明确的翻译,然后移除fuzzy标记,并在此编译信息文件。

9.2.7 URL 模式的国际化

Django 为 URL 提供了国际化功能。它包括两种主要的国际化 URL 特性:

  • URL 模式中的语言前缀:把语言前缀添加到 URL 中,在不同的基础 URL 下提供每种语言的版本
  • 翻译后的 URL 模式:标记需要翻译的 URL 模式,因此同一个 URL 对于每种语言是不同的

翻译 URL 的其中一个原因是为搜索引擎优化你的网站。通过在模式中添加语言前缀,你就可以为每种语言提供索引 URL,而不是为所有语言提供一个索引 URL。此外,通过翻译 URL 为不同语言,你可以为搜索引擎提供对每种语言排名更好的 URL。

9.2.7.1 添加语言前缀到 URL 模式中

Django 允许你在 URL 模式中添加语言前缀。例如,网站的英语版本可以/en/起始路径下,而西班牙语版本在/es/下。

要在 URL 模式中使用语言,你需要确保settings.py文件的MIDDLEWARE设置中包括django.middleware.locale.LocaleMiddleware。Django 将用它从请求 URL 中识别当前语言。

让我们在 URL 模式中添加语言前缀。编辑myshop项目的urls.py文件,添加以下导入:

from django.conf.urls.i18n import i18n_patterns

然后添加i18n_patterns(),如下所示:

urlpatterns = i18n_patterns(
    url(r'^admin/', admin.site.urls),
    url(r'^cart/', include('cart.urls', namespace='cart')),
    url(r'^orders/', include('orders.urls', namespace='orders')),
    url(r'^paypal/', include('paypal.standard.ipn.urls')),
    url(r'^payment/', include('payment.urls', namespace='payment')),
    url(r'^coupons/', include('coupons.urls', namespace='coupons')),
    url(r'^rosetta/', include('rosetta.urls')),
    url(r'^', include('shop.urls', namespace='shop')),
)

你可以在patterns()i18n_patterns()中结合 URL 模式,这样有些模式包括语言前缀,有些不包括。但是,最好只使用翻译后的 URL,避免不小心把翻译后的 URL 匹配到没有翻译的 URL 模式。

启动开发服务器,并在浏览器中打开http://127.0.0.1:8000/。因为你使用了LocaleMiddleware中间件,所以 Django 会执行Django 如何决定当前语言中描述的步骤,决定当前的语言,然后重定义到包括语言前缀的同一个 URL。看一下眼浏览器中的 URL,它应该是http://127.0.0.1:8000/en/。如果浏览器的Accept-Language头是西班牙语或者英语,则当前语言是它们之一;否则当前语言是设置中定义的默认LANGUAGE_CODE(英语)。

9.2.7.2 翻译 URL 模式

Django 支持 URL 模式中有翻译后的字符串。对应单个 URL 模式,你可以为每种语言使用不同的翻译。你可以标记需要翻译的 URL 模式,方式与标记字面量一样,使用gettext_lazy()函数。

编辑myshop项目的主urls.py文件,把翻译字符串添加到cartorderspaymentcoupons应用的 URL 模式的正则表达式中:

urlpatterns = i18n_patterns(
    url(r'^admin/', admin.site.urls),
    url(_(r'^cart/'), include('cart.urls', namespace='cart')),
    url(_(r'^orders/'), include('orders.urls', namespace='orders')),
    url(r'^paypal/', include('paypal.standard.ipn.urls')),
    url(_(r'^payment/'), include('payment.urls', namespace='payment')),
    url(_(r'^coupons/'), include('coupons.urls', namespace='coupons')),
    url(r'^rosetta/', include('rosetta.urls')),
    url(r'^', include('shop.urls', namespace='shop')),
)

编辑orders应用的urls.py文件,编辑需要翻译的 URL 模式:

from django.utils.translation import gettext_lazy as _

urlpatterns = [
    url(_(r'^create/$'), views.order_create, name='order_create'),
    # ..
]

编辑payment应用的urls.py文件,如下修改代码:

from django.utils.translation import gettext as _

urlpatterns = [
    url(_(r'^process/$'), views.payment_process, name='process'),
    url(_(r'^done/$'), views.payment_done, name='done'),
    url(_(r'^canceled/$'), views.payment_canceled, name='canceled'),
]

我们不要翻译shop应用的 URL 模式,因为它们由变量构建,不包括任何字面量。

打开终端,执行以下命令更新信息文件:

django-admin makemessages --all

确保开发服务器正在运行。在浏览器中打开http://127.0.0.1:8000/en/rosetta/,然后点击Spanish中的Myshop链接。你可以使用Display过滤器只显示没有翻译的字符串。在 URL 翻译中,一定要保留正则表达式中的特殊字符。翻译 URL 是一个精细的任务;如果你修改了正则表达式,就会破坏 URL。

9.2.8 允许用户切换语言

因为我们现在提供了多种语言,所以我们应该让用户可以切换网站的语言。我们会在网站中添加一个语言选择器。语言选择器用链接显示可用的语言列表。

编辑shop/base.html模板,找到以下代码:

<div id="header">
    <a href="/" class="logo">{% trans "My shop" %}</a>
</div>

替换为以下代码:

<div id="header">
    <a href="/" class="logo">{% trans "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>{% trans "Languages" %}:</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/。你会看到网站右上角有语言选择器,如下图所示:

用户现在可以很容易的切换语言。

9.2.9 用 django-parler 翻译模型

Django 没有为翻译模型提供好的解决方案。你必须实现自己的解决方案来管理不同语言的内容,或者使用第三方模块翻译模型。有一些第三方应用允许你翻译模型字段。每种采用不同的方法存储和访问翻译。其中一个是django-parler。这个模块提供了一种非常高效的翻译模型的方式,并且它和 Django 管理站点集成的非常好。

django-parler为每个模型生成包括翻译的独立的数据库表。这张表包括所有翻译后的字段,以及一个翻译所属的原对象的外键。因为每行存储单个语言的内容,所以它还包括一个语言字段。

9.2.9.1 安装 django-parler

使用pip命令安装django-parler

pip install django-parler

然后编辑项目的settings.py文件,把parler添加到INSTALLED_APPS设置中。并在设置文件中添加以下代码:

PARLER_LANGUAGES = {
    None: (
        {'code': 'en', },
        {'code': 'es', },
    ),
    'default': {
        'fallback': 'en',
        'hide_untranslated': False,
    }
}

这个设置定义了 django-parler 的可用语言enes。我们指定默认语言是en,并且指定 django-parler 不隐藏没有翻译的内容。

9.2.9.2 翻译模型字段

让我们为商品目录添加翻译。django-parler 提供了一个TranslatableModel模型类和一个TranslatedFields包装器(wrapper)来翻译模型字段。编辑shop应用的models.py文件,添加以下导入:

from parler.models import TranslatableModel, TranslatedFields

然后修改Category模型,让nameslug字段可翻译。我们现在还保留非翻译字段:

class Category(TranslatableModel):
    name = models.CharField(max_length=200, db_index=True)
    slug = models.SlugField(max_length=200, db_index=True, unique=True)

    translations = TranslatedFields(
        name = models.CharField(max_length=200, db_index=True),
        slug = models.SlugField(max_length=200, db_index=True, unique=True)
    )

现在Category模型继承自TranslatableModel,而不是models.Model。并且nameslug字段都包括在TranslatedFields包装器中。

编辑Product模型,为nameslugdescription字段添加翻译。同样保留非翻译字段:

class Product(TranslatableModel):
    name = models.CharField(max_length=200, db_index=True)
    slug = models.SlugField(max_length=200, db_index=True)
    description = models.TextField(blank=True)
    translations = TranslatedFields(
        name = models.CharField(max_length=200, db_index=True),
        slug = models.SlugField(max_length=200, db_index=True),
        description = models.TextField(blank=True)
    )
    category = models.ForeignKey(Category, related_name='products')
    image = models.ImageField(upload_to='products/%Y/%m/%d', blank=True)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    stock = models.PositiveIntegerField()
    available = models.BooleanField(default=True)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

django-parler 为每个TranslatableModel模型生成另一个模型。图 9.9 中,你可以看到Product模型的字段和生成的ProductTranslation模型:

django-parler 生成的ProductTranslation模型包括nameslugdescription可翻译字段,一个language_code字段,以及指向Product对象的外键master字段。从ProductProductTranslation是一对多的关系。每个Product对象会为每种语言生成一个ProductTranslation对象。

因为 Django 为翻译使用了单独的数据库表,所以有些 Django 特性不能使用了。一个翻译后的字段不能用作默认的排序。你可以在查询中用翻译后的字段过滤,但你不能再ordering元选项中包括翻译后的字段。编辑shop应用的models.py文件,注释Category类中Meta类的ordering属性:

class Meta:
    # ordering = ('name', )
    verbose_name = 'category'
    verbose_name_plural = 'categories'

我们还必须注释Product类中Meta类的index_together属性,因为当前 django-parler 版本不提供验证它的支持:

class Meta:
    ordering = ('-created', )
    # index_together = (('id', 'slug'), )

你可以在这里阅读更多关于 django-parler 和 Django 兼容性的信息。

9.2.9.3 创建自定义数据库迁移

当你为翻译创建了新模型,你需要执行makemigrations命令为模型生成数据库迁移,然后同步到数据库中。但是当你将已存在字段变为可翻译后,你的数据库中可能已经存在数据了。我们将把当前数据迁移到新的翻译模型中。因此,我们添加了翻译后的字段,但暂时保留了原来的字段。

为已存在字段添加翻译的流程是这样的:

  1. 我们为新的可翻译模型字段创建数据库迁移,并保留原来的字段。
  2. 我们构建一个自定义数据库迁移,从已存在字段中拷贝数据到翻译模型中。
  3. 我们从原来的模型中移除已存在的字段。

执行以下命令,为添加到CategoryProduct模型中的翻译字段创建数据库迁移:

python manage.py makemigrations shop --name "add_translation_model"

你会看到以下输出:

Migrations for 'shop':
  shop/migrations/0002_add_translation_model.py
    - Create model CategoryTranslation
    - Create model ProductTranslation
    - Change Meta options on category
    - Alter index_together for product (0 constraint(s))
    - 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))

现在我们需要创建一个自定义数据库迁移,把已存在的数据拷贝到新的翻译模型中。使用以下命令创建一个空的数据库迁移:

python manage.py makemigrations --empty shop --name "migrate_translatable_fields"

你会看到以下输出:

Migrations for 'shop':
  shop/migrations/0003_migrate_translatable_fields.py

编辑shop/migrations/0003_migrate_translatable_fields.py文件,并添加以下代码:

# -*- coding: utf-8 -*-
# Generated by Django 1.11.1 on 2017-05-17 01:18
from __future__ import unicode_literals
from django.db import models, migrations
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist

translatable_models = {
    'Category': ['name', 'slug'],
    'Product': ['name', 'slug', 'description'],
}

def forwards_func(apps, schema_editor):
    for model, fields in translatable_models.items():
        Model = apps.get_model('shop', model)
        ModelTranslation = apps.get_model('shop', '{}Translation'.format(model))

        for obj in Model.objects.all():
            translation_fields = {field: getattr(obj, field) for field in fields}
            translation = ModelTranslation.objects.create(
                master_id=obj.pk,
                language_code=settings.LANGUAGE_CODE,
                **translation_fields
            )

def backwards_func(apps, shcema_editor):
    for model, fields in translatable_models.items():
        Model = apps.get_model('shop', model)
        ModelTranslation = apps.get_model('shop', '{}Translation'.format(model))

        for obj in Model.objects.all():
            translation = _get_translation(obj, ModelTranslation)
            for field in fields:
                setattr(obj, field, getattr(translation, field))
            obj.save()

def _get_translation(obj, MyModelTranslation):
    translation = MyModelTranslation.objects.filter(master_id=obj.pk)
    try:
        # Try default translation
        return translation.get(language_code=settings.LANGUAGE_CODE)
    except ObjectDoesNotExist:
        # Hope there is a single translation
        return translations.get()

class Migration(migrations.Migration):

    dependencies = [
        ('shop', '0002_add_translation_model'),
    ]

    operations = [
        migrations.RunPython(forwards_func, backwards_func)
    ]

这个迁移包括forwards_func()backwards_func()函数,其中包含要执行数据库同步和反转的代码。

迁移流程是这样的:

  1. 我们在translatable_models字典中定义模型和可翻译的字段。
  2. 要同步迁移,我们用app.get_model()迭代包括翻译的模型,来获得模型和它可翻译的模型类。
  3. 我们迭代数据库中所有存在的对象,并为项目设置中定义的LANGUAGE_CODE创建一个翻译对象。我们包括了一个指向原对象的ForeignKey,以及从原字段中拷贝的每个可翻译字段。

backwards_func()函数执行相反的操作,它查询默认的翻译对象,并把可翻译字段的值拷贝回原对象。

我们已经创建了一个数据库迁移来添加翻译字段,以及一个从已存在字段拷贝内容到新翻译模型的迁移。

最后,我们需要删除不再需要的原字段。编辑shop应用的models.py文件,移除Category模型的nameslug字段。现在Category模型字段是这样的:

class Category(TranslatableModel):
    translations = TranslatedFields(
        name = models.CharField(max_length=200, db_index=True),
        slug = models.SlugField(max_length=200, db_index=True, unique=True)
    )

移除Product模型的nameslugdescription字段。它现在是这样的:

class Product(TranslatableModel):
    translations = TranslatedFields(
        name = models.CharField(max_length=200, db_index=True),
        slug = models.SlugField(max_length=200, db_index=True),
        description = models.TextField(blank=True)
    )
    category = models.ForeignKey(Category, related_name='products')
    image = models.ImageField(upload_to='products/%Y/%m/%d', blank=True)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    stock = models.PositiveIntegerField()
    available = models.BooleanField(default=True)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

现在我们需要创建最后一个迁移,让修改生效。但是,如果我们尝试执行manage.py工具,我们会看到一个错误,因为我们还没有让管理站点适配可翻译模型。让我们先修改管理站点。

9.2.9.4 在管理站点集成翻译

Django 管理站点可以很好的跟 django-parler 集成。django-parler 包括一个TranslatableAdmin类,它覆写了 Django 提供的ModelAdmin类,来管理模型翻译。

编辑shop应用的admin.py文件,添加以下导入:

from parler.admin import TranslatableAdmin

修改CategoryAdminProductAdmin类,让它们从TranslatableAdmin继承。django-parler 还不知道prepopulated_fields属性,但它支持相同功能的get_ prepopulated_fields()方法。让我们相应的修改,如下所示:

from django.contrib import admin
from parler.admin import TranslatableAdmin
from .models import Category, Product

class CategoryAdmin(TranslatableAdmin):
    list_display = ('name', 'slug')

    def get_prepopulated_fields(self, request, obj=None):
        return {'slug': ('name', )}

admin.site.register(Category, CategoryAdmin)

class ProductAdmin(TranslatableAdmin):
    list_display = ('name', 'slug', 'price', 'stock', 'available', 'created', 'updated')
    list_filter = ('available', 'created', 'updated')
    list_editable = ('price', 'stock', 'available')

    def get_prepopulated_fields(self, request, obj=None):
        return {'slug': ('name', )}
        
admin.site.register(Product, ProductAdmin)

我们已经让管理站点可以与新的翻译模型一起工作了。现在可以同步模型修改到数据库中。

9.2.9.5 为模型翻译同步数据库迁移

适配管理站点之前,我们已经从模型中移除了旧的字段。现在我们需要为这个修改创建一个迁移。打开终端执行以下命令:

python manage.py makemigrations shop --name "remove_untranslated_fields"

你会看到以下输出:

Migrations for 'shop':
  shop/migrations/0004_remove_untranslated_fields.py
    - Change Meta options on 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

通过这次迁移,我们移除了原字段,保留了可翻译字段。

总结一下,我们已经创建了以下迁移:

  1. 添加可翻译字段到模型中
  2. 从原字段迁移已存在字段到可翻译字段
  3. 从模型中移除原字段

执行以下命令,同步我们创建的三个迁移:

python manage.py migrate shop

你会看到包括以下行的输出:

Applying shop.0002_add_translation_model... OK
Applying shop.0003_migrate_translatable_fields... OK
Applying shop.0004_remove_untranslated_fields... OK

现在模型已经跟数据库同步了。让我们翻译一个对象。

使用python manage.py runserver启动开发服务器,然后在浏览器中打开http://127.0.0.1:8000/en/admin/shop/category/add/。你会看到Add category页面包括两个标签页,一个英语和一个西班牙语翻译:

现在你可以添加一个翻译,然后点击Save按钮。确保切换标签页之前保存修改,否则输入的信息会丢失。

9.2.9.6 为翻译适配视图

我们必须让shop的视图适配翻译的QuerySet。在命令行中执行python manage.py shell,看一眼如何检索和查询翻译字段。要获得当前语言的字段内容,你只需要与访问普遍模型字段一样访问该字段:

>>> from shop.models import Product
>>> product = Product.objects.first()
>>> product.name
'Black tea'

当你访问翻译后的字段时,它们已经被当前语言处理了。你可以在对象上设置另一个当前语言,来访问指定的翻译:

>>> product.set_current_language('es')
>>> product.name
'Té negro'
>>> product.get_current_language()
'es'

当使用filter()执行QeurySet时,你可以在相关的翻译对象上用translations__语法过滤:

>>> Product.objects.filter(translations__name='Black tea')
[<Product: Black tea>]

你也可以用language()管理器为对象检索指定语言:

>>> Product.objects.language('es').all()
[<Product: Té negro>, <Product: Té en polvo>, <Product: Té rojo>, <Product: Té verde>]

正如你所看到的,访问和查询翻译字段非常简单。

让我们适配商品目录视图。编辑shop应用的views.py文件,在product_list视图中找到这一行代码:

category = get_object_or_404(Category, slug=category_slug)

替换为以下代码:

language = request.LANGUAGE_CODE
category = get_object_or_404(
    Category, 
    translations__language_code=language,
    translations__slug=category_slug)

接着编辑product_detail视图,找到这一行代码:

product = get_object_or_404(Product, id=id, slug=slug, available=True)

替换为以下代码:

language = request.LANGUAGE_CODE
product = get_object_or_404(
    Product, 
    id=id, 
    translations__language_code=language,
    translations__slug=slug, 
    available=True)

现在product_listproduct_detail视图已经适配了用翻译字段检索对象。启动开发服务器,并在浏览器中打开http://127.0.0.1:8000/es/。你会看到商品列表页面,所有商品都已经翻译为西班牙语:

现在用slug字段构建的每个商品的 URL 已经翻译为当前语言。例如,一个商品的西班牙语 URL 是http://127.0.0.1:8000/es/1/te-negro/,而英语的 URL 是http://127.0.0.1:8000/en/1/black-tea/。如果你导航到一个商品的详情页面,你会看到翻译后的 URL 和选中语言的内容,如下图所示:

如果你想进一步了解 django-parler,你可以在这里找到所有文档。

你已经学习了如何翻译 Python 代码,模板,URL 模式和模型字段。要完成国际化和本地化过程,我们还需要显示本地化格式的日期,时间和数组。

9.2.10 格式的本地化

根据用户的地区,你可能希望以不同格式显示日期,时间和数字。修改项目的settings.py文件中的USE_L10N设置为True,可以启动本地化格式。

启用USE_L10N后,当 Django 在模板中输出值时,会尝试使用地区特定格式。你可以看到,你的英文版网站中的十进制用点号分隔小数部分,而不在西班牙版本中显示为逗号。这是因为 Django 为es地区指定了地区格式。你可以在这里查看西班牙格式配置。

通常你会设置USE_L10NTrue,让 Django 为每个地区应用本地化格式。但是,有些情况下你可能不想使用地区化的值。当输出必须提供机器可读的 JavaScript 或 JSON 时,这一点尤其重要。

Django 提供了{% localize %}模板标签,运行你在模板块中开启或关闭本地化。这让你可以控制格式的本地化。要使用这个模板标签,你必须加载l10n标签。下面这个例子展示了如何在模板中开启或关闭本地化:

{% load l10n %}

{% localize on %}
	{{ value }}
{% endlocalize %}

{% localize off %}
	{{ value }}
{% endlocalize %}

Django 还提供了localizeunlocalize模板过滤器,强制或避免本地化一个值,如下所示:

{{ value|localize }}
{{ value|unlocalize }}

你还可以创建自定义格式过滤器来指定本地格式。你可以在这里查看更多关于格式本地化的信息。

9.2.11 用 django-localflavor 验证表单字段

django-localflavor 是一个第三方模板,其中包含一组特定用途的功能,比如每个国家特定的表单字段或模型字段。验证本地区域,本地电话号码,身份证,社会安全号码等非常有用。这个包由一系列以 ISO 3166 国家代码命名的模块组成。

用以下命令安装 django-localflavor:

pip install django-localflavor

编辑项目的settings.py文件,把localflavor添加到INSTALLED_APPS设置中。

我们会添加一个美国(U.S)邮政编码字段,所以创建新订单时需要一个有效的美国邮政编码。

编辑orders应用的forms.py文件,如下修改:

from django import forms
from .models import Order
from localflavor.us.forms import USZipCodeField

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字段。在浏览器中打开http://127.0.0.1:8000/en/orders/create/,尝试输入一个 3 个字母的邮政编码。你会看USZipCodeField抛出的验证错误:

Enter a zip code in the format XXXXX or XXXXX-XXXX.

这只是一个简单的例子,说明如何在你的项目中使用localflavor的自定义字段进行验证。localflavor提供的本地组件对于让你的应用适应特定国家非常有用。你可以在这里阅读django-localflavor文档,查看每个国家所有可用的本地组件。

接下来,我们将在商店中构建一个推荐引擎。

9.3 构建推荐引擎

推荐引擎是一个预测用户对商品的偏好或评价的系统。系统根据用户行为和对用户的了解选择商品。如今,很多在线服务都使用推荐系统。它们帮助用户从大量的可用数据中选择用户可能感兴趣的内容。提供良好的建议可以增强用户参与度。电子商务网站还可以通过推荐相关产品提高销量。

我们将创建一个简单,但强大的推荐引擎,来推测用户通常会一起购买的商品。我们将根据历史销售确定通常一起购买的商品,来推荐商品。我们将在两个不同的场景推荐补充商品:

  • 商品详情页面:我们将显示一个通常与给定商品一起购买的商品列表。它会这样显示:购买了这个商品的用户还买了 X,Y,Z。我们需要一个数据结构,存储每个商品与显示的商品一起购买的次数。
  • 购物车详情页面:根据用户添加到购物车中的商品,我们将推荐通常与这些商品一起购买的商品。这种情况下,我们计算的获得相关商品的分数必须汇总。

我们将使用 Redis 存储一起购买的商品。记住,你已经在第六章中使用了 Redis。如果你还没有安装 Redis,请参考第六章。

9.3.1 根据之前的购买推荐商品

现在,我们将根据用户已经添加到购物车中的商品来推荐商品。我们将在 Redis 中为网站中每个出售的商品存储一个键。商品键会包括一个带评分的 Redis 有序集。每次完成一笔新的购买,我们为每个一起购买的商品的评分加 1。

当一个订单支付成功后,我们为购买的每个商品存储一个键,其中包括属于同一个订单的商品有序集。这个有序集让我们可以为一起购买的商品评分。

编辑项目的settings.py文件,编辑以下设置:

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.StrictRedis(
    host=settings.REDIS_HOST,
    port=settings.REDIS_PORT,
    db=settings.REDIS_DB
)

class Recommender:
    def get_product_key(self, id):
        return 'product:{}:purchased_with'.format(id)

    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), with_id, amount=1)

Recommender类允许我们存储购买的商品,以及为给定的商品检索商品推荐。get_product_key()方法接收一个Product对象的 ID,然后为存储相关商品的有序集构建 Redis 键,看起来是这样的:product:[id]:purchased_with

product_bought()方法接收一个一起购买(也就是属于同一个订单)的Product对象列表。我们在这个方法中执行以下任务:

  1. 我们获得给定的Product对象的商品 ID。
  2. 我们迭代商品 ID。对于每个 ID,我们迭代商品 ID,并跳过同一个商品,这样我们获得了与每个商品一起购买的商品。
  3. 我们用get_product_id()方法获得每个购买的商品的 Redis 商品键。对于一个 ID 是 33 的商品,这个方法返回的键是product:33:purchased_with。这个键用于包括与这个商品一起购买的商品 ID 的有序集。
  4. 我们将 ID 包含在有序集中的商品评分加 1。这个评分表示其它商品与给定商品一起购买的次数。

因此这个方法可以保存一起购买的商品,并对它们评分。现在,我们需要一个方法检索与给定的商品列表一起购买的商品。在Recommender类中添加suggest_products_for()方法:

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 = 'tmp_{}'.format(flat_ids)
        # multiple products, combine scores of all products
        # store the resulting sored 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:要获得推荐商品的商品列表。它可以包括一个或多个商品。
  • max_results:一个整数,表示返回的推荐商品的最大数量。

在这个方法中,我们执行以下操作:

  1. 我们获得给定商品对象的商品 ID。
  2. 如果只给定了一个商品,我们检索与该商品一起购买的商品 ID,并按它们一起购买的总次数排序。我们用 Redis 的ZRANGE命令进行排序。我们限制结果数量为max_results参数指定的数量(默认是 6)。
  3. 如果给定的商品多余 1 个,我们用商品 ID 生成一个临时的 Redis 键。
  4. 我们组合每个给定商品的有序集中包括的商品,并求和所有评分。通过 Redis 的ZUNIONSTORE命令实现这个操作。ZUNIONSTORE命令用给定的键执行有序集的并集,并在新的 Redis 键中存储元素的评分总和。你可以在这里阅读更多关于这个命令的信息。我们在一个临时键中存储评分和。
  5. 因为我们正在汇总评分,所以我们得到的有可能是正在获得推荐商品的商品。我们用ZREM命令从生成的有序集中移除它们。
  6. 我们从临时键中检索商品 ID,并用ZRANGE命令根据评分排序。我们限制结果数量为max_results参数指定的数量。然后我们移除临时键。
  7. 最后,我们用给定的 ID 获得Product对象,并按 ID 同样的顺序进行排序。

为了更实用,让我们再添加一个清除推荐的方法。在Recommender类中添加以下方法:

def clear_purchases(self):
    for id in Product.objects.values_list('id', flat=True):
        r.delete(self.get_product_key(id))

让我们试试推荐引擎。确保数据库中包括几个Product对象,并在终端使用以下命令初始化 Redis 服务:

src/redis-server

打开另一个终端,执行python manage.py shell,输入下面代码检索商品:

from shop.models import Product
black_tea = Product.objects.get(translations__name='Black tea')
red_tea = Product.objects.get(translations__name='Red tea')
green_tea = Product.objects.get(translations__name='Green tea')
tea_powder = Product.objects.get(translations__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)

我们已经确认推荐算法如期工作了。让我们为网站的商品显示推荐。

编辑shop应用的views.py文件,并添加以下导入:

from .recommender import Recommender

product_detail()视图的render()函数之前添加以下代码:

r = Recommender()
recommended_products = r.suggest_products_for([product], 4)

我们最多获得 4 个推荐商品。现在product_detail视图如下所示:

from .recommender import Recommender

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
        }
    )

现在编辑shop应用的shop/product/detail.html模板,在{{ product.description|linebreaks }}之后添加以下代码:

{% if recommended_products %}
    <div class="recommendations">
        <h3>{% trans "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 %}

使用python manage.py runserver启动开发服务器,并在浏览器中打开http://127.0.0.1:8000/en/。点击任何一个商品显示详情页面。你会看到商品下面的推荐商品,如下图所示:

接下来我们在购物车中包括商品推荐。基于用户添加到购物车中的商品生成推荐商品。编辑cart应用的views.py文件,添加以下导入:

from shop.recommender import Recommender

然后编辑cart_detail视图,如下所示:

def cart_detail(request):
    cart = Cart(request)
    for item in cart:
        item['update_quantity_form'] = CartAddProductForm(
            initial={'quantity': item['quantity'], 'update': True})
    coupon_apply_form = CouponApplyForm()

    r = Recommender()
    cart_products = [item['product'] for item in cart]
    recommended_products = r.suggest_products_for(cart_products, max_results=4)
    return render(
        request, 'cart/detail.html', 
        {
            'cart': cart, 
            'coupon_apply_form': coupon_apply_form,
            'recommended_products': recommended_products
        }
    )

编辑cart应用的cart/detail.html模板,在</table>标签之后添加以下代码:

{% if recommended_products %}
    <div class="recommendations cart">
        <h3>{% trans "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/,你会看到购物车中商品合计的推荐商品,如下图所示:

恭喜你!你已经用 Django 和 Redis 构建了一个完整的推荐引擎。

9.4 总结

在本章中,你使用会话创建了优惠券系统。你学习了如何进行国际化和本地化。你还用 Redis 构建了一个推荐引擎。

在下一章中,你会开始一个新的项目。你会通过 Django 使用基于类的视图构建一个在线学习平台,你还会创建一个自定义的内容管理系统。

第十章:构建一个在线学习平台

在上一章中,你为在线商店项目添加了国际化。你还构建了一个优惠券系统和一个商品推荐引擎。在本章中,你会创建一个新的项目。你会构建一个在线学习平台,这个平台会创建一个自定义的内容管理系统。

在本章中,你会学习如何:

  • 为模型创建 fixtures
  • 使用模型继承
  • 创建自定义 O 型字典
  • 使用基于类的视图和 mixins
  • 构建表单集
  • 管理组和权限
  • 创建一个内容管理系统

10.1 创建一个在线学习平台

我们最后一个实战项目是一个在线学习平台。在本章中,我们会构建一个灵活的内容管理系统(CMS),允许教师创建课程和管理课程内容。

首先,我们用以下命令为新项目创建一个虚拟环境,并激活它:

mkdir env
virtualenv env/educa
source env/educa/bin/activate

用以下命令在虚拟环境中安装 Django:

pip install Django

我们将在项目中管理图片上传,所以我们还需要用以下命令安装 Pillow:

pip install Pillow

使用以下命令创建一个新项目:

django-admin startproject educa

进入新的educa目录,并用以下命令创建一个新应用:

cd educa
django-admin startapp courses

编辑educa项目的settings.py文件,把courses添加到INSTALLED_APPS设置中:

INSTALLED_APPS = [
    'courses',
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
]

现在courses应用已经在项目激活了。让我们为课程和课程内容定义模型。

10.2 构建课程模型

我们的在线学习平台会提供多种主题的课程。每个课程会划分为可配置的单元数量,而每个单元会包括可配置的内容数量。会有各种类型的内容:文本,文件,图片或者视频。下面这个例子展示了我们的课程目录的数据结构:

Subject 1
	Course 1
		Module 1
			Content 1 (image)
			Content 3 (text)
		Module 2
			Content 4 (text)
			Content 5 (file)
			Content 6 (video)
			...

让我们构建课程模型。编辑courses应用的models.py文件,并添加以下代码:

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

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')
    subject = models.ForeignKey(Subject, related_name='courses')
    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')
    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字段。

打开终端执行以下命令,为应用创建初始的数据库迁移:

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应用的模型已经同步到数据库中。

10.2.1 在管理站点注册模型

我们将把课程模型添加到管理站点。编辑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()装饰器代替admin.site.register()函数。它们的功能是一样的。

10.2.2 为模型提供初始数据

有时你可能希望用硬编码数据预填充数据库。这在项目创建时自动包括初始数据很有用,来替代手工添加数据。Django 自带一种简单的方式,可以从数据库中加载和转储(dump)数据到 fixtures 文件中。

Django 支持 JSON,XML 或者 YAML 格式的 fixtures。我们将创建一个 fixture,其中包括一些项目的初始Subject对象。

首先使用以下命令创建一个超级用户:

python manage.py createsuperuser

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

python manage.py runserver

现在在浏览器中打开http://127.0.0.1:8000/admin/courses/subject/。使用管理站点创建几个主题。列表显示页面如下图所示:

在终端执行以下命令:

python manage.py dumpdata courses --indent=2

你会看到类似这样的输出:

[
{
  "model": "courses.subject",
  "pk": 1,
  "fields": {
    "title": "Programming",
    "slug": "programming"
  }
},
{
  "model": "courses.subject",
  "pk": 2,
  "fields": {
    "title": "Physics",
    "slug": "physics"
  }
},
{
  "model": "courses.subject",
  "pk": 3,
  "fields": {
    "title": "Music",
    "slug": "music"
  }
},
{
  "model": "courses.subject",
  "pk": 4,
  "fields": {
    "title": "Mathematics",
    "slug": "mathematics"
  }
}
]

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

使用管理站点移除你创建的主题。然后使用以下命令把 fixture 加载到数据库中:

python manage.py loaddata subjects.json

fixture 中包括的所有Subject对象已经加载到数据库中。

默认情况下,Django 在每个应用的fixtures/目录中查找文件,但你也可以为loaddata命令指定 fixture 文件的完整路径。你还可以使用FIXTURE_DIRS设置告诉 Django 查找 fixtures 的额外目录。

Fixtures 不仅对初始数据有用,还可以为应用提供简单的数据,或者测试必需的数据。

你可以在这里阅读如何在测试中使用 fixtures。

如果你想在模型迁移中加载 fixtures,请阅读 Django 文档的数据迁移部分。记住,我们在第九章创建了自定义迁移,用于修改模型后迁移已存在的数据。你可以在这里阅读数据库迁移的文档。

10.3 为不同的内容创建模型

我们计划在课程模型中添加不同类型的内容,比如文本,图片,文件和视频。我们需要一个通用的数据模型,允许我们存储不同的内容。在第六章中,我们已经学习了使用通用关系创建指向任何模型对象的外键。我们将创建一个Content模型表示单元内容,并定义一个通过关系,关联到任何类型的内容。

编辑courses应用的models.py文件,并添加以下导入:

from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey

然后在文件结尾添加以下代码:

class Content(models.Model):
    module = models.ForeignKey(Module, related_name='contents')
    content_type = models.ForeignKey(ContentType)
    object_id = models.PositiveIntegerField()
    item = GenericForeignKey('content_type', 'object_id')

这是Content模型。一个单元包括多个内容,所以我们定义了一个指向Module模型的外键。我们还建立了一个通用关系,从代表不同内容类型的不同模型关联到对象。记住,我们需要三个不同字段来设置一个通用关系。在Content模型中,它们分别是:

  • content_type:一个指向ContentType模型的ForeignKey字段。
  • object_id:这是一个PositiveIntegerField,存储关联对象的主键。
  • item:通过组合上面两个字段,指向关联对象的GenericForeignKey字段。

在这个模型的数据库表中,只有content_typeobject_id字段有对应的列。item字段允许你直接检索或设置关联对象,它的功能建立在另外两个字段之上。

我们将为每种内容类型使用不同的模型。我们的内容模型会有通用字段,但它们存储的实际内容会不同。

10.3.1 使用模型继承

Django 支持模型继承,类似 Python 中标准类的继承。Django 为使用模型继承提供了以下三个选择:

  • 抽象模型:当你想把一些通用信息放在几个模型时很有用。不会为抽象模型创建数据库表。
  • 多表模型继承:可用于层次中每个模型本身被认为是一个完整模型的情况下。为每个模型创建一张数据库表。
  • 代理模型:当你需要修改一个模型的行为时很有用。例如,包括额外的方法,修改默认管理器,或者使用不同的元选项。不会为代理模型创建数据库表。

让我们近一步了解它们。

10.3.1.1 抽象模型

一个抽象模型是一个基类,其中定义了你想在所有子模型中包括的字段。Django 不会为抽象模型创建任何数据库表。会为每个子模型创建一张数据库表,其中包括从抽象类继承的字段,和子模型中定义的字段。

要标记一个抽象模型,你需要在它的Meta类中包括abstract=True。Django 会认为它是一个抽象模型,并且不会为它创建数据库表。要创建子模型,你只需要从抽象模型继承。以下是一个Content抽象模型和Text子模型的例子:

from django.db import models

class BaseContent(models.Model):
	title = models.CharField(max_length=200)
	created = models.DateTimeField(auto_now_add=True)
	
	class Meta:
		abstract = True

class Text(BaseContent):
	body = models.TextField()

在这个例子中,Django 只会为Text模型创建数据库表,其中包括titlecreatedbody字段。

10.3.1.2 多表模型继承

在多表继承中,每个模型都有一张相应的数据库表。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字段,并为每个模型创建一张数据库表。

10.3.1.3 代理模型

代理模型用于修改模型的行为,比如包括额外的方法或者不同的元选项。这两个模型都在原模型的数据库表上进行操作。在模型的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 create_delta(self):
		return timezone.now() - self.created

我们在这里定义了一个OrderedContent模型,它是Content模型的代理模型。这个模型为 QuerySet 提供了默认排序和一个额外的created_delta()方法。ContentOrderedContent模型都在同一张数据库表上操作,并且可以用 ORM 通过任何一个模型访问对象。

10.3.2 创建内容模型

courses应用的Content模型包含一个通用关系来关联不同的内容类型。我们将为每种内容模型创建不用的模型。所有内容模型会有一些通用的字段,和一些额外字段存储自定义数据。我们将创建一个抽象模型,它会为所有内容模型提供通用字段。

编辑courses应用的models.py文件,并添加以下代码:

class ItemBase(models.Model):
    owner = models.ForeignKey(User, related_name='%(class)s_related')
    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抽象模型继承的内容模型。分别是:

  • Text:存储文本内容。
  • File:存储文件,比如 PDF。
  • Image:存储图片文件。
  • Video:存储视频。我们使用URLField字段来提供一个视频的 URL,从而可以嵌入视频。

除了自身的字段,每个子模型还包括ItemBase类中定义的字段。会为TextFileImageVideo模型创建对应的数据库表。因为ItemBase是一个抽象模型,所以它不会关联到数据库表。

编辑你之前创建的Content模型,修改它的content_type字段:

content_type = models.ForeignKey(
    ContentType,
    limit_choices_to = {
        'model__in': ('text', 'video', 'image', 'file')
    }
)

我们添加了limit_choices_to参数来限制ContentType对象可用于的通用关系。我们使用了model__in字段查找,来过滤ContentType对象的model属性为textvideoimage或者file

让我们创建包括新模型的数据库迁移。在命令行中执行以下命令:

python manage.py makemigrations

你会看到以下输出:

Migrations for 'courses':
  courses/migrations/0002_content_file_image_text_video.py
    - Create model Content
    - Create model File
    - Create model Image
    - Create model Text
    - Create model Video

然后执行以下命令应用新的数据库迁移:

python manage.py migrate

你看到的输出的结尾是:

Running migrations:
  Applying courses.0002_content_file_image_text_video... OK

我们已经创建了模型,可以添加不同内容到课程单元中。但是我们的模型仍然缺少了一些东西。课程单元和内容应用遵循特定的顺序。我们需要一个字段对它们进行排序。

10.4 创建自定义模板字段

Django 自带一组完整的模块字段,你可以用它们构建自己的模型。但是,你也可以创建自己的模型字段来存储自定义数据,或者修改已存在字段的行为。

我们需要一个字段指定对象的顺序。如果你想用 Django 提供的字段,用一种简单的方式实现这个功能,你可能会想在模型中添加一个PositiveIntegerField。这是一个好的开始。我们可以创建一个从PositiveIntegerField继承的自定义字段,并提供额外的方法。

我们会在排序字段中添加以下两个功能:

  • 没有提供特定序号时,自动分配一个序号。如果存储对象时没有提供序号,我们的字段会基于最后一个已存在的排序对象,自动分配下一个序号。如果两个对象的序号分别是 1 和 2,保存第三个对象时,如果没有给定特定序号,我们应该自动分配为序号 3。
  • 相对于其它字段排序对象。课程单元将会相对于它们所属的课程排序,而模块内容会相对于它们所属的单元排序。

courses应用目录中创建一个fields.py文件,并添加以下代码:

from django.db import models
from django.core.exceptions import ObjectDoesNotExist

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 = last_item.order + 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,我们如下计算序号:
  • 我们构建一个QuerySet检索这个字段模型所有对象。我们通过访问self.model检索字段所属的模型类。
  • 我们用定义在字段的for_fields参数中的模型字段(如果有的话)的当前值过滤QuerySet。这样,我们就能相对于给定字段计算序号。
  • 我们用last_item = qs.lastest(self.attname)从数据库中检索序号最大的对象。如果没有找到对象,我们假设它是第一个对象,并分配序号 0。
  • 如果找到一个对象,我们在找到的最大序号上加 1。
  • 我们用setattr()把计算的序号分配给模型实例中的字段值,并返回这个值。
  1. 如果模型实例有当前字段的值,则什么都不做。

当你创建自定义模型字段时,让它们是通用的。避免分局特定模型或字段硬编码数据。你的字段应该可以用于所有模型。

你可以在这里阅读更多关于编写自定义模型字段的信息。

让我们在模型中添加新字段。编辑courses应用的models.py文件,并导入新的字段:

from .fields import OrderField

然后在Module模型中添加OrderField字段:

order = OrderField(blank=True, for_fields=['course'])

我们命名新字段为order,并通过设置for_fields=['course'],指定相对于课程计算序号。这意味着一个新单元会分配给同一个Course对象中最新的单元加 1。现在编辑Module模型的__str__()方法,并如下引入它的序号:

def __str__(self):
    return '{}. {}'.format(self.order, self.title)

单元内容也需要遵循特定序号。在Content模型中添加一个OrderField字段:

order = OrderField(blank=True, for_fields=['module'])

这次我们指定序号相对于module字段计算。最后,让我们为两个模型添加默认排序。在ModuleContent模型中添加以下Meta类:

class Meta:
    ordering = ['order']

现在ModuleContent模型看起来是这样的:

class Module(models.Model):
    course = models.ForeignKey(Course, related_name='modules')
    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 '{}. {}'.format(self.order, self.title)

class Content(models.Model):
    module = models.ForeignKey(Module, related_name='contents')
    content_type = models.ForeignKey(
        ContentType,
        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']

让我们创建反映新序号字段的模型迁移。打开终端,并执行以下命令:

python manage.py makemigrations courses

你会看到以下输出:

You are trying to add a non-nullable field 'order' to content without a default; we can't do that (the database needs something to populate existing rows).
Please select a fix:
 1) Provide a one-off default now (will be set on all existing rows with a null value for this column)
 2) Quit, and let me add a default in models.py
Select an option:

Django 告诉我们,因为我们在已存在的模型中添加了新字段,所以必须为数据库中已存在的行提供默认值。如果字段有null=True,则可以接受空值,并且 Django 创建迁移时不要求提供默认值。我们可以指定一个默认值,或者取消数据库迁移,并在创建迁移之前在models.py文件的order字段中添加default属性。

输入1,然后按下Enter,为已存在的记录提供一个默认值。你会看到以下输出:

Please enter the default value now, as valid Python
The datetime and django.utils.timezone modules are available, so you can do e.g. timezone.now
Type 'exit' to exit this prompt
>>>

输入0作为已存在记录的默认值,然后按下Enter。Django 还会要求你为Module模型提供默认值。选择第一个选项,然后再次输入0作为默认值。最后,你会看到类似这样的输出:

Migrations for 'courses':
  courses/migrations/0003_auto_20170518_0743.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_auto_20170518_0743... OK

让我们测试新字段。使用python manage.py shell命令打开终端,并如下创建一个新课程:

>>> from django.contrib.auth.models import User
>>> from courses.models import Subject, Course, Module
>>> user = User.objects.latest('id')
>>> subject = Subject.objects.latest('id')
>>> 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字段不会介入,并且使用给定的order值。

让我们添加第四个单元:

>>> m4 = Module.objects.create(course=c1, title='Module 4')
>>> m4.order
6

这个单元的序号已经自动设置了。我们的OrderField字段不能保证连续的序号。但是它关注已存在的序号值,总是根据已存在的最大序号值分配下一个序号。

让我们创建第二个课程,并添加一个单元:

>>> c2 = Course.objects.create(subject=subject, owner=user, title='Course 2', slug='course2')
>>> m5 = Module.objects.create(course=c2, title='Module 1')
>>> m5.order
0

要计算新的单元序号,该字段只考虑属于同一个课程的已存在单元。因为这个第二个课程的第一个单元,所以序号为 0。这是因为我们在Module模型的order字段中指定了for_fields=['course']

恭喜你!你已经成功的创建了第一个自定义模型字段。

10.5 创建内容管理系统

现在我们已经创建了一个万能的数据模型,接下来我们会创建一个内容管理系统(CMS)。CMS 允许教师创建课程,并管理它们的内容。我们需要以下功能:

  • 登录到 CMS
  • 教师创建的课程列表
  • 创建,编辑和删除课程
  • 添加单元到课程,并对它们重新排序
  • 添加不同类型的内容到每个单元中,并对它们重新排序

10.5.1 添加认证系统

我们将在平台中使用 Django 的认证框架。教师和学生都是 Django 的User模型的实例。因此,他们可以使用django.contrib.auth的认证视图登录网站。

编辑educa项目的主urls.py文件,并引入 Django 认证框架的loginlogout视图:

from django.conf.urls import include, url
from django.contrib import admin
from django.contrib.auth import views as auth_views

urlpatterns = [
    url(r'^accounts/login/$', auth_views.login, name='login'),
    url(r'^accounts/logout/$', auth_views.logout, name='logout'),
    url(r'^admin/', admin.site.urls),
]

10.5.2 创建认证模板

courses应用目录中创建以下文件结构:

templates/
	base.html
	registration/
		login.html
		logged_out.html

构建认证模板之前,我们需要为项目准备基础模板。编辑base.html模板,并添加以下内容:

{% load staticfiles %}
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title>{% block title %}Educa{% endblock title %}</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><a href="{% url "logout" %}">Sign out</a></li>
            {% else %}
                <li><a href="{% url "login" %}">Sign in</a></li>
            {% endif %}
        </ul>
    </div>
    <div id="content">
        {% block content %}
        {% endblock content %}
    </div>

    <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
    <script>
        $(document).ready(function() {
            {% block domready %}
            {% endblock domready %}
        });
    </script>
</body>
</html>

这是基础模板,其它模板会从它扩展。在这个模板中,我们定义了以下块:

  • title:其它模块用来为每个页面添加自定义标题的块。
  • content:主要的内容块。所有扩展基础模板的模板必须在这个块中添加内容。
  • domready:位于 jQuery 的$(document).ready()函数内。允许我们在 DOM 完成加载时执行代码。

这个模板中使用的 CSS 样式位于本章实例代码的courses应用的static/目录中。你可以把它拷贝到项目的相同位置。

编辑registration/login.html模板,并添加以下代码:

{% extends "base.html" %}

{% block title %}Log-in{% endblock title %}

{% 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, user 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 content %}

这是 Django 的login视图的标准登录模板。编辑registration/logged_out.html模板,并添加以下代码:

{% extends "base.html" %}

{% block title %}Logged out{% endblock title %}

{% 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 content %}

用户登出后会显示这个模板。执行python manage.py runserver命令启动开发服务器,然后在浏览器中打开http://127.0.0.1:8000/accounts/login/,你会看到以下登录页面:

10.5.3 创建基于类的视图

我们将构建用于创建,编辑和删除课程的视图。我们将使用基于类的视图。编辑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()方法。当你需要为数个基于类的视图提供特定行为,推荐方式是使用minxins

10.5.4 为基于类的视图使用 mixins

Mixins 是一个类的特殊的多重继承。你可以用它们提供常见的离散功能,把它们添加到其它 mixins 中,允许你定义一个类的行为。有两种主要场景下使用 mixins:

  • 你想为一个类提供多个可选的特性
  • 你想在数个类中使用某个特性

你可以在这里阅读如何在基于类的视图中使用 mixins 的文档。

Django 自带几个 mixins,为基于类的视图提供额外的功能。你可以在这里找到所有 mixins。

我们将创建包括一个常见功能的 mixins 类,并把它用于课程的视图。编辑courses应用的views.py文件,如下修改:

from django.core.urlresolvers import reverse_lazy
from django.views.generic.list import ListView
from django.views.generic.edit import CreateView
from django.views.generic.edit import UpdateView
from django.views.generic.edit import DeleteView
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

class OwnerCourseEditMixin(OwnerCourseMixin, OwnerEditMixin):
    fields = ['subject', 'title', 'slug', 'overview']
    success_url = reverse_lazy('manage_course_list')
    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'
    success_url = reverse_lazy('manage_course_list')

在这段代码中,我们创建了OwnerMixinOwnerEditMixin两个 mixins。我们与 Django 提供的ListViewCreateViewUpdateViewDeleteView视图一起使用这些 mixins。OwnerMixin实现了以下方法:

  • get_queryset():视图用这个方法获得基本的 QuerySet。我们的 mixin 会覆写这个方法,通过owner属性过滤对象,来检索属于当前用户的对象(request.user)。

OwnerEditMixin实现以下方法:

  • form_valid():使用 Django 的ModelFormMixin的视图会使用这个方法,比如,带表单或者模型表单的视图(比如CreateViewUpdateView)。当提交的表单有效时,会执行form_valid()。这个方法的默认行为是保存实例(对于模型表单),并重定向用户到success_url。我们覆写这个方法,在被保存对象的owner属性中自动设置当前用户。这样,当保存对象时,我们自动设置了对象的owner

我们的OwnerMixin类可用于与包括owner属性的任何模型交互的视图。

我们还定义了一个OwnerCourseMixin,它从OwnerMixin继承,并为子视图提供以下属性:

  • model:用于 QuerySet 的模型。可以被所有视图使用。

我们用以下属性定义了一个OwnerCourseEditMixin

  • fields:模型的这个字段构建了CreateViewUpdateView视图的模型表单。
  • success_url:当表单提交成功后,CreateViewUpdateView用它重定向用户。

最后,我们创建从OwnerCourseMixin继承的视图:

  • ManageCourseListView:列出用户创建的课程。它从OwnerCourseMixinListView继承。
  • CourseCreateView:用模型表单创建一个新的Course对象。它用在OwnerCourseEditMixin中定义的字段来构建模型表单,它还从CreateView继承。
  • CourseUpdateView:允许编辑一个已存在的Course对象。它从OwnerCourseEditMixinUpdateView继承。
  • CourseDeleteView:从OwnerCourseMixin和通用的DeleteView继承。定义了success_url,用于删除对象后重定向用户。

10.5.5 使用组和权限

我们已经创建了管理课程的基础视图。当前,任何用户都可以访问这些视图。我们想限制这些视图,只有教师有权限创建和管理课程。Django 的认证框架包括一个权限系统,允许你给用户和组分配权限。我们将为教师用户创建一个组,并分配创建,更新和删除课程的权限。

使用python manage.py runserver命令启动开发服务器,并在浏览器中打开http://127.0.0.1:8000/admin/auth/group/add/,然后创建一个新的Group对象。添加组名为Instructors,并选择courses应用的所有权限,除了Subject模型的权限,如下图所示:

正如你所看到,每个模型有三个不同的权限:Can addCan changeCan delete。为这个组选择权限后,点击Save按钮。

Django 自动为模型创建权限,但你也可以创建自定义权限。你可以在这里阅读更多关于添加自定义权限的信息。

打开http://127.0.0.1:8000/admin/auth/user/add/,然后添加一个新用户。编辑用户,并把它添加Instructors组,如下图所示:

用户从它所属的组中继承权限,但你也可以使用管理站点为单个用户添加独立权限。is_superuser设置为True的用户自动获得所有权限。

10.5.5.1 限制访问基于类的视图

我们将限制访问视图,只有拥有适当权限的用户才可以添加,修改或删除Course对象。认证框架包括一个permission_required装饰器来限制访问视图。Django 1.9 将会包括基于类视图的权限 mixins。但是 Django 1.8 不包括它们。因此,我们将使用第三方模块django-braces提供的权限 mixins。

译者注:现在 Django 的最新版本是 1.11.X。

Django-braces 是一个第三方模块,其中包括一组通用的 Django mixins。这些 mixins 为基于类的视图提供了额外的特性。你可以在这里查看 django-braces 提供的所有 mixins。

使用pip命令安装 django-braces:

pip install django-braces

我们将使用 django-braces 的两个 mixins 来限制访问视图:

  • LoginRequiredMixin:重复login_required装饰器的功能。
  • PermissionRequiredMixin:允许有特定权限的用户访问视图。记住,超级用户自动获得所有权限。

编辑courses应用的views.py文件,添加以下导入:

from braces.views import LoginRequiredMixin
from braces.views import PermissionRequiredMixin

OwnerCourseMixinLoginRequiredMixin继承:

class OwnerCourseMixin(OwnerMixin, LoginRequiredMixin):
    model = Course
    fields = ['subject', 'title', 'slug', 'overview']
    success_url = reverse_lazy('manage_course_list')

然后在创建,更新和删除视图中添加permission_required属性:

class CourseCreateView(PermissionRequiredMixin,
                       OwnerCourseEditMixin, 
                       CreateView):
    permission_required = 'courses.add_course'

class CourseUpdateView(PermissionRequiredMixin,
                       OwnerCourseEditMixin, 
                       UpdateView):
    template_name = 'courses/manage/course/form.html'
    permission_required = 'courses.change_course'

class CourseDeleteView(PermissionRequiredMixin,
                       OwnerCourseMixin, 
                       DeleteView):
    template_name = 'courses/manage/course/delete.html'
    success_url = reverse_lazy('manage_course_list')
    permission_required = 'courses.delete_course'

PermissionRequiredMixin检查访问视图的用户是否有permission_required属性中之指定的权限。现在只有合适权限的用户可以访问我们的视图。

让我们为这些视图创建 URL。在courses应用目录中创建urls.py文件,并添加以下代码:

from django.conf.urls import url
from . import views

urlpatterns = [
    url(r'^mine/$', views.ManageCourseListView.as_view(), name='manage_course_list'),
    url(r'^create/$', views.CourseCreateView.as_view(), name='course_create'),
    url(r'^(?P<pk>\d+)/edit/$', views.CourseUpdateView.as_view(), name='course_edit'),
    url(r'^(?P<pk>\d+)/delete/$', views.CourseDeleteView.as_view(), name='course_delete'),
]

这些是列出,创建,编辑和删除课程视图的 URL 模式。编辑educa项目的主urls.py文件,在其中包括courses应用的 URL 模式:

urlpatterns = [
    url(r'^accounts/login/$', auth_views.login, name='login'),
    url(r'^accounts/logout/$', auth_views.logout, name='logout'),
    url(r'^admin/', admin.site.urls),
    url(r'^course/', include('courses.urls')),
]

我们需要为这些视图创建模板。在courses应用的templates/目录中创建以下目录和文件:

courses/
	manage/
		course/
			list.html
			form.html
			delete.html

编辑courses/manage/course/list.html模板,并添加以下代码:

{% extends "base.html" %}

{% block title %}My courses{% endblock title %}

{% 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 content %}

这是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/,如下所示:

这个页面会显示当前用户创建的所有课程。

让我们创建模板,显示创建和更新课程视图的表单。编辑courses/manage/course/form.html模板,并添加以下代码:

{% extends "base.html" %}

{% block title %}
    {% if object %}
        Edit course "{{ object.title }}"
    {% else %}
        Create a new course
    {% endif %}
{% endblock title %}

{% block content %}
    <h1>
        {% if object %}
            Edit course "{{ object.title }}"
        {% else %}
            Create a new course
        {% endif %}
    </h1>
    <div class="module">
        <h2>Course info</h2>
        <form action="." method="post">
            {{ form.as_p }}
            {% csrf_token %}
            <p><input type="submit" value="Save course"></p>
        </form>
    </div>
{% endblock content %}

form.html模板用于CourseCreateViewCourseUpdateView视图。在这个模板中,我们检查上下文是否存在object变量。如果上下文中存在object,我们已经正在更新一个已存在课程,并在页面标题使用它。否则,我们创建一个新的Course对象。

在浏览器中打开http://127.0.0.1:8000/course/mine/,然后点击Create new course。你会看到以下页面:

填写表单,然后点击Save course按钮。课程会被保存,并且你会被重定向到课程列表页面,如下图所示:

然后点击你刚创建的课程的Edit链接。你会再次看到表单,但这次你在编辑已存在的Course对象,而不是创建一个新的。

最后,编辑courses/manage/course/delete.html模板,并添加以下代码:

{% extends "base.html" %}

{% block title %}Delete course{% endblock title %}

{% 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" class="button" value="Confirm">
        </form>
    </div>
{% endblock content %}

这是CourseDeleteView视图的模板。这个视图从 Django 提供的DeleteView视图继承,它希望用户确认是否删除一个对象。

打开你的浏览器,并点击课程的Delete链接。你会看到以下确认页面:

点击CONFIRM按钮。课程会被删除,你会再次被重定向到课程列表页面。

现在教师可以创建,编辑和删除课程。下一步,我们将给教师提供一个内容管理系统,为课程添加单元和内容。我们从管理课程单元开始。

10.5.6 使用表单集

Django 自带一个抽象层,可以在同一个页面使用多个表单。这些表单组称为表单集(formsets)。表单集管理多个确定的FormModelForm实例。所有表单会一次性提交,表单集会负责处理一些事情,比如显示的初始表单数量,限制最大的提交表单数量,以及验证所有表单。

表单集包括一个is_valide()方法,可以一次验证所有表单。你还可以为表单提供初始数据,并指定显示多少额外的空表单。

你可以在这里进一步学习表单集,以及在这里学习模型表单集。

10.5.6.1 管理课程单元

因为一个课程分为多个单元,所以这里可以使用表单集。在courses应用目录中创建forms.py,并添加以下代码:

from django import forms
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()函数构建它。内联表单集(inline formsets)是表单集之上的一个小抽象,可以简化关联对象的使用。这个函数允许我们动态构建一个模型表单集,把Module对象关联到一个Course对象。

我们使用以下参数构建表单集:

  • fields:在表单集的每个表单中包括的字段。
  • extra:允许我们在表单集中设置两个额外的空表单。
  • can_delete:如果设置为True,Django 会为每个表单包括一个布尔值字段,该字段渲染为一个复选框。它允许你标记对象为删除。

编辑courses应用的views.py,并添加以下代码:

from django.shortcuts import redirect, get_object_or_404
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视图处理表单集来添加,更新和删除指定课程的单元。这个视图从以下 mixins 和视图继承:

  • TemplateResponseMixin:这个 mixin 负责渲染模板,并返回一个 HTTP 响应。它需要一个template_name属性,指定被渲染的模板,并提供render_to_response()方法,传入上下文参数,并渲染模板。
  • View:Django 提供的基础的基于类的视图。

在这个视图中,我们实现了以下方法:

  • get_formset():我们定义这个方法,避免构建表单集的重复代码。我们用可选的data为给定的Course对象创建ModuleFormSet对象。
  • dispatch():这个方法由View类提供。它接收一个 HTTP 请求作为参数,并尝试委托到与使用的 HTTP 方法匹配的小写方法:GET 请求委托到get()方法,POST 请求委托到post()方法。在这个方法中,我们用get_object_or_404()函数获得属于当前用户,并且 ID 等于id参数的Course对象。因为 GET 和 POST 请求都需要检索课程,所以我们在dispatch()方法中包括这段代码。我们把它保存在视图的course属性,让其它方法也可以访问。
  • get():GET 请求时执行的方法。我们构建一个空的ModuleFormSet表单集,并使用TemplateResponseMixin提供的render_to_response()方法,把当前Course对象和表单集渲染到模板中。
  • post():POST 请求时执行的方法。在这个方法中,我们执行以下操作:
  1. 我们用提交的数据构建一个ModuleFormSet实例。
  2. 我们执行表单集的is_valid()方法,验证表单集的所有表单。
  3. 如果表单集有效,则调用save()方法保存它。此时,添加,更新或者标记删除的单元等任何修改都会应用到数据库中。然后我们重定向用户到manage_course_list URL。如果表单集无效,则渲染显示错误的模板。

编辑courses应用的urls.py文件,并添加以下 URL 模式:

url(r'^(?P<pk>\d+)/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 title %}

{% block content %}
    <h1>Edit "{{ course.title }}"</h1>
    <div class="module">
        <h2>Course modules</h2>
        <form action="" method="post">
            {{ formset }}
            {{ formset.management_form }}
            {% csrf_token %}
            <input type="submit" class="button" value="Save modules">
        </form>
    </div>
{% endblock content %}

在这个模板中,我们创建了一个<form>元素,其中包括我们的表单集。我们还用{{ 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/,然后点击一个课程的Edit modules链接,你会看到如图所示的表单集:

表单集中包括课程中每个Module对象的表单。在这些表单之后,显示了两个额外的空表单,这是因为我们为ModuleFormSet设置了extra=2。当你保存表单集时,Django 会包括另外两个额外字段来添加新单元。

10.5.7 添加内容到课程单元

现在我们需要一种添加内容到课程单元的方式。我们有四种不同类型的内容:文本,视频,图片和文件。我们可以考虑创建四个不同的视图,来为每种模型创建内容。但是我们会用更通用的方法:创建一个可以处理创建或更新任何内容模型对象的视图。

编辑courses应用的views.py文件,并添加以下代码:

from django.forms.models import modelform_factory
from django.apps import apps
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():在这里,我们检查给定的模型名称是否为四种内容模型之一:文本,视频,图片或文件。然后我们用 Django 的apps.get_model()获得给定模型名的实际类。如果给定的模型名不是四种之一,则返回None
  • get_form():我们用表单框架的modelform_factory()函数动态构建表单。因为我们要为TextVideoImageFile模型构建表单,所以我们使用exclude参数指定要从表单中排出的字段,而让剩下的所有字段自动包括在表单中。这样我们不用根据模型来包括字段。
  • dispatch():它接收以下 URL 参数,并用类属性存储相应的单元,模型和内容对象:
  • module_id:内容会关联的单元的 ID。
  • model_name:内容创建或更新的模型名。
  • id:被更新的对象的 ID。创建新对象时为 None。

ContentCreateUpdateView类中添加以下get()post()方法:

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 请求时执行。我们为被更新的TextVideoImage或者File实例构建模型表单。否则我们不会传递实例来创建新对象,因为如果没有提供id,则self.obj为 None。
  • post():收到 POST 请求时执行。我们传递提交的所有数据和文件来构建模型表单。然后验证它。如果表单有效,我们创建一个新对象,并在保存到数据库之前把request.user作为它的所有者。我们检查id参数。如果没有提供id,我们知道用户正在创建新对象,而不是更新已存在的对象。如果这是一个新对象,我们为给定的单元创建一个Content对象,并把它关联到新的内容。

编辑courses应用的urls.py文件,并添加以下 URL 模式:

url(r'^module/(?P<module_id>\d+)/content/(?P<model_name>\w+)/create/$', 
    views.ContentCreateUpdateView.as_view(), 
    name='module_content_create'),
url(r'^module/(?P<module_id>\d+)/content/(?P<model_name>\w+)/(?P<id>\d+)/$',
    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 a new content
    {% endif %}
{% endblock title %}     

{% block content %}
    <h1>
        {% if object %}
            Edit content "{{ object.title }}"
        {% else %}
            Add a 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 content %}   

这是ContentCreateUpdateView视图的模板。在这个模板中,我们检查上下文中是否存在object变量。如果存在,则表示正在更新一个已存在对象。否则,表示正在创建一个新对象。

因为表单中包含一个上传的FileImage内容模型文件,所以我们在<form>元素中包括了enctype="multipart/form-data

启动开发服务器。为已存在的课程创建一个单元,然后在浏览器中打开http://127.0.0.1:8000/course/module/6/content/image/create/。如果修改的话,请修改 URL 中的单元 ID。你会看到创建一个Image对象的表单,如下图所示:

先不要提交表单。如果你这么做了,提交会失败,因为我们还没有定义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 模式:

url(r'^content/(?P<id>\d+)/delete/$', views.ContentDeleteView.as_view(), name='module_content_delete'),

现在,教师可以很容易的创建,更新和删除内容。

10.5.8 管理单元和内容

我们已经构建创建,编辑,删除课程单元和内容的视图。现在,我们需要一个显示某个课程所有单元和列出特定单元所有内容的视图。

编辑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 模式:

url(r'^module/(?P<module_id>\d+)/$', 
    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 title %}

{% 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 {{ moudle.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>
            <hr>
            <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 content %}

这个模板用于显示某个课程的所有单元,以及选定单元的内容。我们迭代课程单元,并在侧边栏显示它们。我们还迭代单元的内容,并访问content.item获得关联的TextVideoImageFile对象。我们还包括一个用于创建新文本,视频,图片或文件内容的链接。

我们想知道每个对象的item对象的类型:TextVideoImageFile。我们需要模型名构建编辑对象的 URL。除了这个,我们还根据内容的类型,在模板中显示每个不同的item。我们可以从模型的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 %}

这会加载coursse模板标签。然后找到以下代码:

<p>{{ itme }}</p>
<a href="#">Edit</a>

替换为以下代码:

<p>{{ itme }} ({{ item|model_name }})</p>
<a href="{% url "module_content_update" module.id item|model_name item.id %}">
    Edit
</a>

现在我们在模板中显示item模型,并用模型名构建链接来编辑对象。编辑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 %}

新链接允许用户访问课程第一个单元的内容(如果存在的话)。

在浏览器中打开http://127.0.0.1:8000/course/mine/,并点击至少包括一个单元的课程的Manage contents链接。你会看到如图所示的页面:

当你点击左边栏的单元,则会在主区域显示它的内容。模板还包括链接,用于添加文本,视频,图片或文件内容到显示的单元。添加一组不同的内容到单元中,并看一下眼结果。内容会在Module contents下面显示,如下图所示:

10.5.9 重新排序单元和内容

我们需要提供一种简单的方式对课程单元和它们的内容重新排序。我们将使用一个 JavaScript 拖放组件,让用户通过拖拽对课程的单元进行重新排序。当用户完成拖拽一个单元,我们会发起一个异步请求(AJAX)来存储新的单元序号。

我们需要一个视图接收用 JSON 编码的单元id的新顺序。编辑courses应用的views.py文件,并添加以下代码:

from braces.views import CsrfExemptMixin
from braces.views import 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视图。我们使用了 django-braces 的以下 mixins:

  • CsrfExemptMixin:避免在 POST 请求中检查 CSRF 令牌。我们需要它执行 AJAX POST 请求,而不用生成csrf_token
  • JsonRequestResponseMixin:解析数据为 JSON 格式,并序列化响应为 JSON,同时返回带application/json内容类型的 HTTP 响应。

我们可以构建一个类似的视图来排序单元的内容。在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 模式:

url(r'^module/order/$', views.ModuleOrderView.as_view(), name='module_order'),
url(r'^content/order/$', views.ContentOrderView.as_view(), name='content_order'),

最后,我们需要在模板中实现拖放功能。我们将使用 jQuery UI 库实现这个功能。jQuery UI 构建在 jQuery 之上,它提供了一组界面交互,效果和组件。我们将使用它的sortable元素。首先,我们需要在基础模板中加载 jQuery UI。打开courses应用中templates目录的base.html文件,在加载 jQuery 下面加载 jQuery UI:

<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jqueryui/1.11.4/jquery-ui.min.js"></script>

我们在 jQuery 框架之后加载 jQuery UI 库。现在编辑courses/manage/module/content_list.html模板,在底部添加以下代码:

{% block domready %}
    $('#modules').sortable({
        stop: function(event, ui) {
            modules_order = {};
            $('#modules').children().each(function() {
                // update the order field
                $(this).find('.order').text($(this).index() + 1);
                // associate the module's id with its order
                modules_order[$(this).data('id')] = $(this).index();
            });
            $.ajax({
                type: 'POST',
                url: '{% url "module_order" %}',
                contentType: 'application/json; charset=utf-8',
                dataType: 'json',
                data: JSON.stringify(modules_order)
            });
        }
    });

    $('#module-contents').sortable({
        stop: function(event, ui) {
            contents_order = {};
            $('#module-contents').children().each(function() {
                // associate the module's id with its order
                contents_order[$(this).data('id')] = $(this).index();
            });

            $.ajax({
                type: 'POST',
                url: '{% url "content_order" %}',
                contentType: 'application/json; charset=utf-8',
                dataType: 'json',
                data: JSON.stringify(content_order),
            });
        }
    });
{% endblock domready %}

这段 JavaScript 代码在{% block domready %}块中,因此它会包括在 jQuery 的$(document).ready()事件中,这个事件在base.html模板中定义。这确保了一旦页面加载完成,就会执行我们的 JavaScript 代码。我们为侧边栏中的单元列表和单元的内容列表定义了两个不同的sortable元素。它们以同样的方式工作。在这段代码中,我们执行了以下任务:

  1. 首先,我们为modules元素定义了一个sortable元素。记住,因为 jQuery 选择器使用 CSS 语法,所以我们使用了#modules
  2. 我们为stop事件指定了一个函数。每次用户完成对一个元素排序,会触发这个事件。
  3. 我们创建了一个空的modules_order字典。这个字段的key是单元的id,值是分配给每个单元的序号。
  4. 我们迭代#modules的子元素。我们重新计算每个单元的显示序号,并获得它的data-id属性,其中包括了单元的id。我们添加idmodules_order字段的key,单元的新索引作为值。
  5. 我们发起一个 AJAX POST 请求到content_order URL,在请求中包括modules_order序列化后的 JSON 数据。相应的ModuleOrderView负责更新单元序号。

对内容进行排序的sortable元素跟它很类似。回到浏览器中,重新加载页面。现在你可以点击和拖拽单元和内容,对它们进行排序,如下图所示:

非常棒!你现在可以对课程单元和单元内容重新排序了。

10.6 总结

在本章中,你学习了如果创建一个多功能的内容管理系统。你使用了模型继承,并创建自定义模型字段。你还使用了基于类的视图和 mixins。你创建了表单集和一个系统,来管理不同类型的内容。

下一章中,你会创建一个学生注册系统。你还会渲染不同类型的内容,并学习如何使用 Django 的缓存框架。

第十一章:缓存内容

在上一章中,你使用模型继承和通用关系来创建灵活的课程内容模型。你还使用基于类的视图,表单集和 AJAX 排序内容创建了一个课程管理系统。在本章中,你会学习学习以下内容:

  • 创建显示课程信息的公开视图
  • 构建一个学生注册系统
  • 在课程中管理学生报名
  • 渲染不同的课程内容
  • 使用缓存框架缓存内容

我们从创建课程目录开始,让学生可以浏览已存在的课程,并且可以报名参加。

11.1 显示课程

对于我们的课程目录,我们需要构建以下功能:

  • 列出所有可用课程,可用通过主题过滤
  • 显示单个课程的概述

编辑courses应用的views.py文件,添加以下代码:

from .models import Subject
from django.db.models import Count

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 参数,我们检索相应的主题对象,并限制查询属于这个主题的课程。
  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对象。

编辑educa项目的主urls.py文件,并添加以下代码:

from courses.views import CourseListView

urlpatterns = [
	# ...
	url(r'^$', CourseListView.as_view(), name='course_list'),
]

因为我们想在http://127.0.0.1:8000/显示课程列表,而courses应用的其它所有 URL 带/course/前缀,所以我们在项目的主urls.py文件中添加course_list URL 模式。

编辑courses应用的urls.py文件,添加以下 URL 模式:

url(r'^subject/(?P<subject>[\w-]+)/$', 
    views.CourseListView.as_view(), 
    name='course_list_subject'),
url(r'^(?P<slug>[\w-]+)/$', 
    views.CourseDetailView.as_view(),
    name='course_detail'),

我们定义了以下 URL 模式:

  • course_list_subject:显示一个主题的所有课程
  • course_detail_subject:显示单个课程的概述

让我们为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 title %}

{% 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 }} courses</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>
                    {{ coursse.total_modules }} modules.
                    Instructor: {{ course.owner.get_full_name }}
                </p>
            {% endwith %}
        {% endfor %}
    </div>
{% endblock content %}

这是显示可用课程列表的模板。我们创建了一个 HTML 列表显示所有Subject对象,并为每个Subject对象构建一个到course_list_subject URL 的链接。我们点击了selected类高亮显示当前主题(如果存在的话)。我们迭代每个Course对象,显示单元总数和教师姓名。

使用python manage.py runserver命令启动开发服务器,并在浏览器中打开http://127.0.0.1:8000/。你会看到类似以下的页面:

左侧边栏包括所有主题,已经每个主题的课程总数。你可以点击任何一个主题来过滤显示的课程。

编辑courses/course/detail.html模板,并添加以下代码:

{% extends "base.html" %}

{% block title %}
    {{ object.title }}
{% endblock title %}

{% block content %}
    {% with subject=course.subject %}
        <h1>
            {{ object.title }}
        </h1>
        <div class="module">
            <h2>Overview</h2>
            <p>
                <a href="{% url "course_list_subject" subject.slug %}">{{ subject.title }}</a>
                {{ course.modules.count }} modules.
                Instructors: {{ course.owner.get_full_name }}
            </p>
            {{ object.overview|linebreaks }}
        </div>
    {% endwith %}
{% endblock content %}

在这个模板中,我们显示单个课程的概述和详情。在浏览器中打开http://127.0.0.1:8000/,然后点击其中一个课程。你会看到以下结构的页面:

我们已经创建了展示课程的公开区域。接下来,让我们允许用户注册为学生,并报名参加课程。

11.2 添加学生注册

使用以下命令创建一个新应用:

python manage.py startapp students

编辑educa项目的settings.py文件,把students添加到INSTALLED_APPS设置中:

INSTALLED_APPS = [
	# ...
	'students',
]

11.2.1 创建学生注册视图

编辑students应用的views.py文件,并添加以下代码:

from django.core.urlresolvers import reverse_lazy
from django.views.generic.edit import CreateView
from django.contrib.auth.forms import UserCreationForm
from django.contrib.auth import authenticate, login

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['password']
        )
        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.conf.urls import url
from . import views

urlpatterns = [
    url(r'^register/$', views.StudentRegistrationView.as_view(), name='student_registration'),
]

编辑educa项目的主urls.py文件,把students应用的 URL 添加到 URL 配置中:

url(r'^students/', include('students.urls')),

students应用中创建以下文件结构:

templates/
	students/
		student/
			registration.html

编辑students/student/registration.html模板,并添加以下代码:

{% extends "base.html" %}

{% block title %}
    Sign up
{% endblock title %}

{% block content %}
    <h1>
        Sign up
    </h1>
    <div class="module">
        <p>Enter your details to create an account:</p>
        <form action="" method="post">
            {{ form.as_p }}
            {% csrf_token %}
            <p><input type="submit" value="Create my account"></p>
        </form>
    </div>
{% endblock content %}

最后,编辑educa项目的settings.py文件,并添加以下代码:

from django.core.urlresolvers import reverse_lazy
LOGIN_REDIRECT_URL = reverse_lazy('student_course_list')

一次成功登录后,如果请求中没有next参数,则auth模块用这个设置重定义用户。

启动开发服务器,并在浏览器中打开http://127.0.0.1:8000/students/register/。你会看到以下注册表单:

11.2.2 报名参加课程

用户创建账户之后,他们应该可以报名参加课程。为了存储花名册,我们需要在CourseUser模型之间创建多对多关系。编辑courses应用的models.py文件,并在Course模型中添加以下字段:

students = models.ManyToManyField(User, related_name='courses_joined', blank=True)

在终端中执行以下命令,为这个修改创建一个数据库迁移:

python manage.py makemigrations

你会看到类似这样的输出:

Migrations for 'courses':
  courses/migrations/0004_course_students.py
    - Add field students to course

然后执行以下命令,应用数据库迁移:

python manage.py migrate

你会看到以下输出:

Operations to perform:
  Apply all migrations: admin, auth, contenttypes, courses, sessions
Running migrations:
  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.all(),
        widget=forms.HiddenInput
    )

我们将把这个表单用于学生报名。course字段用于学生报名的课程。因此它是一个ModelChoiceField。因为我们不会显示这个字段,所以使用了HiddenInput组件。我们将在CourseDetailView视图中使用这个表单,来显示一个报名按钮。

编辑students应用的views.py文件,并添加以下代码:

from django.views.generic.edit import FormView
from braces.views import LoginRequiredMixin
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继承。我们为form_class属性使用CourseEnrollForm表单,还定义了存储给定Course对象的course属性。当表单有效时,我们添加当前用户到课程的注册学生中。

get_success_url()方法返回一个 URL,如果表单提交成功,则重定向用户到这个 URL。这个方法等同于success_url属性。我们逆向之后会创建的student_course_detail URL,用来显示课程内容。

编辑students应用的urls.py文件,添加以下 URL 模型:

url(r'^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对象初始化表单的隐藏课程字段,因此可以直接提交这个字段。编辑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" class="button" 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/,然后点击一个课程。如果你已经登录,你会在课程概述下面看到ENROLL NOW按钮,如下图所示:

如果你没有登录,则会看到Register to enroll按钮。

11.3 访问课程内容

我们需要一个视图显示学生参加的课程,以及一个访问实际课程内容的视图。编辑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()方法,只检索用户报名的课程:我们用students字段过滤 QuerySet。

然后在views.py文件添加以下代码:

from django.views.generic.detail import DetailView

class StudentCourseDetailView(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()方法来限制 QuerySet 为用户报名的课程。我们还覆写了get_context_data()方法,如果给定了 URL 参数module_id,则在上下文中设置一个课程单元。否则我们设置课程的第一个单元。这样,学生可以在课程中浏览各个单元。

编辑students应用的urls.py文件,并添加以下 URL 模式:

url(r'^courses/$', 
    views.StudentCourseListView.as_view(), 
    name='student_course_list'),
url(r'^course/(?P<pk>\d+)/$', 
    views.StudentCourseDetailView.as_view(), 
    name='student_course_detail'),
url(r'^course/(?P<pk>\d+)/(?P<module_id>\d+)/$', 
    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 title %}

{% 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 content %}

这个模板显示用户报名的课程。编辑students/course/detail.html模板,并添加以下代码:

{% extends "base.html" %}

{% block title %}
    {{ object.title }}
{% endblock title %}

{% 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 content %}

报名的学生通过这个模板访问一个课程的内容。首先,我们构建了一个包括所有课程单元的 HTML 列表,并高亮显示当前单元。然后我们迭代当前单元内容,并用{{ item.render }}访问每个内容项来显示它。接下来我们会添加render()方法到内容模型中。该方法负责适当的渲染内容。

11.3.1 渲染不同类型的内容

我们需要提供一种方式来渲染每种类型的内容。编辑courses应用的models.py文件,并在ItemBase模型中添加render()方法:

from django.template.loader import render_to_string
from django.utils.safestring import mark_safe

class ItemBase(models.Model):
    # ...
    def render(self):
        return render_to_string(
            'courses/content/{}.html'.format(self._meta.model_name),
            {'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|safe}}

编辑courses/content/file.html模板, 并添加以下代码:

<p><a href="{{ item.file.url }}" class="button">Download file</a></p>

编辑courses/content/image.html模板,并添加以下代码:

<p><img src="{{ item.file.url }}"></p>

要使用ImageFieldFileField上传文件,我们需要设置项目用开发服务器提供多媒体文件服务。编辑项目的settings.py文件,并添加以下代码:

MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media/')

记住,MEDIA_URL是提供多媒体文件上传服务的基础 URL,而MEDIA_ROOT是存储文件的本地路径。

编辑项目的主urls.py文件,添加以下导入:

from django.conf import settings
from django.conf.urls.static import static

然后在文件结尾添加以下代码:

urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

现在,你的项目可用使用开发服务器上传和保存多媒体文件了。记住,开发服务器不适合生产环境使用。我们会在下一章学习如何配置生产环境。

我们还需要一个模板渲染Video对象。我们将使用 django-embed-video 嵌入视频内容。Django-embed-video 是一个第三方 Django 应用,允许你通过提供一个视频的公开 URL(比如从 YouTube 或 Vimeo 源),在模板中嵌入视频。

使用以下命令安装这个包:

pip install django-embed-video

然后编辑项目的settings.py文件,把embed_video添加到INSTALLED_APPS设置中。你可以在这里查看 django-embed-video 文档。

编辑courses/content/video.html模板,并添加以下代码:

{% load embed_video_tags %}
{% video item.url 'small' %}

现在启动开发服务器,并在浏览器中访问http://127.0.0.1:8000/course/mine/。使用超级用户或者属于教师组的用户登录网站,然后在一个课程中添加多种内容。对于视频内容,你可以拷贝任何 YouTube 的 URL(比如 https://www.youtube.com/watch?v=bgV39DlmZ2U)到表单的url字段。添加内容到课程后,打开http://127.0.0.1:8000/,点击课程,然后点击ENROLL NOW按钮。你会报名参加课程,并重定向到student_course_detail URL。如下图所示:

非常棒!你已经为渲染课程内容创建了一个通用接口,每种课程内容都已特定方式渲染。

11.4 使用缓存框架

对 Web 应用的 HTTP 请求通常需要数据库访问,数据处理和模板渲染。在处理方法,它们的开销比静态网站大多了。

当网站的流量变得越来越大,有些请求的开销可能会很大。此时缓存变得很有意义。通过在 HTTP 请求中缓存查询,计算结果或者渲染的内容,你会之后的请求中避免昂贵的操作。这意味着服务端更短的响应时间和更少的处理。

Django 包括一个健壮的缓存系统,允许你用不同级别的粒度缓存数据。你可以缓存单个查询,一个特定视图的输出,部分被渲染模板的内容,或者整个网站。内容会在默认时间内存储在缓存系统中。你可以为缓存的数据指定默认的超时时间。

当你的应用收到一个 HTTP 请求时,通常会这样使用缓存框架:

  1. 尝试在缓存中查找请求的数据。
  2. 如果找到,则返回缓存数据。
  3. 如果没有找到,则执行以下步骤:
  • 执行获取数据所需的查询或处理。
  • 在缓存中保存生成的数据。
  • 返回数据

你可以在这里阅读 Django 缓存系统的详细信息。

11.4.1 可用的缓存后台

Django 自带数个缓存后台,分别是:

  • backends.memcached.MemcachedCachebackends.memcached.PyLibMCCache:一个 Memcached 后台。Memcached 是一个快速和高效的基于内存的缓存服务。使用的后台取决于你选择的 Python 绑定的 Memcached。
  • backends.db.DatabaseCache:使用数据库作为缓存系统。
  • backends.filebased.FileBasedCache:使用文件存储系统。在单个文件中序列号和存储每个缓存值。
  • backends.locmem.LocMemCache:本地内存缓存后台。这是默认的缓存后台。
  • backends.dummy.DummyCache:一个只适用于开发的缓存后台。它实现了缓存接口,但不会真正缓存任何数据。这个缓存是预处理和线程安全的。

使用基于内存的缓存后台,比如Memcached,会有最优的性能。

11.4.2 安装 Memcached

我们将使用 Memcached 后台。Memcached 在内存中运行,并分配了一定数量的 RAM。当分配的 RAM 满了之后,Memcached 会移除最旧的数据。

这里下载 Memcached。如果你使用的是 Linux,你可以使用以下命令安装 Memcached:

./configure && make && make test && sudo make install

如果你使用的是 Mac OS X,你可以使用brew install Memcached命令安装。你可以在这里下载 Homebrew。

如果你使用的是 Windows,你可以在这里找到 Windows 二进制版本。

译者注:Windows 版本的链接已经失效。

安装 Memcached 后,打开终端,并使用以下命令启动:

memcached -l 127.0.0.1:11211

Memcached 默认在 11211 端口运行。但你可以使用-l选项指定主机和端口。你可以在这里查看更多关于 Memcached 的信息。

安装 Memcached 后,你需要安装它的 Python 绑定。使用以下命令完成安装:

pip install python3-memcached

11.4.3 缓存设置

Django 提供了以下缓存设置:

  • CACHES:包括项目中所有可用缓存的字典。
  • CACHE_MIDDLEWARE_ALIAS:用于存储的缓存别名。
  • CACHE_MIDDLEWARE_KEY_PREFIX:用于缓存键的前缀。如果你在数个网站中共享同一个缓存,可用设置前缀来避免键冲突。
  • CACHE_MIDDLEWARE_SECONDS:缓存页面的默认秒数。

可用使用CACHES设置来配置项目的缓存系统。这个设置是一个字典,允许你配置多个缓存。CACHES字典中每个缓存可用指定以下数据:

  • BACKEND:使用的缓存后台。
  • KEY_FUNCTION:一个包括点号路径的可调用对象的字符串,可调用对象接收prefixversionkey作为参数,返回最终的缓存键。
  • KEY_PREFIX:所有缓存键的字符串前缀,避免冲突。
  • LOCATION:缓存的位置。根据缓存后台,它可能是一个目录,一个主机和端口,或者内存后台的命名。
  • OPTIONS:传递给缓存后台的任何额外参数。
  • TIMEOUT:存储缓存键的默认超时时间(单位秒)。默认是 300 秒,也就是 5 分钟。如果设置为 None,缓存键永远不会过期。
  • VERSION:缓存键的默认版本号。用于缓存的版本控制。

11.4.4 添加 Memcached 到项目中

让我们为项目配置缓存。编辑educa项目的settings.py文件,并添加以下代码:

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
        'LOCATION': '127.0.0.1:11211',
    }
}

我们使用MemcachedCache后台。我们使用address:port语法之定义它的位置。如果你有多个 Memcached 实例,你可以为LOCATION使用列表。

11.4.4.1 监控 Memcached

有一个django-memcache-status第三方包,可以在管理站点显示 Memcached 实例的统计信息。为了兼容 Python3,使用以下命令从分支中安装:

pip install git+git://github.com/zenx/django-memcache-status.git

译者注:最新版已经兼容 Python3,可以直接用 pip 命令安装。

编辑项目的settings.py文件,把memcache_status添加到INSTALLED_APPS设置中。确保 Memcached 正在运行中,并在另一个终端打开开发服务器,然后在浏览器中打开http://127.0.0.1/8000/admin/。使用超级用户登录管理站点,你会看到以下区域块:

这张图片显示了缓存的使用情况。绿色代表空闲缓存,红色代表已使用的空间。如果你点击标题,则会显示 Memcached 实例的详细统计。

我们已经为项目设置了 Memcached,并且可以监控它。让我们开始缓存数据!

11.4.5 缓存级别

Django 提供了以下缓存级别,按粒度的升序排列:

  • Low-level cache API:提供了最细的粒度。允许你缓存具体的查询或计算。
  • Pre-view cache:为单个视图提供缓存。
  • Template cache:允许你缓存模板片段。
  • Pre-site cache:最高级别缓存。它缓存整个网站。

实现缓存之前,请仔细考虑你的缓存策略。首先考虑费时的查询,或者不是基于单个用户的计算。

11.4.6 使用低级别的缓存 API

低级别缓存 API 允许你在缓存中保存任何粒度的对象。它位于django.core.cache中。你可以这样导入它:

from django.core.cache import cache

这是使用默认缓存。它等价于caches['default']。也可以通过别名访问指定缓存:

from django.core.cache import caches
my_cache = caches['alias']

让我们看看缓存 API 是如何工作的。使用python manage.py shell打开终端,然后执行以下代码:

>>> from django.core.cache import cache
>>> cache.set('musician', 'Django Reinhardt', 20)

我们访问默认缓存后台,并使用set(key, value, timeout)存储misician键 20 秒,它的值是字符串Django Reinhardt。如果我们每页指定超时,则 Django 会使用CACHE设置中为缓存后台指定的默认超时。现在执行以下代码:

>>> cache.get('musician')
'Django Reinhardt'

我们从缓存中检索键。等待 20 秒,然后执行同样的代码:

>>> cache.get('musician')
None

musician缓存键已经过期,get()函数返回 None,因为键已经不再缓存中。

避免在缓存键中存储None,因为你不能区分实际值和缓存丢失。

让我们缓存一个 QuerySet:

>>> from courses.models import Subject
>>> subjects = Subject.objects.all()
>>> cache.set('all_subjects', subjects)

我们在Subject模型上执行了一个 QuerySet,并在all_subjects键中存储返回的对象。让我们检索缓存的数据:

>>> cache.get('all_subjects')
[<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()缓存结果。

启动开发服务器,并在浏览器中打开http://127.0.0.1:8000/。当执行视图时,没有找到缓存键,并且会执行 QuerySet。在浏览器中打开http://127.0.0.1:8000/admin/,并展开 Memcached 统计。你会看到缓存使用数据,如下图所示:

看一眼Curr Items,现在是 1。它表示当前缓存中存储了一条记录。Get Hits表示成功执行了多少次get命令,Get Misses表示键的多少次get请求丢失了。Miss Ratio是这两项值计算出来的结果。

现在回到http://127.0.0.1:8000/,然后重新加载几次页面。如果你现在看一眼缓存统计,会看到更多的读取(Get HitsCmd Get增加了)。

11.4.6.1 基于动态数据缓存

很多时候,你会想要基于动态数据缓存一些东西。这种情况下,你需要动态构建包含所有信息的键,来唯一识别缓存的数据。编辑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 = 'subject_{}_courses'.format(subject.id)
            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缓存键存储所有课程。如果给定了主题了,则使用'subject_{}_courses'.format(subject.id)动态构建键。

请注意,我们不能使用缓存的 QuerySet 来构建另一个 QuerySet,因为我们缓存的是 QuerySet 的实际结果。所以我们不能这么做:

courses = cache.get('all_courses')
courses.filter(subject=subject)

相反,我们必须创建一个基本的 QuerySet:Course.objects.annotate(total_modules=Count('modules')),它在你强制执行之前不会执行。当在缓存中没有找到数据时,使用all_courses.filter(subject=subject)进一步限制 QuerySet。

11.4.7 缓存模板片段

缓存模板片段是一个更高级别的方法。你需要在模板中使用{% load cache %}加载缓存模板标签。然后你才可以使用{% cache %}模板标签缓存指定的模板片段。通常你会这样使用模板标签:

{% cache 300 fragment_name %}
	...
{% endcache %}

{% cache %}有两个必需的参数:超时时间(单位秒)和片段的名称。如果你需要缓存基于动态数据的内容,你可以传递额外的参数给{% cache %}模板标签,来唯一识别片段。

编辑students应用的/students/course/detail.html模板。在{% extend %}标签之后添加以下代码:

{% 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 %}

11.4.8 缓存视图

你可以使用cache_page装饰器缓存单个视图的输出,它位于django.views.decorators.cache中。该装饰器需要一个超时参数(单位秒)。

让我们在视图中使用它。编辑students应用的urls.py文件,添加以下导入:

from django.views.decorators.cache import cache_page

然后在student_course_detailstudent_course_detail_module模式上应用cache_page装饰器,如下所示:

url(r'^course/(?P<pk>\d+)/$', 
    cache_page(60 * 15)(views.StudentCourseDetailView.as_view()), 
    name='student_course_detail'),
url(r'^course/(?P<pk>\d+)/(?P<module_id>\d+)/$', 
    cache_page(60 * 15)(views.StudentCourseDetailView.as_view()), 
    name='student_course_detail_module'),

现在StudentCourseDetailView的结果会缓存 15 分钟。

单个视图缓存使用 URL 构建缓存键。多个指向同一个视图的 URL 会分别缓存。

11.4.81 使用整个站点缓存

这是最高级别的缓存。它允许你缓存整个站点。

编辑项目的settings.py文件,在MIDDLEWARE设置中添加UpdateCacheMiddlewareFetchFromCacheMiddleware类,来启用整个站点缓存:

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.cache.UpdateCacheMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.cache.FetchFromCacheMiddleware',
    # ...
]

记住,在解析请求的过程中,中间件按给定的顺序执行;而在解析响应的过程中,则是逆序执行。UpdateCacheMiddleware放在CommonMiddleware之前是因为它在响应的时候执行,此时中间件是逆序执行的。FetchFromCacheMiddleware特意放在CommonMiddleware之后,是因为它需要访问后者设置的请求数据。

然后,在settings.py文件中添加以下设置:

CACHE_MIDDLEWARE_ALIAS = 'default'
CACEH_MIDDLEWARE_SECONDS = 60 * 15 # 15 minutes
CACHE_MIDDLEWARE_KEY_PRIFIX = 'educa'

在这些设置中,我们为缓存中间件使用默认缓存,并设置全局缓存超时为 15 分钟。我们还为所有缓存键指定一个前缀,来避免在多个项目中使用同一个 Memcached 后台时的冲突。现在我们的网站会缓存数据,并为所有 GET 请求返回缓存的数据。

我们已经测试了整个站点缓存功能。但是,整个站点缓存不适合我们,因为课程管理视图需要立即显示更新的数据。我们项目中最好的方式是缓存模板,或者用于显示课程内容的视图。

我们已经学习了 Django 提供的缓存数据的方法。你应该明智的定义缓存策略,优先考虑开销最大的 QuerySet 或者计算。

11.5 总结

在这章中,我们为课程创建了公开的视图,并构建了一个学生注册和报名课程的系统。我们安装了 Memcached,并实现了不同的缓存级别。

下一章我们会为项目构建 RESTful API。

第十二章:构建 API

在上一章中,你构建了一个学生注册和课程报名系统。你创建了显示课程内容的视图,并学习了如何使用 Django 的缓存框架。在本章中,你会学习以下知识点:

  • 构建一个 RESTful API
  • 为 API 视图处理认证和权限
  • 创建 API 视图集和路由

12.1 构建 RESTful API

你可能想要创建一个接口,让其它服务可以与你的 web 应用交互。通过构建一个 API,你可以允许第三方以编程方式使用信息和操作你的应用。

你可以通过很多方式构建 API,但最好是遵循 REST 原则。REST 架构是表述性状态传递(Representational State Transfer)的缩写。RESTful API 是基于资源的。你的模型代表资源,HTTP 方法(比如 GET,POST,PUT 或 DELETE)用于检索,创建,更新或者删除对象。HTTP 响应代码也可以在这个上下文中使用。返回的不同 HTTP 响应代码表示 HTTP 请求的结果,比如 2XX 响应代码表示成功,4XX 表示错误等等。

RESTful API 最常用的交互数据的格式是 JSON 和 XML。我们将为项目构建一个 JSON 序列化的 REST API。我们的 API 会提供以下功能:

  • 检索主题
  • 检索可用的课程
  • 检索课程内容
  • 报名参加课程

我们可以通过 Django 创建自定义视图,从头开始构建 API。但是有很多第三方模块可以简化创建 API,其中最流行的是Django Rest Framework

12.1.1 安装 Django Rest Framework

Django Rest Framework可以很容易的为项目构建 REST API。你可以在这里查看所有文档。

打开终端,使用以下命令安装框架:

pip install djangorestframework

编辑educa项目的settings.py文件,在INSTALLED_APPS设置中添加rest_framework

INSTALLED_APPS = [
	# ...
	'rest_framework',
]

然后在settings.py文件中添加以下代码:

REST_FRAMEWORK = {
    'DEFAULT_PREMISSION_CLASSES': [
        'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly'
    ]
}

你可以使用REST_FRAMEWORK设置为 API 提供一个特定配置。REST Framework 提供了大量设置来配置默认行为。DEFAULT_PREMISSION_CLASSES设置指定读取,创建,更新或者删除对象的默认权限。我们设置DjangoModelPermissionsOrAnonReadOnly是唯一的默认权限类。这个类依赖 Django 的权限系统,允许用户创建,更新或删除对象,同时为匿名用户提供只读访问。之后你会学习更多关于权限的内容。

你可以访问这里查看完整的 REST Framework 可用设置列表。

12.1.2 定义序列化器

设置 REST Framework 之后,我们需要指定如何序列化我们的数据。输出数据必须序列化为指定格式,输入数据会反序列化处理。框架为单个类构建序列化器提供了以下类:

  • Serializer:为普通 Python 类实例提供序列化
  • ModelSerializer:为模型实例提供序列化
  • HyperlinkedModelSerializer:与ModelSerializer一样,但使用链接而不是主键表示对象关系

让我们构建第一个序列化器。在courses应用目录中创建以下文件结构:

api/
	__init__.py
	serializers.py

我们会在api目录中构建所有 API 功能,保持良好的文件结构。编辑serializers.py文件,并添加以下代码:

from rest_framework import serializers
from ..models import Subject

class SubjectSerializer(serializers.ModelSerializer):
    class Meta:
        model = Subject
        fields = ('id', 'title', 'slug')

这是Subject模型的序列化器。序列化器的定义类似于 Django 的FormModelForm类。Meta类允许你指定序列化的模型和序列化中包括的字段。如果没有设置fields属性,则会包括所有模型字段。

让我们试试序列化器。打开终端执行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

在这个例子中,我们获得一个Subject对象,创建一个SubjectSerializer实例,然后访问序列化的数据。你会看到以下输出:

{'id': 4, 'slug': 'mathematics', 'title': 'Mathematics'}

正如你所看到的,模型数据转换为 Python 的原生数据类型。

12.1.3 理解解析器和渲染器

在 HTTP 响应中返回序列化的数据之前,需要把它渲染为特定格式。同样的,当你获得 HTTP 请求时,在你操作它之前,需要解析传入的数据并反序列化数据。REST Framework 包括渲染器和解析器来处理这些操作。

让我们看看如何解析收到的数据。给定一个 JSON 字符串输入,你可以使用 REST Framework 提供的JSONParser类转换为 Python 对象。在 Python 终端中执行以下代码:

from io import BytesIO
from rest_framework.parsers import JSONParser
data = b'{"id":4,"title":"Music","slug":"music"}'
JSONParser().parse(BytesIO(data))

你会看到以下输出:

{'id': 4, 'title': 'Music', 'slug': 'music'}

REST Framework 还包括Renderer类,允许你格式化 API 响应。框架通过内容协商决定使用哪个渲染器。它检查请求的Accept头,决定响应期望的内容类型。根据情况,渲染器由 URL 格式后缀确定。例如,触发JSONRenderer的访问会返回 JSON 响应。

回到终端执行以下代码,从上一个序列化器例子中渲染serializer对象:

>>> from rest_framework.renderers import JSONRenderer
>>> JSONRenderer().render(serializer.data)

你会看到以下输出:

b'{"id":4,"title":"Mathematics","slug":"mathematics"}'

我们使用JSONRenderer渲染序列化的数据位 JSON。默认情况下,REST Framework 使用两个不同的渲染器:JSONRendererBrowsableAPIRenderer。后者提供一个 web 接口,可以很容易的浏览你的 API。你可以在REST_FRAMEWORK设置的DEFAULT_RENDERER_CLASSES选项中修改默认的渲染器类。

你可以查看更多关于渲染器解析器的信息。

12.1.4 构建列表和详情视图

REST Framework 自带一组构建 API 的通用视图和 mixins。它们提供了检索,创建,更新或删除模型对象的功能。你可以在这里查看 REST Framework 提供的所有通用的 mixins 和视图。

让我们创建检索Subject对象的列表和详情视图。在courses/api/目录中创建views.py文件,并添加以下代码:

from rest_framework import generics
from ..models import Subject
from .serializers import SubjectSerializer

class SubjectListView(generics.ListAPIView):
    queryset = Subject.objects.all()
    serializer_class = SubjectSerializer

class SubjectDetailView(generics.RetrieveAPIView):
    queryset = Subject.objects.all()
    serializer_class = SubjectSerializer

在这段代码中,我们使用了 REST Framework 的通用ListAPIViewRetrieveAPIView。我们在详情视图中包括一个pk URL 参数,来检索给定主键的对象。两个视图都包括以下属性:

  • queryset:用于检索对象的基础QuerySet
  • serializer_class:序列化对象的类。

让我们为视图添加 URL 模式。在courses/api/目录中创建urls.py文件,并添加以下代码:

from django.conf.urls import url
from . import views

urlpatterns = [
    url(r'^subjects/$', views.SubjectListView.as_view(), name='subject_list'),
    url(r'^subjects/(?P<pk>\d+)/$', views.SubjectDetailView.as_view(), name='subject_detail'),
]

编辑educa项目的主urls.py文件,并引入 API 模式:

urlpatterns = [
    # ...
    url(r'^api/', include('courses.api.urls', namespace='api')),
]

我们为 API 的 URL 使用api命名空间。使用python manage.py runserver启动开发服务器。打开终端,并使用curl获取http://127.0.0.1:8000/api/subjects/

bogon:educa lakerszhy$ curl http://127.0.0.1:8000/api/subjects/

你会看到类似以下的响应:

[{"id":4,"title":"Mathematics","slug":"mathematics"},
{"id":3,"title":"Music","slug":"music"},
{"id":2,"title":"Physics","slug":"physics"},
{"id":1,"title":"Programming","slug":"programming"}]

HTTP 响应包括 JSON 格式的Subject对象列表。如果你的操作系统没有安装curl,请在这里下载。除了curl,你还可以使用其它工具发送自定义 HTTP 请求,比如浏览器扩展Postman,你可以在这里下载Postman

在浏览器中打开http://127.0.0.1:8000/api/subjects/。你会看到 REST Framework 的可浏览 API,如下图所示:

这个 HTML 界面由BrowsableAPIRenderer渲染器提供。你还可以在 URL 中包括id来访问一个Subject对象的 API 详情视图。在浏览器中打开http://127.0.0.1:8000/api/subjects/1/。你会看到单个Subject对象以 JSON 格式渲染。

12.1.5 创建嵌套的序列化器

我们将为Course模型创建一个序列化器。编辑api/serializers.py文件,并添加以下代码:

from ..models import Course

class CourseSerializer(serializers.ModelSerializer):
    class Meta:
        model = Course
        fields = ('id', 'subject', 'title', 'slug', 
            'overview', 'created', 'owner', 'modules')

让我们看看一个Course对象是如何被序列化的。在终端执行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)

你获得的 JSON 对象包括我们在CourseSerializer中指定的字段。你会看到modules管理器的关联对象被序列化为主键列表,如下所示:

"modules": [17, 18, 19, 20, 21, 22]

我们想包括每个单元的更多信息,所以我们需要序列化Module对象,并且嵌套它们。修改api/serializers.py文件中的上一段代码,如下所示:

from ..models import Course, Module

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:
        model = Course
        fields = ('id', 'subject', 'title', 'slug', 
            'overview', 'created', 'owner', 'modules')

我们定义了ModuleSerializer,为Module模型提供了序列化。然后我们添加modules属性到CourseSerializer来嵌套ModuleSerializer序列化器。我们设置many=True表示正在序列化的是多个对象。read_only参数表示该字段是可读的,并且不应该包括在任何输入中来创建或更新对象。

打开终端,并再创建一个CourseSerializer实例。使用JSONRenderer渲染序列化器的data属性。这次,单元列表被嵌套的ModuleSerializer序列化器序列化,如下所示:

"modules": [
    {
        "order": 0,
        "title": "Django overview",
        "description": "A brief overview about the Web Framework."
    }, 
    {
        "order": 1,
        "title": "Installing Django",
        "description": "How to install Django."
    },
    ...

你可以在这里阅读更多关于序列化器的信息。

12.1.6 构建自定义视图

REST Framework 提供了一个APIView类,可以在 Django 的View类之上构建 API 功能。APIView类与View类不同,它使用 REST Framework 的自定义RequestResponse对象,并且处理APIException异常返回相应的 HTTP 响应。它还包括一个内置的认证和授权系统来管理视图的访问。

我们将为用户创建课程报名的视图。编辑api/views.py文件,并添加以下代码:

from django.shortcuts import get_object_or_404
from rest_framework.views import APIView
from rest_framework.response import Response
from ..models import Course

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视图处理用户报名参加课程。上面的代码完成以下任务:

  • 我们创建了一个继承自APIView的自定义视图。
  • 我们为 POST 操作定义了post()方法。这个视图不允许其它 HTTP 方法。
  • 我们期望 URL 参数pk包含课程 ID。我们用给定的pk参数检索课程,如果没有找到则抛出 404 异常。
  • 我们添加当前对象到Course对象的多对多关系students中,并返回成功的响应。

编辑api/urls.py文件,并为CourseEnrollView视图添加 URL 模式:

url(r'^courses/(?P<pk>\d+)/enroll/$', views.CourseEnrollView.as_view(), name='course_enroll'),

理论上,我们现在可以执行一个 POST 请求,为当前用户报名参加一个课程。但是,我们需要识别用户,并阻止未认证用户访问这个视图。让我们看看 API 认证和权限是如何工作的。

12.1.7 处理认证

REST Framework 提供了识别执行请求用户的认证类。如果认证成功,框架会在request.user中设置认证的User对象。否则设置为 Django 的AnonymousUser实例。

REST Framework 提供以下认证后台:

  • BasicAuthentication:HTTP 基础认证。客户端用 Base64 在Authorization HTTP 头中发送用户和密码。你可以在这里进一步学习。
  • TokenAuthentication:基于令牌的认证。一个Token模型用于存储用户令牌。用户在Authorization HTTP 头中包括用于认证的令牌。
  • SessionAuthentication:使用 Django 的会话后台用于认证。当执行从你的网站前端到 API 的 AJAX 请求时,这个后台非常有用。

你可以通过继承 REST Framework 提供的BaseAuthentication类,并覆写authenticate()方法来构建自定义认证后台。

你可以基于单个视图设置认证,或者用DEFAULT_AUTHENTICATION_CLASSES设置为全局认证。

认证只识别执行请求的用户。它不会允许或阻止访问视图。你必须使用权限来显示访问视图。

你可以在这里查看所有关于认证的信息。

让我们添加BasicAuthentication到我们的视图。编辑courses应用的api/views.py文件,并添加authentication_classes属性到CourseEnrollView

from rest_framework.authentication import BasicAuthentication

class CourseEnrollView(APIView):
    authentication_classes = (BasicAuthentication, )
    # ...

用户将通过设置在 HTTP 请求中的Authorization头的证书识别。

12.1.8 添加权限到视图

REST Framework 包括一个权限系统,用于限制视图的访问。REST Framework 的一些内置权限是:

  • AllowAny:不限制访问,不管用户是否认证。
  • IsAuthenticated:只允许认证的用户访问。
  • IsAuthenticatedOrReadOnly:认证用户可以完全访问。匿名用户只允许执行读取方法,比如 GET,HEAD 或 OPTIONS。
  • DjangoModelPermissions:捆绑到django.contrib.auth的权限。视图需要一个queryset属性。只有分配了模型权限的认证用户才能获得权限。
  • DjangoObjectPermissions:基于单个对象的 Django 权限。

如果用户被拒绝访问,他们通常会获得以下某个 HTTP 错误代码:

  • HTTP 401:未认证
  • HTTP 403:没有权限

你可以在这里阅读更多关于权限的信息。

编辑courses应用的api/views.py文件,并在CourseEnrollView中添加permission_classes属性:

from rest_framework.authentication import BasicAuthentication
from rest_framework.permissions import IsAuthenticated

class CourseEnrollView(APIView):
    authentication_classes = (BasicAuthentication, )
    permission_classes = (IsAuthenticated, )
    # ..

我们引入了IsAuthenticated权限。这会阻止匿名用户访问这个视图。现在我们可以执行 POST 请求到新的 API 方法。

确保开发服务器正在运行。打开终端并执行以下命令:

curl -i -X POST http://127.0.0.1:8000/api/courses/1/enroll/

你会获得以下响应:

HTTP/1.0 401 UNAUTHORIZED
...
{"detail": "Authentication credentials were not provided."}

因为我们是未认证用户,所以如期获得401 HTTP 代码。让我们用其中一个用户进行基础认证。执行以下命令:

curl -i -X POST -u student:password http://127.0.0.1:8000/api/courses/1/enroll/

用已存在用户凭证替换student:password。你会获得以下响应:

HTTP/1.0 200 OK
...
{"enrolled": true}

你可以访问管理站点,检查用户是否报名参加课程。

12.1.9 创建视图集和路由

ViewSets允许你定义你的 API 交互,并让 REST Framework 用Router对象动态构建 URL。通过视图集,你可以避免多个视图的重复逻辑。视图集包括典型的创建,检索,更新,删除操作,分别是list()create()retrieve()update()partial_update()destroy()

让我们为Course模型创建一个视图集。编辑api/views.py文件,并添加以下代码:

from rest_framework import viewsets
from .serializers import CourseSerializer

class CourseViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = Course.objects.all()
    serializer_class = CourseSerializer

我们从ReadOnlyModelViewSet继承,它提供了只读操作list()retrieve(),用于列出对象或检索单个对象。编辑api/urls.py文件,并为我们的视图集创建一个路由:

from django.conf.urls import url, include
from . import views
from rest_framework import routers

router = routers.DefaultRouter()
router.register('courses', views.CourseViewSet)

urlpatterns = [
    # ...
    url(r'^', include(router.urls)),
]

我们创建了一个DefaultRouter对象,并用courses前缀注册我们的视图集。路由负责为我们的视图集自动生成 URL。

在浏览器中打开http://127.0.0.1:8000/api/。你会看到路由在它的基础 URL 中列出所有视图集,如下图所示:

你现在可以访问http://127.0.0.1:8000/api/courses/检索课程列表。

你可以在这里进一步学习视图集。你还可以在这里查看更多关于路由的信息。

12.1.10 添加额外操作到视图集

你可以添加额外操作到视图集中。让我们把之前的CourseEnrollView视图为一个自定义视图集操作。编辑api/views.py文件,并修改CourseViewSet类:

from rest_framework.decorators import detail_route

class CourseViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = Course.objects.all()
    serializer_class = CourseSerializer

    @detail_route(
        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()方法,它代表这个视图集的一个额外操作。上面的代码执行以下任务:

  • 我们使用框架的detail_route装饰器,指定这是在单个对象上执行的操作。
  • 装饰器允许我们为操作添加自定义属性。我们指定这个视图只允许 POST 方法,并设置了认证和权限类。
  • 我们使用self.get_object()检索Courses对象。
  • 我们把当前用户添加到students多对多关系中,并返回一个自定义的成功响应。

编辑api/urls.py文件,移除以下 URL,因为我们不再需要它:

url(r'^courses/(?P<pk>\d+)/enroll/$', views.CourseEnrollView.as_view(), name='course_enroll'),

然后编辑api/views.py文件,移除CourseEnrollView类。

现在,报名参加课程的 URL 由路由自动生成。因为它使用操作名enroll,所以 URL 保持不变。

12.1.11 创建自定义权限

我们希望学生可以访问它们报名的课程内容。只有报名的学生才可以访问课程内容。最好的实现方式是使用一个自定义权限类。Django 提供的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权限。

12.1.12 序列化课程内容

我们需要序列化课程内容。Content模型包括一个通用外键,允许我们访问关联对象的不同内容模型。但是,我们在上一章为所有内容模型添加了通用的render()方法。我们可以使用这个方法为 API 提供渲染后的内容。

编辑courses应用的api/serializers.py文件,并添加以下代码:

from ..models import Content

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')

在这段代码中,通过继承 REST Framework 提供的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 .permissions import IsEnrolled
from .serializers import CourseWithContentsSerializer

class CourseViewSet(viewsets.ReadOnlyModelViewSet):
    # ...
    @detail_route(
        methods=['get'],
        serializer_class=CourseWithContentsSerializer,
        authentication_classes=[BasicAuthentication],
        permission_classes=[IsAuthenticated, IsEnrolled]
    )
    def contents(self, request, *args, **kwargs):
        return self.retrieve(request, *args, **kwargs)

这个方法执行以下任务:

  • 我们使用detail_route装饰器指定该操作在单个对象上执行。
  • 我们指定该操作只允许 GET 方法。
  • 我们使用新的CourseWithContentsSerializer序列化器类,它包括渲染的课程内容。
  • 我们使用IsAuthenticated和自定义的IsEnrolled权限。这样可以确保只有报名的用户可以访问课程内容。
  • 我们使用存在的retrieve()操作返回课程对象。

在浏览器中打开http://127.0.0.1:8000/api/courses/1/contents/。如果你用正确证书访问视图,你会看到课程的每个单元,包括渲染后的课程内容的 HTML,如下所示:

{
   "order": 0,
   "title": "Installing Django",
   "description": "",
   "contents": [
        {
        "order": 0,
        "item": "<p>Take a look at the following video for installing Django:</p>\n"
        }, 
        {
        "order": 1,
        "item": "\n<iframe width=\"480\" height=\"360\" src=\"http://www.youtube.com/embed/bgV39DlmZ2U?wmode=opaque\" frameborder=\"0\" allowfullscreen></iframe>\n\n"
        } 
    ]
    }

你已经构建了一个简单的 API,允许其它服务通过编程方式访问course应用。REST Framework 还允许你用ModelViewSet视图集管理创建和编辑对象。我们已经学习了 Django Rest Framework 的主要部分,但你仍可以在这里进一步学习它的特性。

12.2 总结

在这一章中,你创建了一个 RESTful API,可以让其它服务与你的 web 应用交互。

额外的第十三章可以在这里下载。它教你如何使用uWSGINGINX构建一个生产环境。你还会学习如何实现一个自定义的中间件和创建自定义的管理命令。

你已经到达了本书的结尾。恭喜你!你已经学会了用 Django 构建一个成功的 web 应用所需要的技巧。本书指导你完成开发实际项目,以及将 Django 与其它技术结合。现在你已经准备好创建自己的 Django 项目,不管是一个简单的原型还是一个大型的 web 应用。

祝你下一次 Django 冒险活动好运!

第十三章:上线

上一章中,你为你的项目创建了 RESTful API。在本章中,你会学习以下知识点:

  • 配置一个生产环境
  • 创建一个自定义的中间件
  • 实现自定义的管理命令

13.1 在生产环境上线

是时候把你的 Django 项目部署到生产环境了。我们将按以下步骤上线我们的项目:

  1. 为生产环境配置项目设置。
  2. 使用 PostgreSQL 数据库。
  3. 使用uWSGINgnix设置一个 web 服务器。
  4. 为静态资源提供服务。
  5. 用 SSL 保护我们的网站。

13.1.1 为多个环境管理设置

在实际项目中,你可能需要处理多个环境。你最少会有一个本地环境和一个生产环境,但是很可能还有别的环境。有些项目设置是所有环境通用的,有些可能需要被每个环境覆盖。让我们为多个环境配置项目设置,同时保持项目的良好组织。

educa项目目录中创建settings/目录。把项目的settings.py文件移动到settings/目录中,并重命名为base.py,然后在新目录中创建以下文件结构:

settings/
	__init__.py
	base.py
	local.py
	pro.py

这些文件分别是:

  • base.py:包括通用和默认设置的基础设置文件
  • local.py:你本地环境的自定义设置
  • pro.py:生产环境的自定义设置

编辑settings/base.py文件,找到这一行代码:

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

替换为下面这一行代码:

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(os.path.join(__file__, os.pardir))))

我们已经把设置文件移动到了低一级的目录中,所以我们需要BASE_DIR正确的指向父目录。我们使用os.pardir指向父目录。

编辑settings/local.py文件,并添加以下代码:

from .base import *

DEBUG = True

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

这是我们本地环境的设置文件。我们导入base.py文件中定义的所有设置,只为这个生产环境定义特定设置。我们从base.py文件中拷贝了DEBUGDATABASES设置,因为每个环境会设置这些选项。你可以从base.py文件中移除DEBUGDATABASES设置。

编辑settings/pro.py文件,并添加以下代码:

from .base import *

DEBUG = False

ADMINS = {
    ('Antonio M', 'email@mydomain.com'),
}

ALLOWED_HOSTS = ['educaproject.com', 'www.educaproject.com']

DATABASES = {
    'default': {
        
    }
}

这些是生产环境的设置。它们分别是:

  • DEBUG:设置DEBUGFalse对任何生产环境都是强制的。不这么做会导致追踪信息和敏感的配置数据暴露给每一个人。
  • ADMINS:当DEBUGFalse,并且一个视图抛出异常时,所有信息会通过邮件发送给ADMINS设置中列出的所有人。确保用你自己的信息替换上面的name/e-mail元组。
  • ALLOWED_HOST:因为DEBUGFalse,Django 只允许这个列表中列出的主机为应用提供服务。这是一个安全措施。我们包括了educaproject.comwww.educaproject.com域名,我们的网站会使用这两个域名。
  • DATABASES:我们保留这个设置为空。我们将在下面讨论生产环境的数据库设置。

处理多个环境时,创建一个基础的设置文件,并为每个环境创建一个设置文件。环境设置文件应用从通用设置继承,并覆写环境特定设置。

我们已经把项目设置从默认的settings.py文件放到了不同位置。除非你指定使用的设置模块,否则不能用manage.py工具执行任何命令。在终端执行管理命令时,你需要添加--settings标记,或者设置DJANGO_SETTINGS_MODULE环境变量。打开终端执行以下命令:

export DJANGO_SETTINGS_MODULE=educa.settings.pro

这会为当前终端会话是设置DJANGO_SETTINGS_MODULE环境变量。如果你不想为每个新终端都执行这个命令,可以在.bashrc.bash_profile文件中,把这个命令添加到你的终端配置。如果你不想设置这个变量,则必须使用--settings标记运行管理命令,比如:

python manage.py migrate --settings=educa.settings.pro

现在你已经成功的为多个环境组织好了设置。

13.1.2 安装 PostgreSQL

在本书中,我们一直使用 SQLite 数据库。它的设置简单快捷,但对于生产环境,你需要一个更强大的数据库,比如 PostgreSQL,MySQL 或者 Oracle。我们将在生产环境使用 PostgreSQL。因为 PostgreSQL 提供的特性和性能,所以它是 Django 的推荐数据库。Django 还自带django.contrib.postgres包,允许你利用 PostgreSQL 的特定特性。你可以在这里阅读更多关于这个模块的信息。

如果你正在使用 Linux,使用以下命令安装 PostgreSQL 的依赖:

sudo apt-get install libpq-dev python-dev

然后使用以下命令安装 PostgreSQL:

sudo apt-get install postgresql postgresql-contrib

如果你正在使用 Mac OS X 或者 Windows,你可以在这里下载 PostgreSQL。

让我们创建一个 PostgreSQL 用户。打开终端,并执行以下命令:

su postgres
createuser -dP educa

会提示你输入密码和给予这个用户的权限。输入密码和权限,然后使用以下命令创建一个新的数据库:

createdb -E utf8 -U educa educa

然后编辑settings/pro.py文件,并修改DATABASES设置:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql_psycopg2',
        'NAME': 'educa',
        'USER': 'educa',
        'PASSWORD': '****',
    }
}

用你创建的数据库名和用户凭证替换上面的数据。现在新数据库是空的。执行以下命令应用所有数据库迁移:

python manage.py migrate

最后使用以下命令创建一个超级用户:

python manage.py createsuperuser

13.1.3 检查你的项目

Django 包括check命令,可以在任何时候检查你的项目。这个命令检查 Django 项目中安装的应用,并输出所有错误或警告。如果你包括了--deploy选项,则只会触发生成环境相关的额外检查。打开终端,运行以下命令执行一次检查:

python manage.py check --deploy

你会看到没有错误,但是有几个警告的输出。这意味着检查成功了,但你应该查看警告,看看是否可以做一些工作,让你的项目在生产环境上是安全的。我们不会深入其中,你需要记住,在生产环境中使用之前,你应该检查项目所有相关的问题。

13.2 通过 WSGI 为 Django 提供服务

Django 的主要部署平台是 WSGI。WSGI 是Web Server Gateway Interface的缩写,它是在网络上为 Python 应用提供服务的标准。

当你使用startproject命令创建一个新项目时,Django 会在项目目录中创建一个wsgi.py文件。这个文件包含一个 WSGI 应用的可调用对象,它是你应用的访问点。使用 Django 开发服务器运行你的项目,以及在生产环境中用你选择的服务器部署应用都会使用 WSGI。

你可以在这里进一步学习 WSGI。

13.2.1 安装 uWSGI

在这本书中,你一直使用 Django 开发服务器在本地环境运行项目。但是你需要一个实际的 Web 服务器在生产环境部署你的应用。

uWSGI是一个非常快速的 Python 应用服务器。它使用 WSGI 规范与你的 Python 应用通信。uWSGI把 Web 请求转换为 Django 项目可用处理的格式。

使用以下命令安装uWSGI

pip install uwsgi

如果你正在使用 Mac OS X,你可以使用brew install uwsgi命令安装uWSGI。如果你想在 Windows 上安装uWSGI,则需要Cygwin。但是推荐你在基于 Unix 的环境中使用uWSGI

13.2.2 配置 uWSGI

你可以从命令行中启动uWSGI。打开终端,在educa项目目录中执行以下命令:

uwsgi --module=educa.wsgi:application \
--env=DJANGO_SETTINGS_MODULE=educa.settings.pro \
--http=127.0.0.1:80 \
--uid=1000 \
--virtualenv=/home/zenx/env/educa/

如果你没有权限,则需要在命令前加上sudo

通过这个命令,我们用以下选项在本地运行uWSGI

  • 我们使用educa.wsgi:application作为 WSGI 的可调用对象。
  • 我们为生产环境加载设置。
  • 我们使用我们的虚拟环境。用你的实际虚拟环境目录替换virtualenv选项的路径。如果你没有使用虚拟环境,则跳过这个选项。

如果你没有在项目目录下运行命令,则需要用你的项目目录包括--chdir=/path/to/educa/选项。

在浏览器中打开http://127.0.0.1:80/。你会看到没有加载 CSS 或者图片的 HTML。这是有道理的,因为我们还没有配置uWSGI为静态文件提供服务。

uWSGI允许你在.ini文件中定义自定义配置。它比在命令行中传递选项更方便。在主educa/目录下创建以下文件结构:

config/
	uwsgi.ini

编辑uwsgi.ini文件,添加以下代码:

[uwsgi]
# variables
projectname = educa
base = /home/zenx/educa

# configuration
master = true
virtualenv = /home/zenx/env/%(projectname)
pythonpath = %(base)
chdir = %(base)
env = DJANGO_SETTINGS_MODULE=%(projectname).settings.pro
module = educa.wsgi:application
socket = /tmp/%(projectname).sock

我们定义了以下变量:

  • projectname:你的 Django 项目名称,这里是educa
  • baseeduca项目的绝对路径。用你的绝对路径替换它。

还有一些会在uWSGI选项中使用的自定义变量。你可以定义任意变量,只要它跟uWSGI选项名不同就行。我们设置了以下选项:

  • master:启用主进程。
  • virtualenv:你的虚拟环境路径。用响应的路径替换它。
  • pythonpath:添加到 Python 路径的路径。
  • chdir:项目目录的路径,加载应用之前,uWSGI改变到这个目录。
  • env:环境变量。我们包括了DJANGO_SETTINGS_MODULE变量,指向生产环境的设置。
  • module:使用的 WSGI 模块。我们把它设置为application可调用对象,它包含在项目的wsgi模块中。
  • socket:绑定到服务器的 UNIX/TCP 套接字。

socket选项用于与第三方路由(比如 Nginx)通信,而http选项用于uWGSI接收传入的 HTTP 请求,并自己进行路由。因为我们将配置 Nginx 作为 Web 服务器,并通过套接字与uWSGI通信,所以我们将使用套接字运行uWSGI

你可以在这里找到所有可用的uWSGI选项列表。

现在你可以使用自定义配置运行uWSGI

uwsgi --ini config/uwsgi.ini

因为uWSGI通过套接字运行,所以你现在不能在浏览器中访问uWSGI实例。让我们完成生产环境。

13.2.3 安装 Nginx

当你为一个网站提供服务时,你必须为动态内容提供服务,同时还需要为静态文件,比如 CSS,JavaScript 文件和图片提供服务。虽然uWSGI可以为静态文件提供服务,但它会在 HTTP 请求上增加不必要的开销。因此,推荐在uWSGI之前设置一个 Web 服务器(比如 Nginx),为静态文件提供服务。

Nginx 是一个专注于高并发,高性能和低内存使用的 Web 服务器。Nginx 还可以充当反向代理,接收 HTTP 请求,并把它们路由到不同的后台。通常情况下,你会在前端使用一个 Web 服务器(比如 Nginx),高效快速的为静态文件提供服务,并且把动态请求转发到uWSGI的工作线程。通过使用 Nginx,你还可以应用规则,并从它的反向代理功能中获益。

使用以下命令安装 Nginx:

sudo apt-get install nginx

如果你正在使用 Mac OS X,你可以使用brew install nginx命令安装 Nginx。你可以在这里找到 Windows 的二进制版本。

13.2.4 生产环境

下图展示了我们最终的生产环境:

当客户端浏览器发起一个 HTTP 请求时,会发生以下事情:

  1. Nginx 接收 HTTP 请求。
  2. 如果请求的是静态文件,则 Nginx 直接为静态文件提供服务。如果请求的是动态页面,则 Nginx 通过套接字把请求转发给uWSGI
  3. uWSGI把请求传递给 Django 处理。返回的 HTTP 响应传递回 Nginx,然后再返回到客户端浏览器。

13.2.5 配置 Nginx

config/目录中创建nginx.conf文件,并添加以下代码:

# the upstream component nginx needs to connect to
upstream educa {
    server unix:///tmp/educa.sock;
}

server {
    listen 80;
    server_name www.educaproject.com educaproject.com;

    location / {
        include /etc/nginx/uwsgi_params;
        uwsgi_pass educa;
    }
}

这是 Nginx 的基础配置。我们设置了一个名为educa的上游(upstream),指向uWSGI创建的套接字。我们使用server指令,并添加以下配置:

  1. 我们告诉 Nginx 监听 80 端口。
  2. 我们设置服务名为www.educaproject.comeducaproject.com。Nginx 会为来自这两个域名的请求服务。
  3. 最后,我们指定/路径下的所有请求都路由到educa套接字(uWSGI)。我们还包括了 Nginx 自带的默认uWSGI配置参数。

你可以在这里阅读 Nginx 文档。主要的 Nginx 配置文件位于/etc/nginx/nginx.conf。它包括了/etc/nginx/sites-enabled/下找到的所有配置文件。要让 Nginx 加载你的自定义配置文件,需要如下创建一个符号链接:

sudo ln -s /home/zenx/educa/config/nginx.conf /etc/nginx/sites-enabled/educa.conf

用你项目的绝对路径替换/home/zenx/educa/。然后打开终端启动uWSGI

uwsgi --ini config/uwsgi.ini

打开第二个终端,用以下命令启动 Nginx:

service nginx start

因为我们正在使用简单的主机名,所以需要把它重定向到本机。编辑你的/etc/hosts文件,添加下面两行:

127.0.0.1 educaproject.com
127.0.0.1 www.educaproject.com

这样,我们把两个主机名路由到我们的本地服务器。在生产环境中你不需要这么用,因为你会在域名的 DNS 配置中把主机名指向你的服务器。

在浏览器中打开http://educaproject.com。你可以看到你的网站,仍然没有加载任何静态资源。我们的生产环境马上就好了。

13.2.6 为静态资源和多媒体资源提供服务

为了最好的性能,我们将直接使用 Nginx 为静态资源提供服务。

编辑settings/base.py文件,并添加以下代码:

STATIC_URL = '/static/'
STATIC_ROOT = os.path.join(BASE_DIR, 'static/')

我们需要用 Django 导出静态资源。collectstatic命令从所有应用中拷贝静态文件,并把它们存储到STATIC_ROOT目录中。打开终端执行以下命令:

python manage.py collectstatic

你会看到以下输出:

You have requested to collect static files at the destination location as specified in your settings:

    /educa/static
    
This will overwrite existing files!
Are you sure you want to do this?

输入yes让 Django 拷贝这些文件。你会看到以下输出:

78 static files copied to /educa/static

现在编辑config/nginx.conf文件,并在server指令中添加以下代码:

location /static/ {
    alias /home/zenx/educa/static/;
}
location /media/ {
    alias /home/zenx/educa/media/;
}

记得把/home/zenx/educa/路径替换为你项目目录的绝对路径。这些指令告诉 Nginx,直接在/static//media/路径下为静态资源提供服务。

使用以下命令重新加载 Nginx 的配置:

server nginx reload

在浏览器中打开http://educaproject.com/。现在你可以看到静态文件了。我们成功的配置了 Nginx 来提供静态文件。

13.3 使用 SSL 保护链接

SSL 协议(Secure Sockets Layer)通过安全连接成为了为网站提供服务的标准。强烈鼓励你在 HTTPS 下为网站提供服务。我们将在 Nginx 中配置一个 SSL 证书,安全的为我们网站提供服务。

13.3.1 创建 SSL 证书

educa项目目录中创建ssl目录。然后使用以下命令生成一个 SSL 证书:

sudo openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ssl/educa.key -out ssl/educa.crt

我们生成一个私有 key 和一个有效期是 1 年的 2048 个字节的证书。你将被要求输入以下信息:

Country Name (2 letter code) [AU]:
State or Province Name (full name) [Some-State]:
Locality Name (eg, city) []: Madrid
Organization Name (eg, company) [Internet Widgits Pty Ltd]: Zenx IT
Organizational Unit Name (eg, section) []:
Common Name (e.g. server FQDN or YOUR name) []: educaproject.com
Email Address []: email@domain.com

你可以用自己的信息填写要求的数据。最重要的字段是Common Name。你必须制定证书的域名。我们将使用educaproject.com

这会在ssl/目录中生成educa.key的私有 key 和实际证书educa.crt文件。

13.3.2 配置 Nginx 使用 SSL

编辑nginx.conf文件,并修改server指令,让它包括以下 SSL 指令:

server {
    listen 80;
    listen 443 ssl;
    ssl_certificate /home/zenx/educa/ssl/educa.crt;
    ssl_certificate_key /home/zenx/educa/ssl/educa.key;
    server_name www.educaproject.com educaproject.com;
    # ...

现在我们的服务器在 80 端口监听 HTTP,在 443 端口监听 HTTPS。我们用ssl_certificate制定 SSL 证书,用ssl_certificate_key制定证书 key。

使用以下命令重启 Nginx:

sudo service nginx restart

Nginx 将会加载新的配置。在浏览器中打开http://educaproject.com。你会看到一个类似这样的静态消息:

不同的浏览器,这个消息可能会不同。它警告你,你的网站没有使用受信任的证书;浏览器不能验证网站的身份。这是因为我们签署了自己的证书,而不是从受信任的认证机构获得证书。当你拥有真正的域名时,你可以申请受信任的 CA 为其颁发 SSL 证书,以便浏览器可以验证其身份。

如果你想为真正域名获得受信任的证书,你可以参数 Linux Foundation 创建的Let's Encrypt项目。这是一个协作项目,目的是免费的简化获取和更新受信任的 SSL 证书。你可以在这里阅读更多信息。

点击Add Exception按钮,让浏览器知道你信任这个证书。你会看到浏览器在 URL 旁显示一个锁的图标,如下图所示:

如果你点击锁图标,则会显示 SSL 证书的详情。

13.3.3 为 SSL 配置我们的项目

Django 包括一些 SSL 的特定设置。编辑settings/pro.py设置文件,添加以下代码:

SECURE_SSL_REDIRECT = True
CSRF_COOKIE_SECURE = True

这些设置分别是:

  • SECURE_SSL_REDIRECT:HTTP 请求是否必须重定义到 HTTPS 请求
  • CSRF_COOKIE_SECURE:这必须为跨站点请求保护设置为建立一个安全的 cookie

非常棒!你已经配置了一个生产环境,它会为你的项目提供高性能的服务。

13.4 创建自定义的中间件

你已经了解了MIDDLEWARE设置,其中包括项目的中间件。一个中间件是一个类,其中包括一些在全局执行的特定方法。你可以把它看成一个低级别的插件系统,允许你实现在请求或响应过程中执行的钩子。每个中间件负责会在所有请求或响应中执行的一些特定操作。

避免在中间件中添加开销昂贵的处理,因为它们为在每个请求中执行。

当收到一个 HTTP 请求时,中间件会以MIDDLEWARE设置中的出现顺序执行。当一个 HTTP 响应由 Django 生成时,中间件的方法会逆序执行。

下图展示了请求和响应阶段时,中间件方法的执行顺序。它还展示了可能被调用的中间件方法:

在请求阶段,会执行中间件的以下方法:

  1. process_request(request):在 Django 决定执行哪个视图之前,在每个请求上调用。request是一个HttpRequest实例。
  2. process_view(request, view_func, view_args, view_kwargs):在 Django 调用视图之前调用。它可以访问视图函数及其收到的参数

在响应阶段,会执行中间件的以下方法:

  1. process_exception(request, exception):只有视图函数抛出Exception异常时才会调用。
  2. process_template_response(request, response):视图执行完成后调用,只有当response对象有render()方法时才调用(比如它是TemplateResponse或者等价对象)
  3. process_response(request, response):响应返回到浏览器之前,在所有响应上调用。

因为中间件可以依赖之前已经执行的其它中间件方法在请求中设置的数据,所以MIDDLEWARE设置中的顺序很重要。请注意,即使因为前一个中间件返回了 HTTP 响应,导致process_request()process_view()被跳过,中间件的process_response()方法也会被调用。这意味着process_response()不能依赖于请求阶段设置的数据。如果一个异常被某个中间件处理,并返回了一个响应,则之前的中间件类不会被调用。

当添加新的中间件到MIDDLEWARE设置中时,确保把它放在了正确的位置。在请求阶段,中间件方法按设置中的出现顺序执行,响应阶段则是逆序执行。

你可以在这里查看更多关于中间件的信息。

我们将创建一个自定义的中间件,允许通过自定义子域名访问课程。每个课程详情视图的 URL(比如http://educaproject.com/courses/django/)也可以通过子域名(用课程的slug字段构建)访问,比如http://django.educaproject.com/

13.4.1 创建子域名中间件

中间件可以位于项目的任何地方。但是,推荐的方式是在应用目录中创建一个middleware.py文件。

courses应用目录中创建middleware.py文件,并添加以下代码:

from django.core.urlresolvers import reverse
from django.shortcuts import get_object_or_404, redirect
from .models import Course

class SubdomainCourseMiddleware:
    def process_request(self, 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)

我们创建了一个实现了process_request()的中间件。当收到 HTTP 请求时,我们执行以下任务:

  1. 我们获得请求中使用的主机名,并把它拆分为多个部分。比如,如果用户访问的是mycourse.educaproject.com,则会生成['mycourse', 'educaproject', 'com']列表。
  2. 通过检查拆分后是否生成两个以上的元素,我们核实包括子域名的主机名。如果主机名包括子域名,并且它不是www,则尝试使用子域名提供的slug获得课程。
  3. 如果没有找到课程,我们抛出Http 404异常。否则,我们使用主域名重定向到课程详情的 URL。

编辑项目的settings/base.py文件,在MIDDLEWARE设置底部添加courses.middleware.SubdomainCourseMiddleware

MIDDLEWARE = [
    # ...
    'courses.middleware.SubdomainCourseMiddleware',
]

现在我们的中间件会在每个请求上执行。

13.4.2 使用 Nginx 为多个子域名服务

我们需要 Nginx 为带任意可能子域名的我们的网站提供服务。编辑config/nginx.conf文件,找到这一行代码:

server_name www.educaproject.com educaproject.com;

替换为下面这一行代码:

server_name *.educaproject.com educaproject.com;

通过使用星号,这条规则会应用与educaproject.com的所有子域名。为了在本地测试我们的中间件,我们需要在/etc/hosts中添加想要测试的子域名。要用别名为djangoCourse对象测试中间件,需要在/etc/hosts文件添加这一行:

127.0.0.1 django.educaproject.com

然后在浏览器中打开https://django.educaproject.com/。中间件会通过子域名找到课程,并重定向到https://educaproject.com/course/django/

13.5 实现自定义管理命令

Django 允许你的应用为manage.py工具注册自定义管理命令。例如,我们在第九章使用makemessagescompilemessages管理命令来创建和编译转换文件。

一个管理命令由一个 Python 模块组成,其中 Python 模块包括一个从django.core.management.BaseCommand继承的Command类。你可以创建简单命令,或者让它们接收位置和可选参数作为输入。

Django 在INSTALLED_APPS设置中激活的每个应用的management/commands/目录中查找管理命令。发现的每个模块注册为以其命名的管理命令。

你可以在这里进一步学习自定义管理命令。

我们将注册一个自定义管理命令,提供学生至少报名一个课程。该命令会给注册时间长于指定时间,但尚未报名任何课程的用户发送一封提醒邮件。

students应用目录中创建以下文件结构:

management/
	__init__.py
	commands/
		__init__.py
		enroll_reminder.py

编辑enroll_reminder.py文件,并添加以下代码:

import datetime
from django.conf import settings
from django.core.management.base import BaseCommand
from django.core.mail import send_mass_mail
from django.contrib.auth.models import User
from django.db.models import Count

class Command(BaseCommand):
    help = 'Send 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', typy=int)

    def handle(self, *args, **kwargs):
        emails = []
        subject = 'Enroll in a course'
        date_joined = datetime.date.today() - datetime.timedelta(days=options['days'])
        users = User.objects.annotate(
            course_count=Count('courses_enrolled')
        ).filter(
            course_count=0, date_joined__lte=date_joined
        )
        for user in users:
            message = 'Dear {},\n\nWe noticed that you didn\'t enroll in any courses yet.'.format(user.first_name)
            emails.append((
                subject,
                message,
                settings.DEFAULT_FROM_EMAIL,
                [user.email]
            ))
        send_mass_mail(emails)
        self.stdout.write('Sent {} reminders' % len(emails))

这是我们的enroll_reminder命令。这段代码完成以下任务:

  • Command类从BaseCommand继承。
  • 我们包括了一个help属性。该属性为命令提供了一个简单描述,如果你执行python manage.py help enroll_reminder命令,则会打印这个描述。
  • 我们使用add_arguments()方法添加--days命名参数。该参数用于指定用户注册了,但没有报名参加任何课程,从而需要接收提醒邮件的最小天数。
  • handle()方法包括实际命令。我们从命令行解析中获得days属性。我们检索注册天数超过指定天数,当仍没有参加任何课程的用户。我们用一个用户报名参加的总课程数量注解(annotate)QuerySet 实现此目的。我们为每个用户生成一封提醒邮件,并把它添加到emails列表中。最后,我们用send_mass_mail()函数发送邮件,这个函数打开单个 SMTP 连接发送所有邮件,而不是每发送一封邮件打开一个连接。

你已经创建了第一个管理命令。打开终端执行你的命令:

python manage.py enroll_reminder --days=20

如果你没有正在运行的本地 SMTP 服务器,你可以参考第二章,我们为第一个 Django 项目配置了 SMTP 设置。另外,你可以添加以下行到settings/local.py文件,让 Django 在开发期间输出邮件到标准输出:

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

让我们调度管理命令,让服务器没有早上 8 点运行它。如果你正在使用基于 Unix 的系统,比如 Linux 或者 Mac OS X,打开终端执行crontab -e来编辑计划任务。在其中添加下面这一行:

0 8 * * * python /path/to/educa/manage.py enroll_reminder --days=20 --settings=educa.settings.pro

如果你不熟悉Cron,你可以在这里学习它。

如果你正在使用 Windows,你可以使用Task scheduler调度任务。你可以在这里进一步学习它。

定期执行操作的另一个方法是用 Celery 创建和调度任务。记住,我们在第七章使用 Celery 执行了异步任务。除了使用Cron创建和调用管理命令,你还可以使用Celery beat scheduler创建异步任务并执行它们。你可以在这里进一步学习使用 Celery 调度定时任务。

对要使用Cron或者 Windows 调度任务控制面板调度的独立脚本使用管理命令。

Django 还包括一个用 Python 调用管理命令的工具。你可以在代码中如下执行管理命令:

from django.core import management
management.call_command('enroll_reminder', days=20)

恭喜你!现在你已经为你的应用创建了自定义管理命令,并在需要时调度它们。

13.6 总结

在这一章中,你使用uWSGINginx配置了一个生产环境。你还实现了一个自定义中间件,并学习了如何创建自定义管理命令。

posted @   绝不原创的飞龙  阅读(45)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
点击右上角即可分享
微信分享提示