第九章 扩展商店功能

在上一章里,为电商站点集成了支付功能,然后可以生成PDF发票发送给用户。在本章,我们将为商店添加优惠码功能。此外,还会学习国际化和本地化的设置和建立一个推荐商品的系统。

本章涵盖如下要点:

1优惠码系统

很多电商网站,会向用户发送电子优惠码,以便用户在购买时使用,以折扣价进行结算。一个在线优惠码通常是一个字符串,然后还规定了有效期限,一次性有效或者可以反复使用。

我们将为站点添加优惠码功能。我们的优惠码带有有效期,但是不限制使用次数,输入之后,就会影响用户购物车中的总价。为了实现这个需求,需要建立一个数据模型来存储优惠码,有效期和对应的折扣比例。

myshop项目创建新的应用coupons

python manage.py startapp coupons

然后在settings.py内激活该应用:

INSTALLED_APPS = [
    # ...
    'coupons.apps.CouponsConfig',
]

1.1创建优惠码数据模型

编辑coupons应用的models.py文件,创建一个Coupon模型:

from django.db import models
from django.core.validators import MinValueValidator, 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模型包含以下字段:

之后执行数据迁移程序。然后将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)

现在启动站点,到http://127.0.0.1:8000/admin/coupons/coupon/add/查看Coupon模型:

输入一个优惠码记录,有效期设置为当前日期,不要忘记勾上Active然后点击SAVE按钮。

1.2为购物车增加优惠码功能

创建数据模型之后,可以查询和获得优惠码对象。现在我们必须增添使用户可以输入优惠码从而获得折扣价的功能。这个功能将按照如下逻辑进行操作:

  1. 用户添加商品到购物车
  2. 用户能通过购物车详情页面的表单输入一个优惠码
  3. 输入优惠码并提交表单之后,需要来判断该码是否在数据库中存在、当前时间是否在valid_fromvalid_to有效时间之间、active属性是否为True
  4. 如果优惠码通过上述检查,将优惠码的信息保存在session中,用折扣重新计算价格并更新购物车中的商品价格
  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视图验证优惠码并将其存储在session中,使用了@require_POST装饰器令该视图仅接受POST请求。这个视图的业务逻辑如下:

  1. 使用请求中的数据初始化CouponApplyForm
  2. 如果表单通过验证,从表单的cleaned_data获取code,然后使用code查询数据库得到coupon对象,这里使用了过滤参数iexact,进行完全匹配;使用active=True过滤出有效的优惠码;使用timezone.now()获取当前时间,valid_fromvalid_to分别采用lte(小于等于)和gte(大于等于)过滤查询以保证当前时间位于有效期内。
  3. 将优惠码ID存入当前用户的session。
  4. 重定向到cart_detail URL对应的购物车详情页,以显示应用了优惠码之后的金额。

需要为coupon_apply视图配置URL,在coupons应用中建立urls.py文件,添加下列代码:

from django.urls import path
from . import views

app_name = 'coupons'
urlpatterns = [
    path('apply/', views.coupon_apply, name='apply'),
]

然后编辑项目的根路由,增加一行:

urlpatterns = [
    # ...
    path('coupons/', include('coupons.urls', namespace='coupons')),
    path('', include('shop.urls', namespace='shop')),
]

依然记得要把这一行放在shop.urls上方。

编辑cart应用中的cart.py文件,添加下列导入:

from coupons.models import Coupon

然后在cart类的__init__()方法的最后添加从session中获得优惠码ID的语句:

class Cart(object):
    def __init__(self, request):
        # ...
        # store current applied coupon
        self.coupon_id = self.session.get('coupon_id')

Cart类中,我们需要通过coupon_id获取优惠码信息并将其保存在Cart对象内,为Cart类添加如下方法:

class Cart(object):
    # ...
    @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_diccount(self):
        return self.get_total_price() - self.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_after_diccount }}</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_diccount|floatformat:"2" }}</td>
    </tr>

这是新的购物车模板。如果包含一个优惠券,就展示一行购物车总价,再展示一行优惠券信息,最后通过get_total_price_after_discount()展示折扣后价格。

在同一个文件内,在</table>后增加下列代码:

{# 在紧挨着</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/order/create.html,在其中找到如下部分:

<ul>
    {% for item in cart %}
    <li>
        {{ item.quantity }} x {{ item.product.name }}
        <span>${{ item.total_price }}</span>
    </li>
    {% endfor %}
</ul>

替换成:

<ul>
    {% for item in cart %}
        <li>
            {{ item.quantity }}x {{ item.product.name }}
            <span>${{ item.total_price|floatformat:"2" }}</span>
        </li>
    {% endfor %}
    {% if cart.coupon %}
        <li>
            "{{ cart.coupon.code }}" ({{ cart.coupon.discount }}% off)
            <span>- ${{ cart.get_discount|floatformat:"2" }}</span>
        </li>
    {% endif %}
</ul>

如果有优惠码,现在的订单页面就展示优惠码信息了。继续找到下边这行:

<p>Total: ${{ cart.get_total_price }}</p>

替换成:

<p>Total: ${{ cart.get_total_price_after_diccount|floatformat:"2" }}</p>

这样总价也变成了折扣后价格。

在浏览器中打开http://127.0.0.1:8000/,添加商品到购物车然后生成订单,可以看到订单页面的价格现在是折扣后的价格了:

1.3在订单中记录优惠码信息

像之前说的,我们需要将优惠码信息保存至order对象中,为此需要修改Order模型。编辑

编辑orders应用的models.py文件,增加导入部分的代码:

from decimal import Decimal
from django.core.validators import MinValueValidator, MaxValueValidator
from coupons.models import Coupon

然后为Order模型增加下列字段:

class Order(models.Model):
    coupon = models.ForeignKey(Coupon, related_name='orders', null=True, blank=True, on_delete=models.SET_NULL)
    discount = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(100)])

这两个字段用于存储优惠码信息。虽然折扣信息保存在Coupon对象中,但这里还是用discount字段保存了当前的折扣,以免未来优惠码折扣发生变化。为coupon字段设置了on_delete=models.SET_NULL,优惠码删除时,该外键字段会变成空值。

增加好字段后数据迁移程序。回到models.py文件,需要修改Order类中的get_total_cost()方法:

class Order(models.Model):
    # ...
    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'))

修改后的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对象存入数据库。

启动站点,在浏览器中访问http://127.0.0.1:8000/,使用一个自己创建的优惠码,在完成购买之后,可以到http://127.0.0.1:8000/admin/orders/order/>查看包含优惠码和折扣信息的订单:

还可以修改管理后台的订单详情页和和PDF发票,以使其包含优惠码和折扣信息。下边我们将为站点增加国际化功能。

译者注:这里有一个问题:用户提交了订单并清空购物车后,如果再向购物车内添加内容,再次进入购物车详情页面可以发现自动使用了上次使用的优惠券。此种情况的原因是作者把优惠券信息附加到了session上,在提交订单的时候没有清除。cart对象实例化的时候又取到了相同的优惠券信息。所以需要对程序进行一下改进。

修改orders应用的order_create视图,在生成OrderItem并清空购物车的代码下增加一行:

def order_create(request):
    cart = Cart(request)
    if request.method == "POST":
        form = OrderCreateForm(request.POST)
        # 表单验证通过就对购物车内每一条记录生成OrderItem中对应的一条记录
        if form.is_valid():
            order = form.save(commit=False)
            if cart.coupon:
                order.coupon = cart.coupon
                order.discount = cart.coupon.discount
            order.save()
            for item in cart:
                OrderItem.objects.create(order=order, product=item['product'], price=item['price'],
                                         quantity=item['quantity'])
            # 成功生成OrderItem之后清除购物车
            cart.clear()

            # 清除优惠券信息
            request.session['coupon_id'] = None

            # 成功完成订单后调用异步任务发送邮件
            order_created.delay(order.id)
            # 在session中加入订单id
            request.session['order_id'] = order.id
            # 重定向到支付页面
            return redirect(reverse('payment:process'))

    else:
        form = OrderCreateForm()
    return render(request, 'orders/order/create.html', {'cart': cart, 'form': form})

2国际化与本地化

Django对于国际化和本地化提供了完整的支持,允许开发者将站点内容翻译成多种语言,而且可以处理本地化的时间日期数字和时区格式等本地化的显示内容。在开始之前,先需要区分一下国际化和本地化两个概念。国际化和本地化都是一种软件开发过程。国际化(Internationalization,通常缩写为i18n),是指一个软件可以被不同的国家和地区使用,而不会局限于某种语言。本地化(Localization,缩写为l10n)是指对国际化的软件将其进行翻译或者其他本地化适配,使之变成适合某一个国家或地区使用的软件的过程。Django通过自身的国际化框架,可以支持超过50种语言。

2.1国际化与本地化设置

Django的国际化框架可以让开发者很方便的在Python代码和模板中标注需要翻译的字符串,这个框架依赖于GNU gettext开源软件来生成和管理消息文件(message file)。消息文件是一个纯文本文件,代表一种语言的翻译,存放着在站点应用中找到的部分或者所有需要翻译的字符串以及对应的某种语言的翻译,就像一个字典一样。消息文件的后缀名是.po

一旦完成翻译,可以把消息文件编译,以快速访问翻译内容,编译后的消息文件的后缀名是.mo

2.1.1国际化与本地化设置

Django提供了一些国际化和本地化的设置,下边一些设置是最重要的:

以上是常用的国际化和本地化设置,完整设置请参见https://docs.djangoproject.com/en/2.1/ref/settings/#globalization-i18n-l10n

2.1.2国际化和本地化管理命令

Django包含了用于管理翻译的命令如下:

需要使用GNU gettext工具来执行上述过程,大部分linux发行版自带有该工具。如果在使用mac OSX,可以通过 http://brew.sh/ 使用命令brew install gettext来安装,之后使用brew link gettext --force强制链接。对于Windows下的安装,参考https://docs.djangoproject.com/en/2.0/topics/i18n/translation/#gettext-on-windows中的步骤。

2.1.3如何为项目增加翻译文件

先来看一下增加翻译需要进行的流程:

  1. 在Python代码和模板中标注出需要翻译的字符串
  2. 运行makemessages命令建立消息文件
  3. 在消息文件中将字符串翻译成另外一种语言,然后运行compilemessages命令编译消息文件

2.1.4Django如何确定当前语言

Django使用中间件django.middleware.locale.LocaleMiddleware来检查HTTP请求中所使用的本地语言。这个中间件做的工作如下:

  1. 如果使用i18_patterns(django特殊的一种URL方式,里边包含语言前缀),中间件会在请求的URL中寻找特定语言的前缀
  2. 如果在URL中没有发现语言前缀,会在session中寻找一个键LANGUAGE_SESSION_KEY
  3. 如果session中没有该键,会在cookie中寻找一个键。可以通过LANGUAGE_COOKIE_NAME自定义该cookie的名称,默认是django_language
  4. 如果cookie中未找到,找HTTP请求头的Accept-Language
  5. 如果Accept-Language头部信息未指定具体语言,则使用LANGUAGE_CODE设置

注意这个过程只有在开启了该中间件的时候才会得到完整执行,如果未开启中间件,Django直接使用LANGUAGE_CODE中的设置。

2.2为项目使用国际化进行准备

我们准备为电商网站增添各种语言的支持,增添英语和西班牙语的支持。编辑settings.py文件,加入LANGUAGES设置,放在LANGUAGE_CODE的旁边:

LANGUAGES = (
    ('en', 'English'),
    ('es', 'Spanish'),
)

LANGUAGES设置包含两个语言代码和名称组成的元组。语言代码可以指定具体语言如en-usen-gb,也可以更模糊,如en。通过这个设置,我们定义了我们的网站仅支持英语和西班牙语。如果不定义LANGUAGES设置,默认支持所有django支持的语言。

设置LANGUAGE_CODE为如下:

LANGUAGE_CODE = 'en'

添加django.middleware.locale.LocaleMiddlewaresettings.py的中间件设置中,位置在SessionMiddleware中间件之后,CommonMiddleware中间件之前,因为LocaleMiddleware需要使用session,而CommonMiddleware需要一种可用语言来解析URL,MIDDLEWARE设置成如下:

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.locale.LocaleMiddleware',
    'django.middleware.common.CommonMiddleware',
    # ...
]

django中间件设置的顺序很重要,中间件会在请求上附加额外的数据,某个中间件会依赖于另外一个中间件附加的数据才能正常工作。

在manage.py文件所在的项目根目录下创建如下目录:

locale/
    en/
    es/

locale目录是用来存放消息文件的目录,编辑settings.py文件加入如下设置:

LOCALE_PATH = (
    os.path.join(BASE_DIR, 'locale/'),
)

LOCALE_PATH指定了Django寻找消息文件的路径,可以是一系列路径,最上边的路径优先级最高。

当使用makemessages命令的时候,消息文件会在我们创建的locale/目录中创建,如果某个应用也有locale/目录,那个应用中的翻译内容会优先在那个应用的目录中创建。

2.3翻译Python代码中的字符串

为了翻译Python代码中的字符串字面量,需要使用django.utils.translation模块中的gettext()方法来标注字符串。这个方法返回翻译后的字符串,通常做法是导入该方法然后命名为一个下划线"_"。可以在https://docs.djangoproject.com/en/2.0/topics/i18n/translation/查看文档。

2.3.1标记字符串

标记字符串的方法如下:

from django.utils.translation import gettext as _
output = _('Text to be translated.')

2.3.2惰性翻译

Django对于所有的翻译函数都有惰性版本,后缀为_lazy()。使用惰性翻译函数的时候,字符串只有被访问的时候才会进行翻译,而不是在翻译函数调用的时候。当字符串位于模块加载的时候才生成的路径中时候特别有效。

使用gettext_lazy()代替gettext()方法,只有在该字符串被访问的时候才会进行翻译,所有的翻译函数都有惰性版本。。

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 Abril"。当需要翻译的文本中存在变量的时候,推荐使用占位符。

2.3.4复数的翻译

对于复数形式的翻译,可以采用ngettext()ngettext_lazy()。这两个函数根据对象的数量来翻译单数或者复数。使用例子如下:

output = ngettext('there is %(count)d product', 'there are %(count)d products', count) % {'count': count}

现在我们了解了Python中翻译字面量的知识,可以来为我们的项目添加翻译功能了。

2.3.5为项目翻译Python字符串字面量

编辑setttings.py,导入gettext_lazy(),然后修改LANGUAGES设置:

from django.utils.translation import gettext_lazy as _

LANGUAGES = (
    ('en', _('English')),
    ('es', _('Spanish')),
)

这里导入了gettext_lazy()并使用了别名"_"来避免重复导入。将显示的名称也进行了翻译,这样对于不同的语言的人来说,可以看懂并选择他自己的语言。

然后打开系统命令行窗口,输入如下命令:

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:107
msgid "English"
msgstr ""

#: .\myshop\settings.py:108
msgid "Spanish"
msgstr ""

每一部分的第一行表示在那个文件的第几行发现了需翻译的内容,每个翻译包含两个字符串:

添加好翻译之后的文件如下:

#: myshop/settings.py:117
msgid "English"
msgstr "Inglés"

#: myshop/settings.py:118
msgid "Spanish"
msgstr "Español"

保存这个文件,之后执行命令编译消息文件:

django-admin compilemessages

可以看到输出如下:

processing file django.po in myshop/locale/en/LC_MESSAGES
processing file django.po in myshop/locale/es/LC_MESSAGES

这表明已经编译了翻译文件,此时查看locale目录,其结构如下:

en/
    LC_MESSAGES/
        django.mo
        django.po
es/
    LC_MESSAGES/
        django.mo
        django.po

可以看到每种语言都生成了.mo文件。

我们已经翻译好了语言名称本身。现在我们来试着翻译一下Order模型的所有字段,修改orders应用的models.py文件:

from django.utils.translation import gettext_lazy as _

class Order(models.Model):
    first_name = models.CharField(_('frist name'), max_length=50)
    last_name = models.CharField(_('last name'), max_length=50)
    email = models.EmailField(_('e-mail'), )
    address = models.CharField(_('address'), max_length=250)
    postal_code = models.CharField(_('postal code'), max_length=20)
    city = models.CharField(_('city'), max_length=100)
    ......

我们为每个显示出来的字段标记了翻译内容,也可以使用verbose_name属性来命名字段。在orders应用中建立如下目录:

locale/
    en/
    es/

通过创建locale目录,当前应用下的翻译内容会优先保存到这个目录中,而不是保存在项目根目录下的locale目录中。这样就可以为每个应用配置独立的翻译文件。

在系统命令行中执行:

django-admin makemessages --all

输出为:

processing locale es
processing locale en

使用文本编辑器打开locale/es/LC_MESSAGES/django.po,可以看到Order模型的字段翻译,在msgstr中为对应的msgid字符串加上西班牙语的翻译:

#: orders/models.py:10
msgid "first name"
msgstr "nombre"

#: orders/models.py:11
msgid "last name"
msgstr "apellidos"

#: orders/models.py:12
msgid "e-mail"
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编辑翻译内容,该软件同样依赖gettext,支持Linux,Windows和macOS X。可以在https://poedit.net/下载该软件。

下边来翻译项目使用的表单。OrderCreateForm这个表单类无需翻译,因为它会自动使用Order类中我们刚刚翻译的verbose_name。现在我们去翻译cartcoupons应用中的内容。

cart应用的forms.py文件中,导入翻译函数,为CartAddProductForm类的quantity字段增加一个参数label,代码如下:

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标签用于展示翻译后的字段名称。

2.4翻译模板

Django为翻译模板内容提供了{% trans %}{% blocktrans %}两个模板标签用于翻译内容,如果要启用这两个标签,需要在模板顶部加入{% load i18n %}

2.4.1使用{% trans %}模板标签

{% trans %}标签用来标记一个字符串,常量或者变量用于翻译。Django内部也是该文本执行gettext()等翻译函数。标记字符串的例子是:

{% trans "Text to be translated" %}

也可以像其他标签变量一样,使用as 将 翻译后的结果放入一个变量中,在其他地方使用。下面的例子使用了一个变量greeting

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

这个标签用于比较简单的翻译,但不能用于带占位符的文字翻译。

2.4.2使用{% blocktrans %}模板标签

{% blocktrans %}标签可以标记包含常量和占位符的内容用于翻译,下边的例子展示了使用一个name变量的翻译:

{% blocktrans %}Hello {{ name }}!{% endblocktrans %}

可以使用with,将具体的表达式设置为变量的值,此时在blocktrans块内部不能够再继续访问表达式和对象的属性,下面是一个使用了capfirst装饰器的例子:

{% blocktrans with name=user.name|capfirst %}
    Hello {{ name }}!
{% endblocktrans %}

如果翻译内容中包含变量,使用{% blocktrans %}代替{% trans %}

2.4.3翻译商店模板

编辑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/base2.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=mycart|length %}
            {% if mycart|length > 0 %}
                {% trans "Your cart" %}:
                <a href="{% url 'cart:cart_detail' %}">
                    {% blocktrans with total_items_plural=total_items|pluralize total_price=cart.get_total_price %}
                    {{ total_items }} items{{ 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(访问对象的方法)创建占位符:

编辑shop应用的shop/product/detail.html,紧接着{% extends %}标签导入i18n标签:

{% load i18n %}

之后找到下边这一行:

<input type="submit" value="Add to cart">

将其替换成:

<input type="submit" value="{% trans "Add to cart" %}">

现在来翻译orders应用,编辑orders/order/create.html,标记如下翻译内容:

{% extends 'shop/base.html' %}
{% load i18n %}
{% block title %}
    {% trans "Checkout" %}
{% endblock %}

{% 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|floatformat:"2" }}</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_diccount|floatformat:"2" }}</p>
    </div>

    <form action="." method="post" class="order-form" novalidate>
        {{ form.as_p }}
        <p><input type="submit" value="{% trans "Place order" %}"></p>
        {% csrf_token %}
    </form>
{% endblock %}

到现在我们完成了如下文件的翻译:

之后来更新消息文件,打开命令行窗口执行:

django-admin makemessages --all

此时myshop项目下的locale目录内有了对应的.po文件,而orders应用的翻译文件优先存放在应用内部的locale目录中。

编辑所有.po文件,在msgstr属性内添加西班牙语翻译。你也可以直接复制随书代码内对应文件的内容。

执行命令编译消息文件:

django-admin compilemessages

可以看到如下输出:

processing file django.po in myshop/locale/en/LC_MESSAGES
processing file django.po in myshop/locale/es/LC_MESSAGES
processing file django.po in myshop/orders/locale/en/LC_MESSAGES
processing file django.po in myshop/orders/locale/es/LC_MESSAGES

针对每一个.po文件都会生成对应的.mo文件。

2.5使用Rosetta翻译界面

Rosetta是一个第三方应用,通过Django管理后台编辑所有翻译内容,让.po文件的管理变得更加方便,先通过pip安装该模块:

pip install django-rosetta==0.8.1

之后在settings.py中激活该应用:

INSTALLED_APPS = [
    # ...
    'rosetta',
]

然后需要为Rosetta配置相应的URL,其二级路由已经配置好,修改项目根路由增加一行:

urlpatterns = [
    # ...
    path('rosetta/', include('rosetta.urls')),
    path('', include('shop.urls', namespace='shop')),
]

这条路径也需要在shop.urls上边。

然后启动站点,使用管理员身份登录http://127.0.0.1:8000/rosetta/ ,再转到http://127.0.0.1:8000/rosetta/,点击右上的THIRD PARTY以列出所有的翻译文件,如下图所示:

点开Spanish下边的Myshop链接,可以看到列出了所有需要翻译的内容:

可以手工编辑需要翻译的地方,OCCURRENCES(S)栏显示了该翻译所在的文件名和行数,对于那些占位符翻译的内容,显示为这样:

Rosetta对占位符使用了不同的背景颜色,在手工输入翻译内容的时候注意不要破坏占位符的结构,例如要翻译下边这一行:

%(total_items)s item%(total_items_plural)s, $%(total_price)s

应该输入:

%(total_items)s producto%(total_items_plural)s, $%(total_price)s

可以参考本章随书代码中的西班牙语翻译来录入翻译内容。

结束输入的时候,点击一下Save即可将当前翻译的内容保存到.po文件中,当保存之后,Rosetta会自动进行编译,所以无需执行compilemessages命令。然而要注意Rosetta会直接读写locale目录,注意要给予其相应的权限。

如果需要其他用户来编辑翻译内容,可以到http://127.0.0.1:8000/admin/auth/group/add/新增一个用户组叫translators,然后到http://127.0.0.1:8000/admin/auth/user/编辑用户的权限以给予其修改翻译的权限,将该用户加入到translators用户组内。仅限超级用户和translators用户组内的用户才能使用Rosetta。

Rosetta的官方文档在https://django-rosetta.readthedocs.io/en/latest/

特别注意的是,当Django已经在生产环境运行时,如果修改和新增了翻译,在运行了compilemessages命令之后,只有重新启动Django才会让新的翻译生效。

2.6待校对翻译Fuzzy translations

你可能注意到了,Rosetta页面上有一列叫做Fuzzy。这不是Rosetta的功能,而是gettext提供的功能。如果将fuzzy设置为true,则该条翻译不会包含在编译后的消息文件中。这个字段用来标记需要由用户进行检查的翻译内容。当.po文件更新了新的翻译字符串时,很可能一些翻译被自动标成了fuzzy。这是因为:在gettext发现一些msgid被修改过的时候,gettext会将其与它认为的旧有翻译进行匹配,然后标注上fuzzy。看到fuzzy出现的时候,人工翻译者必须检查该条翻译,然后取消fuzzy,之后再行编译。

2.7国际化URL

Django提供两种国际化URL的特性:

使用翻译URL模式的优点是对搜索引擎友好。如果采用语言前缀URL,则必须要为每一种语言进行索引,使用翻译URL模式,则一条URL就可以匹配全部语言。下边来看一下两种模式的使用:

2.7.1语言前缀URL模式

Django可以为不同语言在URL前添加前缀,例如我们的网站,英语版以/en/开头,而西班牙语版以/es/开头。

要使用语言前缀URL模式,需要启用LocaleMiddleware中间件,用于从不同的URL中识别语言,在之前我们已经添加过该中间件。

我们来为URL模式增加前缀,现在需要修改项目的根urls.py文件:

from django.conf.urls.i18n import i18n_patterns

urlpatterns = i18n_patterns(
    path('admin/', admin.site.urls),
    path('cart/', include('cart.urls', namespace='cart')),
    path('orders/', include('orders.urls', namespace='orders')),
    path('pyament/', include('payment.urls', namespace='payment')),
    path('coupons/', include('coupons.urls', namespace='coupons')),
    path('rosetta/', include('rosetta.urls')),
    path('', include('shop.urls', namespace='shop')),
)

可以混用未经翻译的标准URL与i18n_patterns类型的URL,使部分URL带有语言前缀,部分不带前缀。但最好只使用翻译URL,以避免把翻译过的URL匹配到未经翻译过的URL模式上。

现在启动站点,到http://127.0.0.1:8000/ ,Django的语言中间件会按照之前介绍的顺序来确定本地语言,然后重定向到带有语言前缀的URL。现在看一下浏览器的地址栏,应该是http://127.0.0.1:8000/en/。当前语言是由请求头Accept-Language所设置,或者就是LANGUAGE_CODE的设置。

2.7.2翻译URL模式

Django支持在URL模式中翻译字符串。针对不同的语言,可以翻译出不同的URL。在urls.py中,使用ugettext_lazy()来标注字符串。

编辑myshop应用的根urls.py,为cartorderspaymentcoupons应用配置URL:

from django.utils.translation import gettext_lazy as _

urlpatterns = i18n_patterns(
    path(_('admin/'), admin.site.urls),
    path(_('cart/'), include('cart.urls', namespace='cart')),
    path(_('orders/'), include('orders.urls', namespace='orders')),
    path(_('payment/'), include('payment.urls', namespace='payment')),
    path(_('coupons/'), include('coupons.urls', namespace='coupons')),
    path('rosetta/', include('rosetta.urls')),
    path('', include('shop.urls', namespace='shop')),
)

编辑orders应用的urls.py文件,修改成如下:

from django.utils.translation import gettext_lazy as _

urlpatterns = [
    path(_('create/'), views.order_create, name='order_create'),
    # ...
]

修改payment应用的urls.py文件,修改成如下:

from django.utils.translation import gettext_lazy as _

urlpatterns = [
    path(_('process/'), views.payment_process, name='process'),
    path(_('done/'), views.payment_done, name='done'),
    path(_('canceled/'), views.payment_canceled, name='canceled'),
]

对于shop应用的URL不需要修改,因为其URL是动态建立的。

执行命令进行编译,更新消息文件:

django-admin makemessages --all

启动站点,访问http://127.0.0.1:8000/en/rosetta/,点击Spanish下的Myshop,可以看到出现了URL对应的翻译。可以点击Untranslated查看所有尚未翻译的字符串,然后输入翻译内容。

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 "Language" %}:</p>
        <ul class="languages">
            {% for language in languages %}
                <li>
                    <a href="/{{ language.code }}/"
                       {% if language.code == LANGUAGE_CODE %} class="selected"{% endif %}>
                        {{ language.name_local }}
                    </a>
                </li>
            {% endfor %}
        </ul>
    </div>
</div>

这个就是我们的语言选择器,逻辑如下:

  1. 页面的最上方加载{% load i18n %}
  2. 使用{% get_current_language %}标签用于获取当前语言
  3. 使用{% get_available_languages %}标签用于从LANGUAGES里获取所有可用的支持语言
  4. 使用{% get_language_info_list %}是为了快速获取语言的属性而设置的变量
  5. 用循环列出了所有可支持的语言,对于当前语言设置CSS类为select

启动站点到http://127.0.0.1:8000/ ,可以看到页面右上方出现了语言选择器,如下图:

2.9使用django-parler翻译模型

Django没有提供直接可用的模型翻译功能,必须采用自己的方式实现模型翻译。有一些第三方工具可以翻译模型字段,每个工具存储翻译的方式都不相同。其中一个工具叫做django-parler,提供了高效的翻译管理,还能够与管理后台进行集成。

django-parler的工作原理是为每个模型建立一个对应的翻译数据表,表内每条翻译记录通过外键连到翻译文字所在的模型,表内还有一个language字段,用于标记是何种语言。

2.9.1安装django-parler

使用pip安装django-parler

pip install django-parler==1.9.2

settings.py内激活该应用:

INSTALLED_APPS = [
    # ...
    'parler',
]

继续添加下列设置:

PARLER_LANGUAGES = {
    None: (
        {'code': 'en'},
        {'code': 'es'},
    ),
    'default': {
        'fallback': 'en',
        'hide_untranslated': False,
    }
}

该配置的含义是指定了django-parler的可用语言为enes,然后指定了默认语言为en,然后指定django-parler不要隐藏未翻译的内容。

2.9.2翻译模型字段

我们为商品品类添加翻译。django-parler提供一个TranslatableModel(此处作者原文有误,写成了TranslatedModelTranslatedFields方法来翻译模型的字段。编辑shop应用的models.py文件,添加导入语句:

from parler.models import TranslatableModel, TranslatedFields

然后修改Category模型的nameslug字段:

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

Category类现在继承了TranslatableModel类,而不是原来的models.Modelnameslug字段被包含在了TranslatedFields包装器里。

编辑Productnameslugdescription,和上边一样的方式:

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)
    available = models.BooleanField(default=True)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)

django-parler通过新创建模型为其他模型提供翻译,在下图可以看到Product与其对应的翻译模型ProductTranslation之间的关系:

译者注:此时如果运行站点,一些IDE会提示模型的字段找不到,这个对于实际运行程序没有影响,该字段依然可用。

django-parler生成的ProductTranslation类包含nameslugdescription,和一个language_code字段,还有一个外键连接到Product类,针对一个Product模型,会按照每种语言生成一个对应的ProductTranslation对象。

由于翻译的部分和原始的类是独立的两个模型,因此一些ORM的功能无法使用,比如不能在Product类中根据一个翻译后的字段进行排序,也不能在Meta类的ordering属性中使用翻译的字段。

所以编辑shop应用的models.py文件,注释掉ordering设置:

class Category(TranslatableModel):
    # ...
    class Meta:
        # ordering = ('name',)
        verbose_name = 'category'
        verbose_name_plural = 'categories'

对于Product类,也要注释掉ordering,还需要注释掉index_together,这是因为目前的django-parler不支持联合索引的验证关系。如下图:

class Product(TranslatableModel):
    # ...
    class Meta:
        pass
        # ordering = ('name',)
        # index_together = (('id', 'slug'),)

译者注:原书在这里遗漏了pass,不要忘记加上。

关于django-parler的兼容性,可以在https://django-parler.readthedocs.io/en/latest/compatibility.html查看。

2.9.3django-parler集成到管理后台

django-parler易于集成到django管理后台中,包含一个TranslatableAdmin类代替了原来的ModelAdmin类。

编辑shop应用的admin.py文件,导入该类:

from parler.admin import TranslatableAdmin

修改CategoryAdminProductAdmin类,使其继承TranslatableAdmin而不是ModelAdmin类,django-parler不支持prepopulated_fields属性,但支持相同功能的get_prepopulated_fields()方法,因此将两个类修改如下:

from django.contrib import admin
from .models import Category, Product
from parler.admin import TranslatableAdmin

@admin.register(Category)
class CategoryAdmin(TranslatableAdmin):
    list_display = ['name', 'slug']

    def get_prepopulated_fields(self, request, obj=None):
        return {'slug': ('name',)}


@admin.register(Product)
class ProductAdmin(TranslatableAdmin):
    list_display = ['name', 'slug', 'price', 'available', 'created', 'updated']
    list_filter = ['available', 'created', 'updated']
    list_editable = ['price', 'available']

    def get_prepopulated_fields(self, request, obj=None):
        return {'slug': ('name',)}

现在在管理后台内也能进行对翻译模型的管理了。现在可以执行数据迁移程序。

2.9.4迁移翻译模型数据

打开shell执行下列命令:

python manage.py makemigrations shop --name "translations"

会看到如下输出:

Migrations for 'shop':
  shop\migrations\0002_translations.py
    - Create model CategoryTranslation
    - Create model ProductTranslation
    - Change Meta options on category
    - Change Meta options on product
    - Remove field name from category
    - Remove field slug from category
    - Alter index_together for product (0 constraint(s))
    - Add field master to producttranslation
    - Add field master to categorytranslation
    - Remove field description from product
    - Remove field name from product
    - Remove field slug from product
    - Alter unique_together for producttranslation (1 constraint(s))
    - Alter unique_together for categorytranslation (1 constraint(s))

django-parler动态地创建了CategoryTranslationProductTranslation。注意,原模型中需要翻译的字段从原模型中删除了,这意味着这几个字段的数据全都丢失了,必须启动站点后重新录入。

之后运行数据迁移:

python manage.py migrate shop

可以看到下列输出:

Applying shop.0002_translations... OK

现在数据已经和数据库同步好了。

启动站点,访问http://127.0.0.1:8000/en/admin/shop/category/,可以看到已经存在的模型失去了那些需要翻译的字段。点击一个category对象进行修改,可以看到包含了两个不同的表格,一个对应英语,一个对应西班牙语,如下图所示:

为所有已存在category记录都添加名称和简称,再为其添加西班牙语的名称和简称,然后点击SAVE按钮,确保在切换标签之前点击了SAVE按钮,否则数据不会被保存。

之后到http://127.0.0.1:8000/en/admin/shop/product/进行同样的工作:补充每个商品的名称、简称、描述以及对应的西班牙语翻译。

2.9.5视图中加入翻译功能

为了正常使用翻译后的模型,必须让shop应用的视图对翻译后的字段也能够获取QuerySet,终端内输入python manage.py shell进入带Django环境的命令行模式来试验一下经过翻译后的查询操作:

看一下如何查询翻译后的字段。为了获取某种语言的查询结果集,需要使用Django的activate()函数:

>>> from shop.models import Product
>>> from django.utils.translation import activate
>>> activate('es')
>>> product=Product.objects.first()
>>> product.name
'Té verde'

另外一种根据不同语言查询的方式是使用django-parler提供的language()模型管理器:

>>> product=Product.objects.language('en').first()
>>> product.name
'Green tea'

当查询翻译字段时,会根据所指定的语言返回结果。可以通过设置管理器的属性得到不同语言的结果,类似这样:

>>> product.set_current_language('es')
>>> product.name
'Té verde'
>>> product.get_current_language()
'es'

如果需要使用filter功能,需要使用tranlations__语法,例子如下:

>>> Product.objects.filter(translations__name='Green tea')
<TranslatableQuerySet [<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/2/te-rojo/,在英语里则是http://127.0.0.1:8000/en/2/red-tea/。如果到一个商品详情页,能够看到翻译后的URL和内容如下:

https://django-parler.readthedocs.io/en/latest/可以查看django-parler的文档。

现在已经知道了如何翻译Python代码,模板,URL和模型的字段,站点已经可以提供不同语言的服务了。为了完成国际化和本地化的过程,还需要对本地的日期,时间,数字格式进行设置。

2.10本地格式化

根据用户的国家和地区,需要以不同的格式显示日期,时间和数字。本地化格式可以通过settings.py里的USE_L10N设置为True来开启。

USE_L10N设置为开启的时候,Django在渲染模板的时候,会尽可能的尝试使用当前本地化的方式进行输出。可以看到我们的站点的小数点是一个圆点显示的,切换到西班牙语的时候,小数点显示为一个逗号。这是通过对每种语言进行不同的格式设置实现的,对于支持的每种语言的格式,Django都有对应的配置文件,例如针对西班牙语的配置文件可以查看https://github.com/django/django/blob/stable/2.0.x/django/conf/locale/es/formats.py

通常情况下,只要设置USE_L10NTrue,Django就会自动应用本地化格式。然而,站点内可能有些内容并不想使用本地化格式,尤其那些标准数据例如代码或者是JSON字符串的内容。

Django提供了一个{% locailze %}模板标签,用于控制模板或者模板片段开启或关闭本地化输出。为了使用这个标签,必须在模板开头使用{% load l10n %}标签。下边是一个如何在模板中控制开启/关闭本地化输出的例子:

{% load l10n %}

{% localize on %}
    {{ value }}
{% endlocalize %}

{% localize off %}
    {{ value }}
{% endlocalize %}

Django还提供了两个模板过滤器用于控制本地化,分别是localizeunlocailze,用来强制让一个值开启/关闭本地化显示。用法如下:

{{ value|localize }}
{{ value|unlocalize }}

除了这两个方法之外,还可以采取自定义格式文件方式,具体看https://docs.djangoproject.com/en/2.0/topics/i18n/formatting/#creating-custom-format-files

2.11用django-localflavor验证表单字段

django-localflavor是一个第三方模块,包含一系列特别针对本地化验证的工具,比如为每个国家单独设计的表单和模型字段,对于验证某些国家的地区,电话号码,身份证,社会保险号码等非常方便。这个模块是按照ISO 3166国家代码标准编写的。

安装django-localflavor

pip install django-localflavor==2.0

settings.py中激活该应用:

INSTALLED_APPS = [
    # ...
    'localflavor',
]

为了使用该模块,我们给订单增加一个美国邮编字段和对应验证,必须是一个有效的美国邮编才能建立订单。

编辑orders应用的forms.py文件,修改成如下:

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

localflaverus模块中导入USZipCodeField字段类型,将OrderCreateForm类的postal_code字段设置为该类型。

运行站点,到http://127.0.0.1:8000/en/orders/create/,输入一些不符合美国邮编的邮政编码,可以看到表单的错误提示:

Enter a zip code in the format XXXXX or XXXXX-XXXX.

这只是一个针对给字段附加本地化验证的一个简单例子。localflavor提供的组件对于将站点快速适配到某些国家非常有用。可以在https://django-localflavor.readthedocs.io/en/latest/阅读django-flavor的官方文档。

现在就结束了所有国际化和本地化配置的工作,下一步是建立一个商品推荐系统。

3创建商品推荐系统

商品推荐系统可以预测用户对一个商品的喜好程度或者评价高低,根据用户的行为和收集到的用户数据,选择可能和用户相关的产品推荐给用户。在电商行业,推荐系统使用的非常广泛。推荐系统可以帮助用户从浩如烟海的商品中选出自己感兴趣的商品。好的推荐系统可以增加用户粘性,对电商平台则意味着销售额的提高。

我们准备建立一个简单但是强大的商品推荐系统,用于推荐经常被一起购买的商品,这些商品基于用户过去的购买数据来给用户进行推荐。我们打算在两个页面向用户推荐商品:

我们将使用Redis数据库记录一起购买的商品。我们在第六章已经使用过Redis,如果还没有安装Redis,可以参考该章节的内容。

3.1根据之前的购买记录推荐商品

现在,需要根据用户加入到购物车内的商品计算排名。对于我们网站每一个被售出的商品,在Redis中存一个键。这个商品键对应的值是一个有序集合,就为同订单的其他商品在当前商品键对应的有序集合中的分数加1。

当一个订单成功支付时,我们为订单每个购买的商品存储一个有序集合,这个有序集合将记录一起购买的商品分数。

安装redis-p模块:

pip install redis==2.10.6

之后在settings.py里配置Redis:

REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 1

这是用于建立和Redis服务通信的设置。在shop应用目录下新建recommender.py文件,添加下列代码:

import redis
from django.conf import settings
from .models import Product

# 连接到Redis
r = redis.StrictRedis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=settings.REDIS_DB)

class Recommender:

    def get_product_key(self, product_id):
        return 'product:{}:purchased_with'.format(product_id)

    def products_bought(self, products):
        product_ids = [p.id for p in products]
        # 针对订单里的每一个商品,将其他商品在当前商品的有序集合中增加1
        for product_id in product_ids:
            for with_id in product_ids:
                if product_id != with_id:
                    r.zincrby(self.get_product_key(product_id), with_id, amount=1)

这个Recommender类用来存储订单购买时的相关信息和根据一个指定的对象获取相关的推荐。get_product_key()方法获取一个Product对象的id,然后创建对应的有序集合,其中的键看起来像这样: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,这个键将用于操作有序集合
  4. 在该商品对应的有序序列将同一订单内的其他商品的分数增加1

我们现在有了一个保存商品相关信息的方法。还需要一个方法来从Redis中获得推荐的商品,继续编写Recommender类,增加suggest_products_for()方法:

class Recommender:
    # ......
    def suggest_products_for(self, products, max_results=6):
        product_ids = [p.id for p in products]
        # 如果当前列表只有一个商品:
        if len(product_ids) == 1:
            suggestions = r.zrange(self.get_product_key(product_ids[0]), 0, -1, desc=True)[:max_results]
        else:
            # 生成一个临时的key,用于存储临时的有序集合
            flat_ids = ''.join([str(id) for id in product_ids])
            tmp_key = 'tmp_{}'.format(flat_ids)
            # 对于多个商品,取所有商品的键名构成keys列表
            keys = [self.get_product_key(id) for id in product_ids]
            # 合并有序集合到临时键
            r.zunionstore(tmp_key, keys)
            # 删除与当前列表内商品相同的键。
            r.zrem(tmp_key, *product_ids)
            # 获得排名结果
            suggestions = r.zrange(tmp_key, 0, -1, desc=True)[:max_results]
            # 删除临时键
            r.delete(tmp_key)
        # 获取关联商品并通过相关性排序
        suggested_products_ids = [int(id) for id in suggestions]
        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()方法接受两个参数:

在这个方法里我们做了如下的事情:

  1. 获取所有Product对象的ID
  2. 如果仅有一个商品,直接查询这个id对应的有序集合,按降序返回结果。为了实现查询,使用了Redis的ZRANGE命令。我们使用max_results属性指定返回的最大数量。
  3. 如果商品数量多于1个,通过ID创建一个临时键名。
  4. 通过Redis的ZUNIONSTORE命令合并所有商品的有序集合。ZUNIONSTORE合并所有的有序集合中相同键的分数,然后将新生成的有序集合存入临时键。关于该命令可以参考https://redis.io/commands/ZUNIONSTORE
  5. 由于已经在当前购物车内的商品无需被推荐,因此使用ZREM命令从临时键的有序集合中删除与当前订单内商品id相同的键。
  6. 从临时键中获取商品ID,使用ZRANGE命令按照分数排序,通过max_results控制返回数量,之后删除临时键。
  7. 根据ID获取Product对象,然后按照与取出的ID相同的顺序进行排列。

为了更加实用,再给Recommender类添加一个清除推荐商品的方法:

class 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进入带有Django项目环境的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)

下边测试一下通过翻译字段获取推荐商品信息:

>>> from django.utils.translation import activate
>>> activate('en')
>>> 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>]

可以实际计算一下是否符合合并有序集合后的结果,例如针对第一条程序,tea_powder的分数是2+1,green_tea的分数是1+1等

测试之后说明我们的推荐算法正常工作,下一步就是将该功能集成到站点中,在商品详情页和购物车清单页进行展示。先修改shop应用的views.py文件中的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/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 %}

然后运行站点,点击商品进入详情页,可以看到类似下图的商品推荐:

我们还需要在购物车详情页增加推荐功能,编辑cart应用的views.py文件中的cart_detail视图:

from shop.recommender import Recommender

def cart_detail(request):
    cart = Cart(request)
    for item in cart:
        item['update_quantity_form'] = CartAddProductForm(initial={'quantity': item['quantity'], '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/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 %}

译者注,由于上述内容使用了{% trans %}模板标签,不要忘记在页面上方加入{% load i18n %},原书这里没有加,会导致报错。

在浏览器中打开http://127.0.0.1:8000/en/。将一些商品加入购物车,然后至http://127.0.0.1:8000/en/cart/查看购物车详情,可以看到出现了推荐商品:

现在我们就使用Redis配合Django完成了一个推荐系统。

译者注,原书其实并没有将功能写完。可以发现,目前的购买数据(调用Recommender类的products_bought()方法)是在我们测试的时候通过命令行添加的,而不是通过网站功能自动添加。按照一开始的分析,应该在付款成功的时候,更新Redis的数据。需要在payment应用的views.py文件中,在payment_process视图中付款响应成功,保存交易id和paid字段之后,发送PDF发票之前,添加如下代码:

from shop.recommender import Recommender

def payment_process(request):
    ......
    if request.method == "POST":
    ......
        if result.is_success:
            order.paid = True
            order.braintree_id = result.transaction.id
            order.save()

            # 更新Redis中本次购买的商品分数
            r = Recommender()
            order_items = [order_item.product for order_item in order.items.all()]
            r.products_bought(order_items)

总结

在这一章,学习了创建优惠码系统和国际化与本地化配置工作。还基于Redis创建了一个商品推荐系统。

在下一章,我们将创建一个新的项目:在线教育平台,里边将使用Django的CBV技术,还会创建一个内容管理系统。