Website I18n with Django REST Framework and django-parler

How to set up and serve translated content with Django REST Framework and django-parler? We will guide you through the entire process!

Django is a full-stack Python framework with a long history. It’s the most respected Python framework for web development, powering thousands of websites around the world.

Django offers a build-in solution for i18n using the i18n and l10 module. In fact, we have written a quick guide on Django i18n before – feel free to give it a look – but it does not mean that it is the only option out there. There is also another independent package called django-parler that offers a simpler solution for providing multilingual support. It can come in quite handy if you are using models that require multiple translations and are saved in a database. For more advanced requirements, it is better to use the framework’s built-in I18n support.

In this tutorial, we are going to expose a simple blog app API using Django REST Framework that also offers multilingual support with django-parler. All the code examples are hosted on GitHub. Let’s get started!

Installation

First, we create a new Python environment and install the necessary packages:

python3 -m venv .env
source .env/bin/activate
pip3 install django django-parler django-parler-rest djangorestframework

For storing the database models, we need a database. We use PostgreSQL in this tutorial, so we need to install some extra drivers:

pip3 install psycopg2

Then start the database service. For example, on Linux/Mac we can use the following command:

pg_ctl -D /usr/local/var/postgres start

Then create a new Django project for our blog:

django-admin startproject myblog

Next, we need to configure the database. Edit the settings.py file and change the DATABASE sections as follows:

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql',
        'NAME': 'myBlog',
        'USER': 'myBlogUser',
        'PASSWORD': 'myPassword',
        'HOST': '127.0.0.1'
    }
}

Add the django-parler and Django REST Framework configuration:

INSTALLED_APPS += (
    'parler',
    'rest_framework'
)

PARLER_LANGUAGES = {
    None: (
        {'code': 'en',},
        {'code': 'el',},
    ),
    'default': {
        'fallbacks': ['en'],      
        'hide_untranslated': False,   # Default
    }
}

Here, we’ve added two supported languages: English and Greek denoted by their language tags (en and el).

Before we run the migrations, we need to create a new user and database as per configuration because Django will complain if they are not valid. The easiest way to do that is by using the pgAdmin tool:

Django pgAdmin tool

You will need to apply the following SQL scripts:

CREATE ROLE "myBlogUser" WITH
    LOGIN
    NOSUPERUSER
    NOCREATEDB
    NOCREATEROLE
    INHERIT
    NOREPLICATION
    CONNECTION LIMIT -1
    PASSWORD 'myPassword';

CREATE DATABASE "myBlog"
    WITH 
    OWNER = "myBlogUser"
    ENCODING = 'UTF8'
    CONNECTION LIMIT = -1;

You are now ready to run the migrations …

python manage.py migrate

… and finally, create our Blog API.

Blog REST API

First, we start by creating the blog app inside the project root folder:

python manage.py startapp blog

Inside the blog/models.py file, we need to add a Post model. We are going to use the django-parler TranslatableModel to derive the translations for us:

from django.db import models
from django.contrib.auth.models import User
from django.utils.translation import gettext as _
from parler.models import TranslatableModel, TranslatedFields

class Post(TranslatableModel):
    translations = TranslatedFields(
        title = models.CharField(_("Title"), max_length=200, unique=True),
        content = models.TextField(_("Content"), blank=True)
    )
    author = models.ForeignKey(User, on_delete= models.CASCADE,related_name='blog_posts')
    updated_on = models.DateTimeField(auto_now= True)
    created_on = models.DateTimeField(auto_now_add=True)

    class Meta:
        ordering = ['-created_on']
        verbose_name = _("Post")
        verbose_name_plural = _("Posts")

    def __str__(self):
        return self.title

The TranslatedFields object wraps all the fields that require translations. To actually create objects, we can also register an admin form that automatically populates the different translation forms.

Update the following file blog/admin.py:

from django.contrib import admin
from parler.admin import TranslatableAdmin
from blog.models import Post

class PostAdmin(TranslatableAdmin):
    list_display = ('title', 'content')
    fieldsets = (
        (None, {
            'fields': ('title', 'content'),
        }),
    )

    def save_model(self, request, obj, form, change):
        obj.author_id = request.user.id
        super().save_model(request, obj, form, change)

admin.site.register(Post, PostAdmin)

Here we used the save_model method to assign the current user when we create a new Post.

Now we login to the admin panel and add a few posts with translations (both English and Greek):

Django pgAdmin tool

The library already created different tabs for each language. For each language tag, we need to provide translations and then click the Save button.

You may be wondering what the underlying model is when we save those translations – we can find that out using the pgAdmin tool really quickly by inspecting the tables created when we apply the migrations.

Navigate to localhost > databases > myBlog > Schemas > Tables and there you will see a new table called blog_post_translation:

Szoter annotated image

As you can see, django-parler created a one-to-many relationship between the Post model and a new model for storing the translations. This way we can programmatically fetch all translations for a given post. The master_id field maps to the post_id param as we look into the foreign key constraint:

ALTER TABLE public.blog_post_translation
    ADD CONSTRAINT blog_post_translation_master_id_9162465c_fk_blog_post_id FOREIGN KEY (master_id)
    REFERENCES public.blog_post (id) MATCH SIMPLE
    ON UPDATE NO ACTION
    ON DELETE NO ACTION
    DEFERRABLE INITIALLY DEFERRED;

In addition, it creates a unique tuple of (master_id, language_code):

ALTER TABLE public.blog_post_translation
    ADD CONSTRAINT blog_post_translation_language_code_master_id_9a50eed2_uniq UNIQUE (language_code, master_id);

Before we expose them to the API, we need to register the router query-set with REST Framework. Edit the myBlog/urls.py file with the following contents:

from django.contrib import admin
from django.urls import path, include
from parler_rest.serializers import TranslatableModelSerializer
from parler_rest.fields import TranslatedFieldsField
from rest_framework import routers, serializers, viewsets
from blog.models import Post

class PostSerializer(TranslatableModelSerializer):
    translations = TranslatedFieldsField(shared_model=Post)

    class Meta:
        model = Post
        fields = ('translations', 'author', 'created_on', 'updated_on')

class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

router = routers.DefaultRouter()
router.register(r'posts', PostViewSet)

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api/', include((router.urls, 'blog'))),
]

Now start the server and head over to the REST API path at http://localhost:8000/api/posts/ so we can see the translated model being served there as JSON:

REST template

There you have it, a fully-configurable REST template for serving translated models in Django.

Other Things We Can Do

Let’s see what other things we can do using this library.

Log in to the management shell first.

Accessing fields from the model

We can retrieve a Post model and check its fields:

>> from blog.models import Post
>>> first = Post.objects.first()
>>> first
<Post: My First Post>
>>> first.title
'My First Post'

To check the same field with a different translation, we need to switch to that locale first:

>>> first.set_current_language('el')
>>> first.title
'Η πρώτη μου δημοσίευση!'

Filtering translations

The translations for each post reside on a different table, and they can be filtered like any other relation:

>>> Post.objects.filter(translations__title='Η πρώτη μου δημοσίευση!')
<TranslatableQuerySet [<Post: My First Post>]>

Note that it returned the English version of the object. If we were to set either the PARLER_DEFAULT_LANGUAGE_CODE or LANGUAGE_CODE in the settings to el, then we would get the Greek translations instead:

>>> Post.objects.filter(translations__title='Η πρώτη μου δημοσίευση!')
<TranslatableQuerySet [<Post: Η πρώτη μου δημοσίευση!>]>

Manually adding translations to the model

We can update an existing model as usual:

>>> first = Post.objects.first()
>>> first.title = 'Μια άλλη ανάρτηση'
>>> first.save()
>>> Post.objects.first()
<Post: Μια άλλη ανάρτηση>

As the current selected language is GREEK this will save a translation for that locale. If we want to switch the current language and save a translation there, we will need to use the .set_current_language() method:

p = Post.objects.first()
p.title='Η πρώτη μου ανάρτηση'
p.save()
Post.objects.all()
<TranslatableQuerySet [<Post: Η πρώτη μου ανάρτηση>, <Post: Μια άλλη ανάρτηση>, <Post: Η δεύτερη μου δημοσίευση>]>

p = Post.objects.first()
p.set_current_language('en')
p.title='Demo Blog Post Title'
p.save()
>>> p
<Post: Demo Blog Post Title>
p.set_current_language('el')
p
<Post: Η πρώτη μου ανάρτηση>

We can create a new translations using the create_translation method of the Post model:

>>> p = Post.objects.create(author_id='1')
>>> p.create_translation('en', title='A different blog post')
>>> p.save()
>>> Post.objects.all()
<TranslatableQuerySet [<Post: A different blog post>, <Post: Μια άλλη ανάρτηση>, <Post: Η δεύτερη μου δημοσίευση>]>

Retrieving REST API data for one translation only

We use the translated method to retrieve query sets that have the specified language translations:

Post.objects.translated('el') # Filter only Greek translations
<TranslatableQuerySet [<Post: Μια άλλη ανάρτηση>, <Post: Η δεύτερη μου δημοσίευση>]>

In order to hook it into REST Framework, though, we need to adopt a new strategy. We can detect the client’s preferred language using the accept_language header and filter the query-set result to contain only the translations for that language. The following code is taken from this example comment here with some stability improvements:

Create a mixins.py file and add the following code:

from myblog import settings
from django.utils.translation import get_language_from_request


class TranslatedSerializerMixin(object):
    """
    Mixin for selecting only requested translation with django-parler-rest
    """

    def to_representation(self, instance):
        inst_rep = super().to_representation(instance)
        request = self.context.get('request')
        lang_code = get_language_from_request(request)

        result = {}
        for field_name, field in self.get_fields().items():
            # add normal field to resulting representation
            if field_name is not 'translations':
                field_value = inst_rep.pop(field_name)
                result.update({field_name: field_value})

            if field_name is 'translations':
                translations = inst_rep.pop(field_name)
                if lang_code not in translations:
                    # use fallback setting in PARLER_LANGUAGES
                    parler_default_settings = settings.PARLER_LANGUAGES['default']
                    if 'fallback' in parler_default_settings:
                        lang_code = parler_default_settings.get('fallback')

                    if 'fallbacks' in parler_default_settings:
                        lang_code = parler_default_settings.get('fallbacks')[0]
                for lang, translation_fields in translations.items():
                    if lang == lang_code:
                        trans_rep = translation_fields.copy()  # make copy to use pop() from
                        for trans_field_name, trans_field in translation_fields.items():
                            field_value = trans_rep.pop(trans_field_name)
                            result.update({trans_field_name: field_value})

        return result

Then add the mix into the PostSerializer:

class PostSerializer(TranslatedSerializerMixin, TranslatableModelSerializer):
    translations = TranslatedFieldsField(shared_model=Post)

    class Meta:
        model = Post
        fields = ('translations', 'author', 'created_on', 'updated_on')

Now, if we head over to http://localhost:8000/api/posts/, we will see only the translations for the English language as our default accepts-language:

If I change the default browser language (via settings) to Greek, then the Greek translations will be returned:

Switching the language

Using the switch_language context manager and given a support language tag, we can switch into the specified language:

>>> from parler.utils.context import switch_language
p = Post.objects.filter(translations__title='Μια άλλη ανάρτηση')
>>> with switch_language(p, 'en'):
...     print(p.title)
My First Post

Conclusion

With django-parler, we can quickly set up and use translatable fields in Django, with good automatic admin integration. We hope that this tutorial has helped you understand how it works and how to use it. For more advanced needs in terms of i18n and l10n in Django, make sure you see what Phrase can do for you in a 14-day free trial.

5 (100%) 73 votes
Comments
close

Automate Your Localization Workflow for Continuous Deployment

Automate Localization for Continuous Deployment

  • Integrate Phrase into your agile environment easily
  • Import and export your localization files in any format
  • Automate your localization workflow to speed up every release