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()

Basic admin interface

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

Model with ordering

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

Tree navigation

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 %}

Tree navigation with back button

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