Introduction
As I wrote in the two previous posts, I wanted the URLs of this site to contain the language the content is in. To do this, I had to write some custom code, including models, managers, middleware and context processors.
Models
The first step on having multilingual content is to define a model that can support it. I thought long and hard and it boils down to these conclusions:
- The same content in different languages shares some data, like the slug, the tags and the publication date
- The different data are the title, the summary and the body.
- Therefore, I have to find a way to "inherit" the shared data to the actual entries, while keeping the different data apart.
Model inheritance in Django is somewhat rough, and so I went with KISS: Keep it simple, stupid, and went with the simplest thing that might just work:
First, define a master entry:
class MasterEntry(models.Model):
slug = models.SlugField(
unique_for_date='pub_date',
prepopulate_from=('title',),
help_text='Do not add language info here.'
)
pub_date = models.DateTimeField()
tags = TagField()
enable_comments = models.BooleanField(default=True)
class Meta:
ordering = ('-pub_date',)
get_latest_by = 'pub_date'
class Admin:
list_display = ('pub_date', 'slug')
list_filter = ('enable_comments', 'pub_date')
search_fields = ['slug','tags']
date_hierarchy = 'pub_date'
def save(self):
super(MasterEntry,self).save()
for entry in self.entry_set.all():
entry.save()
def __str__(self):
return self.slug
Pretty basic (although I removed some methods to help clarify things) except of the save method: it iterates over all the child entries and saves them.
Then, define the child entry:
class Entry(models.Model):
title = models.CharField(maxlength=200)
summary = models.TextField(help_text="One paragraph. Don't add <p> tag.")
body = models.TextField()
master = models.ForeignKey(MasterEntry, 'id')
language = models.CharField(maxlength=2, choices=settings.LANGUAGES)
objects = models.Manager()
public = EntriesManager()
slug = models.SlugField(
unique_for_date='pub_date',
prepopulate_from=('title',),
blank=True,
editable=False
)
pub_date = models.DateTimeField(blank=True,editable=False)
tags = TagField(editable=False)
enable_comments = models.BooleanField(blank=True,editable=False)
is_public = models.BooleanField(default=False)
def save(self):
self.slug=self.master.slug
self.pub_date=self.master.pub_date
self.tags = self.master.tags
self.enable_comments = self.master.enable_comments
super(Entry, self).save()
class Meta:
ordering = ('-pub_date',)
get_latest_by = 'pub_date'
unique_together=(('master','language','slug'),)
verbose_name = _('Entry')
verbose_name_plural = _('Entries')
class Admin:
list_display = ('title','master', 'pub_date', 'is_public',)
list_filter = ('enable_comments', 'pub_date')
search_fields = ['title', 'summary', 'body']
date_hierarchy = 'pub_date'
fields = (
(None, {
'fields': ('master','language','title', 'summary','body','is_public')
}),
)
def __str__(self):
return self.title+" - ("+str(self.master)+")"
def get_absolute_url(self):
if self.is_public:
return "/%s/blog/%s/%s/" % (self.language,self.pub_date.strftime(
"%Y/%m/%d").lower(), self.slug)
else:
return "/%s/blog/preview/%d" %(self.language,self.id)
The child entry has a reference to a master entry. It also has a language field that defines the language it is in. It then goes on to define the same fields the master entry has, which are then overridden in the custom save method. I had to do this in order to be able to use generic views (which require a pub_date field) as I couldn't get them to use the master entry's pub_date directly. I also toyed with properties, but it didn't worked out for me, so I just went with what worked. Notice that the inherited fields are not editable, hence they are not visible in admin.
You can see now that changes in the MasterEntry propagate down to the Entry , via its custom save method.
I also add a custom manager named public. More on that in...
Managers and Views
Defining the models is half the job - custom managers allow you to access advanced functionality with less code, and also to plug into places like generic views and feeds easily. Here are my managers:
class PublicEntriesManager(models.Manager):
def get_query_set(self):
return super(PublicEntriesManager, self).get_query_set().filter(is_public=True)
class EntriesManager(PublicEntriesManager):
def in_language(self, lang):
queryset_lang=super(EntriesManager, self).get_query_set().filter(language=lang)
return queryset_lang
def get_entries(self,pub_date,slug):
qs = Entry.public.filter(pub_date__range=(
datetime.combine(pub_date, time.min),
datetime.combine(pub_date, time.max)
),
slug__exact=slug)
entries = {}
for e in qs: entries[e.language]=e
return entries
def archives(self):
return Entry.public.all().dates('pub_date','month','DESC')
def has_entries_missing(self,the_lang, entry_qs):
if isinstance(entry_qs,models.Manager):entry_qs=entry_qs.all()
master_entries = MasterEntry.objects.filter(entry__in=entry_qs)
return _compare_entries_lang(the_lang,master_entries)
Plus the _compare_entries_lang function:
def _compare_entries_lang2(the_lang, master_entries=MasterEntry.objects):
if isinstance(master_entries,models.Manager):master_entries=master_entries.all()
for me in master_entries:
if me.entry_set.filter(is_public=True).count() < len(settings.LANGUAGES):
#this master entry has entries in less than the supported languages
#lets find out if they are missing in the requested lang
if not me.entry_set.filter(language=the_lang,is_public=True).count():
#yep, they do
for lang,dummy in settings.LANGUAGES:
if lang!=the_lang:
return lang
return None
This is quite big, so I'll break it down:
PublicEntriesManager is simply a manager that filters out the non-public entries. This way, in my view code I can use Entry.public.all() and I can be sure I get only the public entries. This saves some headaches and prevents silly mistakes (like, you show only the public objects on site, but display everything in your feeds).
EntriesManager is quite more complex. I have some complicated language-related functionality here, and it manages it all:
- If you get to a page (like a month archive or the index page) that detects that there are more entries available, but not in the current language, it displays a notification.
- Then, if you request an entry that is not available in your current language, it displays the available entry with a notification.
- Lastly, if you request an entry in a language other than your own, it displays it, along with a notification that you might prefer viewing the content in your own language.
I begin with the simple ones: archives is used to make a listing of dates that have public Entries, so I can create the archive listing on the right. in_language filters the queryset to return only the entries in a language.
To explain has_entries_missing, here is some code that accesses the EntriesManager:
def entry_index(request):
queryset= Entry.public
entries_missing=Entry.public.has_entries_missing(request.url_lang, queryset)
archive_blog_dict = {
'queryset': queryset.in_language(request.url_lang),
'date_field': 'pub_date',
'template_name':'entry_archive.html',
'extra_context':{'entries_missing':entries_missing}
}
return archive_index(request, **archive_blog_dict)
def entry_month(request, year, month):
date = datetime.date(*time.strptime(year+month, '%Y%m')[:3])
first_day = date.replace(day=1)
if first_day.month == 12:
last_day = first_day.replace(year=first_day.year + 1, month=1)
else:
last_day = first_day.replace(month=first_day.month + 1)
lookup_kwargs = {'pub_date__range' : (first_day, last_day)}
queryset= Entry.public
entries_missing = Entry.public.has_entries_missing(request.url_lang,
queryset.filter(**lookup_kwargs) )
archive_month_blog_dict = {
'queryset': queryset.in_language(request.url_lang),
'date_field': 'pub_date',
'template_name':'entry_archive_month.html',
'month_format':'%m',
'allow_empty':True,
'extra_context':{'entries_missing':entries_missing}
}
return archive_month(request, year, month, **archive_month_blog_dict)
The has_entries_missing method checks a QuerySet of Entries to see if they are available in both languages. If they are not, it returns the language code that has the extra entries. I use it in my templates to create a link to the page that displays the missing entries. I now have only two languages, but I think it will work with more than two. I'll let you know when I try it :)
Finally, the get_entries method returns a dictionary with languages as the keys and entries as the values, for a given pub_date and slug. This can be used to check if a single entry is available in one or both languages, and display the relevant information. The view that uses this is:
def entry_detail(request, year, month, day, slug):
pub_date = datetime.date(*time.strptime(year+month+day, '%Y%m%d')[:3])
entries = Entry.public.get_entries(pub_date,slug)
entry_blog_dict = {
'queryset': Entry.public.all(),
'date_field': 'pub_date',
'template_name':'entry_detail.html',
'template_object_name':'entry',
'month_format':'%m',
'extra_context':{'available_languages':len(entries)-1} # so to be either 0 (False) or 1 (True)
}
try:
entry = entries[request.url_lang]
entry_blog_dict['object_id']=entry.id
except KeyError:
entry = entries.values()[0] # we assume only 2 languages active
entry_blog_dict['extra_context']['language_not_found']=entry.language
entry_blog_dict['object_id']=entry.id
return object_detail(request, year=year, month=month, day=day, slug=slug, **entry_blog_dict)
Not the best looking code, but it works ;)
That's all for now
This post got bigger than I expected, and I still haven't covered the middleware and context processors. I'll cover these two in the next post. Until then, please leave a comment and let me know what you think about this!
UPDATE
I guess I should put my money where my mouth is :) I found out that the the _compare_entries_lang function didn't work as expected. I've replaced it with a better, but maybe slightly slower version.
Comments
Comment by Tomi Pieviläinen , 2 years, 9 months ago :
For some reason the links to your previous posts mix their titles. So the actual link to part one says it's for part two and vice versa.
This post is older than 30 days and comments have been turned off.

Comment by monolang , 2 years, 9 months ago :
μεγάλη θέση, grand poteau, großer Pfosten, gran poste, большой столб (this is getting rediculous), great post. really!
thanks