вторник, 23 декабря 2008 г.

Хакаем django админку

Как можно прогнуть django админку под себя?

Админка в django одна из любимых фич многих её поклонников и один из основных аргументов в холиварах django-vs-все_остальные. На самом деле админка это не сама django, а обычное приложение, просто идущее в комплекте и возможности у нее те же самые, т.е. никакой особой core магии в ней нет. Я хочу сказать, что не надо её бояться, думать как там всё запутано и фиг туда влезешь, чтобы что-то подправить под себя. Это ерунда, ею можно вертеть и в хвост и в гриву, было бы желание и немного знания Python.

Лишившись страха кидаемся в бой и первым делом определим наши тактические цели.

Чего мы добиваемся?

У django довольно приличная документация на оф. сайте, по-этому тут мы займемся вопросами не освещенными в ней. Возьмем три примера из реальной жизни, необходимо в зависимости от группы пользователя одни и те же элементы показывать по-разному, а именно:

  1. Перечень объектов в форме списка
  2. Фильтры в списках объектов
  3. Отображение формы редактирования новости

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

Заряжаем орудия.

Как и сказано в документации, стандартный способ поднастроить админку это унаследоваться от admin.ModelAdmin в файле admin.py. Простейший случай будет выглядеть так:


from django.contrib import admin
from mysite.myapp.models import News

class NewsAdmin(admin.ModelAdmin):
    list_display = ('title', 'status', 'pub_date', 'author')
    list_filter = ('author','status','category')
   
admin.site.register(News,NewsAdmin)

Чтобы разобраться как и что работает, лучше всего держать перед глазами родительский класс ModelAdmin из файла django/contrib/admin/options.py.

Как видно ничего нестандартного. Есть модель News, для которой в списке будем выводить поля из list_display, а фильтровать по полям из list_filter

Для достижения наших целей будем наворачивать наш унаследованный класс, пока не будем полностью удовлетворены :) Первым делом подготовимся. Все наши задачи зависят от группы пользователя. Принадлежит ли пользователь группе редакторов (id которой хранится в consts.EDITORS_ID) или нет можно определить так:


bool(request.user.groups.filter(pk=consts.EDITORS_ID))

Как видно, такую громоздкую хрень писать каждый раз не очень радостно, а менее громоздкого метода в документации к django я не нашёл. Вот для пермишеннов есть .has_perm, а для групп нет =(. Представляю своё наколенное решение вопроса (тут и далее буду писать только новые методы класса NewsAdmin):


    def _prepare(self,request):
        """ 
        Подготовка нужных нам данных
        """
        user = request.user
        user.is_editor = bool(user.groups.filter(pk=consts.EDITORS_ID))\
                                or user.is_superuser
        user.is_author = not request.user.is_editor

    def __call__(self,request,url):
        self._prepare(request)
        return super(NewsAdmin,self).__call__(request,url)

Если посмотреть на код ModelAdmin, то видно, что определение какой url чем обрабатывать идёт в методе __call__, поскольку нам группа нужна во всех типах видов (добавление, изменение, список), то мы оборачиваем родительский __call__ в свой, где проводим нужную нам подготовку. В моём случае, теперь всегда будет доступна информация является ли пользователь членом группы редакторов или нет.

В дальнейшем большинство решений будем основывать на подобной технике: переопределить метод, сделать то, что нужно, вызвать родительский метод, доделать то, что нужно. Какой метод переопределять выясняем медитируя над исходниками ModelAdmin.

Перечень объектов в форме списка

Задача стоит предельно просто, авторы новостей должны видеть только свои новости, а редактор все.


    def queryset(self, request):
        """
        Формируем queryset  для list-view

        Фильтруем статьи по авторству, только если мы не
        супер юзер или редактор
        """
        qs = super(NewsAdmin, self).queryset(request) #1
        if request.user.is_editor: 
            return qs                                 #2
        else:
            return qs.filter(author = request.user)   #3

Я искренне не понимаю, почему про метод queryset не сказано в документации. Этот метод вызывается из changelist_view для получения QuerySet со списком объектов. Тут мы воспользовались замечательным свойством QuerySet'ов, что получив их в одном месте, мы можем продолжить навешивать на них фильтры и отдавать дальше. Так и поступаем:

  1. Получаем QuerySet, как оно изначально было задумано авторами django-admin
  2. Супер пользователям и редакторам отдаём в неизменном виде
  3. А всем остальным, фильтруем по полю author, оставляя только записи, где автором является текущий залогиненный пользователь.

Вуаля, админка стала чуть более дружественной и удобной.

Раз уж мы ограничили видимый список новостей для авторов, то надо идти до конца и заблокировать возможность открывать новость в форме редактирования простым подбором id в URL. Сделаем это так:

  1. Через админку в разделе удалим право Change News у группы авторов.
  2. Индивидуально даем разрешение на редактирование статьи, если пользователь совпадает с автором

Т.е. сначала закрутим гайки, потом немного ослабим.

С первым пунктом проблем возникнуть не должно, админку наверное уже всю истыкали :). Переходим ко второму. Снова покурим исходники ModelAdmin и найдем метод

def has_change_permission(self,request,obj=None):

Переопределим его в нашем NewsAdmin:


    def has_change_permission(self, request, obj=None):
        """
        Может ли пользователь заходить на страницу редактирования новости
        """
        if not obj:                                                     #1
            return True

        orig = super(NewsAdmin,self).has_change_permission(request,obj) #2
        return orig or (obj.author == request.user 
                          or request.user.is_editor)                    #3
    has_delete_permission = has_change_permission                       #4

  1. Случай когда obj=None вызывается когда вы еще ходите в районе /admin/ и списка объектов, т.е. когда еще неизвестен конкретный объект для редактирования. Я выбрал просто возвращать True для всех, иначе авторы не смогут получить даже список своих статей, т.к. право Change News мы у них отобрали.
  2. Получаем права согласно разрешениям на группы (именно так действует оригинальный метод, который мы переопределяем)
  3. Если права позволяют, то мы ничего не делаем, если вернулся запрет, то в некоторых случаях его ослабляем.
  4. Приравнял право на изменение к праву на удаление, вы можете поступить по-другому :)

Обратите внимание, что в третьей строке нельзя использовать is вместо ==, т.к. в Django ORM, в отличие от замечательного SQLAlchemy в пределах одной сессии объекты одного типа с одинаковым pk могут быть представлены в виде разных instance, что лично мне совсем не нравится.

В общем получился у нас эдакий row-level-permission

Настройка фильтров

Теперь редакторы видят все новости и могут фильтровать по авторам (помните атрибут list_filter в самом начале?). Но авторы-то уже видят только свои новости, а бесполезный фильтр справа у них все равно мозолит им глаза, заствляя их тыкать туда и задавать вопросы "а что это такое и для чего это нужно"?. Надо его убрать, оставив авторам только ('status','category')

Сначала я бодро решил выдавать list_filter через @property, примерно так


    @property
    def list_filter(self):
        if not self.request.user.is_editor:
            return 'auth','status','category'
        else:
            return 'status','category'

но Джанга не поддалась, т.к. в процессе регистрации Админки (admin.site.register(News,NewsAdmin)) она проверяет тип атрибутов и ей всенепременно нужен был list или tuple. Тогда решил зайти сбоку. Удалим из определения в класса "list_filter", а в _prepare(self, request) сформируем необходимый нам перечень фильтров. Вот что получилось:


    def _prepare(self,request):
        """
        Подготовка нужных нам данных
        """
        user = request.user
        user.is_editor = bool(user.groups.filter(pk=consts.EDITORS_ID))\
                                 or user.is_superuser
        user.is_author = not request.user.is_editor

        #настраивем фильтры под группу
        if user.is_author:
            self.list_filter = ('status','category')
        else:
            self.list_filter = ('author','status','category')

Из недостатков: если поле из list_filter будет отстутсвовать в модели, то вы получите более невнятное сообщение об ошибке чем прежде, т.к. при валидации NewsAdmin класса во время регистрации django пропустит list_filter, как отсутствующий атрибут.

Настраиваем форму редактирования

У меня в модели News имеется поле "editor_comment", куда редактор вбивает свои замечания к новости, если отправляет её на доработку автору. Что бы хотелось сделать с этим полем:

  • Не показывать его при добавлении новости
  • Не показывать его авторам, если оно пустое
  • Показывать его авторам, как обычный текст, если оно не пустое

Еще есть поле 'category', заводить категории могут только редакторы, у авторов права на добавление отобраны, но знак "+" все равно виден, что несколько нелогично. Попробуем убрать и его.

У ModelAdmin есть замечательный метод

def get_form(self,request,obj=None,**kwargs):

который возвращает настроенный и готовый у употреблению класс формы. Воспользуемся им. Уберем для начала плюсик:


    def get_form(self,request,obj=None,**kwargs):
        """
        Динамически создаем форму в зависимости от условий
        """
        form = super(NewsAdmin,self).get_form(request,obj,**kwargs)
        if not request.user.has_perm(self.opts.app_label+'.'
                                 +Category._meta.get_add_permission()):
            #у пользователя нет права добавлят группы, убираем отрисовку плюсика
            #делаем это заменой на стандартный виджет
            tmp = form.base_fields['category'].widget
            form.base_fields['category'].widget = forms.widgets.SelectMultiple(
                                                            choices=tmp.choices)

        return form

Немного поясню. Сначала мы добываем у родительского класса форму, которая бы отобразилась, если бы мы не вмешались. У класса в атрибуте base_fields хранятся поля будущей формы, а к каждому полю уже прикреплен widget. ModelAdmin, когда создавал класс формы везде прикрутил свои собственные виджеты,в нашем случае там был виджет с плюсиком, от которого мы пытаемся избавиться. Сначала проверяем, есть ли у пользователя право добавлять категорию, и если есть начинаем операцию по удалению плюсика :) Из существующего виджета выдираем список доступных элементов для выбора и создаем новый стандартный SelectMultiple, куда подсовываем список элементов, после чего прописываем новый виджет в поле category.

Особо хочу подчеркнуть, что form это всегда разные экземпляры классов, т.е. ModelAdmin формирует их заново при каждом обращении, а не хранит их у себя где-то жёстко зашитыми, по-этому мы с этим классом можем делать абсолютно все, и другой пользователь (скажем редактор), зашедший по точно тому же URL одновременно с нами получит свой экземпляр класса, а значит наши изменения его не коснутся. Если хотите узнать подробнее про динамическое создание классов, то почитайте про __metaclass__.

По похожей схеме разберемся с полем комментариев редактора:

  
class FixedTextWidget(forms.Textarea):
    def render(self, name, value, attrs=None):
        if value is None: value = ''
        final_attrs = self.build_attrs(attrs, name=name)
        return mark_safe(u'<div%s>%s</div>' % (flatatt(final_attrs),
                conditional_escape(force_unicode(value))))

class NewsAdmin(admin.ModelAdmin):
    ..........
    def get_form(self,request,obj=None,**kwargs):
        """
        Динамически создаем форму в зависимости от условий
        """
        form = super(NewsAdmin,self).get_form(request,obj,*args,**kwargs)
        if not request.user.has_perm(self.opts.app_label+'.'
                                 +Category._meta.get_add_permission()):
            #у пользователя нет права добавлят группы, убираем отрисовку плюсика
            #делаем это заменой на стандартный виджет
            tmp = form.base_fields['category'].widget
            form.base_fields['category'].widget =\
                         forms.widgets.SelectMultiple(choices=tmp.choices)

        if not request.user.is_editor:
            if obj and not obj.red_comment:
                #не показывать пустые комментарий не редакторам
                del form.base_fields['red_comment']
            elif obj:
                tmp = form.base_fields['red_comment'].widget =\
                                                      FixedTextWidget()

            #даже если choices были генератором, внутри они стали уже списком
            #оставляем из списка только первые 2 пункта(черновик и редактору)
            tmp = form.base_fields['status'].widget
            tmp.choices = tmp.choices[:2]

Здесь мы либо вообще удаляем поле, и его в форме больше не будет, либо опять же модифицируем widget, заменяя его на свой, слизанный с Textarea, но вместо тега textarea выводится div и у пользователя уже не будет соблазна что-то в это поле вбивать. Наверняка там будет небольшой косяк с атрибутами от textarea пересаженными на div, я не силен в html вёрстке, отображается нормально и ладно :). Шаманство с mark_safe, conditional_escape и прочие заклинания указанные после return я не трогал от греха подальше, т.к. такими плясками выводятся все стандартные виджеты. :)

не забудьте поставить в модели в описаниие этого поля blank=True! Иначе Django ругнется,что вы пытаетесь сохранить объект в базе со слишком пустым полем.

Конец

Вот и причесали админку на свой лад. А ведь еще можно переопределять её шаблоны и резвиться в save_model. Про это сказано в документации, очень советую почитать.

Очень сильно мне помогли (особенно в последнем забеге, где игрались с виджетами и формой) связка python manage.py shell+ipython(http://ipython.scipy.org/)+функция dir - очень удобно играться с произвольными объектами, смотреть их поля, править заменять, вызывать и пр. Если надо покопаться в неизвестной области, очень рекомендую!