Django 2 By Example 电商网站项目–-优惠码、国际化与本地化、商品推荐系统

Django 2 By Example 电商网站项目–-优惠码、国际化与本地化、商品推荐系统

扩展电商网站功能 在上一章里,为电商站点集成了支付功能,然后可以生成PDF发票发送给用户,这些核心功能都是非常重要的。在本章,我们还将为商店添加一个非常常见的功能:优惠券。此外,还会学习国际化和本地化的设置,这对于实际上线的网站非常重要。还有一个功能就是建立一个推荐商品的系统。建立了这些系统之后,可

扩展电商网站功能

在上一章里,为电商站点集成了支付功能,然后可以生成PDF发票发送给用户,这些核心功能都是非常重要的。在本章,我们还将为商店添加一个非常常见的功能:优惠券。此外,还会学习国际化和本地化的设置,这对于实际上线的网站非常重要。还有一个功能就是建立一个推荐商品的系统。建立了这些系统之后,可以说电商网站的基本要素就都涵盖了。

本章的具体内容有:

  • 建立一个优惠券系统
  • 给项目增加国际化功能
  • 使用Rosetta来管理翻译
  • 使用Django-parler翻译模型
  • 建立商品推荐系统

优惠码系统

现实中的很多电商网站,会向用户发送电子优惠券,以便用户在购买的时候使用,最后以抵消优惠券之后的价格进行提交订单和支付。还有一些网站是向用户发送优惠码,规定在一定时间内有效,用户在结账的时候可以输入优惠码改变最终结算价格。

我们准备采取优惠码的形式。首先还是要来思考一下我们的意图:我们打算采取输入优惠码的方式,这个码只在一定时间内有效,但是不限制使用次数,输入之后,就会影响用户购物车中的总价。为了实现这个需求,可见购物码其实也是一段数据,需要建立一个数据模型来存储优惠码。

优惠码的数据表至少应该保存优惠码本身的号码,可使用的时间,以及折扣(实际的优惠码应该是保存计算优惠的一个标记,用于通知业务逻辑代码计算最终价格,比如是减去多少总价,还是折扣等,这里我们采用折扣)

启动新的应用 coupons,然后在settings.py内注册好,之后我们来建立优惠券的数据模型。

建立优惠码数据模型

编辑coupons应用的models.py:

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

这个模型解释如下:

  • 字段code用于存放码,而且设置了唯一,即不允许有重复的优惠码,这是常见做法。
  • valid_from和valid_to共同定义了优惠码的可使用时间,后边的业务逻辑就依靠这个判断用户是否可以使用该码。
  • discount这里,我们第一次使用了vaildators属性,这个属性表示为该字段引入的验证器列表。还记得在第二个项目中编写的自定义验证器吗,就可以加在这里,或者采取clean_字段名的方式。这里引入的是内置的两个验证器,限制了这个数字在0-100的整数之间。
  • active表示该码是否可用

之后执行makemigrations和migrate来同步数据库。之后的套路依然是将该模型加入到管理后台,编辑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/ 看一下新的数据类:

新增一些coupons,为后边使用做准备。

为购物车应用优惠码

优惠码最核心的功能,就是用户使用了之后会改变价格。有了优惠码的数据后,最核心的问题就是给用户提供使用优惠码的界面。先来分析一下优惠码的使用过程:

  1. 用户添加一个商品到购物车
  2. 用户在提交之前应该能输入一个优惠码
  3. 输入优惠码之后,需要来判断该码是否在有效时间内和是否active,如果无效需要提醒用户,有效,则需要记录使用的码然后修改购物车的价格。
  4. 还需要将优惠码的信息也保存在session中,如果用户选择不使用,就从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')

这个视图函数是用来处理优惠码填写后的业务逻辑。其中的主要逻辑是:

  1. 先获取当前时间和表单对象
  2. 判断表单是否通过验证,没有则重定向到购物车详情页。如果表单通过验证,则进行下边的逻辑。
  3. 到Coupon表中寻找用户输入的优惠码,找到则将session中附加上优惠码的id,找不到就附加上none。这里的iexact表示大小写敏感的字符相等。
  4. 最终依然是重定向到购物车详情页。

视图编写好之后,在coupons应用中建立urls.py:

from django.urls import path
from . import views

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

再配置项目的根路由,在shop.urls上边增加一行:

path('coupons/', include('coupons.urls', namespace='coupons')),

很显然,这里根据用户输入的结果,附加或者不附加session信息后重定向到了购物车详情页的视图,所以还需要修改cart应用中相关的功能。

编辑cart应用中的cart.py:

# 增加导入Coupon类的一行:
from coupons.models import Coupon

# 在Cart类的__init__方法中增加获取coupon.id的一行:
class Cart(object):
    def __init__(self, request):
        # ...
        # store current applied coupon
        self.coupon_id = self.session.get('coupon_id')

# 为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_diccount(self):
        return self.get_total_price() - self.get_discount()

修改之后,在每次初始化Cart对象的时候,会从session中获取coupon_id。定义了一个@property的方法.coupon(),用于返回当前购物车对象中是否包含有效的coupon。如果包含,则.get_discount()的返回值是折扣金额,.get_total_price_after_diccount()方法用总金额减去折扣金额得到最终金额。如果不包含,则折扣金额就是0,总价不变。

修改Cart类之后,需要修改视图函数,以便将表单对象传给模板:

# 增加导入表单的行:
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})

页面中现在有了表单变量,此外很显然购物车中的总价就不能再使用原来的.get_total_price()方法,而需要使用.get_total_price_after_diccount()方法。所以需要修改购物车模板cart/templates/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>

模板逻辑相对还是比较简单的,这里就将原来一行显示总价的部分,修改成了根据是否有优惠码展示优惠码和折扣金额的三行。

依然是在detail.html,还需要把优惠码表单添加进去:

{# 在紧挨着</table>标签之后插入: #}
<p>Apply a coupon:</p>
<form action="{% url 'coupons:apply' %}" method="post">
    {{ coupon_apply_form }}
    <input type="submit" value="Apply">
    {% csrf_token %}
</form>

之后启动站点,向购物车内加入一些商品,然后进入购物车页面输入优惠码并提交,可以看到如下所示:

这样就初步做好了购物车内使用优惠码的功能。

但是如果继续生成订单的话,会发现订单的金额依然是原价,这是因为在order_create视图中,我们保存进订单Order表的数据是当时商品的单价和数量,与折扣没有关系。所以在计算实际付款金额的时候出现问题。所以下一步就是要将折扣信息保存到订单中,然后在后续的生成金额和展示中就都不会出错了。

原书在这里直接先修改了orders/order/create.html页面,一个主要原因是该页面的总金额数据从购物车中获取,与视图没有关系,在下一节中才修改orders内的视图函数。译者感觉不妥,二者都属于orders应用的内容,联系较紧密,故将此部分放到下一节中。

将优惠码加入到订单数据中

在购物车中可以使用优惠码之后,点击下一步生成订单的时候,发现该页面的数据依然是未经折扣的金额,这是因为Order表中没有保存折扣信息,对应的视图也没有采用折扣进行计算。因为将折扣信息保存在订单中是非常方便的做法,将来计算总价和与支付信息核对都很方便。

为了在Order表中保存优惠码,需要修改orders应用的models.py:

# 增加导入的部分:
from decimal import Decimal
from django.core.validators import MinValueValidator, MaxValueValidator
from coupons.models import Coupon

# 给Order增加coupon 和 discount 字段:
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)])

给Order增加了两个字段,一个外键用于连到Coupon类的具体字段,这里关键是on_delete设置成了.SET_NULL,表示如果折扣券删除了,就设置成NULL,配合null=True来使用;还有一个字段是折扣信息,用于记录实际的折扣信息。因为有的时候优惠券可能会从数据库中删除,或者优惠券的内容变化,因此外键只是一个关联表示,实际当时生成订单时的折扣,还是需要单独保存下来。

增加好字段后执行 makemigrations 和 migrate。然后需要修改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'))

修改后的金额会把折扣也考虑进去。之后还需要修改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()

修改后的视图会先从cart对象中拿到coupon和discount,然后再保存订单。如果没有coupon,则外键默认是null,discount字段是0。

之后我们来修改订单模板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>

修改好以后,生成订单的页面就可以正常展示了,由于这个页面在提交的时候才会生成订单,所以这里边的数据都直接使用了cart对象。

启动站点,添加商品到购物车然后生成订单,可以看到订单页面的价格现在是折扣后的价格了:

还可以到管理站点里查看新生成订单的详情,如下图所示:

第一个大功能就做完了,只要记得优惠码是一个对象,也是数据库中的数据,使用该优惠码会影响购物车的最终结算价格,在此基础上编写相关功能就可以了。大型电商网站的优惠券和优惠码比例子复杂很多,但其本质是相同的。

这里有一个问题是,当用户提交了订单之后,此时购物车已经清空,如果返回商店再向购物车内添加内容,进去之后可以发现默认就使用了上次使用的优惠券。这个逻辑不太对,原因是作者把优惠券信息附加到了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})

国际化与本地化

Django对于国际化和本地化提供了完整的支持,允许开发者将站点内容翻译成多种语言,而且可以处理本地化的时间日期数字和时区格式等。在开始之前,先需要区分一下国际化(Internationalization,通常缩写为i18n)和本地化(Localization,缩写为l10n)两个概念。

国际化和本地化都是一种软件开发过程。国际化设计的软件,在应用于不同语言和地区的时候,不需要进行项目工程上的变更(修改业务逻辑代码)。本地化是指对国际化的软件将其变成适合某一个国家或地区使用的软件的过程,这个过程包含将原来文件翻译成指定语言,附加针对性的组件等等。

Django有一个国际化框架,可以翻译成多于50种语言。

Django 国际化框架简介与相关准备

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

一旦完成翻译,这个消息文件就会被编译以快速访问翻译后的字符串,编译后的消息文件的后缀名是.mo。

国际化和本地化设置

Django在settings.py内提供了一系列与国际化和本地化相关的设置:

设置 说明
USE_I18N 布尔值,是否启用国际化功能,默认为True
USE_L10N 布尔值,是否启用本地化功能,默认为False,启用后日期和数字的显示会本地化。
USE_TZ 布尔值,设置是否日期和时间与时区相关。如果采用startproject启动项目,默认设置为True
LANGUAGE_CODE 字符串值,表示具体的区域语言。例如美国英语是'en-us',这个设置只有在USE_I18N设置为True的时候才有作用。Unicode下的国际语言代码看这里
LANGUAGES 值为一个元组,每个元素也是一个两元元组,分别由语言代码和语言名称组成。所有可用语言可以在django.conf.global_settings模块内找到。
LOCALE_PATHS 一个列表,里边存放所有消息文件的路径
TIME_ZONE 字符串值,代表项目使用的时区。在使用startproject启动项目是,该值被设置为'UTC'。可以按照大洲/城市的方式设置,例如"Asia/Shanghai"。

以上是部分常用的国际化和本地化设置,其他的设置参考官方文档

国际化和本地化管理命令

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

  • makemessages:运行该命令,会找到项目中所有标注要翻译的字符串,建立或者更新locale目录下的.po文件,对于settings.py中的配置,每种语言会生成单独的.po文件。
  • compliemessages:编译所有的.po文件为.mo文件。

之前提到过,需要使用GNU gettext工具来执行上述过程,大部分linux发行版自带有该工具。如果在使用mac OSX,可以通过 http://brew.sh/ 使用命令 brew install gettext来安装,之后使用 brew link gettext --force强制链接。对于Windows下的安装,参考django官方文档中的步骤。

为项目增加翻译功能的流程

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

  1. 在Python代码和模板中标注出需要翻译的字符串
  2. 运行makemessages命令建立消息文件
  3. 运行compilemessages命令翻译和编译消息文件

本地语言中间件

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. 如果还没有找到,找HTTP请求头的Accept-Language键
  5. 如果还没有找到,则使用LANGUAGE_CODE设置

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

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

在了解上述关于国际化和本地化的一些知识之后,来为我们的电商网站增添国际化的支持,这次会增添英语和西班牙语的支持。编辑settings.py:

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

LANGUAGE_CODE可能已经存在于settings.py中,修改其值为'en',然后增加LANGUAGES配置,其中每一个元组的第一个元素表示语言代码,第二个元素表示显示出来该语言的名称。通过这个设置,我们定义了我们的网站支持语言是英语和西班牙语。如果不定义这些设置,默认支持所有django支持的语言。

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

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

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

在项目的根目录建立locale目录,在其中再分别建立en和es两个子目录。将该目录设置在 settings.py 中设置为存放翻译文件的路径:

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

LOCALE_PATH可以是一系列路径,最上边的路径优先级最高。当使用makemessages命令的时候,消息文件会在我们创建的locale/目录中创建,然后如果某个应用也有locale/目录,会优先在那个目录中进行创建。

翻译字符串字面量

为了翻译Python代码中的字符串字面量,需要使用 django.utils.translation 库中的 gettext() 方法来标注字符串。这个方法返回翻译后的字符串,通常做法是导入该方法然后命名为一个下划线"_"。

使用标记的示例如下:

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

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

带有变量的翻译

被标注的字符串中还可以带有占位符,类似于Python的字符串.format()方法,举个例子:

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

复数的翻译

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

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

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

翻译Python代码

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

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

这里使用了别名"_"来避免重复导入。将显示的名称也进行了翻译,这样对于不同的语言的人来说,当前页面所显示的使用哪种语言是本地化的。

然后打开shell(Pycharm也可以在 terminal中运行,如果新安装了gettext,需要重启一下Pycharm以让PATH更新) ,运行如下命令:

django-admin makemessages --all

可以看到如下输出:

processing locale en
processing locale es

然后查看项目的locale目录,可以看到在en和es目录下建立了LC_MESSAGES目录,然后其中均有一个django.po文件。然后使用一个文本编辑器打开es/LC_MESSAGES/django.po文件,可以看到文件最后的内容如下:

#: .\myshop\settings.py:107
msgid "English"
msgstr ""

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

这里的每一部分,就类似一个键值对,记录了所有在python代码中发现的被gettext_lazy()作为参数的字符串,msgid就是原始字符串,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目录,就会发现生成了django.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)
    created = models.DateTimeField(auto_now_add=True)
    ......

我们给每个字段加上了给用户显示的字段名称,并且标注了这些名称需要进行翻译,之后在orders应用中也建立locale/目录,在其中建立en和es目录。应用中的翻译会优先存放在我们在应用中建立的locale目录内。

同样,还是到shell 执行:

django-admin makemessages --all

输出为:

processing locale es
processing locale en

然后到目录中去看一看,生成了django.po文件,打开西班牙语的django.po文件,手工填入对应的西班牙语翻译:

#: 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编辑翻译内容,支持Linux,Windows和macOS X。官网 https://poedit.net/

之后继续翻译我们的项目。OrderCreateForm这个表单类无需翻译,因为它会自动使用Order类中我们刚刚标注翻译的verbose_name。现在我们去翻译cart和coupons应用。

在cart应用的forms.py文件中,导入翻译函数,为CartAddProductForm类的quantity字段增加一个参数label,这个label的内容采用翻译标注,最终这个label在页面上会和input配对展示:

from django.utils.translation import gettext_lazy as _

class CartAddProductForm(forms.Form):
    quantity = forms.TypedChoiceField(choices=PRODUCT_QUANTITY_CHOICES, coerce=int, label=_('Quantity'))
    ......

之后修改coupons应用的forms.py,为CouponApplyForm类的code字段也增加label属性:

from django.utils.translation import gettext_lazy as _


class CouponApplyForm(forms.Form):
    code = forms.CharField(label=_('Coupon'))
#: .\cart\forms.py:9
msgid "Quantity"
msgstr "cantidad"

#: .\coupons\forms.py:6
msgid "Coupon"
msgstr "vale descuento"

然后执行编译,会把项目根目录内和orders应用内的.po文件一并编译:

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

现在Python代码的翻译工作做好了,下一步是翻译模板。

翻译模板

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

使用{% trans %}

这个标签用来标记一个字符串,常量或者变量用于翻译。django内部也是对该文本执行gettext()等翻译函数。标记字符串的方法是:

{% trans "Text to be translated" %}

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

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

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

使用{% blocktrans %}

这个标签可以标记包含常量和占位符的内容用于翻译,会把其中的模板标签替换成实际内容之后进行翻译,例如:

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

这个标签还可以和with一起用,在这个标签的内部设置具体变量的值。这个时候,必须在标签内部使用占位符,不能够再继续访问表达式和对象的属性。例如:

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

其实这两个标签也就和翻译Python代码中带占位符的形式和功能相同。

翻译商店模板

了解了翻译标签的使用,下边就来修改一下shop应用的base.html:

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

{% trans %}标签的翻译都比较直观,直接应用在需要翻译的内容上就可以。

在原来的模板中,我们使用了:

{{ total_items }} item{{ total_items|pluralize }},
${{ cart.get_total_price }}

来显示商品数量和总价。由于要对这一段进行翻译,则其中不能够访问属性和方法(模板filter也是函数),必须将显示的内容全部换成占位符,所以在{% blocktrans %}中定义了如下关系:

  • total_items|pluralize ---> total_items_plural
  • cart.get_total_price ---> total_price

完成了base.html的翻译之后,再到shop/product/detail.html中进行修改:

{% extends "shop/base.html" %}
{% load i18n %}
{% load static %}
{% block title %}
    {{ product.name }}
{% endblock %}
{% block content %}
    <div class="product-detail">
        <img src="{% if product.image %}{{ product.image.url }}{% else %}
{% static "img/no_image.png" %}{% endif %}">
        <h1>{{ product.name }}</h1>
        <h2><a href="{{ product.category.get_absolute_url }}">{{ product.category }}</a></h2>
        <p class="price">${{ product.price }}</p>
        <form action="{% url 'cart:cart_add' product.id %}" method="post">
            {{ cart_product_form }}
            {% csrf_token %}
            <input type="submit" value="{% trans "Add to cart" %}">
        </form>
        {{ product.description|linebreaks }}
    </div>
{% endblock %}

注意如果使用{% extends %},则该句必须是模板的第一行,所以{% load i18n %}只能写在第二行。这里的修改很简单,继续修改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,此时我们没有在orders应用以外的应用内建立locale/目录,新增的这些翻译内容都会被追加到项目根目录/locale/es/django.po中。在新的.po文件内输入对应的西班牙语翻译,也可以直接使用随书的源代码中的.po文件。之后再执行django-admin compilemessages,就可以看到:

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

这样就做好了全部的翻译工作。

使用Rosetta翻译界面

Rosetta(游戏里经常见到的罗塞塔石)是一个第三方应用,使用Django管理后台编辑所有翻译内容,让.po文件的更新变得更加方便,先安装该模块:

pip install django-rosetta==0.8.1

译者在这里安装了0.9版本,之后将rosetta加入到应用中去:

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

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

path('rosetta/', include('rosetta.urls')),

一次又一次的强调新增的具体url匹配路径,都需要在shop.urls上边。

然后启动站点,登录后进入 http://127.0.0.1:8000/rosetta/ ,点击右上的THIRD PARTY,如下图所示:

点开Spanish下边的shop应用,可以看到列出了所有需要翻译的内容:

可以手工编辑任何需要输入对应翻译的地方,对于那些占位符翻译的内容,显示为这样:

结束输入的时候,点击一下Save即可将当前翻译的内容保存到.po文件中,之后就可以执行 compliemessages命令了。Rosetta会直接修改.po文件,注意要给予其相应的权限。

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

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

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

需要检查的翻译 Fuzzy translations

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

对于译者安装的Rosseta 0.9版本,界面与作者的界面有所区别,按照所有.po文件进行列表,而不是按项目,但是其中的操作和旧版本没有任何区别。

国际化URL

如果在之前使用了随书提供的源代码,可以发现其中的翻译内容除了之前我们标注的字符串外,还有一些路径,这是因为Django还提供了国际化URL的方式。

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

  • Language prefix in URL patterns 语言前缀URL模式:在URL的前边加上不同的语言前缀构成不同的基础URL
  • Translated URL patterns 翻译URL模式:基础URL相同,进行翻译URL展示给用户得到对应不同语言的URL

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

语言前缀URL模式

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

要使用语言前缀URL模式,需要启用LocaleMiddleware中间件。在之前我们已经做过该工作。现在需要修改项目的根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匹配到未经翻译过的URL模式上。

现在启动站点,到 http://127.0.0.1:8000/ ,Django的语言中间件会按照之前介绍的顺序来确定本地语言,然后重定向到带有语言前缀的URL,例如 http://127.0.0.1:8000/en/ ,你也可以尝试通过各种方式改变本地语言的设置,让Django来显示不同的URL。

翻译URL模式

Django 支持在URL模式中翻译字符串。就像我们之前翻译字面量和模板一样,针对不同的语言,作出不同的翻译配置即可。在urls.py中,同样使用ugettext_lazy()来标注字符串。继续修改项目的根urls.py:

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 ,之后到Rosetta中查看,就会发现所有标注的路径,也都添加到了.po文件中可供编辑。

允许用户切换语言

在之前的工作中,我们配置好了西班牙语下的翻译内容,准备好了URL的两种模式,剩下的一大问题就是给用户提供切换语言的选项,准备给网站增加一个语言选择器,列出支持的语言,显示为一系列链接。

编辑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 %}标签用于从settings里获取所有可用的支持语言
  4. {% get_language_info_list %}是为了快速获取语言的属性而设置的变量
  5. 用循环列出了所有可支持的语言,对于当前语言设置CSS类为SELECT

启动站点到 http://127.0.0.1:8000/ ,可以看到页面如下:

页面右上角出现了语言选择器,其链接的URL就是语言代码例如 /en/, /es/等,由于我们之前设置好了前缀URL,就会自动引导到那个语言的网站。我们已经完成了Order类字段的翻译,切换到西班牙语,然后在创建订单页面就可以看到西班牙语的页面:

使用django-parler翻译模型

翻译模型是网站国际化的一个重要内容。类似我们的购物网站,对于商品的介绍有些时候可以不翻译,但是表单字段一般代表着与用户的交互,涉及到实际操作,比如付款和订单主要使用的都是通过模型生成的表单。如果能够方便快捷的国际化项目中的所有模型,站点的国际化就前进了一大步。遗憾的是django对于快捷的翻译模型字段没有提供好的方法,只能自己实现或者借助于第三方软件来翻译模型。

有若干第三方软件可以实现这个功能,django-parler是其中一个,这个模块提供了高效翻译和平稳集成到django 管理后台的功能。

django-parler的工作原理是为每个模型建立一个对应的翻译数据表,表内通过外键连到需要翻译的模型,存储该模型的字段与翻译之间的关系,表内每行存储一种语言的翻译,所以还有一个language字段,用于标记是何种语言。

安装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,
    }
}

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

翻译模型的字段

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

# 新增导入:
from parler.models import TranslatableModel, TranslatedFields

# 修改Category类让其继承TranlatableModel:
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类,原有的字段现在成为了TranslatedFields方法的属性。

知道了这个套路,再来编辑同一个文件内的Product类:

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

由于一些字段不用翻译,这里我们仅把name, slug 和 description 加入TranslatedFields。

这个时候如果使用IDE,可能会注意到IDE提示self.name找不到,这个对于实际运行程序没有影响,该字段依然可用。

这么设置完成之后,如果运行代码,django-parler会对每个模型生成一个新的模型也就是数据表,下图是Product类与其新生成的ProductTranslation类之间的关系:

可见生成的ProductTranslation类包含name, slug, description, 一个language_code字段,一个外键连接到Product类,Product和ProductTranslation是一对多的关系,针对一个Product对象,会按照每种语言生成一个对应的ProductTranslation对象。

注意,由于翻译的部分和原始的类是独立的两个表格,因此一些类的功能不能够使用,比如不能在Product类中使用一个翻译后的字段进行排序,即不能在Meta类的ordering属性中使用翻译的字段。

所以编辑Category类,注释掉按照name排列那一行:

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

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

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

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

关于django-parler的兼容性,可以在这里查看。

把经翻译的类通过django-parler集成到管理后台

django-parler易于集成到django管理后台中,包含一个 TranslatableAdmin 类代替了原来的 ModelAdmin 类。编辑shop应用的admin.py,导入该类:

from parler.admin import TranslatableAdmin

之后我们要让 CategoryAdmin 和 ProductAdmin 继承 TranslatableAdmin 而不是 ModelAdmin类,django-parler不支持prepopulated_fields属性,但支持相同功能的get_prepopulated_fields()方法,因此需要对两个类修改:

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

建立django-parler所需的数据表

打开shell执行(注意,在Pycharm中通过Tools->Run manage.py task是不行的,会报路径错误,必须到Pycharm的terminal或者系统的cmd内执行):

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

可以看到建立了两个表CategoryTranslation和ProductTranslation,但是把需要翻译的字段从模型中删除了(作者不早点说)。这意味着这几个字段的数据全都丢失了,必须启动站点后重新录入。

之后命令行内输入:

python manage.py migrate shop

看到 Applying shop.0002_translations... OK 就表示成功了。启动站点 到 http://127.0.0.1:8000/en/admin/shop/category/ 可以看到已经存在的商品品类因为删除了name和slug,所以变成了空的。随便点一个进去,可以看到带有了多语言的界面:

这里需要把英语的所有字段和西班牙语对应的翻译全部自己补充好,然后点击SAVE按钮。之后到 http://127.0.0.1:8000/en/admin/shop/product/ 进行同样的工作:补充每个商品的名称和slug以及西班牙语翻译。

为翻译建立视图

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

>>> 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类,然后导入了django的activate方法,将当前环境语言设置为es。这个时候去查询库里的第一个商品,得到西班牙语名称。语言设置为en重新查询,就得到了英文名称。使用管理器显然比较方便,可以通过设置管理器的属性得到不同语言的结果,类似这样:

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

如果需要使用filter功能,由于我们的Product类里没有了name等字段,需要使用tranlations__name,前边的这个translations名称就是之前执行 python manage.py makemigrations shop --name "translations" 中定义的名称。例子如下:

>>> Product.objects.filter(translations__name='Green tea')
<TranslatableQuerySet [<Product: Té verde>]>

知道了这些基础操作,就可以来修改我们自己的视图中的查询方法了,修改shop应用中的views.py,找到product_list视图中利用slug查询的那一行:

category = get_object_or_404(Category, slug=category_slug)

替换成如下内容:

def product_list(request, category_slug=None):
    category = None
    categories = Category.objects.all()
    products = Product.objects.filter(available=True)
    if category_slug:
        language = request.LANGUAGE_CODE
        category = get_object_or_404(Category, translations__language_code=language, translations__slug=category_slug)

找到product_detail视图中查询那一行:

product = get_object_or_404(Product, id=id, slug=slug, available=True)

替换成如下内容:

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)

之后启动站点,到 http://127.0.0.1:8000/es/ ,应该可以看到商品名称全部都变成了西班牙语:

如果点到商品详情页,可以看到具体内容都变成了西班牙语,商品的URL也会随着语言而变化,如下图所示:

通过django-parler可以比较快捷的翻译模型,略有不足就是会对模型的字段进行删除,如果站点需要国际化,则最好在一开始的时候就集成该模块,避免之后的数据修改。

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

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

本地格式化工作

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

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

通常情况下,只要设置USE_L10N为True,Django就会通过读取当前的LANGUAGE设置来应用对应的格式。然而,站点内可能有些内容并不想使用本地化格式,尤其那些标准数据例如代码或者是JSON字符串的内容。

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

{% load l10n %}
{% localize on %}
    {{ value }}
{% endlocalize %}
{% localize off %}
    {{ value }}
{% endlocalize %}

Django还提供了两个模板filter用于控制本地化,分别是localize和unlocailze,用来强制让一个值开启/关闭本地化显示。要使用这两个filter也必须在模板中加入{% load l10n %},用法如下:

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

除了这两个方法之外,还可以采取自定义格式文件方式,具体看官方文档

用django-localflavor验证表单字段

与本地化工作相关的一个问题是表单验证。我们的表单验证一般都是基于英语的正则表达式验证的,如果遇到各种本地化的输入和特殊字符,有可能会出问题,常见的比如电话,邮编,地址等,都可能会出问题。django-localflavor是一个第三方模块,包含一系列特别针对本地化验证的工具,比如为每个国家单独设计的表单和模型字段,对于验证某些国家的地区,电话号码,身份证,社会保险号码等非常方便。这个模块是按照ISO 3166 国家代码的标准组织的。

安装django-localflavor:

pip install django-localflavor==2.0

译者在这里安装了2.1版,在settings.py中激活该应用:

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

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

编辑orders应用的forms.py:

# 新增导入:
from localflavor.us.forms import USZipCodeField

# 为OrderCreateForm类增加一个新的字段:
class OrderCreateForm(forms.ModelForm):
    postal_code = USZipCodeField()
    class Meta:
        model = Order
        fields = ['first_name', 'last_name', 'email', 'address', 'postal_code', 'city']

重写了Order类中的postal_code字段,将其设置为导入的USZipCodeField类型的字段。

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

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

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

现在就结束了所有国际化和本地化配置的工作,这大概是目前到现在最漫长的一个章节,经过大量的配置和使用第三方模块,终于将站点变成支持多语言的网站。因此可见,将一个站点国际化并针对不同的地区做本地化配置,需要在站点设计阶段就考虑。

建立商品推荐系统

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

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

  • 首先是商品详情页。如果用户对一个商品感兴趣,商品详情页是他停留时间最长的页面。我们会在此展示一些与当前商品一起购买的商品。展示的文字类似:Users who bought this also bought X, Y, Z. 所以我们需要一个数据结构来存放所有与该商品一同购买的商品及次数来进行排名。
  • 其次是购物车详情页。这个时候我们对于商品的排名计算方式需要变更一下,应该将不同商品与购物车中所有商品的关联购买次数进行求和再进行排名。

还记得吗,针对这种动态的排名。常用的后端数据库是Redis,如果还没有安装Redis,安装过程详见第二个项目的追踪用户行为章节。

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

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

安装python 的redis模块

pip install redis==2.10.6

redis模块是开始翻译本书时,第一个在本书中使用但尚未有最新版本的模块。

之后在settings.py里配置Redis:

REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 1

Redis为什么默认端口号是6379,这还得看作者本人的解释

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

    # 给一个商品ID,返回Redis 中该商品的键名
    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)

我们用这个类来储存数据和取出一个指定的商品相关的推荐。

get_product_key方法获取一个Product对象的id,返回一个键,键是这样的:product:[id]:purchased_with。

关于键为什么长这样,这是使用Redis数据库常用的命名方式,有兴趣可以找一本专门讲解Redis的书一看便知。

product_bought()方法接受属于同一个订单的Product对象的列表,然后做如下操作:

  1. 生成所有商品的id的列表
  2. 针对每一个id,遍历一次全部的id(连续对同一个对象进行了两层迭代),跳过内外循环id相同的部分,这样就针对其中每个商品都遍历了与其一同购买的商品
  3. 针对每一个id在遍历其所有一同购买的商品时,取得该id对应的键
  4. 在键对应的有序序列中把一同购买的商品id键对应的值增加1

还需要一个方法来从Redis中获得推荐的商品,继续编写该类,增加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()方法接受两个参数,products是订单里的所有商品,max_results表示返回几个推荐结果。在这个方法里我们做了如下的事情:

  1. 取得当前Products中所有商品的id列表product_ids
  2. 判断product_ids的长度,如果为1,说明用户订单就买了一个东西,这个时候直接查询这个id对应的有序集合,按降序返回结果即可。
  3. 如果长度大于1,比如买个两个商品A和B,需要把A对应的有序集合和B对应的有序集合里边的内容相加,然后再返回结果。等于要拼一个临时使用的有序集合出来,所以使用了订单内所有id连起来构成的一个临时key当做这个有序集合的键名。
  4. 合并所有商品的有序集合:先取所有商品的键名构成keys列表(每个键的值都是一个有序集合),然后调用ZUNIONSTORE命令,合并所有的有序集合中相同键的值,然后将新生成的有序集合存入tmp_key键。关于ZUNIONSTORE可以参考这里
  5. 由于已经在当前购物车内的商品无需被推荐,因此使用ZREM从临时键的有序集合中删除与当前订单内商品id相同的键,使用max_results参数控制结果数量。采用了Python拆解列表的方式传参数。
  6. 从经过上一步处理的有序集合中内查询到排名结果对应的商品id字符串列表,之后删除临时键。
  7. 最后是对id字符串列表转int,根据id取查询结果然后生成列表,按照原来的顺序排序等操作,得到最终的结果列表。

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

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 %},原书这里没有加,会导致报错。

这段代码的逻辑和商品详情页的几乎相同,都是展示结果。启动站点,将一些商品加入购物车,可以看到出现了推荐商品:

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

注意,作者这里其实没有将功能写完。可以发现,向Redis写入商品购买数据(调用recommend类的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)

总结

真是漫长的一章(在翻译和填完了作者的各种坑之后,不知道为什么我想起了“千奇百怪的漫长旅行”这个成就),本章先建立了一个优惠码系统,没有采用新的第三方模块,而是重在介绍了优惠码的原理,通过session和在订单中保存优惠码数据,并修改了视图计算总价的方法实现了优惠码系统。

之后是有关国际化和本地化的内容,国际化和本地化的内容因为涉及到网站的很多方面,看起来比较琐碎,但只要抓住国际化与本地化设置--对模型,Python代码中的常量,模板和URL分别进行翻译设置--添加语言切换器--设置本地化格式 这样一个逻辑流程就可以将整个网站顺利的变更为支持多语言的国际化网站,还带有本地化格式功能。

最后一部分是一个商品推荐系统,按照关联商品一起购买的次数进行推荐,强化了Redis的使用。

完成这些以后,我们的电商网站的核心功能都具备了。

LICENSED UNDER CC BY-NC-SA 4.0
Comment