In the previous chapter, you learned how to integrate a payment gateway into your shop. You also learned how to generate CSV and PDF files.
In this chapter, you will add a coupon system to your shop. You will also learn how internationalization and localization work, and you will build a recommendation engine.
This chapter will cover the following points:
django-parler
Many online shops give out coupons to customers that can be redeemed for discounts on their purchases. An online coupon usually consists of a code that is given to users and is valid for a specific time frame.
You are going to create a coupon system for your shop. Your coupons will be valid for customers in a certain time frame. The coupons will not have any limitations in terms of the number of times they can be redeemed, and they will be applied to the total value of the shopping cart. For this functionality, you will need to create a model to store the coupon code, a valid time frame, and the discount to apply.
Create a new application inside the myshop
project using the following command:
python manage.py startapp coupons
Edit the settings.py
file of myshop
and add the application to the INSTALLED_APPS
setting, as follows:
INSTALLED_APPS = [
# ...
'coupons.apps.CouponsConfig',
]
The new application is now active in your Django project.
Let's start by creating the Coupon
model. Edit the models.py
file of the coupons
application and add the following code to it:
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
This is the model that you are going to use to store coupons. The Coupon
model contains the following fields:
code
: The code that users have to enter in order to apply the coupon to their purchase.valid_from
: The datetime value that indicates when the coupon becomes valid.valid_to
: The datetime value that indicates when the coupon becomes invalid.discount
: The discount rate to apply (this is a percentage, so it takes values from 0
to 100
). You use validators for this field to limit the minimum and maximum accepted values.active
: A Boolean that indicates whether the coupon is active.Run the following command to generate the initial migration for the coupons
application:
python manage.py makemigrations
The output should include the following lines:
Migrations for 'coupons':
coupons/migrations/0001_initial.py:
- Create model Coupon
Then, execute the next command to apply migrations:
python manage.py migrate
You should see an output that includes the following line:
Applying coupons.0001_initial... OK
The migrations are now applied in the database. Let's add the Coupon
model to the administration site. Edit the admin.py
file of the coupons
application and add the following code to it:
from django.contrib import admin
from .models import Coupon
@admin.register(Coupon)
class CouponAdmin(admin.ModelAdmin):
list_display = ['code', 'valid_from', 'valid_to',
'discount', 'active']
list_filter = ['active', 'valid_from', 'valid_to']
search_fields = ['code']
The Coupon
model is now registered in the administration site. Ensure that your local server is running with the command python manage.py runserver
. Open http://127.0.0.1:8000/admin/coupons/coupon/add/
in your browser.
You should see the following form:
Figure 9.1: The Add coupon form
Fill in the form to create a new coupon that is valid for the current date and make sure that you check the Active checkbox and click the SAVE button.
You can store new coupons and make queries to retrieve existing coupons. Now you need a way for customers to apply coupons to their purchases. The functionality to apply a coupon would be as follows:
active
attribute is True
, and that the current datetime is between the valid_from
and valid_to
values.Create a new file inside the coupons
application directory and name it forms.py
. Add the following code to it:
from django import forms
class CouponApplyForm(forms.Form):
code = forms.CharField()
This is the form that you are going to use for the user to enter a coupon code. Edit the views.py
file inside the coupons
application and add the following code to it:
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')
The coupon_apply
view validates the coupon and stores it in the user's session. You apply the require_POST
decorator to this view to restrict it to POST
requests. In the view, you perform the following tasks:
CouponApplyForm
form using the posted data and check that the form is valid.code
entered by the user from the form's cleaned_data
dictionary. You try to retrieve the Coupon
object with the given code. You use the iexact
field lookup to perform a case-insensitive exact match. The coupon has to be currently active (active=True
) and valid for the current datetime. You use Django's timezone.now()
function to get the current timezone-aware datetime and you compare it with the valid_from
and valid_to
fields performing lte
(less than or equal to) and gte
(greater than or equal to) field lookups, respectively.cart_detail
URL to display the cart with the coupon applied.You need a URL pattern for the coupon_apply
view. Create a new file inside the coupons
application directory and name it urls.py
. Add the following code to it:
from django.urls import path
from . import views
app_name = 'coupons'
urlpatterns = [
path('apply/', views.coupon_apply, name='apply'),
]
Then, edit the main urls.py
of the myshop
project and include the coupons
URL patterns, as follows:
urlpatterns = [
# ...
path('coupons/', include('coupons.urls', namespace='coupons')),
path('', include('shop.urls', namespace='shop')),
]
Remember to place this pattern before the shop.urls
pattern.
Now, edit the cart.py
file of the cart
application. Include the following import:
from coupons.models import Coupon
Add the following code to the end of the __init__()
method of the Cart
class to initialize the coupon from the current session:
class Cart(object):
def __init__(self, request):
# ...
# store current applied coupon
self.coupon_id = self.session.get('coupon_id')
In this code, you try to get the coupon_id
session key from the current session and store its value in the Cart
object. Add the following methods to the Cart
object:
class Cart(object):
# ...
@property
def coupon(self):
if self.coupon_id:
try:
return Coupon.objects.get(id=self.coupon_id)
except Coupon.DoesNotExist:
pass
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()
: You define this method as a property
. If the cart contains a coupon_id
attribute, the Coupon
object with the given ID is returned.get_discount()
: If the cart contains a coupon, you retrieve its discount rate and return the amount to be deducted from the total amount of the cart.get_total_price_after_discount()
: You return the total amount of the cart after deducting the amount returned by the get_discount()
method.The Cart
class is now prepared to handle a coupon applied to the current session and apply the corresponding discount.
Let's include the coupon system in the cart's detail view. Edit the views.py
file of the cart
application and add the following import at the top of the file:
from coupons.forms import CouponApplyForm
Further down, edit the cart_detail
view and add the new form to it, as follows:
def cart_detail(request):
cart = Cart(request)
for item in cart:
item['update_quantity_form'] = CartAddProductForm(initial={
'quantity': item['quantity'],
'override': True})
coupon_apply_form = CouponApplyForm()
return render(request,
'cart/detail.html',
{'cart': cart,
'coupon_apply_form': coupon_apply_form})
Edit the cart/detail.html
template of the cart
application and locate the following lines:
<tr class="total">
<td>Total</td>
<td colspan="4"></td>
<td class="num">${{ cart.get_total_price }}</td>
</tr>
Replace them with the following:
{% if cart.coupon %}
<tr class="subtotal">
<td>Subtotal</td>
<td colspan="4"></td>
<td class="num">${{ cart.get_total_price|floatformat:2 }}</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>
This is the code for displaying an optional coupon and its discount rate. If the cart contains a coupon, you display a first row, including the total amount of the cart as the subtotal. Then, you use a second row to display the current coupon applied to the cart. Finally, you display the total price, including any discount, by calling the get_total_price_after_discount()
method of the cart
object.
In the same file, include the following code after the </table>
HTML tag:
<p>Apply a coupon:</p>
<form action="{% url "coupons:apply" %}" method="post">
{{ coupon_apply_form }}
<input type="submit" value="Apply">
{% csrf_token %}
</form>
This will display the form to enter a coupon code and apply it to the current cart.
Open http://127.0.0.1:8000/
in your browser, add a product to the cart, and apply the coupon you created by entering its code in the form. You should see that the cart displays the coupon discount as follows:
Figure 9.2: The cart detail page, including coupon details and a form to apply a coupon
Let's add the coupon to the next step of the purchase process. Edit the orders/order/create.html
template of the orders
application and locate the following lines:
<ul>
{% for item in cart %}
<li>
{{ item.quantity }}x {{ item.product.name }}
<span>${{ item.total_price }}</span>
</li>
{% endfor %}
</ul>
Replace them with the following code:
<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 class="neg">- ${{ cart.get_discount|floatformat:2 }}</span>
</li>
{% endif %}
</ul>
The order summary should now include the coupon applied, if there is one. Now find the following line:
<p>Total: ${{ cart.get_total_price }}</p>
Replace it with the following:
<p>Total: ${{ cart.get_total_price_after_discount|floatformat:2 }}</p>
By doing this, the total price will also be calculated by applying the discount of the coupon.
Open http://127.0.0.1:8000/orders/create/
in your browser. You should see that the order summary includes the applied coupon, as follows:
Figure 9.3: The order summary, including the coupon applied to the cart
Users can now apply coupons to their shopping cart. However, you still need to store coupon information in the order that it is created when users check out the cart.
You are going to store the coupon that was applied to each order. First, you need to modify the Order
model to store the related Coupon
object, if there is one.
Edit the models.py
file of the orders
application and add the following imports to it:
from decimal import Decimal
from django.core.validators import MinValueValidator,
MaxValueValidator
from coupons.models import Coupon
Then, add the following fields to the Order
model:
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)])
These fields allow you to store an optional coupon for the order and the discount percentage applied with the coupon. The discount is stored in the related Coupon
object, but you include it in the Order
model to preserve it if the coupon is modified or deleted. You set on_delete
to models.SET_NULL
so that if the coupon gets deleted, the coupon
field is set to Null
, but the discount is preserved.
You need to create a migration to include the new fields of the Order
model. Run the following command from the command line:
python manage.py makemigrations
You should see an output like the following:
Migrations for 'orders':
orders/migrations/0003_auto_20191213_1618.py:
- Add field coupon to order
- Add field discount to order
Apply the new migration with the following command:
python manage.py migrate orders
You should see a confirmation indicating that the new migration has been applied. The Order
model field changes are now synced with the database.
Go back to the models.py
file and change the get_total_cost()
method of the Order
model, as follows:
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))
The get_total_cost()
method of the Order
model will now take into account the discount applied, if there is one.
Edit the views.py
file of the orders
application and modify the order_create
view to save the related coupon and its discount when creating a new order. Find the following line:
order = form.save()
Replace it with the following:
order = form.save(commit=False)
if cart.coupon:
order.coupon = cart.coupon
order.discount = cart.coupon.discount
order.save()
In the new code, you create an Order
object using the save()
method of the OrderCreateForm
form. You avoid saving it to the database yet by using commit=False
. If the cart contains a coupon, you store the related coupon and the discount that was applied. Then, you save the order
object to the database.
Make sure that the development server is running with the command python manage.py
runserver
. Open http://127.0.0.1:8000/
in your browser and complete a purchase using the coupon you created.
When you finish a successful purchase, you can go to http://127.0.0.1:8000/admin/orders/order/
and check that the order
object contains the coupon and the applied discount, as follows:
Figure 9.4: The order edit form, including the coupon and discount applied
You can also modify the administration order detail template and the order PDF invoice to display the applied coupon in the same way you did for the cart.
Next, you are going to add internationalization to your project.
Django offers full internationalization and localization support. It allows you to translate your application into multiple languages and it handles locale-specific formatting for dates, times, numbers, and timezones. Let's clarify the difference between internationalization and localization. Internationalization (frequently abbreviated to i18n) is the process of adapting software for the potential use of different languages and locales, so that it isn't hardwired to a specific language or locale. Localization (abbreviated to l10n) is the process of actually translating the software and adapting it to a particular locale. Django itself is translated into more than 50 languages using its internationalization framework.
The internationalization framework allows you to easily mark strings for translation, both in Python code and in your templates. It relies on the GNU gettext toolset to generate and manage message files. A message file is a plain text file that represents a language. It contains a part, or all, of the translation strings found in your application and their respective translations for a single language. Message files have the .po
extension. Once the translation is done, message files are compiled to offer rapid access to translated strings. The compiled translation files have the .mo
extension.
Django provides several settings for internationalization. The following settings are the most relevant ones:
USE_I18N
: A Boolean that specifies whether Django's translation system is enabled. This is True
by default.USE_L10N
: A Boolean indicating whether localized formatting is enabled. When active, localized formats are used to represent dates and numbers. This is False
by default.USE_TZ
: A Boolean that specifies whether datetimes are timezone-aware. When you create a project with the startproject
command, this is set to True
.LANGUAGE_CODE
: The default language code for the project. This is in standard language ID format, for example, 'en-us'
for American English, or 'en-gb'
for British English. This setting requires USE_I18N
to be set to True
in order to take effect. You can find a list of valid language IDs at http://www.i18nguy.com/unicode/language-identifiers.html.LANGUAGES
: A tuple that contains available languages for the project. They come in two tuples of a language code and language name. You can see the list of available languages at django.conf.global_settings
. When you choose which languages your site will be available in, you set LANGUAGES
to a subset of that list.LOCALE_PATHS
: A list of directories where Django looks for message files containing translations for the project.TIME_ZONE
: A string that represents the timezone for the project. This is set to 'UTC'
when you create a new project using the startproject
command. You can set it to any other timezone, such as 'Europe/Madrid'
.These are some of the internationalization and localization settings available. You can find the full list at https://docs.djangoproject.com/en/3.0/ref/settings/#globalization-i18n-l10n.
Django includes the following management commands to manage translations:
makemessages
: This runs over the source tree to find all strings marked for translation and creates or updates the .po
message files in the locale
directory. A single .po
file is created for each language.compilemessages
: This compiles the existing .po
message files to .mo
files that are used to retrieve translations.You will need the gettext toolkit to be able to create, update, and compile message files. Most Linux distributions include the gettext toolkit. If you are using macOS, probably the simplest way to install it is via Homebrew, at https://brew.sh/, with the command brew install gettext
. You might also need to force link it with the command brew link --force gettext
. For Windows, follow the steps at https://docs.djangoproject.com/en/3.0/topics/i18n/translation/#gettext-on-windows.
Let's take a look at the process of internationalizing your project. You will need to do the following:
makemessages
command to create or update message files that include all translation strings from your codecompilemessages
management commandDjango comes with a middleware that determines the current language based on the request data. This is the LocaleMiddleware
middleware that resides in django.middleware.locale.LocaleMiddleware
performs the following tasks:
i18n_patterns
, that is, you are using translated URL patterns, it looks for a language prefix in the requested URL to determine the current language.LANGUAGE_SESSION_KEY
in the current user's session.LANGUAGE_COOKIE_NAME
setting. By default, the name for this cookie is django_language
.Accept-Language
HTTP header of the request.Accept-Language
header does not specify a language, Django uses the language defined in the LANGUAGE_CODE
setting.By default, Django will use the language defined in the LANGUAGE_CODE
setting unless you are using LocaleMiddleware
. The process described here only applies when using this middleware.
Let's prepare your project to use different languages. You are going to create an English and a Spanish version for your shop. Edit the settings.py
file of your project and add the following LANGUAGES
setting to it. Place it next to the LANGUAGE_CODE
setting:
LANGUAGES = (
('en', 'English'),
('es', 'Spanish'),
)
The LANGUAGES
setting contains two tuples that consist of a language code and a name. Language codes can be locale-specific, such as en-us
or en-gb
, or generic, such as en
. With this setting, you specify that your application will only be available in English and Spanish. If you don't define a custom LANGUAGES
setting, the site will be available in all the languages that Django is translated into.
Make your LANGUAGE_CODE
setting look as follows:
LANGUAGE_CODE = 'en'
Add 'django.middleware.locale.LocaleMiddleware'
to the MIDDLEWARE
setting. Make sure that this middleware comes after SessionMiddleware
because LocaleMiddleware
needs to use session data. It also has to be placed before CommonMiddleware
because the latter needs an active language to resolve the requested URL. The MIDDLEWARE
setting should now look as follows:
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
# ...
]
The order of middleware classes is very important because each middleware can depend on data set by other middleware executed previously. Middleware is applied for requests in order of appearance in MIDDLEWARE
, and in reverse order for responses.
Create the following directory structure inside the main project directory, next to the manage.py
file:
locale/
en/
es/
The locale
directory is the place where message files for your application will reside. Edit the settings.py
file again and add the following setting to it:
LOCALE_PATHS = (
os.path.join(BASE_DIR, 'locale/'),
)
The LOCALE_PATHS
setting specifies the directories where Django has to look for translation files. Locale paths that appear first have the highest precedence.
When you use the makemessages
command from your project directory, message files will be generated in the locale/
path you created. However, for applications that contain a locale/
directory, message files will be generated in that directory.
To translate literals in your Python code, you can mark strings for translation using the gettext()
function included in django.utils.translation
. This function translates the message and returns a string. The convention is to import this function as a shorter alias named _
(underscore character).
You can find all the documentation about translations at https://docs.djangoproject.com/en/3.0/topics/i18n/translation/.
The following code shows how to mark a string for translation:
from django.utils.translation import gettext as _
output = _('Text to be translated.')
Django includes lazy versions for all of its translation functions, which have the suffix _lazy()
. When using the lazy functions, strings are translated when the value is accessed, rather than when the function is called (this is why they are translated lazily). The lazy translation functions come in handy when strings marked for translation are in paths that are executed when modules are loaded.
Using gettext_lazy()
instead of gettext()
means that strings are translated when the value is accessed. Django offers a lazy version for all translation functions.
The strings marked for translation can include placeholders to include variables in the translations. The following code is an example of a translation string with a placeholder:
from django.utils.translation import gettext as _
month = _('April')
day = '14'
output = _('Today is %(month)s %(day)s') % {'month': month,
'day': day}
By using placeholders, you can reorder the text variables. For example, an English translation of the previous example might be today is April 14, while the Spanish one might be hoy es 14 de Abril. Always use string interpolation instead of positional interpolation when you have more than one parameter for the translation string. By doing so, you will be able to reorder the placeholder text.
For plural forms, you can use ngettext()
and ngettext_lazy()
. These functions translate singular and plural forms depending on an argument that indicates the number of objects. The following example shows how to use them:
output = ngettext('there is %(count)d product',
'there are %(count)d products',
count) % {'count': count}
Now that you know the basics about translating literals in your Python code, it's time to apply translations to your project.
Edit the settings.py
file of your project, import the gettext_lazy()
function, and change the LANGUAGES
setting as follows to translate the language names:
from django.utils.translation import gettext_lazy as _
LANGUAGES = (
('en', _('English')),
('es', _('Spanish')),
)
Here, you use the gettext_lazy()
function instead of gettext()
to avoid a circular import, thus translating the languages' names when they are accessed.
Open the shell and run the following command from your project directory:
django-admin makemessages --all
You should see the following output:
processing locale es
processing locale en
Take a look at the locale/
directory. You should see a file structure like the following:
en/
LC_MESSAGES/
django.po
es/
LC_MESSAGES/
django.po
A .po
message file has been created for each language. Open es/LC_MESSAGES/django.po
with a text editor. At the end of the file, you should be able to see the following:
#: myshop/settings.py:118
msgid "English"
msgstr ""
#: myshop/settings.py:119
msgid "Spanish"
msgstr ""
Each translation string is preceded by a comment showing details about the file and the line where it was found. Each translation includes two strings:
msgid
: The translation string as it appears in the source code.msgstr
: The language translation, which is empty by default. This is where you have to enter the actual translation for the given string.Fill in the msgstr
translations for the given msgid
string, as follows:
#: myshop/settings.py:118
msgid "English"
msgstr "Inglés"
#: myshop/settings.py:119
msgid "Spanish"
msgstr "Español"
Save the modified message file, open the shell, and run the following command:
django-admin compilemessages
If everything goes well, you should see an output like the following:
processing file django.po in myshop/locale/en/LC_MESSAGES
processing file django.po in myshop/locale/es/LC_MESSAGES
The output gives you information about the message files that are being compiled. Take a look at the locale
directory of the myshop
project again. You should see the following files:
en/
LC_MESSAGES/
django.mo
django.po
es/
LC_MESSAGES/
django.mo
django.po
You can see that a .mo
compiled message file has been generated for each language.
You have translated the language names themselves. Now, let's translate the model field names that are displayed in the site. Edit the models.py
file of the orders
application and add names marked for translation for the Order
model fields as follows:
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)
# ...
You have added names for the fields that are displayed when a user is placing a new order. These are first_name
, last_name
, email
, address
, postal_code
, and city
. Remember that you can also use the verbose_name
attribute to name the fields.
Create the following directory structure inside the orders
application directory:
locale/
en/
es/
By creating a locale
directory, translation strings of this application will be stored in a message file under this directory instead of the main messages file. In this way, you can generate separate translation files for each application.
Open the shell from the project directory and run the following command:
django-admin makemessages --all
You should see the following output:
processing locale es
processing locale en
Open the locale/es/LC_MESSAGES/django.po
file of the order
application using a text editor. You will see the translation strings for the Order
model. Fill in the following msgstr
translations for the given msgid
strings:
#: orders/models.py:11
msgid "first name"
msgstr "nombre"
#: orders/models.py:12
msgid "last name"
msgstr "apellidos"
#: orders/models.py:13
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"
After you have finished adding the translations, save the file.
Besides a text editor, you can use Poedit to edit translations. Poedit is a software for editing translations that uses gettext. It is available for Linux, Windows, and macOS. You can download Poedit from https://poedit.net/.
Let's also translate the forms of your project. The OrderCreateForm
of the orders
application does not have to be translated, since it is a ModelForm
and it uses the verbose_name
attribute of the Order
model fields for the form field labels. You are going to translate the forms of the cart
and coupons
applications.
Edit the forms.py
file inside the cart
application directory and add a label
attribute to the quantity
field of the CartAddProductForm
, and then mark this field for translation, as follows:
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'))
override = forms.BooleanField(required=False,
initial=False,
widget=forms.HiddenInput)
Edit the forms.py
file of the coupons
application and translate the CouponApplyForm
form, as follows:
from django import forms
from django.utils.translation import gettext_lazy as _
class CouponApplyForm(forms.Form):
code = forms.CharField(label=_('Coupon'))
You have added a label to the code
field and marked it for translation.
Django offers the {% trans %}
and {% blocktrans %}
template tags to translate strings in templates. In order to use the translation template tags, you have to add {% load i18n %}
at the top of your template to load them.
The {% trans %}
template tag allows you to mark a literal for translation. Internally, Django executes gettext()
on the given text. This is how to mark a string for translation in a template:
{% trans "Text to be translated" %}
You can use as
to store the translated content in a variable that you can use throughout your template. The following example stores the translated text in a variable called greeting
:
{% trans "Hello!" as greeting %}
<h1>{{ greeting }}</h1>
The {% trans %}
tag is useful for simple translation strings, but it can't handle content for translation that includes variables.
The {% blocktrans %}
template tag allows you to mark content that includes literals and variable content using placeholders. The following example shows you how to use the {% blocktrans %}
tag, including a name
variable in the content for translation:
{% blocktrans %}Hello {{ name }}!{% endblocktrans %}
You can use with
to include template expressions, such as accessing object attributes or applying template filters to variables. You always have to use placeholders for these. You can't access expressions or object attributes inside the blocktrans
block. The following example shows you how to use with
to include an object attribute to which the capfirst
filter is applied:
{% blocktrans with name=user.name|capfirst %}
Hello {{ name }}!
{% endblocktrans %}
Use the {% blocktrans %}
tag instead of {% trans %}
when you need to include variable content in your translation string.
Edit the shop/base.html
template of the shop
application. Make sure that you load the i18n
tag at the top of the template and mark strings for translation, as follows:
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>
{% block title %}{% trans "My shop" %}{% endblock %}
</title>
<link href="{% static "css/base.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=cart|length %}
{% if total_items > 0 %}
{% trans "Your cart" %}:
<a href="{% url "cart:cart_detail" %}">
{% blocktrans with total=cart.get_total_price count items=total_items %}
{{ items }} item, ${{ total }}
{% plural %}
{{ items }} items, ${{ total }}
{% endblocktrans %}
</a>
{% else %}
{% trans "Your cart is empty." %}
{% endif %}
{% endwith %}
</div>
</div>
<div id="content">
{% block content %}
{% endblock %}
</div>
</body>
</html>
Make sure that no template tag is split across multiple lines.
Notice the {% blocktrans %}
tag to display the cart's summary. The cart's summary was previously as follows:
{{ total_items }} item{{ total_items|pluralize }},
${{ cart.get_total_price }}
You changed it and now you use {% blocktrans with ... %}
to set up the placeholder total
with the value of cart.get_total_price
(the object method called here). You also use count
, which allows you to set a variable for counting objects for Django to select the right plural form. You set the items
variable to count objects with the value of total_items
. This allows you to set a translation for the singular and plural forms, which you separate with the {% plural %}
tag within the {% blocktrans %}
block. The resulting code is:
{% blocktrans with total=cart.get_total_price count items=total_items %}
{{ items }} item, ${{ total }}
{% plural %}
{{ items }} items, ${{ total }}
{% endblocktrans %}
Next, edit the shop/product/detail.html
template of the shop
application and load the i18n
tags at the top of it, but after the {% extends %}
tag, which always has to be the first tag in the template:
{% load i18n %}
Then, find the following line:
<input type="submit" value="Add to cart">
Replace it with the following:
<input type="submit" value="{% trans "Add to cart" %}">
Now, translate the orders
application template. Edit the orders/order/create.html
template of the orders
application and mark text for translation, as follows:
{% 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 class="neg">- ${{ cart.get_discount|floatformat:2 }}</span>
</li>
{% endif %}
</ul>
<p>{% trans "Total" %}: ${{
cart.get_total_price_after_discount|floatformat:2 }}</p>
</div>
<form method="post" class="order-form">
{{ form.as_p }}
<p><input type="submit" value="{% trans "Place order" %}"></p>
{% csrf_token %}
</form>
{% endblock %}
Make sure that no template tag is split across multiple lines. Take a look at the following files in the code that accompanies this chapter to see how strings have been marked for translation:
shop
application: Template shop/product/list.html
orders
application: Template orders/order/created.html
cart
application: Template cart/detail.html
You can find the source code for this chapter at https://github.com/PacktPublishing/Django-3-by-Example/tree/master/Chapter09.
Let's update the message files to include the new translation strings. Open the shell and run the following command:
django-admin makemessages --all
The .po
files are inside the locale
directory of the myshop
project and you'll see that the orders
application now contains all the strings that you marked for translation.
Edit the .po
translation files of the project and the orders
application, and include Spanish translations in the msgstr
. You can also use the translated .po
files in the source code that accompanies this chapter.
Run the following command to compile the translation files:
django-admin compilemessages
You will see the following output:
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
A .mo
file containing compiled translations has been generated for each .po
translation file.
Rosetta is a third-party application that allows you to edit translations using the same interface as the Django administration site. Rosetta makes it easy to edit .po
files and it updates compiled translation files. Let's add it to your project.
Install Rosetta via pip
using this command:
pip install django-rosetta==0.9.3
Then, add 'rosetta'
to the INSTALLED_APPS
setting in your project's settings.py
file, as follows:
INSTALLED_APPS = [
# ...
'rosetta',
]
You need to add Rosetta's URLs to your main URL configuration. Edit the main urls.py
file of your project and add the following URL pattern to it:
urlpatterns = [
# ...
path('rosetta/', include('rosetta.urls')),
path('', include('shop.urls', namespace='shop')),
]
Make sure you place it before the shop.urls
pattern to avoid an undesired pattern match.
Open http://127.0.0.1:8000/admin/
and log in with a superuser. Then, navigate to http://127.0.0.1:8000/rosetta/
in your browser. In the Filter menu, click THIRD PARTY to display all the available message files, including those that belong to the orders
application. You should see a list of existing languages, as follows:
Figure 9.5: The Rosetta administration interface
Click the Myshop link under the Spanish section to edit the Spanish translations. You should see a list of translation strings, as follows:
Figure 9.6: Editing Spanish translations using Rosetta
You can enter the translations under the SPANISH column. The OCCURRENCE(S) column displays the files and line of code where each translation string was found.
Translations that include placeholders will appear as follows:
Figure 9.7: Translations including placeholders
Rosetta uses a different background color to display placeholders. When you translate content, make sure that you keep placeholders untranslated. For example, take the following string:
%(items)s items, $%(total)s
It is translated into Spanish as follows:
%(items)s productos, $%(total)s
You can take a look at the source code that comes along with this chapter to use the same Spanish translations for your project.
When you finish editing translations, click the Save and translate next block button to save the translations to the .po
file. Rosetta compiles the message file when you save translations, so there is no need for you to run the compilemessages
command. However, Rosetta requires write access to the locale
directories to write the message files. Make sure that the directories have valid permissions.
If you want other users to be able to edit translations, open http://127.0.0.1:8000/admin/auth/group/add/
in your browser and create a new group named translators
. Then, access http://127.0.0.1:8000/admin/auth/user/
to edit the users to whom you want to grant permissions so that they can edit translations. When editing a user, under the Permissions section, add the translators
group to the Chosen Groups for each user. Rosetta is only available to superusers or users who belong to the translators
group.
You can read Rosetta's documentation at https://django-rosetta.readthedocs.io/.
When you add new translations to your production environment, if you serve Django with a real web server, you will have to reload your server after running the compilemessages
command, or after saving the translations with Rosetta, for changes to take effect.
You might have noticed that there is a FUZZY column in Rosetta. This is not a Rosetta feature; it is provided by gettext. If the fuzzy flag is active for a translation, it will not be included in the compiled message files. This flag marks translation strings that need to be reviewed by a translator. When .po
files are updated with new translation strings, it is possible that some translation strings will automatically be flagged as fuzzy. This happens when gettext finds some msgid
that has been slightly modified. gettext pairs it with what it thinks was the old translation and flags it as fuzzy for review. The translator should then review fuzzy translations, remove the fuzzy flag, and compile the translation file again.
Django offers internationalization capabilities for URLs. It includes two main features for internationalized URLs:
A reason for translating URLs is to optimize your site for search engines. By adding a language prefix to your patterns, you will be able to index a URL for each language instead of a single URL for all of them. Furthermore, by translating URLs into each language, you will provide search engines with URLs that will rank better for each language.
Django allows you to add a language prefix to your URL patterns. For example, the English version of your site can be served under a path starting /en/
, and the Spanish version under /es/
. To use languages in URL patterns, you have to use the LocaleMiddleware
provided by Django. The framework will use it to identify the current language from the requested URL. You added it previously to the MIDDLEWARE
setting of your project, so you don't need to do it now.
Let's add a language prefix to your URL patterns. Edit the main urls.py
file of the myshop
project and add i18n_patterns()
, as follows:
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('payment/', include('payment.urls', namespace='payment')),
path('coupons/', include('coupons.urls', namespace='coupons')),
path('rosetta/', include('rosetta.urls')),
path('', include('shop.urls', namespace='shop')),
)
You can combine non-translatable standard URL patterns and patterns under i18n_patterns
so that some patterns include a language prefix and others don't. However, it's better to use translated URLs only to avoid the possibility that a carelessly translated URL matches a non-translated URL pattern.
Run the development server and open http://127.0.0.1:8000/
in your browser. Django will perform the steps described previously in the How Django determines the current language section to determine the current language, and it will redirect you to the requested URL, including the language prefix. Take a look at the URL in your browser; it should now look like http://127.0.0.1:8000/en/
. The current language is the one set by the Accept-Language
header of your browser if it is Spanish or English; otherwise, it is the default LANGUAGE_CODE
(English) defined in your settings.
Django supports translated strings in URL patterns. You can use a different translation for each language for a single URL pattern. You can mark URL patterns for translation in the same way as you would with literals, using the gettext_lazy()
function.
Edit the main urls.py
file of the myshop
project and add translation strings to the regular expressions of the URL patterns for the cart
, orders
, payment
, and coupons
applications, as follows:
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')),
)
Edit the urls.py
file of the orders
application and mark URL patterns for translation, as follows:
from django.utils.translation import gettext_lazy as _
urlpatterns = [
path(_('create/'), views.order_create, name='order_create'),
# ...
]
Edit the urls.py
file of the payment
application and change the code to the following:
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'),
]
You don't need to translate the URL patterns of the shop
application, since they are built with variables and do not include any other literals.
Open the shell and run the next command to update the message files with the new translations:
django-admin makemessages --all
Make sure the development server is running. Open http://127.0.0.1:8000/en/rosetta/
in your browser and click the Myshop link under the Spanish section. Now you will see the URL patterns for translation. You can click on Untranslated only to only see the strings that have not been translated yet. You can now translate the URLs.
Since you are serving content that is available in multiple languages, you should let your users switch the site's language. You are going to add a language selector to your site. The language selector will consist of a list of available languages displayed using links.
Edit the shop/base.html
template of the shop
application and locate the following lines:
<div id="header">
<a href="/" class="logo">{% trans "My shop" %}</a>
</div>
Replace them with the following code:
<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>
Make sure that no template tag is split into multiple lines.
This is how you build your language selector:
{% load i18n %}
{% get_current_language %}
tag to retrieve the current languageLANGUAGES
setting using the {% get_available_languages %}
template tag{% get_language_info_list %}
to provide easy access to the language attributesselected
class attribute to the current active languageIn the code for the language selector, you used the template tags provided by i18n
, based on the languages available in the settings of your project. Now open http://127.0.0.1:8000/
in your browser and take a look. You should see the language selector in the top right-hand corner of the site, as follows:
Figure 9.8: The product list page, including a language selector in the site header
Users can now easily switch to their preferred language by clicking on it.
Django does not provide a solution for translating models out of the box. You have to implement your own solution to manage content stored in different languages, or use a third-party module for model translation. There are several third-party applications that allow you to translate model fields. Each of them takes a different approach to storing and accessing translations. One of these applications is django-parler. This module offers a very effective way to translate models and it integrates smoothly with Django's administration site.
django-parler generates a separate database table for each model that contains translations. This table includes all the translated fields and a foreign key for the original object that the translation belongs to. It also contains a language field, since each row stores the content for a single language.
Install django-parler
via pip
using the following command:
pip install django-parler==2.0.1
Edit the settings.py
file of your project and add 'parler'
to the INSTALLED_APPS
setting, as follows:
INSTALLED_APPS = [
# ...
'parler',
]
Also, add the following code to your settings:
PARLER_LANGUAGES = {
None: (
{'code': 'en'},
{'code': 'es'},
),
'default': {
'fallback': 'en',
'hide_untranslated': False,
}
}
This setting defines the available languages, en
and es
, for django-parler
. You specify the default language en
and indicate that django-parler
should not hide untranslated content.
Let's add translations for your product catalog. django-parler
provides a TranslatableModel
model class and a TranslatedFields
wrapper to translate model fields. Edit the models.py
file inside the shop
application directory and add the following import:
from parler.models import TranslatableModel, TranslatedFields
Then, modify the Category
model to make the name
and slug
fields translatable, as follows:
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)
)
The Category
model now inherits from TranslatableModel
instead of models.Model
and both the name
and slug
fields are included in the TranslatedFields
wrapper.
Edit the Product
model to add translations for the name
, slug
, and description
fields, as follows:
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
manages translations by generating another model for each translatable model. In the following schema, you can see the fields of the Product
model and what the generated ProductTranslation
model will look like:
Figure 9.9: The Product model and related ProductTranslation model generated by django-parler
The ProductTranslation
model generated by django-parler
includes the name
, slug
, and description
translatable fields, a language_code
field, and a ForeignKey
for the master Product
object. There is a one-to-many relationship from Product
to ProductTranslation
. A ProductTranslation
object will exist for each available language of each Product
object.
Since Django uses a separate table for translations, there are some Django features that you can't use. It is not possible to use a default ordering by a translated field. You can filter by translated fields in queries, but you can't include a translatable field in the ordering Meta
options.
Edit the models.py
file of the shop
application and comment out the ordering
attribute of the Category Meta
class:
class Category(TranslatableModel):
# ...
class Meta:
# ordering = ('name',)
verbose_name = 'category'
verbose_name_plural = 'categories'
You also have to comment out the ordering
and index_together
attributes of the Product Meta
class. The current version of django-parler
does not provide support to validate index_together
. Comment out the Product Meta
class, as follows:
class Product(TranslatableModel):
# ...
# class Meta:
# ordering = ('-name',)
# index_together = (('id', 'slug'),)
You can read more about the django-parler
module's compatibility with Django at https://django-parler.readthedocs.io/en/latest/compatibility.html.
django-parler
integrates smoothly with the Django administration site. It includes a TranslatableAdmin
class that overrides the ModelAdmin
class provided by Django to manage model translations.
Edit the admin.py
file of the shop
application and add the following import to it:
from parler.admin import TranslatableAdmin
Modify the CategoryAdmin
and ProductAdmin
classes to inherit from TranslatableAdmin
instead of ModelAdmin
. django-parler
doesn't support the prepopulated_fields
attribute, but it does support the get_prepopulated_fields()
method that provides the same functionality. Let's change this accordingly. Edit the admin.py
file to make it look as follows:
from django.contrib import admin
from parler.admin import TranslatableAdmin
from .models import Category, Product
@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',)}
You have adapted the administration site to work with the new translated models. You can now sync the database with the model changes that you made.
Open the shell and run the following command to create a new migration for the model translations:
python manage.py makemigrations shop --name "translations"
You will see the following output:
Migrations for 'shop':
shop/migrations/0002_translations.py
- 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))
- Remove field description from product
- Remove field name from product
- Remove field slug from product
- Create model ProductTranslation
- Create model CategoryTranslation
This migration automatically includes the CategoryTranslation
and ProductTranslation
models created dynamically by django-parler
. It's important to note that this migration deletes the previous existing fields from your models. This means that you will lose that data and will need to set your categories and products again in the administration site after running it.
Edit the file migrations/0002_translations.py
of the shop
application and replace the two occurrences of the following line:
bases=(parler.models.TranslatedFieldsModelMixin, models.Model),
with the following one:
bases=(parler.models.TranslatableModel, models.Model),
This is a fix for a minor issue found in the django-parler
version you are using. This change is necessary to prevent the migration from failing when applying it. This issue is related to creating translations for existing fields in the model and will probably be fixed in newer django-parler
versions.
Run the following command to apply the migration:
python manage.py migrate shop
You will see an output that ends with the following line:
Applying shop.0002_translations... OK
Your models are now synchronized with the database.
Run the development server using python manage.py runserver
and open http://127.0.0.1:8000/en/admin/shop/category/
in your browser. You will see that existing categories lost their name and slug due to deleting those fields and using the translatable models generated by django-parler
instead. Click on a category to edit it. You will see that the Change category page includes two different tabs, one for English and one for Spanish translations:
Figure 9.10: The category edit form, including language tabs added by django-parler
Make sure that you fill in a name and slug for all existing categories. Also, add a Spanish translation for each of them and click the SAVE button. Make sure that you save the changes before you switch tab or you will lose them.
After completing the data for existing categories, open http://127.0.0.1:8000/en/admin/shop/product/
and edit each of the products, providing an English and Spanish name, a slug, and a description.
You have to adapt your shop
views to use translation QuerySets. Run the following command to open the Python shell:
python manage.py shell
Let's take a look at how you can retrieve and query translation fields. To get the object with translatable fields translated in a specific language, you can use Django's activate()
function, as follows:
>>> from shop.models import Product
>>> from django.utils.translation import activate
>>> activate('es')
>>> product=Product.objects.first()
>>> product.name
'Té verde'
Another way to do this is by using the language()
manager provided by django-parler, as follows:
>>> product=Product.objects.language('en').first()
>>> product.name
'Green tea'
When you access translated fields, they are resolved using the current language. You can set a different current language for an object to access that specific translation, as follows:
>>> product.set_current_language('es')
>>> product.name
'Té verde'
>>> product.get_current_language()
'es'
When performing a QuerySet using filter()
, you can filter using the related translation objects with the translations__
syntax, as follows:
>>> Product.objects.filter(translations__name='Green tea')
<TranslatableQuerySet [<Product: Té verde>]>
Let's adapt the product catalog views. Edit the views.py
file of the shop
application and, in the product_list
view, find the following line:
category = get_object_or_404(Category, slug=category_slug)
Replace it with the following ones:
language = request.LANGUAGE_CODE
category = get_object_or_404(Category,
translations__language_code=language,
translations__slug=category_slug)
Then, edit the product_detail
view and find the following lines:
product = get_object_or_404(Product,
id=id,
slug=slug,
available=True)
Replace them with the following code:
language = request.LANGUAGE_CODE
product = get_object_or_404(Product,
id=id,
translations__language_code=language,
translations__slug=slug,
available=True)
The product_list
and product_detail
views are now adapted to retrieve objects using translated fields. Run the development server and open http://127.0.0.1:8000/es/
in your browser. You should see the product list page, including all products translated into Spanish:
Figure 9.11: The Spanish version of the product list page
Now, each product's URL is built using the slug
field translated into the current language. For example, the URL for a product in Spanish is http://127.0.0.1:8000/es/2/te-rojo/
, whereas in English, the URL is http://127.0.0.1:8000/en/2/red-tea/
. If you navigate to a product detail page, you will see the translated URL and the contents of the selected language, as shown in the following example:
Figure 9.12: The Spanish version of the product detail page
If you want to know more about django-parler
, you can find the full documentation at https://django-parler.readthedocs.io/en/latest/.
You have learned how to translate Python code, templates, URL patterns, and model fields. To complete the internationalization and localization process, you need to use localized formatting for dates, times, and numbers as well.
Depending on the user's locale, you might want to display dates, times, and numbers in different formats. Localized formatting can be activated by changing the USE_L10N
setting to True
in the settings.py
file of your project.
When USE_L10N
is enabled, Django will try to use a locale-specific format whenever it outputs a value in a template. You can see that decimal numbers in the English version of your site are displayed with a dot separator for decimal places, while in the Spanish version, they are displayed using a comma. This is due to the locale formats specified for the es
locale by Django. You can take a look at the Spanish formatting configuration at https://github.com/django/django/blob/stable/3.0.x/django/conf/locale/es/formats.py.
Normally, you will set the USE_L10N
setting to True
and let Django apply the format localization for each locale. However, there might be situations in which you don't want to use localized values. This is especially relevant when outputting JavaScript or JSON that has to provide a machine-readable format.
Django offers a {% localize %}
template tag that allows you to turn on/off localization for template fragments. This gives you control over localized formatting. You will have to load the l10n
tags to be able to use this template tag. The following is an example of how to turn localization on and off in a template:
{% load l10n %}
{% localize on %}
{{ value }}
{% endlocalize %}
{% localize off %}
{{ value }}
{% endlocalize %}
Django also offers the localize
and unlocalize
template filters to force or avoid the localization of a value. These filters can be applied as follows:
{{ value|localize }}
{{ value|unlocalize }}
You can also create custom format files to specify locale formatting. You can find further information about format localization at https://docs.djangoproject.com/en/3.0/topics/i18n/formatting/.
django-localflavor
is a third-party module that contains a collection of utils, such as form fields or model fields, that are specific for each country. It's very useful for validating local regions, local phone numbers, identity card numbers, social security numbers, and so on. The package is organized into a series of modules named after ISO 3166 country codes.
Install django-localflavor
using the following command:
pip install django-localflavor==3.0.1
Edit the settings.py
file of your project and add localflavor
to the INSTALLED_APPS
setting, as follows:
INSTALLED_APPS = [
# ...
'localflavor',
]
You are going to add the United States' zip code field so that a valid United States zip code is required to create a new order.
Edit the forms.py
file of the orders
application and make it look as follows:
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']
You import the USZipCodeField
field from the us
package of localflavor
and use it for the postal_code
field of the OrderCreateForm
form.
Run the development server and open http://127.0.0.1:8000/en/orders/create/
in your browser. Fill in all fields, enter a three-letter zip code, and then submit the form. You will get the following validation error that is raised by USZipCodeField
:
Enter a zip code in the format XXXXX or XXXXX-XXXX.
This is just a brief example of how to use a custom field from localflavor
in your own project for validation purposes. The local components provided by localflavor
are very useful for adapting your application to specific countries. You can read the django-localflavor
documentation and see all available local components for each country at https://django-localflavor.readthedocs.io/en/latest/.
Next, you are going to build a recommendation engine into your shop.
A recommendation engine is a system that predicts the preference or rating that a user would give to an item. The system selects relevant items for a user based on their behavior and the knowledge it has about them. Nowadays, recommendation systems are used in many online services. They help users by selecting the stuff they might be interested in from the vast amount of available data that is irrelevant to them. Offering good recommendations enhances user engagement. E-commerce sites also benefit from offering relevant product recommendations by increasing their average revenue per user.
You are going to create a simple, yet powerful, recommendation engine that suggests products that are usually bought together. You will suggest products based on historical sales, thus identifying products that are usually bought together. You are going to suggest complementary products in two different scenarios:
You are going to use Redis to store products that are purchased together. Remember that you already used Redis in Chapter 6, Tracking User Actions. If you haven't installed Redis yet, you can find installation instructions in that chapter.
You will recommend products to users based on what they have added to the cart. You are going to store a key in Redis for each product bought on your site. The product key will contain a Redis sorted set with scores. You will increment the score by 1 for each product bought together every time a new purchase is completed. The sorted set will allow you to give scores to products that are bought together.
Remember to install redis-py
in your environment using the following command:
pip install redis==3.4.1
Edit the settings.py
file of your project and add the following settings to it:
REDIS_HOST = 'localhost'
REDIS_PORT = 6379
REDIS_DB = 1
These are the settings required to establish a connection with the Redis server. Create a new file inside the shop
application directory and name it recommender.py
. Add the following code to it:
import redis
from django.conf import settings
from .models import Product
# connect to redis
r = redis.Redis(host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB)
class Recommender(object):
def get_product_key(self, id):
return f'product:{id}:purchased_with'
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),
1,
with_id)
This is the Recommender
class that will allow you to store product purchases and retrieve product suggestions for a given product or products.
The get_product_key()
method receives an ID of a Product
object and builds the Redis key for the sorted set where related products are stored, which looks like product:[id]:purchased_with
.
The products_bought()
method receives a list of Product
objects that have been bought together (that is, belong to the same order).
In this method, you perform the following tasks:
Product
objects.get_product_id()
method. For a product with an ID of 33
, this method returns the key product:33:purchased_with
. This is the key for the sorted set that contains the product IDs of products that were bought together with this one.You now have a method to store and score the products that were bought together. Next, you need a method to retrieve the products that were bought together for a list of given products. Add the following suggest_products_for()
method to the Recommender
class:
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 = f'tmp_{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
The suggest_products_for()
method receives the following parameters:
products
: This is a list of Product
objects to get recommendations for. It can contain one or more products.max_results
: This is an integer that represents the maximum number of recommendations to return.In this method, you perform the following actions:
Product
objects.ZRANGE
command. You limit the number of results to the number specified in the max_results
attribute (6
by default).ZUNIONSTORE
command. The ZUNIONSTORE
command performs a union of the sorted sets with the given keys, and stores the aggregated sum of scores of the elements in a new Redis key. You can read more about this command at https://redis.io/commands/ZUNIONSTORE. You save the aggregated scores in the temporary key.ZREM
command.ZRANGE
command. You limit the number of results to the number specified in the max_results
attribute. Then, you remove the temporary key.Product
objects with the given IDs and you order the products in the same order as them.For practical purposes, let's also add a method to clear the recommendations. Add the following method to the Recommender
class:
def clear_purchases(self):
for id in Product.objects.values_list('id', flat=True):
r.delete(self.get_product_key(id))
Let's try your recommendation engine. Make sure you include several Product
objects in the database and initialize the Redis server using the following command from the shell in your Redis directory:
src/redis-server
Open another shell and run the following command to open the Python shell:
python manage.py shell
Make sure that you have at least four different products in your database. Retrieve four different products by their names:
>>> 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')
Then, add some test purchases to the recommendation engine:
>>> 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])
You have stored the following scores:
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)
Let's activate a language to retrieve translated products and get product recommendations to buy together with a given single product:
>>> 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>]
You can see that the order for recommended products is based on their score. Let's get recommendations for multiple products with aggregated scores:
>>> 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>]
You can see that the order of the suggested products matches the aggregated scores. For example, products suggested for black_tea
and red_tea
are tea_powder
(2+1
) and green_tea
(1+1
).
You have verified that your recommendation algorithm works as expected. Let's now display recommendations for products on your site.
Edit the views.py
file of the shop
application. Add the functionality to retrieve a maximum of four recommended products in the product_detail
view, as follows:
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})
Edit the shop/product/detail.html
template of the shop
application and add the following code after {{ 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 %}
Run the development server and open http://127.0.0.1:8000/en/
in your browser. Click on any product to view its details. You should see that recommended products are displayed below the product, as shown in the following screenshot:
Figure 9.13: The product detail page, including recommended products
You are also going to include product recommendations in the cart. The recommendations will be based on the products that the user has added to the cart.
Edit views.py
inside the cart
application, import the Recommender
class, and edit the cart_detail
view to make it look as follows:
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'],
'override': 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})
Edit the cart/detail.html
template of the cart
application and add the following code just after the </table>
HTML tag:
{% 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 %}
Open http://127.0.0.1:8000/en/
in your browser and add a couple of products to your cart. When you navigate to http://127.0.0.1:8000/en/cart/
, you should see the aggregated product recommendations for the items in the cart, as follows:
Figure 9.14: The shopping cart detail page, including recommended products
Congratulations! You have built a complete recommendation engine using Django and Redis.
In this chapter, you created a coupon system using sessions. You also learned the basics of internationalization and localization for Django projects. You marked code and template strings for translation, and you discovered how to generate and compile translation files. You also installed Rosetta in your project to manage translations through a browser interface. You translated URL patterns and you created a language selector to allow users to switch the language of the site. Then, you used django-parler
to translate models and you used django-localflavor
to validate localized form fields. Finally, you built a recommendation engine using Redis to recommend products that are usually purchased together.
In the next chapter, you will start a new project. You will build an e-learning platform with Django using class-based views and you will create a custom content management system.