目錄
Django2實戰示例 第一章 創建博客應用
Django2實戰示例 第二章 增強博客功能
Django2實戰示例 第三章 擴展博客功能
Django2實戰示例 第四章 創建社交網站
Django2實戰示例 第五章 內容分享功能
Django2實戰示例 第六章 追蹤用戶行為
Django2實戰示例 第七章 創建電商網站
Django2實戰示例 第八章 管理支付與訂單
Django2實戰示例 第九章 擴展商店功能
Django2實戰示例 第十章 創建在線教育平台
Django2實戰示例 第十一章 渲染和緩存課程內容
Django2實戰示例 第十二章 創建API
Django2實戰示例 第十三章 上線
第九章 擴展商店功能
在上一章里,為電商站點集成了支付功能,然后可以生成PDF發票發送給用戶。在本章,我們將為商店添加優惠碼功能。此外,還會學習國際化和本地化的設置和建立一個推薦商品的系統。
本章涵蓋如下要點:
- 建立一個優惠券系統,可以實現折扣功能
- 給項目增加國際化功能
- 使用Rosetta來管理翻譯
- 使用Django-parler翻譯模型
- 建立商品推薦系統
1優惠碼系統
很多電商網站,會向用戶發送電子優惠碼,以便用戶在購買時使用,以折扣價進行結算。一個在線優惠碼通常是一個字符串,然后還規定了有效期限,一次性有效或者可以反復使用。
我們將為站點添加優惠碼功能。我們的優惠碼帶有有效期,但是不限制使用次數,輸入之后,就會影響用戶購物車中的總價。為了實現這個需求,需要建立一個數據模型來存儲優惠碼,有效期和對應的折扣比例。
為myshop
項目創建新的應用coupons
:
python manage.py startapp coupons
然后在settings.py
內激活該應用:
INSTALLED_APPS = [
# ...
'coupons.apps.CouponsConfig',
]
1.1創建優惠碼數據模型
編輯coupons
應用的models.py
文件,創建一個Coupon
模型:
from django.db import models
from django.core.validators import MinValueValidator, MaxValueValidator
class Coupon(models.Model):
code = models.CharField(max_length=50, unique=True)
valid_from = models.DateTimeField()
valid_to = models.DateTimeField()
discount = models.IntegerField(validators=[MinValueValidator(0), MaxValueValidator(100)])
active = models.BooleanField()
def __str__(self):
return self.code
這是用來存儲優惠碼的模型,Coupon
模型包含以下字段:
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按鈕。
1.2為購物車增加優惠碼功能
創建數據模型之后,可以查詢和獲得優惠碼對象。現在我們必須增添使用戶可以輸入優惠碼從而獲得折扣價的功能。這個功能將按照如下邏輯進行操作:
- 用戶添加商品到購物車
- 用戶能通過購物車詳情頁面的表單輸入一個優惠碼
- 輸入優惠碼並提交表單之后,需要來判斷該碼是否在數據庫中存在、當前時間是否在
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
(大於等於)過濾查詢以保證當前時間位於有效期內。 - 將優惠碼ID存入當前用戶的session。
- 重定向到
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/
,添加商品到購物車然后生成訂單,可以看到訂單頁面的價格現在是折扣后的價格了:
1.3在訂單中記錄優惠碼信息
像之前說的,我們需要將優惠碼信息保存至order
對象中,為此需要修改Order
模型。編輯
編輯orders
應用的models.py
文件,增加導入部分的代碼:
from decimal import Decimal
from django.core.validators import MinValueValidator, MaxValueValidator
from coupons.models import Coupon
然后為Order
模型增加下列字段:
class Order(models.Model):
coupon = models.ForeignKey(Coupon, related_name='orders', null=True, blank=True, on_delete=models.SET_NULL)
discount = models.IntegerField(default=0, validators=[MinValueValidator(0), MaxValueValidator(100)])
這兩個字段用於存儲優惠碼信息。雖然折扣信息保存在Coupon對象中,但這里還是用discount
字段保存了當前的折扣,以免未來優惠碼折扣發生變化。為coupon
字段設置了on_delete=models.SET_NULL
,優惠碼刪除時,該外鍵字段會變成空值。
增加好字段后數據遷移程序。回到models.py
文件,需要修改Order
類中的get_total_cost()
方法:
class Order(models.Model):
# ...
def get_total_cost(self):
total_cost = sum(item.get_cost() for item in self.items.all())
return total_cost - total_cost * (self.discount / Decimal('100'))
修改后的get_total_cost()
方法會把折扣也考慮進去。之后還需要修改orders
應用里的views.py
文件中的order_create
視圖,以便在生成訂單的時候,存儲這兩個新增的字段。找到下邊這行:
order = form.save()
將其替換成如下代碼:
order = form.save(commit=False)
if cart.coupon:
order.coupon = cart.coupon
order.discount = cart.coupon.discount
order.save()
在修改后代碼中,通過調用OrderCreateForm
表單對象的save()
方法,創建一個order
對象,使用commit=False
暫不存入數據庫。如果購物車對象中有折扣信息,就保存折扣信息。然后將order
對象存入數據庫。
啟動站點,在瀏覽器中訪問http://127.0.0.1:8000/,使用一個自己創建的優惠碼,在完成購買之后,可以到http://127.0.0.1:8000/admin/orders/order/>查看包含優惠碼和折扣信息的訂單:
還可以修改管理后台的訂單詳情頁和和PDF發票,以使其包含優惠碼和折扣信息。下邊我們將為站點增加國際化功能。
譯者注:這里有一個問題:用戶提交了訂單並清空購物車后,如果再向購物車內添加內容,再次進入購物車詳情頁面可以發現自動使用了上次使用的優惠券。此種情況的原因是作者把優惠券信息附加到了session上,在提交訂單的時候沒有清除。cart對象實例化的時候又取到了相同的優惠券信息。所以需要對程序進行一下改進。
修改orders
應用的order_create
視圖,在生成OrderItem
並清空購物車的代碼下增加一行:
def order_create(request):
cart = Cart(request)
if request.method == "POST":
form = OrderCreateForm(request.POST)
# 表單驗證通過就對購物車內每一條記錄生成OrderItem中對應的一條記錄
if form.is_valid():
order = form.save(commit=False)
if cart.coupon:
order.coupon = cart.coupon
order.discount = cart.coupon.discount
order.save()
for item in cart:
OrderItem.objects.create(order=order, product=item['product'], price=item['price'],
quantity=item['quantity'])
# 成功生成OrderItem之后清除購物車
cart.clear()
# 清除優惠券信息
request.session['coupon_id'] = None
# 成功完成訂單后調用異步任務發送郵件
order_created.delay(order.id)
# 在session中加入訂單id
request.session['order_id'] = order.id
# 重定向到支付頁面
return redirect(reverse('payment:process'))
else:
form = OrderCreateForm()
return render(request, 'orders/order/create.html', {'cart': cart, 'form': form})
2國際化與本地化
Django對於國際化和本地化提供了完整的支持,允許開發者將站點內容翻譯成多種語言,而且可以處理本地化的時間日期數字和時區格式等本地化的顯示內容。在開始之前,先需要區分一下國際化和本地化兩個概念。國際化和本地化都是一種軟件開發過程。國際化(Internationalization,通常縮寫為i18n),是指一個軟件可以被不同的國家和地區使用,而不會局限於某種語言。本地化(Localization,縮寫為l10n)是指對國際化的軟件將其進行翻譯或者其他本地化適配,使之變成適合某一個國家或地區使用的軟件的過程。Django通過自身的國際化框架,可以支持超過50種語言。
2.1國際化與本地化設置
Django的國際化框架可以讓開發者很方便的在Python代碼和模板中標注需要翻譯的字符串,這個框架依賴於GNU gettext開源軟件來生成和管理消息文件(message file)。消息文件是一個純文本文件,代表一種語言的翻譯,存放着在站點應用中找到的部分或者所有需要翻譯的字符串以及對應的某種語言的翻譯,就像一個字典一樣。消息文件的后綴名是.po
。
一旦完成翻譯,可以把消息文件編譯,以快速訪問翻譯內容,編譯后的消息文件的后綴名是.mo
。
2.1.1國際化與本地化設置
Django提供了一些國際化和本地化的設置,下邊一些設置是最重要的:
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。
2.1.2國際化和本地化管理命令
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中的步驟。
2.1.3如何為項目增加翻譯文件
先來看一下增加翻譯需要進行的流程:
- 在Python代碼和模板中標注出需要翻譯的字符串
- 運行
makemessages
命令建立消息文件 - 在消息文件中將字符串翻譯成另外一種語言,然后運行
compilemessages
命令編譯消息文件
2.1.4Django如何確定當前語言
Django使用中間件django.middleware.locale.LocaleMiddleware
來檢查HTTP請求中所使用的本地語言。這個中間件做的工作如下:
- 如果使用i18_patterns(django特殊的一種URL方式,里邊包含語言前綴),中間件會在請求的URL中尋找特定語言的前綴
- 如果在URL中沒有發現語言前綴,會在session中尋找一個鍵
LANGUAGE_SESSION_KEY
- 如果session中沒有該鍵,會在cookie中尋找一個鍵。可以通過
LANGUAGE_COOKIE_NAME
自定義該cookie的名稱,默認是django_language
- 如果cookie中未找到,找HTTP請求頭的
Accept-Language
鍵 - 如果
Accept-Language
頭部信息未指定具體語言,則使用LANGUAGE_CODE
設置
注意這個過程只有在開啟了該中間件的時候才會得到完整執行,如果未開啟中間件,Django直接使用LANGUAGE_CODE
中的設置。
2.2為項目使用國際化進行准備
我們准備為電商網站增添各種語言的支持,增添英語和西班牙語的支持。編輯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/
目錄,那個應用中的翻譯內容會優先在那個應用的目錄中創建。
2.3翻譯Python代碼中的字符串
為了翻譯Python代碼中的字符串字面量,需要使用django.utils.translation
模塊中的gettext()
方法來標注字符串。這個方法返回翻譯后的字符串,通常做法是導入該方法然后命名為一個下划線"_"。可以在https://docs.djangoproject.com/en/2.0/topics/i18n/translation/查看文檔。
2.3.1標記字符串
標記字符串的方法如下:
from django.utils.translation import gettext as _
output = _('Text to be translated.')
2.3.2惰性翻譯
Django對於所有的翻譯函數都有惰性版本,后綴為_lazy()
。使用惰性翻譯函數的時候,字符串只有被訪問的時候才會進行翻譯,而不是在翻譯函數調用的時候。當字符串位於模塊加載的時候才生成的路徑中時候特別有效。
使用gettext_lazy()
代替gettext()
方法,只有在該字符串被訪問的時候才會進行翻譯,所有的翻譯函數都有惰性版本。。
2.3.3包含變量的翻譯
被標注的字符串中還可以帶有占位符,以下是一個占位符的例子:
from django.utils.translation import gettext as _
month = _('April')
day = '14'
output = _('Today is %(month)s %(day)s') % {'month': month, day': day}
通過使用占位符,可以使用字符串變量。例如,上邊這個例子的英語如果是"Today is April 14",翻譯成的西班牙語就是"Hoy es 14 de Abril"。當需要翻譯的文本中存在變量的時候,推薦使用占位符。
2.3.4復數的翻譯
對於復數形式的翻譯,可以采用ngettext()
和ngettext_lazy()
。這兩個函數根據對象的數量來翻譯單數或者復數。使用例子如下:
output = ngettext('there is %(count)d product', 'there are %(count)d products', count) % {'count': count}
現在我們了解了Python中翻譯字面量的知識,可以來為我們的項目添加翻譯功能了。
2.3.5為項目翻譯Python字符串字面量
編輯setttings.py
,導入gettext_lazy()
,然后修改LANGUAGES
設置:
from django.utils.translation import gettext_lazy as _
LANGUAGES = (
('en', _('English')),
('es', _('Spanish')),
)
這里導入了gettext_lazy()
並使用了別名"_"來避免重復導入。將顯示的名稱也進行了翻譯,這樣對於不同的語言的人來說,可以看懂並選擇他自己的語言。
然后打開系統命令行窗口,輸入如下命令:
django-admin makemessages --all
可以看到如下輸出:
processing locale en
processing locale es
然后查看項目的locale
目錄,可以看到如下文件和目錄結構:
en/
LC_MESSAGES/
django.po
es/
LC_MESSAGES/
django.po
每個語言都生成了一個.po
消息文件,使用文本編輯器打開es/LC_MESSAGES/django.po
文件,在末尾可以看到如下內容:
#: .\myshop\settings.py:107
msgid "English"
msgstr ""
#: .\myshop\settings.py:108
msgid "Spanish"
msgstr ""
每一部分的第一行表示在那個文件的第幾行發現了需翻譯的內容,每個翻譯包含兩個字符串:
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標簽用於展示翻譯后的字段名稱。
2.4翻譯模板
Django為翻譯模板內容提供了{% trans %}
和{% blocktrans %}
兩個模板標簽用於翻譯內容,如果要啟用這兩個標簽,需要在模板頂部加入{% load i18n %}
。
2.4.1使用{% trans %}
模板標簽
{% trans %}
標簽用來標記一個字符串,常量或者變量用於翻譯。Django內部也是該文本執行gettext()
等翻譯函數。標記字符串的例子是:
{% trans "Text to be translated" %}
也可以像其他標簽變量一樣,使用as 將 翻譯后的結果放入一個變量中,在其他地方使用。下面的例子使用了一個變量greeting
:
{% trans "Hello!" as greeting %}
<h1>{{ greeting }}</h1>
這個標簽用於比較簡單的翻譯,但不能用於帶占位符的文字翻譯。
2.4.2使用{% blocktrans %}
模板標簽
{% blocktrans %}
標簽可以標記包含常量和占位符的內容用於翻譯,下邊的例子展示了使用一個name
變量的翻譯:
{% blocktrans %}Hello {{ name }}!{% endblocktrans %}
可以使用with,將具體的表達式設置為變量的值,此時在blocktrans
塊內部不能夠再繼續訪問表達式和對象的屬性,下面是一個使用了capfirst
裝飾器的例子:
{% blocktrans with name=user.name|capfirst %}
Hello {{ name }}!
{% endblocktrans %}
如果翻譯內容中包含變量,使用{% blocktrans %}
代替{% trans %}
。
2.4.3翻譯商店模板
編輯shop
應用的base.html
,在其頂部加入i18n
標簽,然后標注如下要翻譯的部分:
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>{% block title %}{% trans "My shop" %}{% endblock %}</title>
<link href="{% static "css/base2.css" %}" rel="stylesheet">
</head>
<body>
<div id="header">
<a href="/" class="logo">{% trans "My shop" %}</a>
</div>
<div id="subheader">
<div class="cart">
{% with total_items=mycart|length %}
{% if mycart|length > 0 %}
{% trans "Your cart" %}:
<a href="{% url 'cart:cart_detail' %}">
{% blocktrans with total_items_plural=total_items|pluralize total_price=cart.get_total_price %}
{{ total_items }} items{{ total_items_plural }}, ${{ total_price }}
{% endblocktrans %}
</a>
{% else %}
{% trans "Your cart is empty." %}
{% endif %}
{% endwith %}
</div>
</div>
<div id="content">
{% block content %}
{% endblock %}
</div>
</body>
</html>
注意{% blocktrans %}
展示購物車總價部分的方法,在原來的模板中,我們使用了:
{{ total_items }} item{{ total_items|pluralize }},
${{ cart.get_total_price }}
現在改用{% blocktrans with ... %}
來為total_items|pluralize
(使用了過濾器)和cart.get_total_price
(訪問對象的方法)創建占位符:
編輯shop
應用的shop/product/detail.html
,緊接着{% extends %}
標簽導入i18n
標簽:
{% load i18n %}
之后找到下邊這一行:
<input type="submit" value="Add to cart">
將其替換成:
<input type="submit" value="{% trans "Add to cart" %}">
現在來翻譯orders
應用,編輯orders/order/create.html
,標記如下翻譯內容:
{% extends 'shop/base.html' %}
{% load i18n %}
{% block title %}
{% trans "Checkout" %}
{% endblock %}
{% block content %}
<h1>{% trans "Checkout" %}</h1>
<div class="order-info">
<h3>{% trans "Your order" %}</h3>
<ul>
{% for item in cart %}
<li>
{{ item.quantity }}x {{ item.product.name }}
<span>${{ item.total_price|floatformat:"2" }}</span>
</li>
{% endfor %}
{% if cart.coupon %}
<li>
{% blocktrans with code=cart.coupon.code discount=cart.coupon.discount %}
"{{ code }}" ({{ discount }}% off)
{% endblocktrans %}
<span>- ${{ cart.get_discount|floatformat:"2" }}</span>
</li>
{% endif %}
</ul>
<p>{% trans "Total" %}: ${{ cart.get_total_price_after_diccount|floatformat:"2" }}</p>
</div>
<form action="." method="post" class="order-form" novalidate>
{{ form.as_p }}
<p><input type="submit" value="{% trans "Place order" %}"></p>
{% csrf_token %}
</form>
{% endblock %}
到現在我們完成了如下文件的翻譯:
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
文件。
2.5使用Rosetta翻譯界面
Rosetta是一個第三方應用,通過Django管理后台編輯所有翻譯內容,讓.po
文件的管理變得更加方便,先通過pip
安裝該模塊:
pip install django-rosetta==0.8.1
之后在settings.py
中激活該應用:
INSTALLED_APPS = [
# ...
'rosetta',
]
然后需要為Rosetta配置相應的URL,其二級路由已經配置好,修改項目根路由增加一行:
urlpatterns = [
# ...
path('rosetta/', include('rosetta.urls')),
path('', include('shop.urls', namespace='shop')),
]
這條路徑也需要在shop.urls
上邊。
然后啟動站點,使用管理員身份登錄http://127.0.0.1:8000/rosetta/ ,再轉到http://127.0.0.1:8000/rosetta/,點擊右上的THIRD PARTY以列出所有的翻譯文件,如下圖所示:
點開Spanish下邊的Myshop鏈接,可以看到列出了所有需要翻譯的內容:
可以手工編輯需要翻譯的地方,OCCURRENCES(S)欄顯示了該翻譯所在的文件名和行數,對於那些占位符翻譯的內容,顯示為這樣:
Rosetta對占位符使用了不同的背景顏色,在手工輸入翻譯內容的時候注意不要破壞占位符的結構,例如要翻譯下邊這一行:
%(total_items)s item%(total_items_plural)s, $%(total_price)s
應該輸入:
%(total_items)s producto%(total_items_plural)s, $%(total_price)s
可以參考本章隨書代碼中的西班牙語翻譯來錄入翻譯內容。
結束輸入的時候,點擊一下Save即可將當前翻譯的內容保存到.po
文件中,當保存之后,Rosetta會自動進行編譯,所以無需執行compilemessages
命令。然而要注意Rosetta會直接讀寫locale
目錄,注意要給予其相應的權限。
如果需要其他用戶來編輯翻譯內容,可以到http://127.0.0.1:8000/admin/auth/group/add/新增一個用戶組叫translators
,然后到http://127.0.0.1:8000/admin/auth/user/編輯用戶的權限以給予其修改翻譯的權限,將該用戶加入到translators
用戶組內。僅限超級用戶和translators
用戶組內的用戶才能使用Rosetta。
Rosetta的官方文檔在https://django-rosetta.readthedocs.io/en/latest/。
特別注意的是,當Django已經在生產環境運行時,如果修改和新增了翻譯,在運行了compilemessages
命令之后,只有重新啟動Django才會讓新的翻譯生效。
2.6待校對翻譯Fuzzy translations
你可能注意到了,Rosetta頁面上有一列叫做Fuzzy。這不是Rosetta的功能,而是gettext
提供的功能。如果將fuzzy設置為true,則該條翻譯不會包含在編譯后的消息文件中。這個字段用來標記需要由用戶進行檢查的翻譯內容。當.po
文件更新了新的翻譯字符串時,很可能一些翻譯被自動標成了fuzzy。這是因為:在gettext
發現一些msgid
被修改過的時候,gettext
會將其與它認為的舊有翻譯進行匹配,然后標注上fuzzy。看到fuzzy出現的時候,人工翻譯者必須檢查該條翻譯,然后取消fuzzy,之后再行編譯。
2.7國際化URL
Django提供兩種國際化URL的特性:
- Language prefix in URL patterns 語言前綴URL模式:在URL的前邊加上不同的語言前綴構成不同的基礎URL
- Translated URL patterns 翻譯URL模式:基礎URL相同,把基礎URL按照不同語言翻譯給用戶得到不同語言的URL
使用翻譯URL模式的優點是對搜索引擎友好。如果采用語言前綴URL,則必須要為每一種語言進行索引,使用翻譯URL模式,則一條URL就可以匹配全部語言。下邊來看一下兩種模式的使用:
2.7.1語言前綴URL模式
Django可以為不同語言在URL前添加前綴,例如我們的網站,英語版以/en/
開頭,而西班牙語版以/es/
開頭。
要使用語言前綴URL模式,需要啟用LocaleMiddleware
中間件,用於從不同的URL中識別語言,在之前我們已經添加過該中間件。
我們來為URL模式增加前綴,現在需要修改項目的根urls.py
文件:
from django.conf.urls.i18n import i18n_patterns
urlpatterns = i18n_patterns(
path('admin/', admin.site.urls),
path('cart/', include('cart.urls', namespace='cart')),
path('orders/', include('orders.urls', namespace='orders')),
path('pyament/', include('payment.urls', namespace='payment')),
path('coupons/', include('coupons.urls', namespace='coupons')),
path('rosetta/', include('rosetta.urls')),
path('', include('shop.urls', namespace='shop')),
)
可以混用未經翻譯的標准URL與i18n_patterns
類型的URL,使部分URL帶有語言前綴,部分不帶前綴。但最好只使用翻譯URL,以避免把翻譯過的URL匹配到未經翻譯過的URL模式上。
現在啟動站點,到http://127.0.0.1:8000/ ,Django的語言中間件會按照之前介紹的順序來確定本地語言,然后重定向到帶有語言前綴的URL。現在看一下瀏覽器的地址欄,應該是http://127.0.0.1:8000/en/。當前語言是由請求頭Accept-Language
所設置,或者就是LANGUAGE_CODE
的設置。
2.7.2翻譯URL模式
Django支持在URL模式中翻譯字符串。針對不同的語言,可以翻譯出不同的URL。在urls.py
中,使用ugettext_lazy()
來標注字符串。
編輯myshop
應用的根urls.py
,為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查看所有尚未翻譯的字符串,然后輸入翻譯內容。
2.8允許用戶切換語言
在之前的工作中,我們配置好了英語和西班牙語的翻譯,應該給用戶提供切換語言的選項,為此准備給網站增加一個語言選擇器,列出所有支持的語言,顯示為一系列鏈接。
編輯shop
應用下的base.html
,找到下邊這三行:
<div id="header">
<a href="/" class="logo">{% trans "My shop" %}</a>
</div>
將其替換成:
<div id="header">
<a href="/" class="logo">{% trans "My shop" %}</a>
{% get_current_language as LANGUAGE_CODE %}
{% get_available_languages as LANGUAGES %}
{% get_language_info_list for LANGUAGES as languages %}
<div class="languages">
<p>{% trans "Language" %}:</p>
<ul class="languages">
{% for language in languages %}
<li>
<a href="/{{ language.code }}/"
{% if language.code == LANGUAGE_CODE %} class="selected"{% endif %}>
{{ language.name_local }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
這個就是我們的語言選擇器,邏輯如下:
- 頁面的最上方加載
{% load i18n %}
- 使用
{% get_current_language %}
標簽用於獲取當前語言 - 使用
{% get_available_languages %}
標簽用於從LANGUAGES
里獲取所有可用的支持語言 - 使用
{% get_language_info_list %}
是為了快速獲取語言的屬性而設置的變量 - 用循環列出了所有可支持的語言,對於當前語言設置CSS類為
select
啟動站點到http://127.0.0.1:8000/ ,可以看到頁面右上方出現了語言選擇器,如下圖:
2.9使用django-parler翻譯模型
Django沒有提供直接可用的模型翻譯功能,必須采用自己的方式實現模型翻譯。有一些第三方工具可以翻譯模型字段,每個工具存儲翻譯的方式都不相同。其中一個工具叫做django-parler
,提供了高效的翻譯管理,還能夠與管理后台進行集成。
django-parler
的工作原理是為每個模型建立一個對應的翻譯數據表,表內每條翻譯記錄通過外鍵連到翻譯文字所在的模型,表內還有一個language
字段,用於標記是何種語言。
2.9.1安裝django-parler
使用pip
安裝django-parler
:
pip install django-parler==1.9.2
在settings.py
內激活該應用:
INSTALLED_APPS = [
# ...
'parler',
]
繼續添加下列設置:
PARLER_LANGUAGES = {
None: (
{'code': 'en'},
{'code': 'es'},
),
'default': {
'fallback': 'en',
'hide_untranslated': False,
}
}
該配置的含義是指定了django-parler
的可用語言為en
和es
,然后指定了默認語言為en
,然后指定django-parler
不要隱藏未翻譯的內容。
2.9.2翻譯模型字段
我們為商品品類添加翻譯。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查看。
2.9.3將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',)}
現在在管理后台內也能進行對翻譯模型的管理了。現在可以執行數據遷移程序。
2.9.4遷移翻譯模型數據
打開shell執行下列命令:
python manage.py makemigrations shop --name "translations"
會看到如下輸出:
Migrations for 'shop':
shop\migrations\0002_translations.py
- Create model CategoryTranslation
- Create model ProductTranslation
- Change Meta options on category
- Change Meta options on product
- Remove field name from category
- Remove field slug from category
- Alter index_together for product (0 constraint(s))
- Add field master to producttranslation
- Add field master to categorytranslation
- Remove field description from product
- Remove field name from product
- Remove field slug from product
- Alter unique_together for producttranslation (1 constraint(s))
- Alter unique_together for categorytranslation (1 constraint(s))
django-parler
動態地創建了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/進行同樣的工作:補充每個商品的名稱、簡稱、描述以及對應的西班牙語翻譯。
2.9.5視圖中加入翻譯功能
為了正常使用翻譯后的模型,必須讓shop
應用的視圖對翻譯后的字段也能夠獲取QuerySet,終端內輸入python manage.py shell
進入帶Django環境的命令行模式來試驗一下經過翻譯后的查詢操作:
看一下如何查詢翻譯后的字段。為了獲取某種語言的查詢結果集,需要使用Django的activate()
函數:
>>> from shop.models import Product
>>> from django.utils.translation import activate
>>> activate('es')
>>> product=Product.objects.first()
>>> product.name
'Té verde'
另外一種根據不同語言查詢的方式是使用django-parler
提供的language()
模型管理器:
>>> product=Product.objects.language('en').first()
>>> product.name
'Green tea'
當查詢翻譯字段時,會根據所指定的語言返回結果。可以通過設置管理器的屬性得到不同語言的結果,類似這樣:
>>> product.set_current_language('es')
>>> product.name
'Té verde'
>>> product.get_current_language()
'es'
如果需要使用filter
功能,需要使用tranlations__
語法,例子如下:
>>> Product.objects.filter(translations__name='Green tea')
<TranslatableQuerySet [<Product: Té verde>]>
了解了基礎操作,可以來修改我們自己的視圖中的查詢方法了,修改shop
應用中的views.py
,找到product_list
視圖中如下這行:
category = get_object_or_404(Category, slug=category_slug)
替換成如下內容:
language = request.LANGUAGE_CODE
category = get_object_or_404(Category, translations__language_code=language, translations__slug=category_slug)
然后編輯product_detail
視圖,找到下邊這行:
product = get_object_or_404(Product, id=id, slug=slug, available=True)
替換成如下內容:
language = request.LANGUAGE_CODE
product = get_object_or_404(Product, id=id, translations__language_code=language, translations__slug=slug,
available=True)
product_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和模型的字段,站點已經可以提供不同語言的服務了。為了完成國際化和本地化的過程,還需要對本地的日期,時間,數字格式進行設置。
2.10本地格式化
根據用戶的國家和地區,需要以不同的格式顯示日期,時間和數字。本地化格式可以通過settings.py
里的USE_L10N
設置為True
來開啟。
當USE_L10N
設置為開啟的時候,Django在渲染模板的時候,會盡可能的嘗試使用當前本地化的方式進行輸出。可以看到我們的站點的小數點是一個圓點顯示的,切換到西班牙語的時候,小數點顯示為一個逗號。這是通過對每種語言進行不同的格式設置實現的,對於支持的每種語言的格式,Django都有對應的配置文件,例如針對西班牙語的配置文件可以查看https://github.com/django/django/blob/stable/2.0.x/django/conf/locale/es/formats.py。
通常情況下,只要設置USE_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。
2.11用django-localflavor驗證表單字段
django-localflavor
是一個第三方模塊,包含一系列特別針對本地化驗證的工具,比如為每個國家單獨設計的表單和模型字段,對於驗證某些國家的地區,電話號碼,身份證,社會保險號碼等非常方便。這個模塊是按照ISO 3166國家代碼標准編寫的。
安裝django-localflavor
:
pip install django-localflavor==2.0
在settings.py
中激活該應用:
INSTALLED_APPS = [
# ...
'localflavor',
]
為了使用該模塊,我們給訂單增加一個美國郵編字段和對應驗證,必須是一個有效的美國郵編才能建立訂單。
編輯orders
應用的forms.py
文件,修改成如下:
from localflavor.us.forms import USZipCodeField
class OrderCreateForm(forms.ModelForm):
postal_code = USZipCodeField()
class Meta:
model = Order
fields = ['first_name', 'last_name', 'email', 'address', 'postal_code', 'city']
從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
的官方文檔。
現在就結束了所有國際化和本地化配置的工作,下一步是建立一個商品推薦系統。
3創建商品推薦系統
商品推薦系統可以預測用戶對一個商品的喜好程度或者評價高低,根據用戶的行為和收集到的用戶數據,選擇可能和用戶相關的產品推薦給用戶。在電商行業,推薦系統使用的非常廣泛。推薦系統可以幫助用戶從浩如煙海的商品中選出自己感興趣的商品。好的推薦系統可以增加用戶粘性,對電商平台則意味着銷售額的提高。
我們准備建立一個簡單但是強大的商品推薦系統,用於推薦經常被一起購買的商品,這些商品基於用戶過去的購買數據來給用戶進行推薦。我們打算在兩個頁面向用戶推薦商品:
- 首先是商品詳情頁。我們會在此展示一些與當前商品一起購買的商品。展示的文字類似:Users who bought this also bought X, Y, Z. 所以我們需要一個數據結構來存放所有與該商品一同購買的次數。
- 其次是購物車詳情頁。這時將不同商品與購物車中所有商品的關聯購買次數進行求和再進行排名。
我們將使用Redis數據庫記錄一起購買的商品。我們在第六章已經使用過Redis,如果還沒有安裝Redis,可以參考該章節的內容。
3.1根據之前的購買記錄推薦商品
現在,需要根據用戶加入到購物車內的商品計算排名。對於我們網站每一個被售出的商品,在Redis中存一個鍵。這個商品鍵對應的值是一個有序集合,就為同訂單的其他商品在當前商品鍵對應的有序集合中的分數加1。
當一個訂單成功支付時,我們為訂單每個購買的商品存儲一個有序集合,這個有序集合將記錄一起購買的商品分數。
安裝redis-p
模塊:
pip install redis==2.10.6
之后在settings.py
里配置Redis:
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 1
這是用於建立和Redis服務通信的設置。在shop
應用目錄下新建recommender.py
文件,添加下列代碼:
import redis
from django.conf import settings
from .models import Product
# 連接到Redis
r = redis.StrictRedis(host=settings.REDIS_HOST, port=settings.REDIS_PORT, db=settings.REDIS_DB)
class Recommender:
def get_product_key(self, product_id):
return 'product:{}:purchased_with'.format(product_id)
def products_bought(self, products):
product_ids = [p.id for p in products]
# 針對訂單里的每一個商品,將其他商品在當前商品的有序集合中增加1
for product_id in product_ids:
for with_id in product_ids:
if product_id != with_id:
r.zincrby(self.get_product_key(product_id), with_id, amount=1)
這個Recommender
類用來存儲訂單購買時的相關信息和根據一個指定的對象獲取相關的推薦。get_product_key()
方法獲取一個Product
對象的id,然后創建對應的有序集合,其中的鍵看起來像這樣:product:[id]:purchased_with
。
product_bought()
方法接受屬於同一個訂單的Product
對象的列表,然后做如下操作:
- 獲取所有
Product
對象的ID - 針對每一個ID遍歷一次全部的ID,跳過內外循環ID相同的部分,這樣就針對其中每個商品都遍歷了與其一同購買的商品
- 使用
get_product_id()
方法得到每個商品的Redis鍵名。例如針對ID為33的商品,返回的鍵名是product:33:purchased_with
,這個鍵將用於操作有序集合 - 在該商品對應的有序序列將同一訂單內的其他商品的分數增加1
我們現在有了一個保存商品相關信息的方法。還需要一個方法來從Redis中獲得推薦的商品,繼續編寫Recommender
類,增加suggest_products_for()
方法:
class Recommender:
# ......
def suggest_products_for(self, products, max_results=6):
product_ids = [p.id for p in products]
# 如果當前列表只有一個商品:
if len(product_ids) == 1:
suggestions = r.zrange(self.get_product_key(product_ids[0]), 0, -1, desc=True)[:max_results]
else:
# 生成一個臨時的key,用於存儲臨時的有序集合
flat_ids = ''.join([str(id) for id in product_ids])
tmp_key = 'tmp_{}'.format(flat_ids)
# 對於多個商品,取所有商品的鍵名構成keys列表
keys = [self.get_product_key(id) for id in product_ids]
# 合並有序集合到臨時鍵
r.zunionstore(tmp_key, keys)
# 刪除與當前列表內商品相同的鍵。
r.zrem(tmp_key, *product_ids)
# 獲得排名結果
suggestions = r.zrange(tmp_key, 0, -1, desc=True)[:max_results]
# 刪除臨時鍵
r.delete(tmp_key)
# 獲取關聯商品並通過相關性排序
suggested_products_ids = [int(id) for id in suggestions]
suggested_products = list(Product.objects.filter(id__in=suggested_products_ids))
suggested_products.sort(key=lambda x: suggested_products_ids.index(x.id))
return suggested_products
suggest_products_for()
方法接受兩個參數:
products
:表示為哪些商品進行推薦,可以包含一個或多個商品max_results
:整數值,表示最大推薦幾個商品
在這個方法里我們做了如下的事情:
- 獲取所有
Product
對象的ID - 如果僅有一個商品,直接查詢這個id對應的有序集合,按降序返回結果。為了實現查詢,使用了Redis的
ZRANGE
命令。我們使用max_results
屬性指定返回的最大數量。 - 如果商品數量多於1個,通過ID創建一個臨時鍵名。
- 通過Redis的
ZUNIONSTORE
命令合並所有商品的有序集合。ZUNIONSTORE
合並所有的有序集合中相同鍵的分數,然后將新生成的有序集合存入臨時鍵。關於該命令可以參考https://redis.io/commands/ZUNIONSTORE。 - 由於已經在當前購物車內的商品無需被推薦,因此使用
ZREM
命令從臨時鍵的有序集合中刪除與當前訂單內商品id相同的鍵。 - 從臨時鍵中獲取商品ID,使用
ZRANGE
命令按照分數排序,通過max_results
控制返回數量,之后刪除臨時鍵。 - 根據ID獲取
Product
對象,然后按照與取出的ID相同的順序進行排列。
為了更加實用,再給Recommender
類添加一個清除推薦商品的方法:
class Recommender:
# ......
def clear_purchases(self):
for id in Product.objects.values_list('id', flat=True):
r.delete(self.get_product_key(id))
我們來測試一下推薦引擎是否正常工作。確保Product
數據表中有一些商品信息,然后先啟動Redis:
src/redis-server
通過python manage.py shell
進入帶有Django項目環境的shell中:
from shop.models import Product
black_tea = Product.objects.get(translations__name='Black tea')
red_tea = Product.objects.get(translations__name='Red tea')
green_tea = Product.objects.get(translations__name='Green tea')
tea_powder = Product.objects.get(translations__name='Tea powder')
之后增加一些測試購買數據:
from shop.recommender import Recommender
r = Recommender()
r.products_bought([black_tea, red_tea])
r.products_bought([black_tea, green_tea])
r.products_bought([red_tea, black_tea, tea_powder])
r.products_bought([green_tea, tea_powder])
r.products_bought([black_tea, tea_powder])
r.products_bought([red_tea, green_tea])
進行完上述操作后,我們實際為四個商品保存的有序集合是:
black_tea: red_tea (2), tea_powder (2), green_tea (1)
red_tea: black_tea (2), tea_powder (1), green_tea (1)
green_tea: black_tea (1), tea_powder (1), red_tea(1)
tea_powder: black_tea (2), red_tea (1), green_tea (1)
下邊測試一下通過翻譯字段獲取推薦商品信息:
>>> from django.utils.translation import activate
>>> activate('en')
>>> r.suggest_products_for([black_tea])
[<Product: Tea powder>, <Product: Red tea>, <Product: Green tea>]
>>> r.suggest_products_for([red_tea])
[<Product: Black tea>, <Product: Tea powder>, <Product: Green tea>]
>>> r.suggest_products_for([green_tea])
[<Product: Black tea>, <Product: Tea powder>, <Product: Red tea>]
>>> r.suggest_products_for([tea_powder])
[<Product: Black tea>, <Product: Red tea>, <Product: Green tea>]
如果看到商品是按照它們的分數進行降序排列的,就說明引擎工作正常了。再測試一下多個商品的推薦:
>>> r.suggest_products_for([black_tea, red_tea])
[<Product: Tea powder>, <Product: Green tea>]
>>> r.suggest_products_for([green_tea, red_tea])
[<Product: Black tea>, <Product: Tea powder>]
>>> r.suggest_products_for([tea_powder, black_tea])
[<Product: Red tea>, <Product: Green tea>]
可以實際計算一下是否符合合並有序集合后的結果,例如針對第一條程序,tea_powder
的分數是2+1,green_tea
的分數是1+1等
測試之后說明我們的推薦算法正常工作,下一步就是將該功能集成到站點中,在商品詳情頁和購物車清單頁進行展示。先修改shop
應用的views.py
文件中的product_detail
視圖:
from .recommender import Recommender
def product_detail(request, id, slug):
language = request.LANGUAGE_CODE
product = get_object_or_404(Product, id=id, translations__language_code=language, translations__slug=slug,
available=True)
cart_product_form = CartAddProductForm()
r = Recommender()
recommended_products = r.suggest_products_for([product], 4)
return render(request, 'shop/product/detail.html', {'product': product, 'cart_product_form': cart_product_form,
'recommended_products': recommended_products})
編輯shop/product/detail.html
模板,增加下列代碼到{{ product.description|linebreaks }}
之后:
{% if recommended_products %}
<div class="recommendations">
<h3>{% trans "People who bought this also bought" %}</h3>
{% for p in recommended_products %}
<div class="item">
<a href="{{ p.get_absolute_url }}">
<img src="{% if p.image %}{{ p.image.url }}{% else %}{% static "img/no_image.png" %}{% endif %}">
</a>
<p><a href="{{ p.get_absolute_url }}">{{ p.name }}</a></p>
</div>
{% endfor %}
</div>
{% endif %}
然后運行站點,點擊商品進入詳情頁,可以看到類似下圖的商品推薦:
我們還需要在購物車詳情頁增加推薦功能,編輯cart
應用的views.py
文件中的cart_detail
視圖:
from shop.recommender import Recommender
def cart_detail(request):
cart = Cart(request)
for item in cart:
item['update_quantity_form'] = CartAddProductForm(initial={'quantity': item['quantity'], 'update': True})
coupon_apply_form = CouponApplyForm()
r = Recommender()
cart_products = [item['product'] for item in cart]
recommended_products = r.suggest_products_for(cart_products, max_results=4)
return render(request, 'cart/detail.html',
{'cart': cart, 'coupon_apply_form': coupon_apply_form, 'recommended_products': recommended_products})
然后修改對應的模板 cart/detail.html,在 </table>
之后增加下列代碼:
{% if recommended_products %}
<div class="recommendations cart">
<h3>{% trans "People who bought this also bought" %}</h3>
{% for p in recommended_products %}
<div class="item">
<a href="{{ p.get_absolute_url }}">
<img src="{% if p.image %}{{ p.image.url }}{% else %}{% static "img/no_image.png" %}{% endif %}">
</a>
<p><a href="{{ p.get_absolute_url }}">{{ p.name }}</a></p>
</div>
{% endfor %}
</div>
{% endif %}
譯者注,由於上述內容使用了{% trans %}模板標簽,不要忘記在頁面上方加入{% load i18n %},原書這里沒有加,會導致報錯。
在瀏覽器中打開http://127.0.0.1:8000/en/。將一些商品加入購物車,然后至http://127.0.0.1:8000/en/cart/查看購物車詳情,可以看到出現了推薦商品:
現在我們就使用Redis配合Django完成了一個推薦系統。
譯者注,原書其實並沒有將功能寫完。可以發現,目前的購買數據(調用Recommender
類的products_bought()
方法)是在我們測試的時候通過命令行添加的,而不是通過網站功能自動添加。按照一開始的分析,應該在付款成功的時候,更新Redis的數據。需要在payment
應用的views.py
文件中,在payment_process
視圖中付款響應成功,保存交易id和paid
字段之后,發送PDF發票之前,添加如下代碼:
from shop.recommender import Recommender
def payment_process(request):
......
if request.method == "POST":
......
if result.is_success:
order.paid = True
order.braintree_id = result.transaction.id
order.save()
# 更新Redis中本次購買的商品分數
r = Recommender()
order_items = [order_item.product for order_item in order.items.all()]
r.products_bought(order_items)
總結
在這一章,學習了創建優惠碼系統和國際化與本地化配置工作。還基於Redis創建了一個商品推薦系統。
在下一章,我們將創建一個新的項目:在線教育平台,里邊將使用Django的CBV技術,還會創建一個內容管理系統。