django by example 实践 myshop 项目(三)


点我查看本文集的说明及目录。


本项目相关内容包括:

实现过程

CH7 创建在线商店

CH8 管理支付和订单

CH9 扩展商店


CH9 扩展商店


上一章,我们学习了如何在商店中集成支付网关,管理支付消息以及如何生成 CSV 和 PDF 文件。本章,我们将为商店添加折扣系统,学习国际化和本地化如何工作,以及如何创建推荐引擎。

本章将包括以下知识点:

  • 创建折扣系统来实现折扣;
  • 项目实现国际化功能;
  • 使用 Rosetta 管理翻译;
  • 使用 django-parler 翻译模型;
  • 创建商品推荐引擎。

创建一个折扣系统


许多在线商店为用户提供优惠券,优惠券可以在用户购买商品时打折。在线优惠券通常是提供给用户的优惠码,这个优惠码在特定时间范围内有效。优惠券可以使用一个或多次。

我们将为商店创建一个折扣系统,使用特定时间段内有效的优惠码作为优惠券。优惠码不受次数限制,将在购物车的总价中实现折扣。为了实现这个功能,我们需要创建一个模型来保存优惠码、有效时间段和折扣。

使用以下命令在 myshop 项目中新建一个应用:

python manage.py startapp coupons

编辑 myshop 的 settings.py 文件,将刚刚创建的应用添加到 INSTALLED_APPS 中:

INSTALLED_APPS = ['django.contrib.admin', 'django.contrib.auth',
                  'django.contrib.contenttypes', 'django.contrib.sessions',
                  'django.contrib.messages', 'django.contrib.staticfiles',
                  'shop', 'cart', 'orders', 'paypal.standard.ipn',
                  'payment.apps.PaymentConfig', 'coupons']

项目已经激活了新的应用。

创建折扣模型


我们首先创建 Coupon模型,编辑 coupons 应用的 models.py 文件并添加以下代码:

from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models


# Create your models here.

class Coupon(models.Model):
    code = models.CharField(max_length=50, unique=True)
    valid_from = models.DateTimeField()
    valid_to = models.DateTimeField()
    discount = models.IntegerField(
        validators=[MinValueValidator(0), MaxValueValidator(100)])
    active = models.BooleanField()

    def __str__(self):
        return self.code

这是存储优惠券的模型,Coupon 模型包含以下字段:

  • code:用于获得折扣的优惠码;

  • valid_from:优惠码有效的开始时间;

  • valid_to:优惠码有效的结束时间;

  • discount:应用的折扣(百分比,数值为0 到 100)。我们为这个字段设置了最大值和最小值验证。

  • active:表示优惠券是否被激活的布尔值。

运行以下命令生成 coupons 应用的初始迁移文件:

python manage.py makemigrations

输出应该包含以下内容:

Migrations for 'coupons':
  coupons/migrations/0001_initial.py
- Create model Coupon

然后运行下面的命令实现迁移:

python manage.py migrate

你应该可以看到输出包括以下内容:

Operations to perform:
  Apply all migrations: admin, auth, contenttypes, coupons, ipn, orders, sessions, shop
Running migrations:
  Applying coupons.0001_initial... OK

迁移现在已经应用到了数据库,下面将 Coupons 模型添加到 admin 网站。编辑 coupons 应用的 admin.py 文件并添加以下代码:

from django.contrib import admin

from .models import Coupon


# Register your models here.

class CouponAdmin(admin.ModelAdmin):
    list_display = ['code', 'valid_from', 'valid_to', 'discount', 'active']
    list_editable = ['active', 'valid_from', 'valid_to']
    search_fields = ['code']


admin.site.register(Coupon, CouponAdmin) 

Coupon 模型现在注册到 admin 网站了。使用 python manage.py runserver 运行开发服务器。

在浏览器中打开 http://127.0.0.1:8000/admin/coupons/coupon/add/ ,你应该可以看到这样的表单:

CH9-1.png

填写表单新建一条当前时间有效的优惠券,确保选中 Active 选择框,并点击 Save 按钮。

将折扣应用到购物车


我们已经可以保存新的优惠券以及查询已有的优惠券。现在需要用户购物时能够应用优惠券,想一想该如何实现这个功能呢?应用优惠券的流程应该是这样的:

  1. 用户将商品添加到购物车中;
  2. 用户在购物车详情页面的一个表单中输入优惠码;
  3. 用户输入优惠码并提交表单后,后台查询输入的优惠码是否有效。需要检查用户输入的优惠码是否存在,它的 active 属性是否为 True 以及当前的时间是否在 valid_from 和 valid_to 之间。
  4. 如果找到了优惠券,我们将它保存到用户会话中并显示商品打折后的价格并更新总价。
  5. 当用户提交订单时,我们将优惠券保存到订单中。

在 coupons 应用目录下新建一个名为 forms.py 文件并添加以下代码:

from django import forms


class CouponApplyForm(forms.Form):
    code = forms.CharField()

这是用户输入优惠码的表单。编辑 coupons 应用的 views.py 文件并添加以下代码:

from django.shortcuts import redirect
from django.utils import timezone
from django.views.decorators.http import require_POST

from .forms import CouponApplyForm
from .models import Coupon


# Create your views here.
@require_POST
def coupon_apply(request):
    now = timezone.now()
    form = CouponApplyForm(request.POST)
    if form.is_valid():
        code = form.cleaned_data['code']
        try:
            coupon = Coupon.objects.get(code__iexact=code, valid_from__lte=now,
                                        valid_to__gte=now, active=True)
            request.session['coupon_id'] = coupon.id
        except Coupon.DoesNotExist:
            request.session['coupon_id'] = None
    return redirect('cart:cart_detail')

coupon_apply 视图对优惠码进行验证并保存到用户会话中。我们对视图使用 require_POST 装饰器来限制视图只能用于 POST 请求。在视图中,我们实现了以下任务:

  1. 使用 post 数据实例化一个 CouponApplyForm 表单并检查表单是否有效。

  2. 如果表单有效,我们从表单的 cleaned_data 属性中获取用户输入的 code 。尝试获取 code 对应的 Coupon 对象。我们使用 iexact 字段事先大小写敏感的准确匹配。优惠码还需要处于激活状态(active=True)并且在有效时间段内。使用 Django 的timezone.now() 函数可以获得包含时区的当前时间并与 valid_from 和 valid_to 字段进行对比。

  3. 将优惠码的 id 保存到用户会话中;

  4. 重定向到 cart_detail URL 展示应用优惠码后的购物车。

我们需要为 coupon_apply 视图设置一个 URL 模式。在 Coupons 应用目录下新建一个 urls.py 的文件。并添加以下代码:

from django.conf.urls import url

from . import views

app_name = 'coupons'
urlpatterns = [url(r'^apply/$', views.coupon_apply, name='apply')]

然后,编辑 myshop 项目的 urls.py 文件并包括 coupons 的 URL 模式:

url(r'coupons/',include('coupons.urls')),

记得放在 shop.urls 模式之前。

现在,编辑 cart 应用的 cart.py 文件,导入以下模型:

from coupons.models import Coupon

在 Cart 类的__init__方法中根据当前会话初始化优惠码:

# store current applied coupon
self.coupon_id = self.session.get('coupon_id')

在代码中,我们尝试从当前会话(session)中获取 coupon_id 的值,并将其保存到 Cart 对象中,为 Cart 对象添加以下方法:

@property
def coupon(self):
    if self.coupon_id:
        return Coupon.objects.get(id=self.coupon_id)
    return None

def get_discount(self):
    if self.coupon:
        return (self.coupon.discount / Decimal(
            '100')) * self.get_total_price()
    return Decimal('0')

def get_total_price_after_discount(self):
    return self.get_total_price() - self.get_discount() 

这些方法是:

  • coupon(): 这个方法定义为属性。如果 cart 包含一个 coupon_id 属性,返回特定 id 的 Coupon 对象;

  • get_discount():如果 cart 包含 coupon ,获取它的折扣并返回应该在总价中减去的价格;

  • get_total_price_after_discount():返回减去 get_discount() 计算得到的折扣以后购物车中商品的总价。

Cart 类现在可以处理当前会话中的优惠码并实现相应折扣了。

我们将折扣系统应用到购物车详情视图,编辑 cart 应用的 views.py 文件并在文件头部添加以下导入:

from coupons.forms import CouponApplyForm

然后,编辑 cart_detail 视图并如下所示添加新的表单:

def cart_detail(request):
    cart = Cart(request)
    for item in cart:
        item['update_quantity_form'] = CartAddProductForm(
            initial={'quantity': item['quantity'], 'update': True})
    coupon_apply_form = CouponApplyForm()
    return render(request, 'cart/detail.html',
                  {'cart': cart, 'coupon_apply_form': coupon_apply_form})

编辑 cart 应用的 cart/detail.html 模板并找到以下行:

<tr class="total">
    <td>Total</td>
    <td colspan="4"></td>
    <td class="num">${{ cart.get_total_price }}</td>
</tr>

用下面的代码代替上面的代码:

{% if cart.coupon %}
    <tr class="subtotal">
        <td>Subtotal</td>
        <td colspan="4"></td>
        <td class="num">${{ cart.get_total_price }}</td>
    </tr>
    <tr>
        <td>
            "{{ cart.coupon.code }}" coupon
            ({{ cart.coupon.discount }}% off)
        </td>
        <td colspan="4"></td>
        <td class="num neg">
            - ${{ cart.get_discount|floatformat:"2" }}
        </td>
    </tr>
{% endif %}
<tr class="total">
    <td>Total</td>
    <td colspan="4"></td>
    <td class="num">
        ${{ cart.get_total_price_after_discount|floatformat:"2" }}
    </td>
</tr>

这是展示可选的优惠券及其折扣的代码。如果购物车包含优惠码,在第一行显示购物车中的商品总价,然后在第二行显示购车车应用的折扣,最后通过调用 cart 对象的 get_total_price_after_discount() 方法显示折扣后的价格。

在同一文件中,在 </table> 之后添加以下代码:

<p>Apply a coupon:</p>
<form action="{% url "coupons:apply" %}" method="post">
    {{ coupon_apply_form }}
    <input type="submit" value="Apply">
    {% csrf_token %}
</form>

这是用于输入应用到当前购物车的优惠码的表单。

在浏览器中打开 http://127.0.0.1:8000/,向购物车中添加一件商品,通过在表单中输入优惠码应用你创建的优惠券,我们应该可以看到这样的折扣信息:

CH9-2.png

接下来为购物过程的下一步添加优惠码,编辑 order 应用的 orders/order/create.html 模板并找到下面几行:

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

将其替换为:

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

如果有折扣,那么订单总价现在应该应用折扣。

现在找到下面一行:

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

将其替换为:

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

这样,总价也会计入相应的折扣。

在浏览器中打开 http://127.0.0.1:8000/orders/create/。你应该可以看到应用折扣后的订单汇总:

CH9-3.png

用户现在可以在购物车中使用优惠券了。然后,我们还需要用户结算时创建的订单中保存优惠券信息。

将折扣应用到订单

我们将保存订单使用的优惠券。首先需要更改 Order 模型,在有优惠券的情况下存储对应的 Coupon 对象。

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

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

然后,为 Order 模型添加下面的字段:

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

这些字段将用于存储订单的优惠券和优惠券的折扣。折扣存储在相关的 Coupon 模型中,但是我们在 Order 模型中也保存了折扣,以免优惠券更改或者删除。

由于更改了 Order 模型,我们需要创建迁移文件,在命令行运行以下代码:

python manage.py makemigrations

你应该可以看到这样的输出:

Migrations for 'orders':
  orders/migrations/0002_auto_20180226_0233.py
- Add field coupon to order
- Add field discount to order

使用以下命令运行迁移文件:

python manage.py migrate orders

你应该可以看到迁移文件的信息。Order 模型的更改现在已经同步到数据库了。

回到 models.py 文件并更改 Order 模型的 get_total_cost 方法:


def get_total_cost(self):
    total_cost = sum(item.get_cost() for item in self.items.all())
    return total_cost - total_cost * (self.discount / Decimal('100'))

笔者注:

这里需要导入 Decimal :

from decimal import Decimal

如果有折扣,Order 模型的 get_total_cost() 方法将会计入折扣。

编辑 orders 应用的 views.py 文件并更改 order_create 视图,在创建新订单时保存对应的优惠券及其折扣。找到下面一行:

order = form.save()

使用以下代码代替:

order = form.save(commit=False)
if cart.coupon:
    order.coupon = cart.coupon
    order.discount = cart.coupon.discount
order.save()

在新的代码中,我们使用 OrderCreateForm 表单的 save() 方法创建了 Order 对象。通过设置 commit=False 避免将数据保存到数据库中。如果购物车中包含优惠券,将保存对应的优惠券及其折扣,然后将 order 对象保存到数据库中。

使用 python manage.py runserver 命令运行开发服务器,使用以下命令运行 Ngrok :

./ngrok http 8000

在浏览器中打开 Ngrok 提供的 URL 并使用创建的优惠券完成一次购买。当完成一次成功的购买之后,打开 http://127.0.0.1:8000/admin/orders/order/ 并检查订单对象包含优惠券和折扣:

CH9-4.png

我们还可以使用相同的办法更改 admin 订单详情模板和 PDF 账单来显示使用的优惠券。

下一步,我们将为项目添加国际化。

添加国际化和本地化

Django 提供国际化和本地化支持。它可以将应用转换成多种语言,还可以处理本地日期、时间、数字和时区。国际化(经常缩写为 i18n ) 是针对潜在用于不同语言和地区的软件进行调整的过程,因而不能设置为特定的语言或本地化。本地化 (缩写为 l10n)是将软件调整为特定语言环境的过程。 Django 可以使用自己的国际化框架转换 50 多种语言。

Django 国际化

国际化框架帮助用户在 Python 代码和模板中轻松地标记字符串。它依赖 GNU gettext工具生成和管理消息文件。消息文件是一种语言的纯文本文件。它包含应用中的部分或全部转换字符串及字符串在某种语言的对应转换。消息文件使用 .po 后缀。

转换完成后,消息文件将被编译以便快速提供转换字符串。编译后的文件使用 .mo 后缀。

国际化和本地化设置

Django 为国际化提供几项设置,下面的是最常用到的设置:

  • USE_I18N:表示是否启用 Django 转换系统的布尔值。默认设置为 True。

  • USE_L10N:表示是否启用本地化格式的布尔值。激活时,将使用本地化格式表示日期和数字,默认设置为 False。

  • USE_TZ: 表示时间是否使用时区的布尔值,当使用 startproject 命令新建项目时,这个值设为 True。

  • LANGUAGE_CODE :项目默认使用的语言。使用标准语言 ID 格式,比如,'en-us' 表示美式英语,'en-gb' 表示英式英语。这个设置要求 USE_I18N 设置为 True。你可以从下面的链接找到有效的语言 ID 列表:http://www.i18nguy.com/unicode/language-identifiers.html

  • LANGUAGES:项目可选语言的元组,格式是语言代码和语言名称的二元组。我们可以在 django.conf.global_settings 中找到可选的语言列表。选定网站使用的语言后,将 LANGUAGES 设置为这些语言的列表。

  • LOCAL_PATHS : Django 查找项目转换信息文件的目录列表。

  • TIME_ZONE :项目所在时区的字符串。使用 startproject 创建项目时设置为 'UTC'。你可以将它设置为其它时区,比如 'Europe/Madrid'。

这是一些可用的国际化或本地化设置。可以从以下链接找到全部设置:https://docs.djangoproject.com/en/1.11/ref/settings/#globalization-i18n-l10n

国际化管理命令

Django 使用 manage.py 或 django-admin 工具通过以下命令对翻译进行管理:

  • makemessages—遍历所有源文件树找到标记翻译的所有字符串,在 locale 目录中创建或者更新 .po 消息文件。每种语言对应一个 .po 文件。

  • compilemessage—将现有的 .po 消息文件编译为获取翻译使用的 .mo 文件。

我们需要 gettext 工具来创建、更新和编译消息文件。大多数 Linux 包含 gettext 工具包。如果使用 Mac OS X,最简单的方法是使用 HomeBrewbrew install gettext 命令进行安装。可能还需要使用 brew link gettext —force 创建强制链接。如果使用 Windows ,查看文档https://docs.djangoproject.com/en/1.11/topics/i18n/translation/#gettext-on-windows

如何为 Django 项目增加翻译

我们来看一下项目国际化的过程。我们需要完成以下工作:

  1. 在 Python 代码和模板中标记需要翻译的字符串;
  2. 运行 makemessages 命令创建或更新包含所有翻译字符串的信息文件;
  3. 使用 compilemessage 命令翻译消息文件中的字符串并比编译。

Django 如何确定当前语言

Django 内置根据请求数据确定当前语言的中间件。 django.middleware.locale 中的 LocaleMiddleware 中间件完成以下任务:

  1. 如果使用 i18_patterns ,也就是说使用翻译的 URL模式,将在请求的 URL 中查找语言前缀来确定当前语言;
  2. 如果没有找到任何语言前缀,将在用户当前 session 中查找 LANGUAGE_SESSION_KEY 。
  3. 如果 session 中没有设置语言,将查找 cookie。这个 cookie 的默认名称为 django_language ,可以使用 LANGUAGE_COOKIE_NAME 自定义 cookie 名称。
  4. 如果没有找到 cookie ,将查找请求的 Accept-Language 标头;
  5. 如果 Accept-Language 标头没有指定语言, Django 使用 LANGUAGE_CODE 中定义的语言。

默认情况下,Django 将使用 LANGUAGE_CODE 设置的语言,除非我们使用 LocaleMiddleware 。上述过程只适用于设置 LocaleMiddleware 中间件的情况。

为项目国际化做准备

我们为项目使用不同的语言做准备。我们将为商店创建英文版和西班牙语版本。编辑项目的 settings.py 文件并添加以下的 LANGUAGES 设置。将其放在 LANGUAGE_CODE 设置的后面:

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

LANGUAGES 为包含语言代码和名称的二元组。语言代码可以本地指定,比如 en-us 或 en-gb ,或使用通用的,比如 en 。通过这个设置,我们指定应用只能使用英语和西班牙语。如果我们不定义自定义 LANGUAGES 设置。网站可以使用 Django 可以翻译的任何语言。

这样设置 LANGUAGE_CODE :

LANGUAGE_CODE = 'en'

在 MIDDLEWARECLASSES 中添加 ‘django.middleware.locale.LocaleMiddleware',确保这个中间件位于 SessionMiddleware 之后,这是因为 LocaleMiddleware 需要使用会话数据。它还需要位于 CommonMiddleware 之前,这是由于 CommonMiddleware 需要使用激活的语言来处理请求的 URL 。现在的 MIDDLEWARECLASSES 看起来应该是这样的:

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.locale.LocaleMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

注意:

中间件的顺序非常重要,因为每个中间件可以依赖上一个中间件设置的数据。 中间件顺序应用于请求,倒序应用于响应。

在项目目录下( manage.py 之后)新建以下目录结构:

CH9-5.png

locale 目录是应用的消息文件存放的位置。再次编辑 settings.py 文件并添加以下设置:

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

LOCALE_PATHS 设置 Django 查找翻译文件的目录。最早出现的 Locale 路径优先级最高。

应用 makemessage 时会将消息文件添加到 locale/ 目录下。然而,对于包含 locale/ 目录的应用,消息文件将包含在应用的 locale/ 目录中。

翻译 Python 代码

在 Python 代码中翻译文字,只需要使用 django.utils.translation 中的 gettext() 函数标记要翻译的字符串。这个函数翻译信息并返回字符串。方便起见,可以为这个函数设置别名 _(下划线) 来使用函数。

我们可以从这个链接找到关于翻译的文档 https://docs.djangoproject.com/en/1.11/topics/i18n/translation/

标准翻译

下面的代码展示了如何为翻译标记字符串:

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

lazy 翻译

Django 为所有的翻译函数提供 lazy 形式(使用_lazy() 后缀)。当使用 lazy 函数时,访问时再对字符串进行翻译,而不在调用函数时进行翻译。如果标记翻译的字符串在加载模块时执行的路径中, lazy 翻译功能就可以派上用场了。

注意:

使用 gettext_lazy() 代替 gettext() ,这样在使用字符串时进行翻译,而不是在调用函数时进行翻译。Django 为所有翻译函数提供 lazy 形式。

包含变量的翻译

标记翻译的字符串可以包含占位符来将变量添加到翻译中。下面的代码是一个带有占位符的例子:

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 代码中进行翻译的基本形式,我们可以在项目中使用了。

翻译你的代码

编辑项目的 settings.py 文件,导入 gettext_lazy() 函数,并按照下面的样子更改 LANGUAGES 设置中的翻译语言名称:

from django.utils.translation import gettext_lazy as _

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

这里,我们使用 gettext_lazy() 函数代替 gettext() 来避免循环导入,使用时再翻译语言名称。打开 shell 并在项目目录运行以下命令:

django-admin makemessages -all

你应该可以看到这样的输出:

processing locale en
processing locale es

查看以下 locale/ 目录,你应该可以看到这样的文件结构:

CH9-6.png

每种语言都创建了 .po 文件。使用文本编辑器打开 es/LC_MESSAGES/django.po,在文件中应该可以看到下面的内容:

#: myshop3/settings.py:84
msgid "English"
msgstr ""

#: myshop3/settings.py:84
msgid "Spanish"
msgstr "" 

每个翻译字符串都包含内容所在文件和行的注释。每个翻译包含两个字符串:

  • msgid : 源码中出现的翻译字符串;

  • msgstr : 语言翻译,默认为空。这是你为给定字符串输入翻译的地方。

为给定 msgid 字符串输入下面的 msgstr 翻译:

#: myshop3/settings.py:84
msgid "English"
msgstr "Inglés"

#: myshop3/settings.py:84
msgid "Spanish"
msgstr "Español"

保存更改的翻译文件,打开 shell ,运行以下命令:

django-admin compilemessages

如果一切顺利,你将看到下面的输出:

processing file django.po in /Users/apple/profile/django_by_example/myshop/myshop3/locale/en/LC_MESSAGES
processing file django.po in /Users/apple/profile/django_by_example/myshop/myshop3/locale/es/LC_MESSAGES

输出给出了编译使用的消息文件。查看一下 myshop 项目的 locale 目录。你应该可以看到下面的文件:

CH9-7.png

可以看到,每种语言生成了编译的消息文件 .mo。

我们已经翻译了语言的名称。现在,我们来网站中展示的翻译模型字段名称,编辑 orders 应用的 models.py 文件并为 Order 模型字段的名称进行如下标记:

from django.utils.translation import gettext_lazy as _

class Order(models.Model):
    first_name = models.CharField(_('first name'), max_length=50)
    last_name = models.CharField(_('last name'), max_length=50)
    email = models.EmailField(_('e-mail'))
    address = models.CharField(_('address'), max_length=250)
    postal_code = models.CharField(_('postal code'), max_length=20)
    city = models.CharField(_('city'), max_length=100)
    created = models.DateTimeField(auto_now_add=True)
    updated = models.DateTimeField(auto_now=True)
    paid = models.BooleanField(default=False)
    coupon = models.ForeignKey(Coupon, related_name='orders', null=True,
                               blank=True)
    discount = models.IntegerField(default=0, validators=[MinValueValidator(0),
                                                          MaxValueValidator(
                                                              100)])

我们为用户下单时使用的字段添加了名称,这些字段包括 first_name、last_name、email、address、postal_code 和 city 。我们可以为名称字段设置 verbose_name 属性。

在 orders 应用中创建下面的目录结构:

CH9-8.png

通过创建 locale 目录,这个应用的翻译字符串将被保存在应用目录下的消息文件中。这样,我们可以为每个应用生成隔离的翻译文件。

打开 shell 在项目目录下运行以下命令:

django-admin makemessages -all

你应该可以看到这样的输出:

processing locale en
processing locale es

使用文本编辑器打开 es/LC_MESSAGES/django.po,我们将看到 Order 模型的翻译字符串,为下面的 msgid 填入相应的 msgstr 翻译:

msgid "first name"
msgstr "nombre"

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

#: orders/models.py:17
msgid "e-mail"
msgstr "e-mail"

#: orders/models.py:18
msgid "address"
msgstr "dirección"

#: orders/models.py:19
msgid "postal code"
msgstr "código postal"

#: orders/models.py:20
msgid "city"
msgstr "ciudad"

添加完翻译后保存文件。

除了文本编辑器,你可以使用 Poedit 编辑翻译。 Poedit 是一个编辑翻译的软件,它也使用 gettext,有 Linux、Windows 和 Mac OS X 版本。你可以在 http://poedit.net/ 下载 Poedit 。

我们来翻译项目的表单。orders 应用的 OrderCreateForm 由于是 ModelForm 并且表单字段标签使用 Order 模型的 verbose_name 属性,因此不用翻译。我们将翻译 cart 和 coupon 应用中的表单。

编辑 cart 应用中的 forms.py 并为 CartAddProductForm 的 quality 字段添加 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 字段添加了标签并为翻译进行了标记。

翻译模板


Django 提供 {% trans %} 和 {% blocktrans %} 模板标签翻译模板中的字符串。为了使用翻译模板标签,我们需要在模板的顶部添加 {% load i18n %} 来加载它们。

{% trans %} 模板标签

{% trans %} 模板标签可以标记翻译的字符串、常量或者变量内容。Django 将在内部为给定文本执行 gettext() 方法。这是在模板中标记要翻译的字符串的方法:

{% trans "Text to be translated" %}

我们可以使用 as 将翻译的内容保存到变量中从而在整个模板中使用。下面的例子在 greeting 变量中保存翻译的文本:

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

{% trans %} 对于简单的字符串翻译非常有用,但是不能处理包含变量的需要翻译的内容。

{% blocktrans %}模板标签

{% blocktrans %}模板标签可以对包含使用占位符表示的字符和变量的内容进行标记。下面的例子表示如何使用 {% blocktrans %} 标签翻译包含 name 变量的内容:

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

我们可以使用 with 来包含模板表达式,比如访问对象属性或对变量应用模板过滤器。我们要对它们应用占位符,不能在 blocktrans 块中访问表达式或对象属性。下面的例子表示如何使用 with 表示应用 capfirst 过滤器的对象属性:

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

注意:

当需要翻译的字符串中包含变量内容时,使用 {% blocktrans %} 代替 {% trans %}。

翻译商店模板

编辑 shop 应用的 shop/base.html 模板。确保在模板顶部加载 i18n 标签并这样标记要翻译的字符串:

{% load static %}
{% load i18n %}
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8"/>
    <title>{% block title %}{% trans 'My shop' %}{% endblock %}</title>
    <link href="{% static "shop/css/base.css" %}" rel="stylesheet">
</head>
<body>
<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>
<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=total_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=total_items|pluralize total_price=cart.get_total_price %}
                        {{ total_items }} item{{ total_items_plural }},
                        ${{ total_price }}
                    {% endblocktrans %}

下一步,编辑 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 应用的 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 }}</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 %}

查看一下本章代码中下面的文件了解如何为标记翻译的字符串:

  • shop 应用: shop/product/list.html 模板

  • orders 应用: orders/order/created.html 模板

  • cart 应用:cart/detail.html 模板

更新消息文件从而包含新的翻译字符串。打开 shell 并运行以下命令:

django-admin makemessages --all

.po 文件位于 myshop 项目的 locale 目录中,你将看到 orders 应用现在包含为翻译标记的所有字符串。

编辑项目和 orders应用中的 .po 翻译文件并添加西班牙语翻译。可以参考本章附带源码中翻译过的 .po 文件。

打开 shell 在项目目录运行以下命令:

cd orders/

django-admin compilemessages

cd ../

我们已经为 orders 应用编译了翻译文件。

运行以下命令,将没有包含 locale 目录的应用的翻译文件添加到项目消息文件中:

django-admin compilemessages

使用 Rosetta 翻译接口

Rosetta 是编辑翻译的第三方应用,它与 Django admin网站使用相同接口。Rosetta 使得翻译 .po 文件更加方便,它还可以更新编译的翻译文件。我们将它集成到项目中。

使用以下命令安装 Rosetta :

pip install django-rosetta

然后,在项目 settings.py 文件中的 INSTALLED_APPS 中添加 ’rosetta' 。

我们需要在主 URL 配置中添加 Rosetta 的 URLs。编辑项目的 urls.py 文件并添加以下 URL 模式:

url(r'^rosetta/',include('rosetta.urls')),

这个 url 应该放在 shop.urls 模式之前避免错误的模式匹配。

打开 http://127.0.0.1:8000/admin/ 并使用超级账户登录。然后,在浏览器中打开 http://127.0.0.1:8000/rosetta/,你应该可以看到这样的已有语言列表:

CH9-9.png

在 Filter 中,点击 All 来展示所有可用的消息文件(包括 order 应用的消息文件)。点击 Spanish 中的 Myshop3 链接编辑西班牙翻译。你应该可以看到这样的翻译字符串列表:

CH9-10.png

我们可以在 Spanish 列中输入翻译字符串,Occurrences 列展示根据翻译字符串找到的文件和代码行。

包含占位符的翻译看起来是这样的:

CH9-11.png

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 文件。保存文件时 Rosetta 将编辑消息文件,因此,这里不再要运行 compilemessage 命令,然而, Rosstta 需要 locale 目录的写权限来写消息文件。

如果希望其它用户能够编辑翻译,可以在浏览器中打开 http://127.0.0.1:8000/admin/auth/group/add 并创建一个 translators 组。然后,访问 https://127.0.0.1:8000/admin/auth/user/ 来编辑想要授予翻译权限的用户。编辑用户时,为每个授予翻译权限的用户的 Permission 一节中的 Chosen Groups 中添加 translators 。只有超级管理员和在 translators 组中的用户才可以使用 Rosetta 。

注意:

向生产环境(使用真正的生产服务器)添加新的翻译时,需要运行完 compilemessage 命令或者使用 Rosetta 保存完翻译后重启服务器来保证更改有效。

模糊翻译

你可能注意到 Rosetta 中包含 Fuzzy 列。这不是 Rosetta 特性,它是 gettext 提供的。如果激活了翻译的 fuzzy 标志位,将会从编辑的消息文件中删除。这个标志位用于需要翻译人员修改的字符串。当使用新的翻译字符串更新 .po 文件时,一些翻译字符串可能会自动标记为 fuzzy 。这种情况发生在 gettext 找到一些稍作修改的 msgid ,gettext 将它与旧翻译进行对比并标记为 fuzzy 以供检查时使用。翻译人员应检查模糊翻译,删除 fuzzy 标志位,并再次编译消息文件。

国际化 URL 模式

Django 的 URLs 可以实现国际化兼容。Django 为国际化 URLs 提供两个特性:

  • language prefix in URL pattern : 为 URL 添加语言前缀实现不同语言使用不同的基础 URL。

  • Translated URL pattern : 为本地化标记 URL 模式,这样每种语言使用的 URL 都不相同。

翻译 URL 的目的在于优化网站以便于搜索引擎使用。通过为模式添加语言前缀,我们将能够为每种语言的 URL 创建索引。而且,将 URL 翻译成不同语言,我们可以使不同语言的 URLs 在搜索引擎中排名更加靠前。

URL 模式添加语言前缀

我们可以为 URL 模式添加语言前缀,例如,英语版本的网站可以使用 /en/ 开头的路径,西班牙语版本的网站可以使用 /es/ 开头的路径。

为了在 URL 模式中使用语言,我们需要项目的settings.py 文件中的 MIDDLEWARE_CLASSES 中包含 django.middleware.locale.LocaleMiddleware 。Django 将使用这个中间件通过请求的 URL 识别当前语言。

我们在 URL 模式中添加语言前缀,编辑 myshop 项目的主 urls.py 文件并添加以下内容:

from django.conf.urls.i18n import i18n_patterns

然后这样使用 i18n_pattern :

urlpatterns = i18n_patterns(url(r'^admin/', admin.site.urls), 
                            url(r'^cart/',include('cart.urls',namespace='cart')),
                            url(r'^orders/', include('orders.urls')),
                            url(r'^paypal/',
                                include('paypal.standard.ipn.urls')),
                            url(r'^payment/', include('payment.urls')),
                            url(r'^coupons/', include('coupons.urls')),
                            url(r'^rosetta/', include('rosetta.urls')),
                            url(r'^', include('shop.urls', namespace='shop')), )

我们可以组合使用 patterns() 和 i18n_patterns() 以便于一部分 URL 使用语言前缀,另一部分不使用。然而,最好只使用翻译的 URLs 以避免需要翻译的 URL 匹配到没有翻译的 URL 模式。

运行开发服务器并在浏览器中打开 http://127.0.0.1:8000 。由于使用了 LocaleMiddleware 前缀,Django 将使用 Django 确定当前语言一节中描述的过程决定当前语言,然后重定向到包含语言前缀的相同 URL 中。在浏览器中查看一下 URL,现在应该是 http://127.0.0.1:8000/en/。当前语言应该是浏览器 Accept-Lauguage 标头设置的英语或者西班牙语,或者使用设置的默认 LANGUAGE_CODE(English)。

翻译 URL 模式

Django 支持翻译 URL 模式中的字符串。 每种语言使用为 URL 使用不同的翻译。使用 gettext_lazy() 为翻译标记 URL 模式。

编辑 myshop3 项目的 urls.py 文件并为 cart、order、payment 和 coupons 应用的 URL 模式的正则表达式添加翻译字符串:

from django.utils.translation import gettext_lazy as _

urlpatterns = i18n_patterns(
                url(r'^admin/', admin.site.urls), 
                url(_(r'^cart/'), include('cart.urls', namespace='cart')),
                url(_(r'^orders/'), include('orders.urls')),
                url(r'^paypal/', include('paypal.standard.ipn.urls')),
                url(_(r'^payment/'), include('payment.urls')),
                url(_(r'^coupons/'), include('coupons.urls')),
                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 _

app_name = 'orders'

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

编辑 payment 应用的 urls.py 文件并将代码更改为:

from django.conf.urls import url
from django.utils.translation import gettext_lazy as _
from . import views

app_name = 'payment'
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 模式而且不包含任何其它字符串,我们不需要为 shop 应用翻译 URL 模式。

打开 shell 并运行下面的命令来使用新的翻译更新消息文件:

django-admin makemessage --all

确保开发服务器正在运行,在浏览器中打开 http://127.0.0.1:8000/en/rosetta 并在 Spanish 一节中点击 Myshop 链接。我们可以使用展示过滤器查找没有翻译的字符串。确保保留 URL 模式中正则表达式中的特殊字符。翻译 URLs 是一项复杂的任务,更改正则表达式很可能会破坏 URL。

允许用户选择语言

现在,我们已经为国际化提供了内容,现在应该为用户提供选择语言的功能。我们将在网站中添加一个语言选择器。语言选择器由可选的语言(使用链接表示)列表构成。

编辑 shop/base.html 模板并找到下面的内容:

<div id="header">
    <a href="/" class="logo">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 %} 模板标签获得 LANGUAGE 设置中定义的语言;

  4. 使用 {% get_language_info_list %} 模板标签便于访问语言属性;

  5. 创建 HTML 列表来展示所有可以获得的语言,并为当前激活的语言添加 selected 类属性。

我们使用基于项目设置的可用语言的 i18n 提供的模板标签。现在在浏览器中打开 http://127.0.0.1:8000 看一下,应该可以在页面的右上角看到语言选择器:

CH9-12.png

用户现在可以很方便的选择语言了。

使用 django-parler 翻译模型

Django 不提供翻译模型的解决方案。 我们必须使用自己的解决方案来管理不同语言存储的内容,或使用第三方模块实现模型翻译。 有几个可用的第三方应用程序,这些第三方应用程序采取不同的方式来存储和访问翻译。 其中一个应用是 django-parler 。 这个模块提供了一种非常有效的翻译模型的方式,并且与 Django 的管理站点进行了很好的集成。

django-parler 为每个包含翻译的模型生成单独的数据表。这个表包含所有翻译的字段、一个外键(指向翻译对象属于的原始对象)以及一个语言字段,每条记录保存一种语言的内容。

安装 django-parler

使用下面的命令安装 django-parler:

pip install django-parler

然后,编辑 项目的 settings.py 文件并将 ‘parler’ 添加到 INSTALLED_APPS 中。在设置文件中添加以下代码:

# django-parler settings
PARLER_LANGUAGES = {
    None: ({'code': 'en', }, {'code': 'es', },),
    'default': {'fallback': 'en', 'hide_untranslated': False, }

这些设置为 django-parler 定义可用的语言 en 和 es 。默认使用 en 并设置 django-parler 不能隐藏未翻译的内容。

翻译模型字段

我们来为产品目录添加翻译。 django-parler 提供 TranslatedModel 模型类以及翻译模型字段的 TranslatedFields 封装。在 shop 应用目录下的 models.py 文件中添加以下代码:

from parler.models import TranslatableModel,TranslatedFields

然后,更改 Category 模型来翻译 name 和 slug 字段。我们还保留着没有翻译的字段:

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,name 和 slug 字段都包含在 TranslatedFields 封装内。

编辑 Product 模型为 name、slug 和 description 字段添加翻译,同时保留没有翻译的字段:

class Product(TranslatableModel):
    category = models.ForeignKey(Category, related_name='products')
    name = models.CharField(max_length=200, db_index=True)
    slug = models.SlugField(max_length=200, db_index=True)
    image = models.ImageField(upload_to='products/%Y/%m/%d', blank=True)
    description = models.TextField(blank=True)
    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))
    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 为每个可以翻译的模型生成另外一个模型。下面的图中,你可以看到 Product 模型和生成的 ProductTranslation 模型:

CH9-13.png

django-parler生成的 ProductTranslation 模型包括 name、slug 和 description 翻译字段,language_code 字段和表示 Product 对象的 master 外键。Product 到 ProductTranslation 存在一个一对多关系。为每个 Product 对象的每种语言设置一个 ProductTranslation 对象。

由于 Django 使用单独的数据库表进行翻译,有些 Django 特性将不可用。不能使用翻译的字段进行排序,可以使用翻译的字段进行过滤,但是不能在 ordering Meta 选项中包含翻译的字段。编辑 shop 应用的 models.py 文件并注释掉 Category Meta 类中的 ordering 属性:

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

我们还需要注释掉 Product 的 Meta 类中的 index_together 属性。由于当前版本的 django-parler 不提供验证。编辑 Product Meta 类:

class Meta:
    ordering = ('name',)
    # index_together = (('id', 'slug'),)

django-parler 更多与 Django 兼容的特性见 http://django-parler.readthedocs.io/en/latest/compatibility.html

创建自定义迁移

使用翻译创建新模型时,我们需要执行 makemigrations 为模型生成迁移文件,然后同步到数据库。然而,设置字段可翻译时,很可能数据库中已经存在数据了。我们将现有数据迁移到新的翻译模型中。这样,既保留了原始字段又添加了翻译字段:

为已有字段添加翻译的步骤如下:

  1. 为新的可翻译字段创建迁移文件,保留原始字段;
  2. 创建自定义迁移将已有字段的数据拷贝到可翻译字段;
  3. 从原始模型中删除已有字段。

运行以下命令来为添加到 Category 和 Product 模型的翻译字段创建迁移文件:

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

笔者注:

运行时出现错误:
File "/Users/apple/.virtualenvs/myshop3_env/lib/python3.6/site-packages/parler/models.py", line 1027, in contribute_translations
raise TypeError("The model '{0}' already has a field named '{1}'".format(shared_model.name, name))
TypeError: The model 'Product' already has a field named 'name'

https://github.com/django-parler/django-parler/issues/154 中有同一问题记录,这里使用记录中解决方案:

  1. 为 translations 中的字段都加后缀_t,这时,Category 模型中的 translations 变为:

    translations = TranslatedFields(
        name_t=models.CharField(max_length=200, db_index=True),
        slug_t=models.SlugField(max_length=200, db_index=True, unique=True))
    

    Product 模型中的 translations 变为:

    translations = TranslatedFields(
        name_t=models.CharField(max_length=200, db_index=True),
        slug_t=models.SlugField(max_length=200, db_index=True),
        description_t=models.TextField(blank=True))
    
  2. 将 migrations 中生成的000X_add_translation_model.py 中的相应内容手动更改为模型中的字段名称:

    migrations.CreateModel(
             name='CategoryTranslation',
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('language_code', models.CharField(db_index=True, max_length=15, verbose_name='Language')),
                 ('name_t', models.CharField(db_index=True, max_length=200)),
                 ('slug_t', models.SlugField(max_length=200, unique=True)),
             ],
             options={
                 'verbose_name': 'category Translation',
                 'db_table': 'shop_category_translation',
                 'db_tablespace': '',
                 'managed': True,
                 'default_permissions': (),
             },
         ),   
    

    中的 name_t、slug_t 更改为 name、slug。

    migrations.CreateModel(
             name='ProductTranslation',
             fields=[
                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                 ('language_code', models.CharField(db_index=True, max_length=15, verbose_name='Language')),
                 ('name_t', models.CharField(db_index=True, max_length=200)),
                 ('slug_t', models.SlugField(max_length=200)),
                 ('description_t', models.TextField(blank=True)),
             ],
             options={
                 'verbose_name': 'product Translation',
                 'db_table': 'shop_product_translation',
                 'db_tablespace': '',
                 'managed': True,
                 'default_permissions': (),
             },
         ),
    

    中的 name_t、slug_t、describe_t 更改为 name、slug、describe。

应该可以看到下面的输出:

 - Create model CategoryTranslation
    - Create model ProductTranslation
    - Change Meta options on category
    - Change Meta options on product
    - 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 on 2018-04-12 04:40
from __future__ import unicode_literals

from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
from django.db import migrations

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, 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 = _get_translation(obj, ModelTranslation)
            for field in fields:
                setattr(obj, field, getattr(translation, field))
            obj.save()


def _get_translation(obj, MyModelTranslation):
    translations = MyModelTranslation.objects.filter(master_id=obj.pk)
    try:
        # Try default translation
        return translations.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. 为了实现迁移,对包含翻译的模型进行迭代处理(使用 apps.get_model 获取模型及对应的翻译模型);

  3. 对数据库所有存在的对象进行迭代处理为项目设置中定义的 LANGUAGE_CODE 创建翻译对象。这里包含一个到原始对象的 ForeignKey 和翻译字段使用的原始字段副本。

反向函数执行反向过程,获取默认翻译对象并将翻译字段的值拷贝回原始对象。

我们已经创建了添加翻译字段的迁移文件,然后将内容从已有字段拷贝到新的翻译字段。

最后,我们需要移除不再需要的原始字段,编辑 shop 应用的 models.py 文件并删除 Categery 模型的 name 和 slug 字段。 现在的 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 模型的 name、slug 和 description 字段。现在 Product 模型看起来是这样的:

class Product(TranslatableModel):
    category = models.ForeignKey(Category, related_name='products')
    image = models.ImageField(upload_to='products/%Y/%m/%d', 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))
    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)

笔者注:

这里有移除了原来添加的 t 后缀。

最后,我们需要创建反应模型改变的最终迁移文件。然而,如果我们尝试运行 manage.py ,将会出现错误。这是因为我们没有为翻译模型调整 admin网站。我们首先来修复 admin网站。

在 admin网站集成翻译

Django-parler 很容易与 Django admin网站集成。它包含覆盖 Django 提供的 ModelAdmin 类的 TranslatableAdmin 来管理模型翻译。

编辑 shop 应用的 admin.py 文件并添加下面的代码:

from parler.admin import TranslatableAdmin

修改 CategoryAdmin 和 ProductAdmin 使其继承 TranslatableAdmin 。Django-parler 不提供 prepopulated_fields 属性,但是提供实现相同功能的 get_prepopulated_fields() 方法,我们来进行相应修改。 admin.py 文件现在应该是这样的:

from django.contrib import admin

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

# Register your models here.

class CategoryAdmin(TranslatableAdmin):
    list_display = ['name', 'slug']

    # prepopulated_fields = {'slug': ('name',)}
    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']

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


admin.site.register(Product, ProductAdmin)

我们已经调整了 admin网站来使用新的翻译模型。我们现在可以使用迁移文件同步数据库了。

为模型翻译应用迁移

我们在调整 admin网站之前移除了模型中的旧字段。现在,我们需要为这些改变创建迁移文件。打开 shell 并运行以下命令:

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

我们应该可以看到这样的输出:

    - 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

你将看到这样的输出:

Operations to perform:
  Apply all migrations: admin, auth, contenttypes, coupons, ipn, orders, sessions, shop, thumbnail
Running migrations:
  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 页面包含两个选项卡,一个为 English 另一个为 Spanish :

CH9-14.png

你现在已经可以添加翻译并点击 Save 按钮了。确保在更改选项页之前进行保存,否则填入的内容将会消失。

为翻译更改视图

我们已经使用翻译的 QuerySets 匹配我们的 shop 视图了。在命令行运行 python manage.py shell 查看一下如何获取和查询翻译字段。获取当前激活语言的一个字段的内容,只需要使用访问普通模型字段的方法访问字段即可:

>>> from shop.models import Product
>>> product = Product.objects.first()
>>> product.name

笔者注:

product.name 输出报错:

Traceback (most recent call last):
  File "<console>", line 1, in <module>
  File "/Users/apple/.virtualenvs/myshop3_env/lib/python3.6/site-packages/parler/fields.py", line 92, in __get__
    translation = instance._get_translated_model(use_fallback=True, meta=meta)
  File "/Users/apple/.virtualenvs/myshop3_env/lib/python3.6/site-packages/parler/models.py", line 467, in _get_translated_model
    raise ValueError(get_null_language_error())
ValueError: language_code can't be null, use translation.activate(..) when accessing translated models outside the request/response loop.

大致意思是 language_code 不能为空,在 request/response 之外访问翻译模型时需要使用 translation.activate(..) 来激活语言。但暂时没找到 Translation 属于哪个模块。因此使用 以下命令继续执行:

>>> product.set_current_language('en')
>>> product.name
'Black Tea'

我们可以为对象设置不同的语言这样可以访问特定的翻译:

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

使用 filter() 实现 Queryset 时,我们可以使用 translations__ 语法对相关的翻译对象进行过滤:

>>> Product.objects.language('en').filter(translations__name='Black Tea')
<TranslatableQuerySet [<Product: Black Tea>]>

笔者注:

这里在 objects 后使用 .language('en') 来指定语言,否则报与上面获取 product.name 时一样的错误。

还可以使用 language() 管理器来设置指定语言:

>>> >>> Product.objects.language('es').all()
<TranslatableQuerySet [<Product: Té negro>]>

我们可以看到,访问和查询翻译字段非常简单。

我们来更改商品目录视图。编辑 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_list 和 product_detail 视图已经可以使用翻译字段获取对象了。现在运行开发服务器并在浏览器中打开 http://127.0.0.1:8000/es/ 。我们应该可以看到商品列表页面都翻译成了西班牙语:

CH9-15.png

现在,每个商品的 URL 都已经使用翻译为当前语言的 slug 字段创建了。例如,一个商品的西班牙与链接为 http://127.0.0.1:8000/es/2/te-rojo/ ,英语链接则为 http://127.0.0.1:8000/es/2/red-tea/ 。如果浏览商品详情页面,你将看到使用当前语言的 URL (http://127.0.0.1:8000/es/3/te-rojo/) 和内容:

CH9-16.png

笔者注:

这里没有填入西班牙语的描述,所以图片中没有文字描述。

如果希望了解更多 django-parler 的内容,请查阅 http://django-parler.readthedocs.io/en/latest/

我们已经学习了如何翻译 Python 代码、模板、URL 模式以及模型字段。为了完成国际化和本地化过程,我们需要为日期、时间和数字使用本地化格式。

本地格式化

由于用户所在区域不同,你可以希望使用不同的格式展示日期、时间和数字。我们可以将项目 settings.py 文件中的 USE_L10N 设置为 true 来激活本地化。

启动 USE_L10N 时, Django 将在模板中尝试使用本地特定格式进行输出。您可以看到,英文版的小数点用点分隔符显示,而在西班牙语版本中,使用逗号显示。这是 Django 为 es语言环境指定的设置格式。可以从这里找到更多西班牙语设置格式 https://github.com/django/django/blob/stable/1.11.x/django/conf/locale/es/formats.py

通常,我们将 USE_L10N 设置为 true 把本地化的任务交给 Django 。然而,可能某些情况您不希望使用本地化格式,尤其是输出 为 JavaScript 或 JSON 格式输出(需要提供机器可读格式)时。

Django 提供一个 {% localize %} 模板标签帮助我们为模板片段开启/关闭本地化。这使得我们可以控制本地化格式。必须加载 l10n 标签才能使用这个模板标签。下面是在模板中打开和关闭本地化的例子:

{% load l10n %}

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

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

Django 还提供 localize 和 unlocalize 模板过滤器对数值进行强制或者取消本地化设置。这些过滤器这么使用:

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

我们可以创建自定义格式文件来指定本地化格式,更多本地化格式的信息见 https://docs.djangoproject.com/en/1.11/topics/i18n/formatting/

使用 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 localflavor.us.forms import USZipCodeField

from .models import Order


class OrderCreateForm(forms.ModelForm):
    postal_code = USZipCodeField()

    class Meta:
        model = Order
        fields = ['first_name', 'last_name', 'email', 'address', 'postal_code',
                  'city']

我们从 localflavor 的 us 包中导入 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-localfevor 文档并查看每个国家/地区的所有可用本地组件,网址为https://django-localflavor.readthedocs.org/en/latestst/

接下来,我们将为商店创建推荐引擎。

创建一个推荐引擎[416-427]

推荐引擎是一个预测用户对商品的偏好和评价的系统。系统根据用户的行为和对他们的了解为用户选择相关商品。如今,许多在线服务都使用推荐系统。他们根据大量不相关的可用数据帮助用户选择可能感兴趣的商品。好的商品推荐会提高用户的购物热情。电子商务网站还可以通过提供相关商品推荐提高商品的平均销量来获益。

我们将创建一个简单但是一个强大的推荐引擎,这个推荐引擎推荐一起购买的商品。我们将通过用户的历史购买记录推荐商品,然后识别通常一起购买的商品。我们将在两种不同的场景中推荐商品:

商品详情页面:将展示与该商品共同购买的产品列表,将这样进行展示:购买本商品的用户还购买了 X, Y, Z。我们需要一个数据结构来存储这些共同购买的商品与本页面一起购买的次数。

购物车详情页面:基于用户放到购物车中的商品推荐通常与这些商品共同购买的商品。在这种情况下,将对获取相关产品的分数进行汇总。

我们将使用 redis 存储共同购买的商品。我们已经在第六章使用过 redis 了。如果你还没有安装 redis ,可以在第六章找到安装说明。

基于以前的订单推荐产品

现在,我们基于用户添加到购物车中的商品向用户推荐其它商品。我们将为网站中的每个商品在 redis 中保存一个 key,redis 中商品的 key 将设置得分。每次新的购买过程完成后,我们将为共同购买的商品分数加 1 。

订单付款成功后,我们为购买的每个商品保存一个 key 来设置同一个订单中商品的有序集合。有序集合可以帮助我们对共同购买的商品进行打分。

编辑项目的 settings.py 文件并添加下面的设置:

# redis  settings
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 1

这是创建 Redis 服务器连接所需要的设置。在 shop 应用目录下新建一个 recommender.py 文件。添加下面的代码:

import redis
from django.conf import settings
from .models import Product
# connect to redis
r = redis.StrictRedis(host=settings.REDIS_HOST, port=settings.REDIS_PORT,
                      db=settings.REDIS_DB)


class Recommender(object):
    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() 方法获取商品对象的 id 并创建保存商品的有序集合的 Redis 键(格式为 product:[id]:purchased_with)。

products_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 sorted set in a temporary key
            keys = [self.get_product_key(id) for id in product_ids]
            r.zunionstore(tmp_key, keys)
            # remove ids for the products the recommendation is for
            r.zrem(tmp_key, *product_ids)
            # get the product ids by their score, descendant sort
            suggestions = r.zrange(tmp_key, 0, -1, desc=True)[:max_results]
            # remove the temporary key
            r.delete(tmp_key)
        suggested_products_ids = [int(id) for id in suggestions]

        # get suggested products and sort by order of appearance
        suggested_products = list(
            Product.objects.filter(id__in=suggested_products_ids))
        suggested_products.sort(
            key=lambda x: suggested_products_ids.index(x.id))
        return suggested_products

suggest_products_for() 方法获取下面的参数:

  • products : 需要推荐商品的商品列表,包含一个或多个商品。

  • max_results : 表示可以返回的推荐商品的最大数量。

在这个方法中,我们完成了以下工作:

  1. 获取给定 Product 对象的 ID;

  2. 如果步骤 1 中只有一件商品,只需获取该商品的共同购买列表(通过共同购买的次数进行由多到少的倒序排序)。这个功能通过 Redis 的 ZRANGE 命令实现,然后使用 max_results 属性指定的数值对结果进行截取。

  3. 如果步骤 1 中的商品多于一个,则使用这些商品的 ID 生成一个临时 Redis 键。

  4. 对特定商品有序集合中的各项进行组合并对分数进行加和,这个功能通过 Redis 的 ZUNIONSTORE 命令实现。ZUNIONSTORE 命令计算给定键的有序集合的并集,并在步骤 3 生成的 Redis 键中保存聚合计算得到的分数。关于 ZUNIONSTORE 命令的详细信息见 https://redis.io/commands/ZUNIONSTORE 。我们将聚合计算得到的分数保存到步骤 3 创建的临时键中。

  5. 由于正在聚合计算分数,有可能获得完全相同的推荐商品。这里使用 ZREM 删除生成的有序集合中的重复值。

  6. 从步骤3中创建的临时键中获取商品 ID ,使用 ZRANGE 命令根据分数对它们进行排序。我们将结果限制在 max_results 属性指定的值的范围内,然后我们删除步骤3 中创建的临时键。

  7. 最终,根据给定 id 获取对应商品对象并且与 id 使用相同的顺序。

笔者注:
这里主要使用了 Redis 有序集合的功能,有序集合相关命令详见http://doc.redisfans.com/sorted_set/index.html

我们再来练习添加一个清除推荐的方法,向 Recommender 类添加下面的方法:

    def clear_purchases(self):
        for id in Product.objects.values_list('id',flat=True):
            r.delete(self.get_product_key(id))

我们来使用以下推荐引擎。确保数据库中包含几个商品对象并使用下面的命令启动 Redis 服务器:

src/redis-server

打开另一个 shell ,执行 python manage.py shell,并使用下面的代码获取几件商品:

>>> from shop.models import Product
>>> black_tea = Product.objects.language('en').get(translations__name = 'Black Tea')
>>> red_tea = Product.objects.language('en').get(translations__name = 'Red Tea')
>>> green_tea = Product.objects.language('en').get(translations__name = 'Green Tea')
>>> tea_powder = Product.objects.language('en').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: Tea powder>, <Product: Red Tea>, <Product: Black Tea>]
>>> r.suggest_products_for([tea_powder])
[<Product: Black Tea>, <Product: Red Tea>, <Product: Green Tea>]

笔者注:

为了在 shell 中顺利执行,将

suggested_products = list(Product.objects.filter(id__in=suggested_products_ids))

更改为了:

suggested_products = list(Product.objects.language('en').filter(id__in=suggested_products_ids))

shell 使用完后再更改回来。

我们可以看到,推荐商品按照它们的得分排序,下面将通过聚合得分获取多个商品的推荐商品:

>>> 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_tea 和 red_tea 的用户推荐 tea_powder(2+1) 和 green_tea(1+1)。

我们已经验证了算法结果符合预期,下一步将在网站上进行商品推荐。

编辑 shop 应用的 views.py 文件并添加下面代码:

from .recommender import Recommender

在 product_detail 视图的 render() 函数之前添加下面的代码:

r = Recommender()
recommened_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()
    recommened_products = r.suggest_products_for([product], 4)
    return render(request, 'shop/product/detail.html',
                  {'product': product, 'cart_product_form': cart_product_form,
                   'recommended_products': recommened_products})

现在,编辑 shop 应用的 shop/product/detail.html 模板 并在{{ product.description|linebreaks }} 之后添加下面的代码:

{% if recommended_products %}
  <div class="recommendations">
    <h3>{% trans "People who bought this also bought" %}</h3>
    {% for p in recommended_products %}
      <div class="item">
        <a href="{{ p.get_absolute_url }}">
          <img src="{% if p.image %}{{ p.image.url }}{% else %}{% static "img/no_image.png" %}{% endif %}">
        </a>
        <p><a href="{{ p.get_absolute_url }}">{{ p.name }}</a></p>
      </div>
    {% endfor %}
  </div>
{% endif %}

使用 python manage.py runserver 命令运行开发服务器并在浏览器中打开 http://127.0.0.1:8000/en/ 。点击任意商品查看详情页面。你应该可以看到商品下面是推荐的商品,如下图所示:

CH9-17.png

我们还将在购物车中添加商品推荐,将基于用户添加到购物车中的商品进行推荐。编辑 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> HTML 元素之后添加下面的代码:

    {% if recommended_products %}
        <div class="recommendations cart">
            <h3>{% trans "People who bought this also bought" %}</h3>
            {% for p in recommended_products %}
            <div class="item">
                <a href="{{ p.get_absolute_url }}">
                    <img src="{% if p.image %}{{ p.image.url }}{% else %}{% static "img/no_image.png" %}{% endif %}">
                </a>
                <p><a href="{{ p.get_absolute_url }}">{{ p.name }}</a></p>
            </div>
            {% endfor %}
        </div>
    {% endif %}

在浏览器中打开 http://127.0.0.1:8000/en 并在购物车中添加几件商品,打开 http://127.0.0.1:8000/en/cart/ ,应该可以看到根据购物车中商品聚合计算得到的推荐商品,如下图所示:

CH9-18.png

祝贺你,已经使用 Django 和 Redis 创建了完整的推荐引擎。

总结


本章,我们使用 session 创建了折扣系统,学习了如何实现国际化和本地化,还使用 Redis 创建了推荐工程。

下一章,我们将创建一个新项目,使用 Django 类视图创建在线学习平台,并将创建自定义内容管理系统。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容