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


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

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

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

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

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

                  Спасибо.
                  • +1
                    жесть)

                    Небольшое замечание: пользуйтесь документацией по 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
                      Еще раз спасибо Вам за библиотеку. Я пользовался документацией на packages.python.org/pymorphy/ Спасибо, теперь буду смотреть на rtfd.org.

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

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

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

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