вторник, 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 - очень удобно играться с произвольными объектами, смотреть их поля, править заменять, вызывать и пр. Если надо покопаться в неизвестной области, очень рекомендую!

среда, 4 июня 2008 г.

Пара Git трюков

Git - яркий представитель модных ныне распределенных систем контроля версий. Проще говоря то, что хранит все ваши исходники, формирует патчи, показывает вам, что вы делали неделю назад, и кто тот засранец, который так криво написал вот этот кусок (частенько оказывается, что это вы сами :) ). Иными словами если вас достали бесконечные папки project.old, project.old2, project.work и т.д. то подобные игрушки как раз для вас, рекомендую Git, хотя другие (bzr, hg, darcs) даже не пробовал.

Не буду тут разводить туториал, как-нибудь в другой раз, просто расскажу про пару приемчиков.

Отложить текущие наработки

Вы делаете какую-то мега классную фичу, процесс в самом разгаре и тут бац, срочно надо исправить баг. Как вы обычно поступаете?


$ git checkout -b undone_work (1)
$ git commit -a -m "незаконченная фича" (2)
$ git checkout master (3)
... правим баг ...
$ git commit -a -m "Исправлен баг"
$ git checkout undone_work (4)
$ git reset --soft HEAD^ (5)
  1. Создаете временную ветку
  2. комитите туда то что наработали
  3. переходите на ветку, где надо исправить баг, исправляем, коммитим
  4. возвращаемся на временную ветку с нашей недоделаной работой
  5. отменяем последний коммит, т.к. он безсмысленен сам по себе
Работать оно, конечно, работает, но у Git есть более удобное средство. git-stash. В уже описанном выше случае все действия сведутся к:

$ git stash (1)
... правим баг ...
$ git commit -a -m "Исправлен баг" (2)
$ git stash pop (3)
  1. откладываем текущие наработки. Файлы вернуться в состояние HEAD (т.е. последний коммит текущей ветке)
  2. исправляем баг
  3. возвращаем наши отложенные изменения и продолжаем работу
Как видно, всё намного проще, а главное понятнее :) При этом если исправление бага затронет те же файлы, что мы хачили делая новую супер фичу, то Git сначала попытается разрулить конфликт сам, а если не выйдет предложит это пользователю (в общем как и при git merge)

git-stash умеет класть на полку и доставать оттуда несколько недоделаных работ.


$ git-stash (1)
$ git-checkout another_branch
$ git-stash
$ git-stash list (2)
$ git-stash pop stash@{_номер_} (3)
  1. несколько раз в разных местах вызываем git-stash
  2. просматриваем все что наоткладывали
  3. вернуть недоделку и продолжить над ней работу можно при помощи git-stash apply stash@{_номер_недоделки_}.

Возврат потерянных данных

Git, наверно, можно познавать вечно :). Недавно узнал, как пользоваться reflog. Оказывается Git записывает у себя все ваши действия, даже переходы между ветками! Доступ прошлым состояним можно получить через HEAD@{}. HEAD в Git всегда указывает на ваше текущее дерево. Переключаясь между ветками вы переносите HEAD (ну можно представить что HEAD - это считывающая головка ползающая по диску). То, где находились на прошлом шаге (например до переключения на другую ветку) можно получить через HEAD@{1}, на позапрошлом - HEAD@{2} и т.д. Т.е. в Git можно добраться не только до истории комитов!

Например, чтобы показать мощь этого инструмента - жёстко удалим коммит, так что в истории по git-log его уже не будет, но всё же попробуем его вытащить обратно:

... правим файл...
$ git-commit -a -m "Исправлен файл А" (1)
$ git-reset --hard HEAD^  (2)
$ git-log (3)
$ git-show HEAD@{1} (4)
$ git-reset --hard HEAD@{1} (5)
  1. Сделали необдуманый комит
  2. Поняли что всё сдали не так и отменили все изменения. Опция --hard откатывает назад не только текущее дерево, но и историю комитов. HEAD^ ссылается на предыдущий коммит в текущей ветке (не путать с HEAD@{}, который является ссылка на историю самого HEAD).
  3. Убеждаемся, что в истории комитов отмененного комита нет
  4. И тут все-таки решаем, что доля рационального в нашем комите все же была. Как его вытащить? На прошлом шаге HEAD как раз указывал на то, что мы сейчас пытаемся достать. Посмотрим так ли это? Комманда git-show просто показывает сообщение комита и его diff, т.е. убеждаемся что мы нашли наш потерянный коммит.
  5. Тут для примера мы просто назад восстанавливаем дерево на то состояние, которое нам бы хотелось.

Т.е. иными словами всё, что когда-то было зафиксировано в Git можно достать, правда до тех пор, пока вы не сделаете git-gc --prune, который убивает всю ненужную историю изменений, оставляя только историю коммитов. Также можно ссылаться на прошлое состояние не только HEAD, но и конкретной ветки, для ветки master это будет master@{}

Вот собственно и все, но Git бесконечен, как только открою для себя еще что-то новое - обязательно напишу.

воскресенье, 25 мая 2008 г.

Введение и web.py

В сети появился еще один блог :). Надеюсь будет кому-то полезным. Писать буду про то, с чем сталкиваюсь ковыряя очередную свою поделку, но в основном всё будет крутиться вокруг Python, Linux и web-штучек в целом. В последнее время взялся за свой первый в жизни сайт =) Это морда к сервру Ragnarok для моего любительского игрового проекта simhost.org. В качестве средства решения задачи выбрал web.py, т.к. по заверениям авторов он простой, как три копейки и ни в чем не ограничивает свободу выбора. В итоге два дня убил на лазание по инету, ознакомление с туториалами и немногочисленными примерами. Время провел с пользой: пощупал вживую CSS, вдоволь начитался споров про HTML vs XHTML, начал осиливать vim (это моя, наверное, уже пятая попытка, но на этот раз зашел дальше всего) и вообще нагрузил себя новой информацией по самую макушку. В итоге web.py оказался действительно простым и действительно гибким. В web.py все данные от пользователя можно получить через web.input(), который возвращает словарь (если быть точнее, то ThreadedDict), но возникла небольшая заминка: как разделить переменные полученные в теле POST запроса от тех, что переданы через URL. Если это сделать, то можно более четко определить, что ты хочешь и чего ты не хочешь от программы. В частности при валидации форм мне видется это полезным. Чтобы наглядно показать смешение переменных приведу такой синтетический пример (все подписи к полям выкинул для наглядности).
<form name="login" method="post" action="/test?a=555&amp;b=666">
<input type="text" name="login" id="login"/>
<input type="password" id="password" name="password"/>
<input type="submit"/>
</form>
Как видите, данные с формы отправляются методом POST по URL /test?a=555&b=666. В итоге в обработчике /test, выполнив web.input() мы получим словарь вида:
{
'name':'testname',
'password':'testpass',
'a':'555'
'b':'666'
}
Конечно, в данном случае мы сами смешали переменные, но согласитесь, что обрабатывая результат заполнения формы, мы ожидаем только переменные из POST, так давайте только их и обрабатывать, просто на всякий случай, чтобы спать спокойно. Как часто бывает в мире OpenSource лучшая документация это код. В моём недолгом освоении web.py это правило сработывало неоднократно, так получилось и на этот раз. Код web.input() находится в web/webapi.py, при желании можете посмотреть сами, но результатом моих поисков стало следующее знание: web.input(_method='post') и web.input(_method='get') выдадут только переменные переданные методом POST и GET соответственно. На этом всё. В следующем сообщении расскажу про валидатор форм, выполненный ввиде декоратора цельнопизженного из Pylons. Правда эта конструкция в моих руках может и не взлететь, тогда расскажу о чем-нибудь другом.