Tree navigation in Django
Do you have a ManyToMany relationship with self and you want to implement a tree navigation in the django admin interface? You don't want dependencies, learning new API and fix annoying bugs? Well, maybe you want to read this.
The purpose of this article is to let you understand how it's easy to customize the Django admin interface and add new features.
Many other applications, django-mptt for example, do the same thing in a better way.
Step 1: set up a new application
We'll create a simple Page Model with a ManyToManyField on itself.
Let's create a new application
python manage.py startapp pages
and set up the Page
model
pages.models.py
class Page(models.Model):
parent=models.ForeignKey('self',null=True,blank=True,related_name='children')
title=models.CharField(max_length=255)
body=models.TextField(blank=True)
order=models.PositiveIntegerField(editable=False)
def __unicode__(self):
return self.title
class Meta:
ordering = ["order"]
add pages
in INSTALLED_APPS
settings.py
INSTALLED_APPS = (
...
'pages',
)
and run syncdb.
python manage.py syncdb
Finally we have to configure a basic admin interface for our Model
pages.admin.py
class PageAdmin(admin.ModelAdmin):
pass
admin.site.register(Page, PageAdmin)
Step 2: add ordering support
We'll add the possibility to order our pages (via djangosnippets).
Let's override the save
method to set up the order
field
pages.models.py
@staticmethod
def extra_filters(obj):
if not obj.parent:
return {'parent__isnull': True}
return {'parent__id': obj.parent.id }
def save(self):
if not self.id:
try:
filters = self.__class__.extra_filters(self)
self.order = self.__class__.objects.filter(
**filters
).order_by("-order")[0].order + 1
except IndexError:
self.order = 0
super(Page, self).save()
Now we can configure the admin interface to support the ordering.
In pages.admin.py add the following line to PageAdmin
list_display = ['title', 'order_link']
and the order_link
method:
def order_link(self, obj):
model_type_id = ContentType.objects.get_for_model(obj.__class__).id
model_id = obj.id
kwargs = {
"direction": "up",
"model_type_id": model_type_id,
"model_id": model_id
}
url_up = reverse("pages-admin-move", kwargs=kwargs)
kwargs["direction"] = "down"
url_down = reverse("pages-admin-move", kwargs=kwargs)
return '<a href="%s" class="up">%s</a><a href="%s" class="down">%s</a>' % (
url_up, 'up', url_down, 'down'
)
order_link.allow_tags = True
order_link.short_description = 'Move'
order_link.admin_order_field = 'order'
Note that we have created two links to move up and down a page and mapped them to the view that will save the changes.
So let's create the view first.
Create pages.admin_views.py:
@staff_member_required
@transaction.commit_on_success
def admin_move_ordered_model(request, direction, model_type_id, model_id):
if direction == "up":
PageAdmin.move_up(model_type_id, model_id)
else:
PageAdmin.move_down(model_type_id, model_id)
ModelClass = ContentType.objects.get(id=model_type_id).model_class()
app_label = ModelClass._meta.app_label
model_name = ModelClass.__name__.lower()
redirect_url = request.META.get('HTTP_REFERER')
if redirect_url is None:
redirect_url = "/admin/%s/%s/" % (app_label, model_name)
return HttpResponseRedirect(redirect_url)
create pages.admin_urls.py
urlpatterns = patterns('',
url(r'^orderedmove/(?P<direction>up|down)/(?P<model_type_id>\d+)/(?P<model_id>\d+)/$',
'pages.admin_views.admin_move_ordered_model',
name="pages-admin-move"
),
)
and configure the mapping in urls.py
url(r'^pagesorter/', include('pages.admin_urls')),
Finally we have to add the move_up
and move_down
methods in pages.admin.py.
@staticmethod
def move_down(model_type_id, model_id):
try:
ModelClass = ContentType.objects.get(id=model_type_id).model_class()
lower_model = ModelClass.objects.get(id=model_id)
filters = ModelClass.extra_filters(lower_model)
filters['order__gt'] = lower_model.order
higher_model = ModelClass.objects.filter(**filters)[0]
lower_model.order, higher_model.order=higher_model.order, lower_model.order
higher_model.save()
lower_model.save()
except IndexError:
pass
except ModelClass.DoesNotExist:
pass
@staticmethod
def move_up(model_type_id, model_id):
try:
ModelClass = ContentType.objects.get(id=model_type_id).model_class()
higher_model = ModelClass.objects.get(id=model_id)
filters = ModelClass.extra_filters(higher_model)
filters['order__lt'] = higher_model.order
lower_model = ModelClass.objects.filter(**filters).reverse()[0]
lower_model.order, higher_model.order=higher_model.order, lower_model.order
higher_model.save()
lower_model.save()
except IndexError:
pass
except ModelClass.DoesNotExist:
pass
Step 3: configure the tree structure
We'll customize the django admin interface to support the tree navigation.
We need to update pages.admin.py adding other two columns
list_display = ['expand', 'title', 'add_child', 'order_link']
create the expand
and add_child
methods im pages.admin.py:
def expand(self, obj):
if not obj.children.all():
return ''
return '<a href="?parent=%d">+</a>' % (obj.id)
expand.allow_tags = True
expand.short_description = 'Expand'
def add_child(self, obj):
return '<a href="add/?parent=%d">+</a>' % obj.id
add_child.allow_tags = True
add_child.short_description = 'Add Child'
and finally override the queryset
method to get the pages through the parent parameter.
def queryset(self, request):
parent = request.GET.get('parent')
qs = super(PageAdmin, self).queryset(request)
if not parent:
return qs.filter(parent__isnull=True)
return qs
What else? We can add a back button on the top right of the page to complete the navigation.
To do so, let's override the change_list.html
template for the Page
model.
Create the page templates/admin/pages/page/change_list.html
{% extends "admin/change_list.html" %}{% load i18n %}
{% block object-tools %}
{% if has_add_permission %}
<ul class="object-tools">
{% with cl.result_list|first as first_obj %}
{% if first_obj.parent %}
<li>
<a href="
{% if first_obj.parent.parent %}
?parent={{ first_obj.parent.parent.id }}
{% else %}
.
{% endif %}
">Back</a>
</li>
{% endif %}
{% endwith %}
<li>
<a href="add/{% if is_popup %}?_popup=1{% endif %}" class="addlink">
{% blocktrans with cl.opts.verbose_name as name %}
Add {{ name }}
{% endblocktrans %}
</a>
</li>
</ul>
{% endif %}
{% endblock %}
And that's it! You can improve the navigation changing the breadcrumbs and adding few more things if you want.
In conclusion
Django let you use the admin interface for simple architectures and basic usage but sometimes you need something else.
We have seen that you don't have to use the standard interface if you don't want to, you can customize it in few minutes and still have your code under control.
11 June 2009