Displaying translated ForeignKey objects in Django admin with django-hvad

For multilingual websites built with Django the extension django-hvad is a must, since it allows to specify, edit and fetch multilingual (i.e. translatable) objects very easily and is generally well integrated into the Django framework. However, some caveats for using django-hvad’s TranslatableModel in the Django model admin backend system exist, especially when dealing with relations to TranslatableModel objects. I want to address three specific problems in this post: First, it’s not possible to display a translatable field directly in a model admin list display. Secondly, related (and also translated) ForeignKey objects are only displayed by their primary key ID in such a list display. And lastly, a similar problem exists for the list display filter and drop-down selection boxes in the edit form.

Introduction

Suppose you have a number of political parties and each political party is related to a country. In the party and the country objects some fields such as the party description or the country name can be translated. We use django-hvad’s TranslatableModel class to implement the models as follows:

from django.db import models
from hvad.models import TranslatableModel, TranslatedFields

class Country(TranslatableModel):
    translations = TranslatedFields(
        name=models.CharField('Country name', max_length=512, blank=False, null=False),
        description=models.TextField('General description', blank=True)
    )
    # possibly more (shared) fields here...

    def __str__(self):
        # return "name" from translation
        return self.safe_translation_getter('name', str(self.pk))

class Party(TranslatableModel):
    name_orig=models.CharField('Name in original language', max_length=512, blank=False, null=False),
    country = models.ForeignKey(Country, verbose_name='Country', blank=False, null=False)
    translations = TranslatedFields(
        name_translated=models.CharField('Translated party name', max_length=512, blank=False, null=False),
        description=models.TextField('General description', blank=True)
    )
    # possibly more (shared) fields here...

    def __str__(self):
        return self.name_orig

So Country only has the translatable (non-shared) fields name and description. We also gave it a __str__ method which return the translated name for the currently selected language (English by default) using safe_translation_getter(), which we’ll use later again.

Party has the shared fields (i.e. shared across all translations) name_orig and country along with the translatable fields name_translated and description. Party‘s country field creates a relation to a Country object (n:1 relation as seen from the Party‘s side).

List display

Let’s try to create a list display view with the ID, the original name (shared field), the translated name field and the related country:

from hvad.admin import TranslatableAdmin

class PartyAdmin(TranslatableAdmin):
    list_display = ('id', 'name_orig', 'name_translated', 'country')

Unfortunately, this will fail with the following exception, because we can’t access a translated field like name_translated directly in the list_display definition:

hvad.exceptions.WrongManager: To access translated fields like 'name_translated' from an untranslated model, you must use a translation aware manager. For non-translatable models, you can get one using hvad.utils.get_translation_aware_manager.
For translatable models, use the language() method.

However, we can define a method in the Party model which loads the correct translation using the safe_translation_getter() method:

class Party(TranslatableModel):
    # (field definitions here ...)
    def get_name_translated():
        return self.safe_translation_getter('name_translated', str(self.pk))
    get_name_translated.short_description = 'translated name'

Now in PartyAdmin we specify to use our model’s method get_name_translated:

class PartyAdmin(TranslatableAdmin):
    list_display = ('id', 'name_orig', 'get_name_translated', 'country')

Now the list display is loaded without errors and the translated name for the current language is shown, but unfortunately the country IDs show up instead of their names:

django_hvad_list_display_1

This happens because the model admin can only display shared, i.e. non-translatable fields. We have to implement a little work around to solve this. Let’s fetch all country translations beforehand and store them in a dictionary with country ID to country name mapping. Then we use a custom list_display field method in the model admin to return the country name for a specific ID.

Let’s start with implementing a small helper method to fetch all country name translations and store them in a dictionary. We’ll just use a hard-coded default language (“en”) here. We’ll call the above defined __str__ method of each Country object to retrieve the translated name:

def get_country_names_dict():
    country_objs = Country.objects.language('en').all()
    return {c.pk: str(c) for c in country_objs}

Now we change the PartyAdmin class to add a new attribute country_names. This will be filled in the overridden changelist_view() method and will be accessed in the custom list display field method get_country_name():

class PartyAdmin(TranslatableAdmin):
    list_display = ('id', 'name_orig', 'get_name_translated', 'get_country_name')

    def __init__(self, *args, **kwargs):
        super(PartyAdmin, self).__init__(*args, **kwargs)
        self.country_names = None

    def get_country_name(self, obj):
        return self.country_names[obj.country.pk]

    def changelist_view(self, request, extra_context=None):
        if not self.country_names:
            self.country_names = get_country_names_dict()
        return super(PartyAdmin, self).changelist_view(request=request, extra_context=extra_context)

Now the country names are correctly displayed instead of their IDs.

List display filter

When we want to add a filter to reduce the result set by country (with list_filter = ('country', ) in the PartyAdmin definition), we will notice that again only the country IDs are displayed as seen here:

django_hvad_filter_country

We can fix this by extending RelatedFieldListFilter from django.contrib.admin as follows:

from django.contrib.admin import RelatedFieldListFilter

class RelatedCountryFieldListFilter(RelatedFieldListFilter):
    def __init__(self, field, request, params, model, model_admin, field_path):
        self.country_names = get_country_names_dict()
        super(RelatedCountryFieldListFilter, self).__init__(field=field,
                                                            request=request,
                                                            params=params,
                                                            model=model,
                                                            model_admin=model_admin,
                                                            field_path=field_path)

    def field_choices(self, field, request, model_admin):
        choices = super(RelatedCountryFieldListFilter, self).field_choices(field=field,
                                                                           request=request,
                                                                           model_admin=model_admin)
        choices = [(c_id, self.country_names[c_id]) for c_id, _ in choices]
        return sorted(choices, key=lambda x: x[1])

In __init__() we fetch the country names and in field_choices() redefine the choices so that they contain tuples of country ID and country name. We furthermore sort these choices by country name. We can now use our modified list filter class like this and it will display the country names instead of the IDs:

class PartyAdmin(TranslatableAdmin):
    # ...
    list_filter = (('country', RelatedCountryFieldListFilter), )
    # ...

drop-down selection boxes in the edit form

One last problem is that in the edit form, related TranslatableModel objects are also only displayed as IDs:

django_hvad_country_select

We can fix this by extending TranslatableModelForm so that we modify the country field in order to load all ordered translated country objects. Furthermore, the label_from_instance is set to return the country names:

class PartyAdminForm(TranslatableModelForm):
    def __init__(self, *args, **kwargs):
        super(PartyAdminForm, self).__init__(*args, **kwargs)
        self.fields['country'].queryset = Country.objects.language('en').all().order_by('name')
        country_names = get_country_names_dict()
        self.fields['country'].label_from_instance = lambda obj: country_names[obj.pk]

Now we only have to specify to use our extended form in the PartyAdmin:

class PartyAdmin(TranslatableAdmin):
    # ...
    form = PartyAdminForm
    # ...

With these three small modifications we can seamlessly work with translated objects in the Django model admin.

Comments are closed.

Post Navigation