Как можно прогнуть django админку под себя?
Админка в django одна из любимых фич многих её поклонников и один из основных аргументов в холиварах django-vs-все_остальные. На самом деле админка это не сама django, а обычное приложение, просто идущее в комплекте и возможности у нее те же самые, т.е. никакой особой core магии в ней нет. Я хочу сказать, что не надо её бояться, думать как там всё запутано и фиг туда влезешь, чтобы что-то подправить под себя. Это ерунда, ею можно вертеть и в хвост и в гриву, было бы желание и немного знания Python.
Лишившись страха кидаемся в бой и первым делом определим наши тактические цели.
Чего мы добиваемся?
У django довольно приличная документация на оф. сайте, по-этому тут мы займемся вопросами не освещенными в ней. Возьмем три примера из реальной жизни, необходимо в зависимости от группы пользователя одни и те же элементы показывать по-разному, а именно:
- Перечень объектов в форме списка
- Фильтры в списках объектов
- Отображение формы редактирования новости
Примеры интересны тем, что во-первых не займут много кода, а во-вторых могут быть полезными кому-то еще, ну и может натолкнут Вас на решение ваших собственных задач :)
Заряжаем орудия.
Как и сказано в документации, стандартный способ поднастроить админку это унаследоваться от 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'ов, что получив их в одном месте, мы можем продолжить навешивать на них фильтры и отдавать дальше. Так и поступаем:
- Получаем QuerySet, как оно изначально было задумано авторами django-admin
- Супер пользователям и редакторам отдаём в неизменном виде
- А всем остальным, фильтруем по полю author, оставляя только записи, где автором является текущий залогиненный пользователь.
Вуаля, админка стала чуть более дружественной и удобной.
Раз уж мы ограничили видимый список новостей для авторов, то надо идти до конца и заблокировать возможность открывать новость в форме редактирования простым подбором id в URL. Сделаем это так:
- Через админку в разделе удалим право Change News у группы авторов.
- Индивидуально даем разрешение на редактирование статьи, если пользователь совпадает с автором
Т.е. сначала закрутим гайки, потом немного ослабим.
С первым пунктом проблем возникнуть не должно, админку наверное уже всю истыкали :). Переходим ко второму. Снова покурим исходники 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
- Случай когда obj=None вызывается когда вы еще ходите в районе /admin/ и списка объектов, т.е. когда еще неизвестен конкретный объект для редактирования. Я выбрал просто возвращать True для всех, иначе авторы не смогут получить даже список своих статей, т.к. право Change News мы у них отобрали.
- Получаем права согласно разрешениям на группы (именно так действует оригинальный метод, который мы переопределяем)
- Если права позволяют, то мы ничего не делаем, если вернулся запрет, то в некоторых случаях его ослабляем.
- Приравнял право на изменение к праву на удаление, вы можете поступить по-другому :)
В общем получился у нас эдакий 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.
По похожей схеме разберемся с полем комментариев редактора:
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 я не трогал от греха подальше, т.к. такими плясками выводятся все стандартные виджеты. :)
Конец
Вот и причесали админку на свой лад. А ведь еще можно переопределять её шаблоны и резвиться в save_model. Про это сказано в документации, очень советую почитать.
Очень сильно мне помогли (особенно в последнем забеге, где игрались с виджетами и формой) связка python manage.py shell+ipython(http://ipython.scipy.org/)+функция dir - очень удобно играться с произвольными объектами, смотреть их поля, править заменять, вызывать и пр. Если надо покопаться в неизвестной области, очень рекомендую!