9

Extending Your Shop

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:

  • Creating a coupon system to apply discounts
  • Adding internationalization to your project
  • Using Rosetta to manage translations
  • Translating models using django-parler
  • Building a product recommendation engine

Creating a coupon system

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.

Building the coupon model

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.

Applying a coupon to the shopping cart

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:

  1. The user adds products to the shopping cart.
  2. The user can enter a coupon code in a form displayed on the shopping cart detail page.
  3. When the user enters a coupon code and submits the form, you look for an existing coupon with the given code that is currently valid. You have to check that the coupon code matches the one entered by the user, that the active attribute is True, and that the current datetime is between the valid_from and valid_to values.
  4. If a coupon is found, you save it in the user's session and display the cart, including the discount applied to it and the updated total amount.
  5. When the user places an order, you save the coupon to the given order.

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:

  1. You instantiate the CouponApplyForm form using the posted data and check that the form is valid.
  2. If the form is valid, you get the 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.
  3. You store the coupon ID in the user's session.
  4. You redirect the user to the 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()

These methods are as follows:

  • 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.

Applying coupons to orders

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.

Adding internationalization and localization

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.

Internationalization with Django

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.

Internationalization and localization settings

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.

Internationalization management commands

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.

How to add translations to a Django project

Let's take a look at the process of internationalizing your project. You will need to do the following:

  1. Mark strings for translation in your Python code and your templates
  2. Run the makemessages command to create or update message files that include all translation strings from your code
  3. Translate the strings contained in the message files and compile them using the compilemessages management command

How Django determines the current language

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

  1. If you are using 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.
  2. If no language prefix is found, it looks for an existing LANGUAGE_SESSION_KEY in the current user's session.
  3. If the language is not set in the session, it looks for an existing cookie with the current language. A custom name for this cookie can be provided in the LANGUAGE_COOKIE_NAME setting. By default, the name for this cookie is django_language.
  4. If no cookie is found, it looks for the Accept-Language HTTP header of the request.
  5. If the 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.

Preparing your project for internationalization

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.

Translating Python code

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/.

Standard translations

The following code shows how to mark a string for translation:

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

Lazy translations

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.

Translations including variables

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.

Plural forms in translations

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.

Translating your own code

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.

Translating templates

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

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

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.

Translating the shop templates

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:

  • The shop application: Template shop/product/list.html
  • The orders application: Template orders/order/created.html
  • The 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.

Using the Rosetta translation interface

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.

Fuzzy translations

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.

URL patterns for internationalization

Django offers internationalization capabilities for URLs. It includes two main features for internationalized URLs:

  • Language prefix in URL patterns: Adding a language prefix to URLs to serve each language version under a different base URL
  • Translated URL patterns: Translating URL patterns so that every URL is different for each language

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.

Adding a language prefix to URL patterns

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.

Translating URL patterns

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.

Allowing users to switch language

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:

  1. You load the internationalization tags using {% load i18n %}
  2. You use the {% get_current_language %} tag to retrieve the current language
  3. You get the languages defined in the LANGUAGES setting using the {% get_available_languages %} template tag
  4. You use the tag {% get_language_info_list %} to provide easy access to the language attributes
  5. You build an HTML list to display all available languages and you add a selected class attribute to the current active language

In 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.

Translating models with django-parler

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.

Installing django-parler

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.

Translating model fields

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.

Integrating translations into the administration site

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.

Creating migrations for model translations

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.

Adapting views for translations

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.

Format localization

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/.

Using django-localflavor to validate form fields

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.

Building a recommendation engine

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:

  • Product detail page: You will display a list of products that are usually bought with the given product. This will be displayed as users who bought this also bought X, Y, Z. You need a data structure that allows you to store the number of times that each product has been bought together with the product being displayed.
  • Cart detail page: Based on the products users add to the cart, you are going to suggest products that are usually bought together with these ones. In this case, the score you calculate to obtain related products has to be aggregated.

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.

Recommending products based on previous purchases

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:

  1. You get the product IDs for the given Product objects.
  2. You iterate over the product IDs. For each ID, you iterate again over the product IDs and skip the same product so that you get the products that are bought together with each product.
  3. You get the Redis product key for each product bought using the 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.
  4. You increment the score of each product ID contained in the sorted set by 1. The score represents the times another product has been bought together with the given product.

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:

  1. You get the product IDs for the given Product objects.
  2. If only one product is given, you retrieve the ID of the products that were bought together with the given product, ordered by the total number of times that they were bought together. To do so, you use Redis' ZRANGE command. You limit the number of results to the number specified in the max_results attribute (6 by default).
  3. If more than one product is given, you generate a temporary Redis key built with the IDs of the products.
  4. You combine and sum all scores for the items contained in the sorted set of each of the given products. This is done using the Redis 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.
  5. Since you are aggregating scores, you might obtain the same products you are getting recommendations for. You remove them from the generated sorted set using the ZREM command.
  6. You retrieve the IDs of the products from the temporary key, ordered by their score using the ZRANGE command. You limit the number of results to the number specified in the max_results attribute. Then, you remove the temporary key.
  7. Finally, you get the 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.

Summary

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.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset