11 августа 2011 в 10:14

Полная русификация админки


Здравствуйте. На днях возникла задача русифицировать админку django включая названия моделей, полей и приложений. Основной целью было избежать модифицирования кода django. Продолжительное гугление не дало целостной картины этого процесса. Поэтому я решил собрать все в одном месте.
Сразу скажу, что еще в самом начале проекта поставил django-admin-tools и тем самым сохранил себе некоторое количество нервных клеток. А все манипуляции проводились над django 1.3.


Подготовка


Для начала пропишем в конфигурационном файле

LANGUAGE_CODE = 'ru-RU'
USE_I18N = True


Затем создадим свои классы для приборной панели и меню django-admin-tools. Для этого выполним последовательно команды

python manage.py custommenu
python manage.py customdashboard

В результате выполнения этих комманд у вас в корневой директории проекта появятся два файла dashboard.py и menu.py. Далее в конфигурационном файле проекта нужно указать где находятся нужные классы. Для этого допишем в него следующие строчки

ADMIN_TOOLS_MENU = 'myproject.menu.CustomMenu'
ADMIN_TOOLS_INDEX_DASHBOARD = 'myproject.dashboard.CustomIndexDashboard'
ADMIN_TOOLS_APP_INDEX_DASHBOARD = 'myproject.dashboard.CustomAppIndexDashboard'

Путь может быть любой. Главное чтоб по нему находились нужные классы

Для перевода нам понадобится утилита gettext. Её установка отличается для разных систем. Поэтому углубляться в сей процесс не будем. Работать будем с кодировкой utf-8.
Gettext использует для перевода словари с раширением .po, которые переводит в бинарный формат с расширением .mo. Для того чтоб их подготовить нужно в корневой директории проекта или приложения создать папку locale. Именно папку, а не модуль python. То есть без файла __init__.py иначе будут ошибки.
Далее нужно открыть консоль и перейти в ту директорию в которую положили папку locale и выполнить команду

python manage.py makemessages -l ru

При выполнении этой команды будут просканированы все файлы на предмет обращения к словарю и составлен файл django.po, который появится в папке locale/ru/LC_MESSAGES. Можно выполнять эту команду регулярно после добавлений новых обращений к словарю в коде или же править файл django.po руками.
Чтоб изменения в словаре вступили в силу нужно выполнить команду

python manage.py compilemessages

после завершения которой рядом с файлом django.po появится django.mo.

Перевод имени приложения


Первым делом нужно заставить админку отображать русское название для имени приложения. На одном из форумов советовали просто прописать в поле app_label подкласса Meta в модели нужное значение, но от этого я отказался сразу. Так как меняется url приложения и с syncdb начались проблемы. Перекрытие метода title у str тоже не помогло так как слетал фильтр и admin-tools начинал лепить все модели в один бокс.
Я обычно запускаю команду makemessages в процессе работы над проектом, а значит нам нужно место, где будет обозначено обращение к словарю. Проще говоря я вписываю в файл __init__.py своего приложения следующий код

from django.utils.translation import ugettext_lazy as _
_('Feedback')

Здесь мы импортируем модуль ugettext_lazy и делаем обращение к словарю за переводом. Если после этого запустить команду makemessages еще раз, то в файл django.po будут добавлены следующие строки

#: feedback/__init__.py:2 msgid "Feedback" msgstr ""
и мы сможем подставить в msgstr свой перевод. В данном случае «Обратная связь». Теперь нам нужно сделать так чтоб при отображении шаблона названия приложения бралось из нашего словаря. Для этого сначала переопределим шаблон app_list.html. Этот шаблон используется при выводе модуля AppList.
В нашей директории templates создадим определенную структуру директорий и положим туда файл app_list.html так чтоб у нас получился путь

templates/admin_tools/dashboard/modules/add_list.html
Этот файл должен иметь то же содержание что и оригинальный app_list.html. Теперь изменим код в строке 5 на следующий

<h3><a href="{{ child.url }}">{% trans child.title %}</a></h3>

Таким образом при отображении названия приложения в общем списке будет браться наше значение из словаря.
В общем списке название отображается нормально, но когда мы заходим в само приложение, то заголовок модуля все еще не переведен. Для того чтоб исправить это заглянем в наш файл dashboard.py, который мы создавали в начале, и найдем там класс CustomAppIndexDashboard. Он отвечает за формирования страницы приложения в админке. В его методе __init__ исправим код чтоб получить следующее

self.children += [
    modules.ModelList(_(self.app_title), self.models),
    #... дальше оставляем все как было

Здесь мы завернули self.app_title в функцию ugettext_lazy и теперь на странице приложения название будет так же переведено.
Остались только хлебные крошки. Там по-прежнему отображается оригинальное название.
Модуль breadcrumbs используется в большом количестве шаблонов, поэтому за мыслями я полез потрошить файлы django.contrib.admin. Результатом чего стал вот такой класс. Его надо прописать в файле admin.py вашего приложения до регистрации модулей админки. Забегая вперед скажу, что здесь мы так же переводим и заголовки страниц просмотра, редактирования и добавления модели c помощью библиотеки, о которой расскажу чуть ниже.
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from django.utils.text import capfirst
from django.db.models.base import ModelBase
from django.conf import settings
from pymorphy import get_morph

morph = get_morph(settings.PYMORPHY_DICTS['ru']['dir'])

class I18nLabel():
    def __init__(self, function):
        self.target = function
        self.app_label = u''

    def rename(self, f, name = u''):
        def wrapper(*args, **kwargs):
            extra_context = kwargs.get('extra_context', {})
            if 'delete_view' != f.__name__:
                extra_context['title'] = self.get_title_by_name(f.__name__, args[1], name)
            else:
                extra_context['object_name'] = morph.inflect_ru(name, u'вн').lower()
            kwargs['extra_context'] = extra_context
            return f(*args, **kwargs)
        return wrapper

    def get_title_by_name(self, name, request={}, obj_name = u''):
        if 'add_view' == name:
            return _('Add %s') % morph.inflect_ru(obj_name, u'вн,стр').lower()
        elif 'change_view' == name:
            return _('Change %s') % morph.inflect_ru(obj_name, u'вн,стр').lower()
        elif 'changelist_view' == name:
            if 'pop' in request.GET:
                title = _('Select %s')
            else:
                title = _('Select %s to change')
            return title % morph.inflect_ru(obj_name, u'вн,стр').lower()
        else:
            return ''

    def wrapper_register(self, model_or_iterable, admin_class=None, **option):
        if isinstance(model_or_iterable, ModelBase):
            model_or_iterable = [model_or_iterable]
        for model in model_or_iterable:
            if admin_class is None:
                admin_class = type(model.__name__+'Admin', (admin.ModelAdmin,), {})
            self.app_label = model._meta.app_label
            current_name = model._meta.verbose_name.upper()
            admin_class.add_view = self.rename(admin_class.add_view, current_name)
            admin_class.change_view = self.rename(admin_class.change_view, current_name)
            admin_class.changelist_view = self.rename(admin_class.changelist_view, current_name)
            admin_class.delete_view = self.rename(admin_class.delete_view, current_name)
        return self.target(model, admin_class, **option)

    def wrapper_app_index(self, request, app_label, extra_context=None):
        if extra_context is None:
            extra_context = {}
        extra_context['title'] = _('%s administration') % _(capfirst(app_label))
        return self.target(request, app_label, extra_context)

    def register(self):
        return self.wrapper_register

    def index(self):
        return self.wrapper_app_index

admin.site.register = I18nLabel(admin.site.register).register()
admin.site.app_index = I18nLabel(admin.site.app_index).index()

При помощи него мы заменяем контекст для рендеринга шаблона на вызов функции ugettext_lazy. Таким образом мы перевели название приложения в хлебных крошках и заголовке страницы. Но это еще не все. Для полноты картины нам надо перегрузить еще один шаблон admin/app_index.html И строку 11 заменим на

{% trans app.name %}

Осталось только перевести имя приложения в выпадающем меню. Для этого достаточно перегрузить шаблон admin_tools/menu/item.html и поправить пару строк. В блок load второй строки добавляем i18n, а в конец 5й строки вместо {{ item.title }} пишем {% trans item.title %}.
Теперь все названия нашего приложения будут отображаться из словаря django.mo. Можем идти дальше

Перевод названия модели и полей


Если название приложения нам нужно просто выводить в переведенном виде, то название модели хорошо бы выводить с учетом падежа, рода и числа. В поисках красивого решения я наткнулся на великолепный модуль pymorphy от kmike, за который ему огромное спасибо. Он очень удобен в использовании и прекрасно делает свою работу! К тому же для админки нам большая скорость не нужна. Все что нам остается — это установить модуль pymorphy и интегрировать его в django руководствуясь шагами из документации.
Теперь нам нужно переопределить несколько шаблонов в админке и расставить там фильтры pymorphy при этом все переводы строк должны оставаться в одном месте. А именно в файле django.po.
Дальше будем для примера русифицировать модель Picture чтоб она отображалась как «Картинка». Первым делом в этой модели пропишем

class Picture(models.Model):
    title = models.CharField(max_length=255, verbose_name=_('title'))
    ...
    class Meta:
        verbose_name = _(u'picture')
        verbose_name_plural = _(u'pictures')

И добавим в файл django.po

msgid "picture" msgstr "картинка" msgid "pictures" msgstr "картинки" msgid "title" msgstr "заголовок"
Теперь осталось сделать чтоб переведенные слова отображались с учетом падежа и числа
Начнем с шаблона admin/change_list.html. Он отвечает за вывод списка элементов модели. Для начала добавим в блок load модуль pymorphy_tags. Например в строку 2. Чтоб получилось

{% load adminmedia admin_list i18n pymorphy_tags %}

Далее находим там строку 64, которая отвечает за вывод кнопки добавления

{% blocktrans with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktrans %}
и меняем её на
{% blocktrans with cl.opts.verbose_name|inflect:"вн" as name %}Add {{ name }}{% endblocktrans %}

Здесь мы добавили изменение названия модели в винительный падеж. И получили правильную надпись «Добавить картинку». Подробнее о формах изменения можно почитать здесь.
Заголовки страниц уже переведены в нужной форме с помощью класса I18nLabel так что можно двигаться дальше.
Теперь перегрузим шаблон admin/change_form.html. Сначала нужно добавить модуль pymorphy_tags в блок load, а затем исправить там хлебные крошки заменив в строке 22

{{ opts.verbose_name }}
на
{{ opts.verbose_name|inflect:"вн" }}

Далее во списку идет шаблон admin/delete_selected_confirmation.html. В нем все правки делаем тем же способом, что и в предыдущих случаях. Здесь нужно сначала исправить хлебные крошки вот так

{% trans app_label|capfirst %}

К сожалению функция delete_selected, которая отвечает за вывод этой страницы не поддерживает extra_context, что меня очень печалит. Поэтому я сделал свой фильтр, который изменяет форму числа в зависимости от величины объекта.

from django import template
from django.conf import settings
from pymorphy import get_morph

register = template.Library()
morph = get_morph(settings.PYMORPHY_DICTS['ru']['dir'])

@register.filter
def plural_from_object(source, object):
    l = len(object[0])
    if 1 == l:
        return source
    return morph.pluralize_inflected_ru(source.upper(), l).lower()


Теперь во всех местах надо расширить блок blocktrans примерно так

{% blocktrans %}Are you sure you want to delete the selected {{ objects_name }}? All of the following objects and their related items will be deleted:{% endblocktrans %}
исправить на
{% blocktrans with objects_name|inflect:"вн"|plural_from_object:deletable_objects as objects_name %}Are you sure you want to delete the selected {{ objects_name }}? All of the following objects and their related items will be deleted:{% endblocktrans %}

После всего этого остается лишь перегрузить шаблон admin/pagination.html, подключить в нем модуль pymorphy_tags и заменить в нем строку 9 на

{{ cl.result_count }} {{ cl.opts.verbose_name|lower|plural:cl.result_count }}

фильтр lower я добавил потому что возникала ошибка при преобразовании прокси объекта gettext в фильтре plural. Но, возможно, это у меня в окружении такой глюк и у вас не будет необходимости его добавлять.

Следующий по плану шаблон admin/filter.html Тут просто первые две строки заменить на

{% load i18n pymorphy_tags %} <h3>{% blocktrans with title|inflect:"дт" as filter_title %} By {{ filter_title }} {% endblocktrans %}</h3>

Остались только пользовательские сообщения, которые все еще выводятся без учета числа. Для того чтоб исправить эту досадную несправедливость нужно переопределить метод message_user у класса ModelAdmin. Можно вставить это в admin.py. У меня получилось делать следующим образом

def message_wrapper(f):
    def wrapper(self, request, message):
        gram_info = morph.get_graminfo( self.model._meta.verbose_name.upper() )[0]
        if -1 != message.find(u'"'):
            """
            Message about some action with a single element
            """
            words = [w for w in re.split("( |\\\".*?\\\".*?)", message) if w.strip()]
            form = gram_info['info'][:gram_info['info'].find(',')]
            message = u' '.join(words[:2])
            for word in words[2:]:
                if not word.isdigit():
                    word = word.replace(".", "").upper()
                    try:
                        info = morph.get_graminfo(word)[0]
                        if u'КР_ПРИЛ' != info['class']:
                            word = morph.inflect_ru(word, form).lower()
                        elif 0 <= info['info'].find(u'мр'):
                            word = morph.inflect_ru(word, form, u'КР_ПРИЧАСТИЕ').lower()
                        else:
                            word = word.lower()
                    except IndexError:
                        word = word.lower()
                message += u' ' + word
        else:
            """
            Message about some action with a group of elements
            """
            num = int(re.search("\d", message).group(0))
            words = message.split(u' ')
            message = words[0]
            pos = gram_info['info'].find(',')
            form = gram_info['info'][:pos] + u',' + u'ед' if 1 == num else u'мн'
            for word in words[1:]:
                if not word.isdigit():
                    word = word.replace(".", "").upper()
                    info = morph.get_graminfo(word)[0]
                    if u'КР_ПРИЛ' != info['class']:
                        word = morph.pluralize_inflected_ru(word, num).lower()
                    else:
                        word = morph.inflect_ru(word, form, u'КР_ПРИЧАСТИЕ').lower()
                message += u' ' + word

        message += '.'
        return f(self, request, capfirst(message))
    return wrapper

admin.ModelAdmin.message_user = message_wrapper(admin.ModelAdmin.message_user)

Здесь мы разбираем сообщение по словам и склоняем их в нужную форму. Отдельно отличаются сообщения для групп объектов и для единиц.

Вот теперь мы можем наблюдать примерно такую картинку


Заключение


Отсутствие предусмотренных решений в архитектуре django, безусловно, расстраивает, но все в наших руках. Возможно некоторые решения могут показаться вам кривыми, но я пока не нашел способа сделать это изящнее.
При написании статьи я старался изложить кратко и по пунктам, и не смотря на количество текста получается не так много движений для достижения результата. Это при условии использования приведенного выше кода.

Основная цель этой работы была перевести административный интерфейс и сохранить все переведенные строки в одном месте, а именно в языковом файле. Что мы и получили.

Буду благодарен за любые замечания и предложения. Спасибо за внимание.

P.S. Уже готовые шаблоны вы можете забрать здесь. Вам нужно будет распаковать содержимое архива в вашу директорию templates.

[UPD]: Михаил (kmike) завел проект на bitbucket под названием django-russian-admin для автоматизации всех приведенных выше действий.
+67
7248
151

Комментарии (16)

+1
Volshebnyi, #
Огромное спасибо!
Как раз стоит такая проблема, так что я даже не знаю, сколько времени бы у меня ушло, чтобы дойти до такого решения самостоятельно.
+1
northicewind, #
Рад что статья оказалась полезной
+1
hardtop, #
Проделана мегаработа, за что низкий поклон и большое спасибо! А какую версию джанги используете: 1.2 или 1.3?
+1
northicewind, #
Спасибо. Использую версию 1.3 релизную.
+1
northicewind, #
Добавил информацию о версии в пост.
–3
karkarramba, #
Форма подачи «а-ля хауту» — это плохо. очень плохо.
За информацию, что есть такая штука как pymorphy — спасибо.
И еще, может стоит упомянуть, что кодировка должна быть utf-8?
+1
northicewind, #
Спасибо за замечание. Насчет кодировки добавил пометку. По данному вопросу теории немного, поэтому я подумал, что данная форма изложения может быть уместна в целях сокращения количества текста. Учту на будущее.
+1
Guria, #
Вариант для ленивых будет?
+1
northicewind, #
Надо подумать как это красивее сделать. Пока могу предложить скопировать код класса I18nLabel и функции message_wrapper в Ваш admin.py и тег plural_from_object в templatetags. А шаблоны уже готовые я приложил в конец поста.
+2
kmike, #
Код можно запаковать в какой-нибудь пакет (django-russian-admin, например). Шаблоны положить в templates и потом в README написать «добавьте russian_admin в INSTALLED_APPS после admin и admin_tools».

Только admin.site.register и admin.site.app_index патчить нехорошо как-то, лучше сделать наследника AdminSite, наверное.
+2
kmike, #
По договоренности с Николаем начал потихоньку пилить версию для ленивых:
bitbucket.org/kmike/django-russian-admin

Пока она, правда, для не очень ленивых получается (т.к. пошел путем своего AdminSite), и там еще почти ничего не работает, но кое-что уже есть (см. тестовый проект). План такой: сначала сделать версию для не очень ленивых, а потом, быть может, добавить манкипатчилку для совсем ленивых.

+1
northicewind, #
Спасибо, добавил информацию в пост
0
helm2004, #
Мегаспасибо Вам! Столько нервных клеток сберегли!
0
alTus, #
Всё правильно сделал (с) :D

Каждый раз раздражает это отсутствие склонений/падежей, но попривык уже, а тут по большому счету даже манкипатчинга удалось избежать.

Спасибо.
+1
kmike, #
жесть)

Небольшое замечание: пользуйтесь документацией по pymorphy с rtfd ( pymorphy.rtfd.org/), там довольно много всего переписано и, в частности, показан способ, как повторно использовать экземпляр морф. анализатора в случае интеграции с джангой. На packages.pypi.ru неудобно тем, что нужно за ней следить постоянно, и что версия там ровно одна, переехать бы на rtfd полностью, но боюсь, что ссылки кому-нибудь сломаю.

Немного по коду:

1. А вот этот код точно нужен?
form = gram_info['info'][:pos] + u',' + u'ед' if 1 == num else u'мн'


Все методы, принимающие грам. форму, должны это внутри делать (т.е. им нужно передавать не полную грам. форму, а минимальную часть, которая нужно поменять или удостовериться, что полученное слово ей соответствует).

2. Вот тут можете пояснить? Если это какая-то штука, которая всегда должна быть такой, может имеет смысл ее прямо в pymorphy включить и в inflect_ru добавить?
if u'КР_ПРИЛ' != info['class']:
    word = morph.pluralize_inflected_ru(word, num).lower()
else:
    word = morph.inflect_ru(word, form, u'КР_ПРИЧАСТИЕ').lower()


3. Там не получится упростить код, воспользовавшись напрямую шаблонными фильтрами? В джанге шаблонные фильтры можно использовать как обычные функции (берут строку, возвращают строку), а фильтры в pymorphy умеют для строк, размеченных [[ ]], части склонять, а части оставлять как есть (+ они сами более-менее сохраняют регистр слов и разбивают текст на слова).
+1
northicewind, #
Еще раз спасибо Вам за библиотеку. Я пользовался документацией на packages.python.org/pymorphy/ Спасибо, теперь буду смотреть на rtfd.org.

1. В этой строке я первым делом брал род из названия модели, но число у него постоянно единственное, для этого просто приписал выбор числа по количеству элементов. Это было сделано для работы с краткими прилагательными. К примеру сообщение об удалении картинки выглядело так «Успешно удалены 1 картинка». Первые два слова определялись как «КР_ПРИЛ». Я начал со второго, так как первое всегда будет оставаться в нужной форме. А второе в данном случае будет изменяться с 'жр, ед' на 'жр, мн' и будет удалена и удалены. Если у Вас есть предложение по переделке, я с радостью выслушаю

2. Метод inflect_ru я использовал для работы только над прилагательными. Но заметил одну особенность. Допустим слово «удалена» если убрать приведение в форме «КР_ПРИЧАСТИЕ», то она выводилась с удвоенной «Н» то есть как «удаленна» возможно я что-то не так делал, если да, то был бы рад узнать как надо было.

3. Я пытался сначала реализовать все на шаблонных фильтрах, но в таком случае фильтр бы прищлось применять к уже сформированному сообщению и это давало не совсем корректные результаты.

Только зарегистрированные пользователи могут оставлять комментарии.
Войдите, пожалуйста.