第九章 扩展你的商店(下)

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

{% trans %}模板标签允许你标记需要翻译的字符串,常量或者变量。在内部,Django在给定的文本上执行gettext()。以下是在模板中标记需要翻译的字符串:

{% trans "Text to be translated" %}

你可以使用as在变量中存储翻译后的内容,然后就能在整个模板中使用这个变量。下面这个例子在greeting变量中存储翻译后的文本:

{% trans "Hello!" as greeting %}
<h1>{{ greeting }}</h1>

{% trans %}标签对简单的翻译字符串很有用,但它不能处理包括变量的翻译内容。

9.2.4.2 模板标签{% blocktrans %}

{% 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 }}">
                    ![]({% if p.image %}{{ p.image.url }}{% else %}{% static )
                </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 }}">
                    ![]({% if p.image %}{{ p.image.url }}{% else %}{% static )
                </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使用基于类的视图构建一个在线学习平台,你还会创建一个自定义的内容管理系统。

推荐阅读更多精彩内容