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:
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:
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:
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.
Recent Comments