在上一章里,为电商站点集成了支付功能,然后可以生成PDF发票发送给用户。在本章,我们将为商店添加优惠码功能。此外,还会学习国际化和本地化的设置和建立一个推荐商品的系统。
本章涵盖如下要点:
很多电商网站,会向用户发送电子优惠码,以便用户在购买时使用,以折扣价进行结算。一个在线优惠码通常是一个字符串,然后还规定了有效期限,一次性有效或者可以反复使用。
我们将为站点添加优惠码功能。我们的优惠码带有有效期,但是不限制使用次数,输入之后,就会影响用户购物车中的总价。为了实现这个需求,需要建立一个数据模型来存储优惠码,有效期和对应的折扣比例。
为myshop
项目创建新的应用coupons
:
python manage.py startapp coupons
然后在settings.py
内激活该应用:
INSTALLED_APPS = [ # ... 'coupons.apps.CouponsConfig', ]
编辑coupons
应用的models.py
文件,创建一个Coupon
模型:
from django.db import models from django.core.validators import MinValueValidator, MaxValueValidator class Coupon(models.Model): code = models.CharField(max_length=50, unique=True) valid_from = models.DateTimeField() valid_to = models.DateTimeField() discount = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(100)]) active = models.BooleanField() def __str__(self): return self.code
这是用来存储优惠码的模型,Coupon
模型包含以下字段:
code
:用于存放码的字符串valid_from
:优惠码有效期的开始时间。valid_to
:优惠码有效期的结束时间。discount
:该券对应的折扣,是一个百分比,所以取值为0-100
,我们使用了内置验证器控制该字段的取值范围。active
:表示该码是否有效之后执行数据迁移程序。然后将Coupon
模型加入到管理后台,编辑coupons
应用的admin.py
文件:
from django.contrib import admin from .models import Coupon class CouponAdmin(admin.ModelAdmin): list_display = ['code', 'valid_from', 'valid_to', 'discount', 'active'] list_filter = ['active', 'valid_from', 'valid_to'] search_fields = ['code'] admin.site.register(Coupon, CouponAdmin)
现在启动站点,到http://127.0.0.1:8000/admin/coupons/coupon/add/查看Coupon
模型:
输入一个优惠码记录,有效期设置为当前日期,不要忘记勾上Active然后点击SAVE按钮。
创建数据模型之后,可以查询和获得优惠码对象。现在我们必须增添使用户可以输入优惠码从而获得折扣价的功能。这个功能将按照如下逻辑进行操作:
valid_from
和valid_to
有效时间之间、active
属性是否为True
。session
中,用折扣重新计算价格并更新购物车中的商品价格在coupons
应用里建立forms.py
文件,添加下列代码:
from django import forms class CouponApplyForm(forms.Form): code = forms.CharField()
这个表单用于用户输入优惠码。然后来编辑coupons
应用的views.py
文件:
from django.shortcuts import render, redirect from django.utils import timezone from django.views.decorators.http import require_POST from .models import Coupon from .forms import CouponApplyForm @require_POST def coupon_apply(request): now = timezone.now() form = CouponApplyForm(request.POST) if form.is_valid(): code = form.cleaned_data['code'] try: coupon = Coupon.objects.get(code__iexact=code, valid_from__lte=now, valid_to__gte=now, active=True) request.session['coupon_id'] = coupon.id except Coupon.DoesNotExist: request.session['coupon_id'] = None return redirect('cart:cart_detail')
这个coupon_apply
视图验证优惠码并将其存储在session中,使用了@require_POST
装饰器令该视图仅接受POST
请求。这个视图的业务逻辑如下:
CouponApplyForm
cleaned_data
获取code
,然后使用code
查询数据库得到coupon
对象,这里使用了过滤参数iexact
,进行完全匹配;使用active=True
过滤出有效的优惠码;使用timezone.now()
获取当前时间,valid_from
和valid_to
分别采用lte
(小于等于)和gte
(大于等于)过滤查询以保证当前时间位于有效期内。cart_detail
URL对应的购物车详情页,以显示应用了优惠码之后的金额。需要为coupon_apply
视图配置URL,在coupons
应用中建立urls.py
文件,添加下列代码:
from django.urls import path from . import views app_name = 'coupons' urlpatterns = [ path('apply/', views.coupon_apply, name='apply'), ]
然后编辑项目的根路由,增加一行:
urlpatterns = [ # ... path('coupons/', include('coupons.urls', namespace='coupons')), path('', include('shop.urls', namespace='shop')), ]
依然记得要把这一行放在shop.urls
上方。
编辑cart
应用中的cart.py
文件,添加下列导入:
from coupons.models import Coupon
然后在cart
类的__init__()
方法的最后添加从session中获得优惠码ID的语句:
class Cart(object): def __init__(self, request): # ... # store current applied coupon self.coupon_id = self.session.get('coupon_id')
在Cart
类中,我们需要通过coupon_id
获取优惠码信息并将其保存在Cart
对象内,为Cart
类添加如下方法:
class Cart(object): # ... @property def coupon(self): if self.coupon_id: return Coupon.objects.get(id=self.coupon_id) return None def get_discount(self): if self.coupon: return (self.coupon.discount / Decimal('100')) * self.get_total_price() return Decimal('0') def get_total_price_after_diccount(self): return self.get_total_price() - self.get_discount()
这些方法解释如下:
coupon()
:我们使用@property
将该方法定义为属性,如果购物车包含一个coupon_id
属性,会返回该id对应的Coupon
对象get_discount()
:如果包含优惠码id,计算折扣价格,否则返回0。get_total_price_after_discount()
:返回总价减去折扣价之后的折扣后价格。现在Cart
类就具备了根据优惠码计算折扣价的功能。
现在还需要修改购物车详情视图函数,以便在页面中应用表单和展示折扣金额,修改cart
应用的views.py
文件,增加导入代码:
from coupons.forms import CouponApplyForm
然后修改cart_detail
视图,添加表单:
def cart_detail(request): cart = Cart(request) for item in cart: item['update_quantity_form'] = CartAddProductForm(initial={'quantity': item['quantity'], 'update': True}) coupon_apply_form = CouponApplyForm() return render(request, 'cart/detail.html', {'cart': cart, 'coupon_apply_form': coupon_apply_form})
修改cart
应用的购物车模板cart/detail.html
,找到如下几行:
<tr class="total"> <td>total</td> <td colspan="4"></td> <td class="num">${{ cart.get_total_price }}</td> </tr>
替换成如下代码:
{% if cart.coupon %} <tr class="subtotal"> <td>Subtotal</td> <td colspan="4"></td> <td class="num">${{ cart.get_total_price_after_diccount }}</td> </tr> <tr> <td>"{{ cart.coupon.code }}" coupon ({{ cart.coupon.discount }}% off)</td> <td colspan="4"></td> <td class="num neg">- ${{ cart.get_discount|floatformat:"2" }}</td> </tr> {% endif %} <tr class="total"> <td>Total</td> <td colspan="4"></td> <td class="num">${{ cart.get_total_price_after_diccount|floatformat:"2" }}</td> </tr>
这是新的购物车模板。如果包含一个优惠券,就展示一行购物车总价,再展示一行优惠券信息,最后通过get_total_price_after_discount()
展示折扣后价格。
在同一个文件内,在</table>
后增加下列代码:
{# 在紧挨着</table>标签之后插入: #} <p>Apply a coupon:</p> <form action="{% url 'coupons:apply' %}" method="post"> {{ coupon_apply_form }} <input type="submit" value="Apply"> {% csrf_token %} </form>
上边这段代码展示输入优惠码的表单。
在浏览器中打开http://127.0.0.1:8000/
,向购物车内加入一些商品,然后进入购物车页面输入优惠码并提交,可以看到如下所示:
之后来修改订单模板orders/order/create.html
,在其中找到如下部分:
<ul> {% for item in cart %} <li> {{ item.quantity }} x {{ item.product.name }} <span>${{ item.total_price }}</span> </li> {% endfor %} </ul>
替换成:
<ul> {% for item in cart %} <li> {{ item.quantity }}x {{ item.product.name }} <span>${{ item.total_price|floatformat:"2" }}</span> </li> {% endfor %} {% if cart.coupon %} <li> "{{ cart.coupon.code }}" ({{ cart.coupon.discount }}% off) <span>- ${{ cart.get_discount|floatformat:"2" }}</span> </li> {% endif %} </ul>
如果有优惠码,现在的订单页面就展示优惠码信息了。继续找到下边这行:
<p>Total: ${{ cart.get_total_price }}</p>
替换成:
<p>Total: ${{ cart.get_total_price_after_diccount|floatformat:"2" }}</p>
这样总价也变成了折扣后价格。
在浏览器中打开http://127.0.0.1:8000/
,添加商品到购物车然后生成订单,可以看到订单页面的价格现在是折扣后的价格了:
像之前说的,我们需要将优惠码信息保存至order
对象中,为此需要修改Order
模型。编辑
编辑orders
应用的models.py
文件,增加导入部分的代码:
from decimal import Decimal from django.core.validators import MinValueValidator, MaxValueValidator from coupons.models import Coupon
然后为Order
模型增加下列字段:
class Order(models.Model): coupon = models.ForeignKey(Coupon, related_name='orders', null=True, blank=True, on_delete=models.SET_NULL) discount = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(100)])
这两个字段用于存储优惠码信息。虽然折扣信息保存在Coupon对象中,但这里还是用discount
字段保存了当前的折扣,以免未来优惠码折扣发生变化。为coupon
字段设置了on_delete=models.SET_NULL
,优惠码删除时,该外键字段会变成空值。
增加好字段后数据迁移程序。回到models.py
文件,需要修改Order
类中的get_total_cost()
方法:
class Order(models.Model): # ... def get_total_cost(self): total_cost = sum(item.get_cost() for item in self.items.all()) return total_cost - total_cost * (self.discount / Decimal('100'))
修改后的get_total_cost()
方法会把折扣也考虑进去。之后还需要修改orders
应用里的views.py
文件中的order_create
视图,以便在生成订单的时候,存储这两个新增的字段。找到下边这行:
order = form.save()
将其替换成如下代码:
order = form.save(commit=False) if cart.coupon: order.coupon = cart.coupon order.discount = cart.coupon.discount order.save()
在修改后代码中,通过调用OrderCreateForm
表单对象的save()
方法,创建一个order
对象,使用commit=False
暂不存入数据库。如果购物车对象中有折扣信息,就保存折扣信息。然后将order
对象存入数据库。
启动站点,在浏览器中访问http://127.0.0.1:8000/,使用一个自己创建的优惠码,在完成购买之后,可以到http://127.0.0.1:8000/admin/orders/order/>查看包含优惠码和折扣信息的订单:
还可以修改管理后台的订单详情页和和PDF发票,以使其包含优惠码和折扣信息。下边我们将为站点增加国际化功能。
译者注:这里有一个问题:用户提交了订单并清空购物车后,如果再向购物车内添加内容,再次进入购物车详情页面可以发现自动使用了上次使用的优惠券。此种情况的原因是作者把优惠券信息附加到了session上,在提交订单的时候没有清除。cart对象实例化的时候又取到了相同的优惠券信息。所以需要对程序进行一下改进。
修改orders
应用的order_create
视图,在生成OrderItem
并清空购物车的代码下增加一行:
def order_create(request): cart = Cart(request) if request.method == "POST": form = OrderCreateForm(request.POST) # 表单验证通过就对购物车内每一条记录生成OrderItem中对应的一条记录 if form.is_valid(): order = form.save(commit=False) if cart.coupon: order.coupon = cart.coupon order.discount = cart.coupon.discount order.save() for item in cart: OrderItem.objects.create(order=order, product=item['product'], price=item['price'], quantity=item['quantity']) # 成功生成OrderItem之后清除购物车 cart.clear() # 清除优惠券信息 request.session['coupon_id'] = None # 成功完成订单后调用异步任务发送邮件 order_created.delay(order.id) # 在session中加入订单id request.session['order_id'] = order.id # 重定向到支付页面 return redirect(reverse('payment:process')) else: form = OrderCreateForm() return render(request, 'orders/order/create.html', {'cart': cart, 'form': form})
Django对于国际化和本地化提供了完整的支持,允许开发者将站点内容翻译成多种语言,而且可以处理本地化的时间日期数字和时区格式等本地化的显示内容。在开始之前,先需要区分一下国际化和本地化两个概念。国际化和本地化都是一种软件开发过程。国际化(Internationalization,通常缩写为i18n),是指一个软件可以被不同的国家和地区使用,而不会局限于某种语言。本地化(Localization,缩写为l10n)是指对国际化的软件将其进行翻译或者其他本地化适配,使之变成适合某一个国家或地区使用的软件的过程。Django通过自身的国际化框架,可以支持超过50种语言。
Django的国际化框架可以让开发者很方便的在Python代码和模板中标注需要翻译的字符串,这个框架依赖于GNU gettext开源软件来生成和管理消息文件(message file)。消息文件是一个纯文本文件,代表一种语言的翻译,存放着在站点应用中找到的部分或者所有需要翻译的字符串以及对应的某种语言的翻译,就像一个字典一样。消息文件的后缀名是.po
。
一旦完成翻译,可以把消息文件编译,以快速访问翻译内容,编译后的消息文件的后缀名是.mo
。
Django提供了一些国际化和本地化的设置,下边一些设置是最重要的:
USE_I18N
:布尔值,是否启用国际化功能,默认为True
USE_L10N
:布尔值,设置本地化功能是否启用,设置为True
时,数字和日期将采用本地化显示。默认为False
USE_TZ
:布尔值,指定时间是否根据时区进行调整,当使用startproject
创建项目时,默认为True
LANGUAGE_CODE
:项目的默认语言代码,采用标准的语言代码格式,例如'en-us'表示美国英语,'en-gb'表示英国英语。这个设置需要USE_I18N
设置为True
才会生效。在http://www.i18nguy.com/unicode/language-identifiers.html可以找到语言代码清单。LANGUAGES
:一个包含项目所有可用语言的元组,其中每个元素是语言代码和语言名称构成的二元组。可以在django.conf.global_settings
查看所有可用的语言。这个属性可设置的值必须是django.conf.global_settings
中列出的值。LOCALE_PATHS
:一个目录列表,目录内存放项目的翻译文件。TIME_ZONE
:字符串,代表项目所采用的时区。如果使用startproject
启动项目,该值被设置为'UTC'
。可以按照实际情况将其设置为具体时区,如'Europe/Madrid'
。中国的时区是'Asia/Shanghai'
,大小写敏感。以上是常用的国际化和本地化设置,完整设置请参见https://docs.djangoproject.com/en/2.1/ref/settings/#globalization-i18n-l10n。
Django包含了用于管理翻译的命令如下:
makemessages
:运行该命令,会找到项目中所有标注要翻译的字符串,建立或者更新locale
目录下的.po
文件,每种语言会生成单独的.po
文件。compilemessages
:编译所有的.po
文件为.mo
文件。需要使用GNU gettext工具来执行上述过程,大部分linux发行版自带有该工具。如果在使用mac OSX,可以通过 http://brew.sh/ 使用命令brew install gettext
来安装,之后使用brew link gettext --force
强制链接。对于Windows下的安装,参考https://docs.djangoproject.com/en/2.0/topics/i18n/translation/#gettext-on-windows中的步骤。
先来看一下增加翻译需要进行的流程:
makemessages
命令建立消息文件compilemessages
命令编译消息文件Django使用中间件django.middleware.locale.LocaleMiddleware
来检查HTTP请求中所使用的本地语言。这个中间件做的工作如下:
LANGUAGE_SESSION_KEY
LANGUAGE_COOKIE_NAME
自定义该cookie的名称,默认是django_language
Accept-Language
键Accept-Language
头部信息未指定具体语言,则使用LANGUAGE_CODE
设置注意这个过程只有在开启了该中间件的时候才会得到完整执行,如果未开启中间件,Django直接使用LANGUAGE_CODE
中的设置。
我们准备为电商网站增添各种语言的支持,增添英语和西班牙语的支持。编辑settings.py
文件,加入LANGUAGES
设置,放在LANGUAGE_CODE
的旁边:
LANGUAGES = ( ('en', 'English'), ('es', 'Spanish'), )
LANGUAGES
设置包含两个语言代码和名称组成的元组。语言代码可以指定具体语言如en-us
或en-gb
,也可以更模糊,如en
。通过这个设置,我们定义了我们的网站仅支持英语和西班牙语。如果不定义LANGUAGES
设置,默认支持所有django支持的语言。
设置LANGUAGE_CODE
为如下:
LANGUAGE_CODE = 'en'
添加django.middleware.locale.LocaleMiddleware
到settings.py
的中间件设置中,位置在SessionMiddleware
中间件之后,CommonMiddleware
中间件之前,因为LocaleMiddleware
需要使用session,而CommonMiddleware
需要一种可用语言来解析URL,MIDDLEWARE
设置成如下:
MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.locale.LocaleMiddleware', 'django.middleware.common.CommonMiddleware', # ... ]
django中间件设置的顺序很重要,中间件会在请求上附加额外的数据,某个中间件会依赖于另外一个中间件附加的数据才能正常工作。
在manage.py文件所在的项目根目录下创建如下目录:
locale/ en/ es/
locale
目录是用来存放消息文件的目录,编辑settings.py
文件加入如下设置:
LOCALE_PATH = ( os.path.join(BASE_DIR, 'locale/'), )
LOCALE_PATH
指定了Django寻找消息文件的路径,可以是一系列路径,最上边的路径优先级最高。
当使用makemessages
命令的时候,消息文件会在我们创建的locale/
目录中创建,如果某个应用也有locale/
目录,那个应用中的翻译内容会优先在那个应用的目录中创建。
为了翻译Python代码中的字符串字面量,需要使用django.utils.translation
模块中的gettext()
方法来标注字符串。这个方法返回翻译后的字符串,通常做法是导入该方法然后命名为一个下划线"_"。可以在https://docs.djangoproject.com/en/2.0/topics/i18n/translation/查看文档。
标记字符串的方法如下:
from django.utils.translation import gettext as _ output = _('Text to be translated.')
Django对于所有的翻译函数都有惰性版本,后缀为_lazy()
。使用惰性翻译函数的时候,字符串只有被访问的时候才会进行翻译,而不是在翻译函数调用的时候。当字符串位于模块加载的时候才生成的路径中时候特别有效。
使用gettext_lazy()
代替gettext()
方法,只有在该字符串被访问的时候才会进行翻译,所有的翻译函数都有惰性版本。。
被标注的字符串中还可以带有占位符,以下是一个占位符的例子:
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中翻译字面量的知识,可以来为我们的项目添加翻译功能了。
编辑setttings.py
,导入gettext_lazy()
,然后修改LANGUAGES
设置:
from django.utils.translation import gettext_lazy as _ LANGUAGES = ( ('en', _('English')), ('es', _('Spanish')), )
这里导入了gettext_lazy()
并使用了别名"_"来避免重复导入。将显示的名称也进行了翻译,这样对于不同的语言的人来说,可以看懂并选择他自己的语言。
然后打开系统命令行窗口,输入如下命令:
django-admin makemessages --all
可以看到如下输出:
processing locale en processing locale es
然后查看项目的locale
目录,可以看到如下文件和目录结构:
en/ LC_MESSAGES/ django.po es/ LC_MESSAGES/ django.po
每个语言都生成了一个.po
消息文件,使用文本编辑器打开es/LC_MESSAGES/django.po
文件,在末尾可以看到如下内容:
#: .\myshop\settings.py:107 msgid "English" msgstr "" #: .\myshop\settings.py:108 msgid "Spanish" msgstr ""
每一部分的第一行表示在那个文件的第几行发现了需翻译的内容,每个翻译包含两个字符串:
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
目录,其结构如下:
en/ LC_MESSAGES/ django.mo django.po es/ LC_MESSAGES/ django.mo django.po
可以看到每种语言都生成了.mo
文件。
我们已经翻译好了语言名称本身。现在我们来试着翻译一下Order
模型的所有字段,修改orders
应用的models.py
文件:
from django.utils.translation import gettext_lazy as _ class Order(models.Model): first_name = models.CharField(_('frist name'), max_length=50) last_name = models.CharField(_('last name'), max_length=50) email = models.EmailField(_('e-mail'), ) address = models.CharField(_('address'), max_length=250) postal_code = models.CharField(_('postal code'), max_length=20) city = models.CharField(_('city'), max_length=100) ......
我们为每个显示出来的字段标记了翻译内容,也可以使用verbose_name
属性来命名字段。在orders
应用中建立如下目录:
locale/ en/ es/
通过创建locale
目录,当前应用下的翻译内容会优先保存到这个目录中,而不是保存在项目根目录下的locale
目录中。这样就可以为每个应用配置独立的翻译文件。
在系统命令行中执行:
django-admin makemessages --all
输出为:
processing locale es processing locale en
使用文本编辑器打开locale/es/LC_MESSAGES/django.po
,可以看到Order模型的字段翻译,在msgstr
中为对应的msgid
字符串加上西班牙语的翻译:
#: orders/models.py:10 msgid "first name" msgstr "nombre" #: orders/models.py:11 msgid "last name" msgstr "apellidos" #: orders/models.py:12 msgid "e-mail" msgstr "e-mail" #: orders/models.py:13 msgid "address" msgstr "dirección" #: orders/models.py:14 msgid "postal code" msgstr "código postal" #: orders/models.py:15 msgid "city" msgstr "ciudad"
添加完翻译之后保存文件。
除了常用的文本编辑软件,还可以考虑使用Poedit编辑翻译内容,该软件同样依赖gettext,支持Linux,Windows和macOS X。可以在https://poedit.net/下载该软件。
下边来翻译项目使用的表单。OrderCreateForm
这个表单类无需翻译,因为它会自动使用Order
类中我们刚刚翻译的verbose_name
。现在我们去翻译cart
和coupons
应用中的内容。
在cart
应用的forms.py
文件中,导入翻译函数,为CartAddProductForm
类的quantity
字段增加一个参数label
,代码如下:
from django import forms from django.utils.translation import gettext_lazy as _ PRODUCT_QUANTITY_CHOICES = [(i, str(i)) for i in range(1, 21)] class CartAddProductForm(forms.Form): quantity = forms.TypedChoiceField(choices=PRODUCT_QUANTITY_CHOICES, coerce=int, label=_('Quantity')) update = forms.BooleanField(required=False, initial=False, widget=forms.HiddenInput)
译者注:红字部分是本书上一版的遗留,无任何作用,读者可以忽略。
之后修改coupons
应用的forms.py
文件,为CouponApplyForm
类增加翻译:
from django import forms from django.utils.translation import gettext_lazy as _ class CouponApplyForm(forms.Form): code = forms.CharField(label=_('Coupon'))
我们为code
字段增加了一个label标签用于展示翻译后的字段名称。
Django为翻译模板内容提供了{% trans %}
和{% blocktrans %}
两个模板标签用于翻译内容,如果要启用这两个标签,需要在模板顶部加入{% load i18n %}
。
{% trans %}
模板标签{% trans %}
标签用来标记一个字符串,常量或者变量用于翻译。Django内部也是该文本执行gettext()
等翻译函数。标记字符串的例子是:
{% trans "Text to be translated" %}
也可以像其他标签变量一样,使用as 将 翻译后的结果放入一个变量中,在其他地方使用。下面的例子使用了一个变量greeting
:
{% trans "Hello!" as greeting %} <h1>{{ greeting }}</h1>
这个标签用于比较简单的翻译,但不能用于带占位符的文字翻译。
{% blocktrans %}
模板标签{% blocktrans %}
标签可以标记包含常量和占位符的内容用于翻译,下边的例子展示了使用一个name
变量的翻译:
{% blocktrans %}Hello {{ name }}!{% endblocktrans %}
可以使用with,将具体的表达式设置为变量的值,此时在blocktrans
块内部不能够再继续访问表达式和对象的属性,下面是一个使用了capfirst
装饰器的例子:
{% blocktrans with name=user.name|capfirst %} Hello {{ name }}! {% endblocktrans %}
如果翻译内容中包含变量,使用{% blocktrans %}
代替{% trans %}
。
编辑shop
应用的base.html
,在其顶部加入i18n
标签,然后标注如下要翻译的部分:
{% load i18n %} {% load static %} <!DOCTYPE html> <html> <head> <meta charset="utf-8"/> <title>{% block title %}{% trans "My shop" %}{% endblock %}</title> <link href="{% static "css/base2.css" %}" rel="stylesheet"> </head> <body> <div id="header"> <a href="/" class="logo">{% trans "My shop" %}</a> </div> <div id="subheader"> <div class="cart"> {% with total_items=mycart|length %} {% if mycart|length > 0 %} {% trans "Your cart" %}: <a href="{% url 'cart:cart_detail' %}"> {% blocktrans with total_items_plural=total_items|pluralize total_price=cart.get_total_price %} {{ total_items }} items{{ total_items_plural }}, ${{ total_price }} {% endblocktrans %} </a> {% else %} {% trans "Your cart is empty." %} {% endif %} {% endwith %} </div> </div> <div id="content"> {% block content %} {% endblock %} </div> </body> </html>
注意{% blocktrans %}
展示购物车总价部分的方法,在原来的模板中,我们使用了:
{{ total_items }} item{{ total_items|pluralize }}, ${{ cart.get_total_price }}
现在改用{% blocktrans with ... %}
来为total_items|pluralize
(使用了过滤器)和cart.get_total_price
(访问对象的方法)创建占位符:
编辑shop
应用的shop/product/detail.html
,紧接着{% extends %}
标签导入i18n
标签:
{% load i18n %}
之后找到下边这一行:
<input type="submit" value="Add to cart">
将其替换成:
<input type="submit" value="{% trans "Add to cart" %}">
现在来翻译orders
应用,编辑orders/order/create.html
,标记如下翻译内容:
{% extends 'shop/base.html' %} {% load i18n %} {% block title %} {% trans "Checkout" %} {% endblock %} {% block content %} <h1>{% trans "Checkout" %}</h1> <div class="order-info"> <h3>{% trans "Your order" %}</h3> <ul> {% for item in cart %} <li> {{ item.quantity }}x {{ item.product.name }} <span>${{ item.total_price|floatformat:"2" }}</span> </li> {% endfor %} {% if cart.coupon %} <li> {% blocktrans with code=cart.coupon.code discount=cart.coupon.discount %} "{{ code }}" ({{ discount }}% off) {% endblocktrans %} <span>- ${{ cart.get_discount|floatformat:"2" }}</span> </li> {% endif %} </ul> <p>{% trans "Total" %}: ${{ cart.get_total_price_after_diccount|floatformat:"2" }}</p> </div> <form action="." method="post" class="order-form" novalidate> {{ form.as_p }} <p><input type="submit" value="{% trans "Place order" %}"></p> {% csrf_token %} </form> {% endblock %}
到现在我们完成了如下文件的翻译:
shop
应用的shop/product/list.html
模板orders
应用的orders/order/created.html
模板cart
应用的cart/detail.html
模板之后来更新消息文件,打开命令行窗口执行:
django-admin makemessages --all
此时myshop
项目下的locale
目录内有了对应的.po
文件,而orders
应用的翻译文件优先存放在应用内部的locale
目录中。
编辑所有.po
文件,在msgstr
属性内添加西班牙语翻译。你也可以直接复制随书代码内对应文件的内容。
执行命令编译消息文件:
django-admin compilemessages
可以看到如下输出:
processing file django.po in myshop/locale/en/LC_MESSAGES processing file django.po in myshop/locale/es/LC_MESSAGES processing file django.po in myshop/orders/locale/en/LC_MESSAGES processing file django.po in myshop/orders/locale/es/LC_MESSAGES
针对每一个.po
文件都会生成对应的.mo
文件。
Rosetta是一个第三方应用,通过Django管理后台编辑所有翻译内容,让.po
文件的管理变得更加方便,先通过pip
安装该模块:
pip install django-rosetta==0.8.1
之后在settings.py
中激活该应用:
INSTALLED_APPS = [ # ... 'rosetta', ]
然后需要为Rosetta配置相应的URL,其二级路由已经配置好,修改项目根路由增加一行:
urlpatterns = [ # ... path('rosetta/', include('rosetta.urls')), path('', include('shop.urls', namespace='shop')), ]
这条路径也需要在shop.urls
上边。
然后启动站点,使用管理员身份登录http://127.0.0.1:8000/rosetta/ ,再转到http://127.0.0.1:8000/rosetta/,点击右上的THIRD PARTY以列出所有的翻译文件,如下图所示:
点开Spanish下边的Myshop链接,可以看到列出了所有需要翻译的内容:
可以手工编辑需要翻译的地方,OCCURRENCES(S)栏显示了该翻译所在的文件名和行数,对于那些占位符翻译的内容,显示为这样:
Rosetta对占位符使用了不同的背景颜色,在手工输入翻译内容的时候注意不要破坏占位符的结构,例如要翻译下边这一行:
%(total_items)s item%(total_items_plural)s, $%(total_price)s
应该输入:
%(total_items)s producto%(total_items_plural)s, $%(total_price)s
可以参考本章随书代码中的西班牙语翻译来录入翻译内容。
结束输入的时候,点击一下Save即可将当前翻译的内容保存到.po
文件中,当保存之后,Rosetta会自动进行编译,所以无需执行compilemessages
命令。然而要注意Rosetta会直接读写locale
目录,注意要给予其相应的权限。
如果需要其他用户来编辑翻译内容,可以到http://127.0.0.1:8000/admin/auth/group/add/新增一个用户组叫translators
,然后到http://127.0.0.1:8000/admin/auth/user/编辑用户的权限以给予其修改翻译的权限,将该用户加入到translators
用户组内。仅限超级用户和translators
用户组内的用户才能使用Rosetta。
Rosetta的官方文档在https://django-rosetta.readthedocs.io/en/latest/。
特别注意的是,当Django已经在生产环境运行时,如果修改和新增了翻译,在运行了compilemessages
命令之后,只有重新启动Django才会让新的翻译生效。
你可能注意到了,Rosetta页面上有一列叫做Fuzzy。这不是Rosetta的功能,而是gettext
提供的功能。如果将fuzzy设置为true,则该条翻译不会包含在编译后的消息文件中。这个字段用来标记需要由用户进行检查的翻译内容。当.po
文件更新了新的翻译字符串时,很可能一些翻译被自动标成了fuzzy。这是因为:在gettext
发现一些msgid
被修改过的时候,gettext
会将其与它认为的旧有翻译进行匹配,然后标注上fuzzy。看到fuzzy出现的时候,人工翻译者必须检查该条翻译,然后取消fuzzy,之后再行编译。
Django提供两种国际化URL的特性:
使用翻译URL模式的优点是对搜索引擎友好。如果采用语言前缀URL,则必须要为每一种语言进行索引,使用翻译URL模式,则一条URL就可以匹配全部语言。下边来看一下两种模式的使用:
Django可以为不同语言在URL前添加前缀,例如我们的网站,英语版以/en/
开头,而西班牙语版以/es/
开头。
要使用语言前缀URL模式,需要启用LocaleMiddleware
中间件,用于从不同的URL中识别语言,在之前我们已经添加过该中间件。
我们来为URL模式增加前缀,现在需要修改项目的根urls.py
文件:
from django.conf.urls.i18n import i18n_patterns urlpatterns = i18n_patterns( path('admin/', admin.site.urls), path('cart/', include('cart.urls', namespace='cart')), path('orders/', include('orders.urls', namespace='orders')), path('pyament/', include('payment.urls', namespace='payment')), path('coupons/', include('coupons.urls', namespace='coupons')), path('rosetta/', include('rosetta.urls')), path('', include('shop.urls', namespace='shop')), )
可以混用未经翻译的标准URL与i18n_patterns
类型的URL,使部分URL带有语言前缀,部分不带前缀。但最好只使用翻译URL,以避免把翻译过的URL匹配到未经翻译过的URL模式上。
现在启动站点,到http://127.0.0.1:8000/ ,Django的语言中间件会按照之前介绍的顺序来确定本地语言,然后重定向到带有语言前缀的URL。现在看一下浏览器的地址栏,应该是http://127.0.0.1:8000/en/。当前语言是由请求头Accept-Language
所设置,或者就是LANGUAGE_CODE
的设置。
Django支持在URL模式中翻译字符串。针对不同的语言,可以翻译出不同的URL。在urls.py
中,使用ugettext_lazy()
来标注字符串。
编辑myshop
应用的根urls.py
,为cart
,orders
,payment
和coupons
应用配置URL:
from django.utils.translation import gettext_lazy as _ urlpatterns = i18n_patterns( path(_('admin/'), admin.site.urls), path(_('cart/'), include('cart.urls', namespace='cart')), path(_('orders/'), include('orders.urls', namespace='orders')), path(_('payment/'), include('payment.urls', namespace='payment')), path(_('coupons/'), include('coupons.urls', namespace='coupons')), path('rosetta/', include('rosetta.urls')), path('', include('shop.urls', namespace='shop')), )
编辑orders
应用的urls.py
文件,修改成如下:
from django.utils.translation import gettext_lazy as _ urlpatterns = [ path(_('create/'), views.order_create, name='order_create'), # ... ]
修改payment
应用的urls.py
文件,修改成如下:
from django.utils.translation import gettext_lazy as _ urlpatterns = [ path(_('process/'), views.payment_process, name='process'), path(_('done/'), views.payment_done, name='done'), path(_('canceled/'), views.payment_canceled, name='canceled'), ]
对于shop
应用的URL不需要修改,因为其URL是动态建立的。
执行命令进行编译,更新消息文件:
django-admin makemessages --all
启动站点,访问http://127.0.0.1:8000/en/rosetta/,点击Spanish下的Myshop,可以看到出现了URL对应的翻译。可以点击Untranslated查看所有尚未翻译的字符串,然后输入翻译内容。
在之前的工作中,我们配置好了英语和西班牙语的翻译,应该给用户提供切换语言的选项,为此准备给网站增加一个语言选择器,列出所有支持的语言,显示为一系列链接。
编辑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>
这个就是我们的语言选择器,逻辑如下:
{% load i18n %}
{% get_current_language %}
标签用于获取当前语言{% get_available_languages %}
标签用于从LANGUAGES
里获取所有可用的支持语言{% get_language_info_list %}
是为了快速获取语言的属性而设置的变量select
启动站点到http://127.0.0.1:8000/ ,可以看到页面右上方出现了语言选择器,如下图:
Django没有提供直接可用的模型翻译功能,必须采用自己的方式实现模型翻译。有一些第三方工具可以翻译模型字段,每个工具存储翻译的方式都不相同。其中一个工具叫做django-parler
,提供了高效的翻译管理,还能够与管理后台进行集成。
django-parler
的工作原理是为每个模型建立一个对应的翻译数据表,表内每条翻译记录通过外键连到翻译文字所在的模型,表内还有一个language
字段,用于标记是何种语言。
使用pip
安装django-parler
:
pip install django-parler==1.9.2
在settings.py
内激活该应用:
INSTALLED_APPS = [ # ... 'parler', ]
继续添加下列设置:
PARLER_LANGUAGES = { None: ( {'code': 'en'}, {'code': 'es'}, ), 'default': { 'fallback': 'en', 'hide_untranslated': False, } }
该配置的含义是指定了django-parler
的可用语言为en
和es
,然后指定了默认语言为en
,然后指定django-parler
不要隐藏未翻译的内容。
我们为商品品类添加翻译。django-parler
提供一个TranslatableModel
类(此处作者原文有误,写成了TranslatedModel
)和TranslatedFields
方法来翻译模型的字段。编辑shop
应用的models.py
文件,添加导入语句:
from parler.models import TranslatableModel, TranslatedFields
然后修改Category
模型的name
和slug
字段:
class Category(TranslatableModel): translations = TranslatedFields( name=models.CharField(max_length=200, db_index=True), slug=models.SlugField(max_length=200, db_index=True, unique=True) )
Category
类现在继承了TranslatableModel
类,而不是原来的models.Model
,name
和slug
字段被包含在了TranslatedFields
包装器里。
编辑Product
,name
,slug
,description
,和上边一样的方式:
class Product(TranslatableModel): translations = TranslatedFields( name=models.CharField(max_length=200, db_index=True), slug=models.SlugField(max_length=200, db_index=True), description=models.TextField(blank=True) ) category = models.ForeignKey(Category, related_name='products') image = models.ImageField(upload_to='products/%Y/%m/%d', blank=True) price = models.DecimalField(max_digits=10, decimal_places=2) available = models.BooleanField(default=True) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True)
django-parler
通过新创建模型为其他模型提供翻译,在下图可以看到Product
与其对应的翻译模型ProductTranslation
之间的关系:
译者注:此时如果运行站点,一些IDE会提示模型的字段找不到,这个对于实际运行程序没有影响,该字段依然可用。
django-parler
生成的ProductTranslation
类包含name
,slug
,description
,和一个language_code
字段,还有一个外键连接到Product
类,针对一个Product
模型,会按照每种语言生成一个对应的ProductTranslation
对象。
由于翻译的部分和原始的类是独立的两个模型,因此一些ORM的功能无法使用,比如不能在Product
类中根据一个翻译后的字段进行排序,也不能在Meta
类的ordering
属性中使用翻译的字段。
所以编辑shop
应用的models.py
文件,注释掉ordering
设置:
class Category(TranslatableModel): # ... class Meta: # ordering = ('name',) verbose_name = 'category' verbose_name_plural = 'categories'
对于Product
类,也要注释掉ordering
,还需要注释掉index_together
,这是因为目前的django-parler
不支持联合索引的验证关系。如下图:
class Product(TranslatableModel): # ... class Meta: pass # ordering = ('name',) # index_together = (('id', 'slug'),)
译者注:原书在这里遗漏了pass,不要忘记加上。
关于django-parler
的兼容性,可以在https://django-parler.readthedocs.io/en/latest/compatibility.html查看。
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()方法,因此将两个类修改如下:
from django.contrib import admin from .models import Category, Product from parler.admin import TranslatableAdmin @admin.register(Category) class CategoryAdmin(TranslatableAdmin): list_display = ['name', 'slug'] def get_prepopulated_fields(self, request, obj=None): return {'slug': ('name',)} @admin.register(Product) class ProductAdmin(TranslatableAdmin): list_display = ['name', 'slug', 'price', 'available', 'created', 'updated'] list_filter = ['available', 'created', 'updated'] list_editable = ['price', 'available'] def get_prepopulated_fields(self, request, obj=None): return {'slug': ('name',)}
现在在管理后台内也能进行对翻译模型的管理了。现在可以执行数据迁移程序。
打开shell执行下列命令:
python manage.py makemigrations shop --name "translations"
会看到如下输出:
Migrations for 'shop': shop\migrations\0002_translations.py - Create model CategoryTranslation - Create model ProductTranslation - Change Meta options on category - Change Meta options on product - Remove field name from category - Remove field slug from category - Alter index_together for product (0 constraint(s)) - Add field master to producttranslation - Add field master to categorytranslation - Remove field description from product - Remove field name from product - Remove field slug from product - Alter unique_together for producttranslation (1 constraint(s)) - Alter unique_together for categorytranslation (1 constraint(s))
django-parler
动态地创建了CategoryTranslation
和ProductTranslation
。注意,原模型中需要翻译的字段从原模型中删除了,这意味着这几个字段的数据全都丢失了,必须启动站点后重新录入。
之后运行数据迁移:
python manage.py migrate shop
可以看到下列输出:
Applying shop.0002_translations... OK
现在数据已经和数据库同步好了。
启动站点,访问http://127.0.0.1:8000/en/admin/shop/category/,可以看到已经存在的模型失去了那些需要翻译的字段。点击一个category
对象进行修改,可以看到包含了两个不同的表格,一个对应英语,一个对应西班牙语,如下图所示:
为所有已存在category记录都添加名称和简称,再为其添加西班牙语的名称和简称,然后点击SAVE按钮,确保在切换标签之前点击了SAVE按钮,否则数据不会被保存。
之后到http://127.0.0.1:8000/en/admin/shop/product/进行同样的工作:补充每个商品的名称、简称、描述以及对应的西班牙语翻译。
为了正常使用翻译后的模型,必须让shop
应用的视图对翻译后的字段也能够获取QuerySet,终端内输入python manage.py shell
进入带Django环境的命令行模式来试验一下经过翻译后的查询操作:
看一下如何查询翻译后的字段。为了获取某种语言的查询结果集,需要使用Django的activate()
函数:
>>> from shop.models import Product >>> from django.utils.translation import activate >>> activate('es') >>> product=Product.objects.first() >>> product.name 'Té verde'
另外一种根据不同语言查询的方式是使用django-parler
提供的language()
模型管理器:
>>> product=Product.objects.language('en').first() >>> product.name 'Green tea'
当查询翻译字段时,会根据所指定的语言返回结果。可以通过设置管理器的属性得到不同语言的结果,类似这样:
>>> product.set_current_language('es') >>> product.name 'Té verde' >>> product.get_current_language() 'es'
如果需要使用filter
功能,需要使用tranlations__
语法,例子如下:
>>> Product.objects.filter(translations__name='Green tea') <TranslatableQuerySet [<Product: Té verde>]>
了解了基础操作,可以来修改我们自己的视图中的查询方法了,修改shop
应用中的views.py
,找到product_list
视图中如下这行:
category = get_object_or_404(Category, slug=category_slug)
替换成如下内容:
language = request.LANGUAGE_CODE category = get_object_or_404(Category, translations__language_code=language, translations__slug=category_slug)
然后编辑product_detail
视图,找到下边这行:
product = get_object_or_404(Product, id=id, slug=slug, available=True)
替换成如下内容:
language = request.LANGUAGE_CODE product = get_object_or_404(Product, id=id, translations__language_code=language, translations__slug=slug, available=True)
product_list
和product_detail
现在都具备了根据翻译字段查询数据库的功能。启动站点,到http://127.0.0.1:8000/es/,应该可以看到商品名称全部都变成了西班牙语,如下图:
可以看到通过每个商品的slug
字段生成的URL也变成了西班牙语。比如一个商品的URL在西班牙语下是http://127.0.0.1:8000/es/2/te-rojo/
,在英语里则是http://127.0.0.1:8000/en/2/red-tea/
。如果到一个商品详情页,能够看到翻译后的URL和内容如下:
在https://django-parler.readthedocs.io/en/latest/可以查看django-parler
的文档。
现在已经知道了如何翻译Python代码,模板,URL和模型的字段,站点已经可以提供不同语言的服务了。为了完成国际化和本地化的过程,还需要对本地的日期,时间,数字格式进行设置。
根据用户的国家和地区,需要以不同的格式显示日期,时间和数字。本地化格式可以通过settings.py
里的USE_L10N
设置为True
来开启。
当USE_L10N
设置为开启的时候,Django在渲染模板的时候,会尽可能的尝试使用当前本地化的方式进行输出。可以看到我们的站点的小数点是一个圆点显示的,切换到西班牙语的时候,小数点显示为一个逗号。这是通过对每种语言进行不同的格式设置实现的,对于支持的每种语言的格式,Django都有对应的配置文件,例如针对西班牙语的配置文件可以查看https://github.com/django/django/blob/stable/2.0.x/django/conf/locale/es/formats.py。
通常情况下,只要设置USE_L10N
为True
,Django就会自动应用本地化格式。然而,站点内可能有些内容并不想使用本地化格式,尤其那些标准数据例如代码或者是JSON字符串的内容。
Django提供了一个{% locailze %}
模板标签,用于控制模板或者模板片段开启或关闭本地化输出。为了使用这个标签,必须在模板开头使用{% load l10n %}
标签。下边是一个如何在模板中控制开启/关闭本地化输出的例子:
{% load l10n %} {% localize on %} {{ value }} {% endlocalize %} {% localize off %} {{ value }} {% endlocalize %}
Django还提供了两个模板过滤器用于控制本地化,分别是localize
和unlocailze
,用来强制让一个值开启/关闭本地化显示。用法如下:
{{ value|localize }} {{ value|unlocalize }}
除了这两个方法之外,还可以采取自定义格式文件方式,具体看https://docs.djangoproject.com/en/2.0/topics/i18n/formatting/#creating-custom-format-files。
django-localflavor
是一个第三方模块,包含一系列特别针对本地化验证的工具,比如为每个国家单独设计的表单和模型字段,对于验证某些国家的地区,电话号码,身份证,社会保险号码等非常方便。这个模块是按照ISO 3166国家代码标准编写的。
安装django-localflavor
:
pip install django-localflavor==2.0
在settings.py
中激活该应用:
INSTALLED_APPS = [ # ... 'localflavor', ]
为了使用该模块,我们给订单增加一个美国邮编字段和对应验证,必须是一个有效的美国邮编才能建立订单。
编辑orders
应用的forms.py
文件,修改成如下:
from localflavor.us.forms import USZipCodeField class OrderCreateForm(forms.ModelForm): postal_code = USZipCodeField() class Meta: model = Order fields = ['first_name', 'last_name', 'email', 'address', 'postal_code', 'city']
从localflaver
的us
模块中导入USZipCodeField
字段类型,将OrderCreateForm
类的postal_code
字段设置为该类型。
运行站点,到http://127.0.0.1:8000/en/orders/create/,输入一些不符合美国邮编的邮政编码,可以看到表单的错误提示:
Enter a zip code in the format XXXXX or XXXXX-XXXX.
这只是一个针对给字段附加本地化验证的一个简单例子。localflavor
提供的组件对于将站点快速适配到某些国家非常有用。可以在https://django-localflavor.readthedocs.io/en/latest/阅读django-flavor
的官方文档。
现在就结束了所有国际化和本地化配置的工作,下一步是建立一个商品推荐系统。
商品推荐系统可以预测用户对一个商品的喜好程度或者评价高低,根据用户的行为和收集到的用户数据,选择可能和用户相关的产品推荐给用户。在电商行业,推荐系统使用的非常广泛。推荐系统可以帮助用户从浩如烟海的商品中选出自己感兴趣的商品。好的推荐系统可以增加用户粘性,对电商平台则意味着销售额的提高。
我们准备建立一个简单但是强大的商品推荐系统,用于推荐经常被一起购买的商品,这些商品基于用户过去的购买数据来给用户进行推荐。我们打算在两个页面向用户推荐商品:
我们将使用Redis数据库记录一起购买的商品。我们在第六章已经使用过Redis,如果还没有安装Redis,可以参考该章节的内容。
现在,需要根据用户加入到购物车内的商品计算排名。对于我们网站每一个被售出的商品,在Redis中存一个键。这个商品键对应的值是一个有序集合,就为同订单的其他商品在当前商品键对应的有序集合中的分数加1。
当一个订单成功支付时,我们为订单每个购买的商品存储一个有序集合,这个有序集合将记录一起购买的商品分数。
安装redis-p
模块:
pip install redis==2.10.6
之后在settings.py
里配置Redis:
REDIS_HOST = 'localhost' REDIS_PORT = 6379 REDIS_DB = 1
这是用于建立和Redis服务通信的设置。在shop
应用目录下新建recommender.py
文件,添加下列代码:
import redis from django.conf import settings from .models import Product # 连接到Redis r = redis.StrictRedis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=settings.REDIS_DB) class Recommender: def get_product_key(self, product_id): return 'product:{}:purchased_with'.format(product_id) def products_bought(self, products): product_ids = [p.id for p in products] # 针对订单里的每一个商品,将其他商品在当前商品的有序集合中增加1 for product_id in product_ids: for with_id in product_ids: if product_id != with_id: r.zincrby(self.get_product_key(product_id), with_id, amount=1)
这个Recommender
类用来存储订单购买时的相关信息和根据一个指定的对象获取相关的推荐。get_product_key()
方法获取一个Product
对象的id,然后创建对应的有序集合,其中的键看起来像这样:product:[id]:purchased_with
。
product_bought()
方法接受属于同一个订单的Product
对象的列表,然后做如下操作:
Product
对象的IDget_product_id()
方法得到每个商品的Redis键名。例如针对ID为33的商品,返回的键名是product:33:purchased_with
,这个键将用于操作有序集合我们现在有了一个保存商品相关信息的方法。还需要一个方法来从Redis中获得推荐的商品,继续编写Recommender
类,增加suggest_products_for()
方法:
class Recommender: # ...... def suggest_products_for(self, products, max_results=6): product_ids = [p.id for p in products] # 如果当前列表只有一个商品: if len(product_ids) == 1: suggestions = r.zrange(self.get_product_key(product_ids[0]), 0, -1, desc=True)[:max_results] else: # 生成一个临时的key,用于存储临时的有序集合 flat_ids = ''.join([str(id) for id in product_ids]) tmp_key = 'tmp_{}'.format(flat_ids) # 对于多个商品,取所有商品的键名构成keys列表 keys = [self.get_product_key(id) for id in product_ids] # 合并有序集合到临时键 r.zunionstore(tmp_key, keys) # 删除与当前列表内商品相同的键。 r.zrem(tmp_key, *product_ids) # 获得排名结果 suggestions = r.zrange(tmp_key, 0, -1, desc=True)[:max_results] # 删除临时键 r.delete(tmp_key) # 获取关联商品并通过相关性排序 suggested_products_ids = [int(id) for id in suggestions] suggested_products = list(Product.objects.filter(id__in=suggested_products_ids)) suggested_products.sort(key=lambda x: suggested_products_ids.index(x.id)) return suggested_products
suggest_products_for()
方法接受两个参数:
products
:表示为哪些商品进行推荐,可以包含一个或多个商品max_results
:整数值,表示最大推荐几个商品在这个方法里我们做了如下的事情:
Product
对象的IDZRANGE
命令。我们使用max_results
属性指定返回的最大数量。ZUNIONSTORE
命令合并所有商品的有序集合。ZUNIONSTORE
合并所有的有序集合中相同键的分数,然后将新生成的有序集合存入临时键。关于该命令可以参考https://redis.io/commands/ZUNIONSTORE。ZREM
命令从临时键的有序集合中删除与当前订单内商品id相同的键。ZRANGE
命令按照分数排序,通过max_results
控制返回数量,之后删除临时键。Product
对象,然后按照与取出的ID相同的顺序进行排列。为了更加实用,再给Recommender
类添加一个清除推荐商品的方法:
class Recommender: # ...... def clear_purchases(self): for id in Product.objects.values_list('id', flat=True): r.delete(self.get_product_key(id))
我们来测试一下推荐引擎是否正常工作。确保Product
数据表中有一些商品信息,然后先启动Redis:
src/redis-server
通过python manage.py shell
进入带有Django项目环境的shell中:
from shop.models import Product black_tea = Product.objects.get(translations__name='Black tea') red_tea = Product.objects.get(translations__name='Red tea') green_tea = Product.objects.get(translations__name='Green tea') tea_powder = Product.objects.get(translations__name='Tea powder')
之后增加一些测试购买数据:
from shop.recommender import Recommender r = Recommender() r.products_bought([black_tea, red_tea]) r.products_bought([black_tea, green_tea]) r.products_bought([red_tea, black_tea, tea_powder]) r.products_bought([green_tea, tea_powder]) r.products_bought([black_tea, tea_powder]) r.products_bought([red_tea, green_tea])
进行完上述操作后,我们实际为四个商品保存的有序集合是:
black_tea: red_tea (2), tea_powder (2), green_tea (1) red_tea: black_tea (2), tea_powder (1), green_tea (1) green_tea: black_tea (1), tea_powder (1), red_tea(1) tea_powder: black_tea (2), red_tea (1), green_tea (1)
下边测试一下通过翻译字段获取推荐商品信息:
>>> from django.utils.translation import activate >>> activate('en') >>> r.suggest_products_for([black_tea]) [<Product: Tea powder>, <Product: Red tea>, <Product: Green tea>] >>> r.suggest_products_for([red_tea]) [<Product: Black tea>, <Product: Tea powder>, <Product: Green tea>] >>> r.suggest_products_for([green_tea]) [<Product: Black tea>, <Product: Tea powder>, <Product: Red tea>] >>> r.suggest_products_for([tea_powder]) [<Product: Black tea>, <Product: Red tea>, <Product: Green tea>]
如果看到商品是按照它们的分数进行降序排列的,就说明引擎工作正常了。再测试一下多个商品的推荐:
>>> r.suggest_products_for([black_tea, red_tea]) [<Product: Tea powder>, <Product: Green tea>] >>> r.suggest_products_for([green_tea, red_tea]) [<Product: Black tea>, <Product: Tea powder>] >>> r.suggest_products_for([tea_powder, black_tea]) [<Product: Red tea>, <Product: Green tea>]
可以实际计算一下是否符合合并有序集合后的结果,例如针对第一条程序,tea_powder
的分数是2+1,green_tea
的分数是1+1等
测试之后说明我们的推荐算法正常工作,下一步就是将该功能集成到站点中,在商品详情页和购物车清单页进行展示。先修改shop
应用的views.py
文件中的product_detail
视图:
from .recommender import Recommender def product_detail(request, id, slug): language = request.LANGUAGE_CODE product = get_object_or_404(Product, id=id, translations__language_code=language, translations__slug=slug, available=True) cart_product_form = CartAddProductForm() r = Recommender() recommended_products = r.suggest_products_for([product], 4) return render(request, 'shop/product/detail.html', {'product': product, 'cart_product_form': cart_product_form, 'recommended_products': recommended_products})
编辑shop/product/detail.html
模板,增加下列代码到{{ product.description|linebreaks }}
之后:
{% if recommended_products %} <div class="recommendations"> <h3>{% trans "People who bought this also bought" %}</h3> {% for p in recommended_products %} <div class="item"> <a href="{{ p.get_absolute_url }}"> <img src="{% if p.image %}{{ p.image.url }}{% else %}{% static "img/no_image.png" %}{% endif %}"> </a> <p><a href="{{ p.get_absolute_url }}">{{ p.name }}</a></p> </div> {% endfor %} </div> {% endif %}
然后运行站点,点击商品进入详情页,可以看到类似下图的商品推荐:
我们还需要在购物车详情页增加推荐功能,编辑cart
应用的views.py
文件中的cart_detail
视图:
from shop.recommender import Recommender def cart_detail(request): cart = Cart(request) for item in cart: item['update_quantity_form'] = CartAddProductForm(initial={'quantity': item['quantity'], 'update': True}) coupon_apply_form = CouponApplyForm() r = Recommender() cart_products = [item['product'] for item in cart] recommended_products = r.suggest_products_for(cart_products, max_results=4) return render(request, 'cart/detail.html', {'cart': cart, 'coupon_apply_form': coupon_apply_form, 'recommended_products': recommended_products})
然后修改对应的模板 cart/detail.html,在 </table>
之后增加下列代码:
{% if recommended_products %} <div class="recommendations cart"> <h3>{% trans "People who bought this also bought" %}</h3> {% for p in recommended_products %} <div class="item"> <a href="{{ p.get_absolute_url }}"> <img src="{% if p.image %}{{ p.image.url }}{% else %}{% static "img/no_image.png" %}{% endif %}"> </a> <p><a href="{{ p.get_absolute_url }}">{{ p.name }}</a></p> </div> {% endfor %} </div> {% endif %}
译者注,由于上述内容使用了{% trans %}模板标签,不要忘记在页面上方加入{% load i18n %},原书这里没有加,会导致报错。
在浏览器中打开http://127.0.0.1:8000/en/。将一些商品加入购物车,然后至http://127.0.0.1:8000/en/cart/查看购物车详情,可以看到出现了推荐商品:
现在我们就使用Redis配合Django完成了一个推荐系统。
译者注,原书其实并没有将功能写完。可以发现,目前的购买数据(调用Recommender
类的products_bought()
方法)是在我们测试的时候通过命令行添加的,而不是通过网站功能自动添加。按照一开始的分析,应该在付款成功的时候,更新Redis的数据。需要在payment
应用的views.py
文件中,在payment_process
视图中付款响应成功,保存交易id和paid
字段之后,发送PDF发票之前,添加如下代码:
from shop.recommender import Recommender def payment_process(request): ...... if request.method == "POST": ...... if result.is_success: order.paid = True order.braintree_id = result.transaction.id order.save() # 更新Redis中本次购买的商品分数 r = Recommender() order_items = [order_item.product for order_item in order.items.all()] r.products_bought(order_items)
在这一章,学习了创建优惠码系统和国际化与本地化配置工作。还基于Redis创建了一个商品推荐系统。
在下一章,我们将创建一个新的项目:在线教育平台,里边将使用Django的CBV技术,还会创建一个内容管理系统。