Django Menuing System

Posted by Ross Poulton on Tue 27 November 2007 #geeky #django #programming

On most of the websites that I've built with Django, I have had a desire to be able to manage little elements of the website from the Django administration screen without having to touch my templates. My intent is for the templates to become the presentation vehicle, with anything that matters being built out of the Django databases.

One such thing that I want to keep out of my templates is navigation. Sure, the template has a place for navigation (including an empty <ul>), but the contents of my navigation bars are driven by a dynamic Django application.

The application has but two files: the models and a template tag.

First up is the model file, menu/models.py:

    from django.db import models

    class Menu(models.Model):
        name = models.CharField(maxlength=100)
        slug = models.SlugField()
        base_url = models.CharField(maxlength=100, blank=True, null=True)
        description = models.TextField(blank=True, null=True)

        class Admin:
            pass

        def __unicode__(self):
            return "%s" % self.name

        def save(self):
            """
            Re-order all items at from 10 upwards, at intervals of 10.
            This makes it easy to insert new items in the middle of 
            existing items without having to manually shuffle 
            them all around.
            """
            super(Menu, self).save()

            current = 10
            for item in MenuItem.objects.filter(menu=self).order_by('order'):
                item.order = current
                item.save()
                current += 10

    class MenuItem(models.Model):
        menu = models.ForeignKey(Menu)
        order = models.IntegerField()
        link_url = models.CharField(maxlength=100, help_text='URL or URI to the content, eg /about/ or http://foo.com/')
        title = models.CharField(maxlength=100)
        login_required = models.BooleanField(blank=True, null=True)

        class Admin:
            pass

        def __unicode__(self):
            return "%s %s. %s" % (self.menu.slug, self.order, self.title)

Next is a template tag that builds named or path-based menus - menu/templatetags/menubuilder.py:

    from menu.models import Menu, MenuItem
    from django import template

    register = template.Library()

    def build_menu(parser, token):
        """
        {% menu menu_name %}
        """
        try:
            tag_name, menu_name = token.split_contents()
        except:
            raise template.TemplateSyntaxError, "%r tag requires exactly one argument" % token.contents.split()[0]
        return MenuObject(menu_name)

    class MenuObject(template.Node):
        def __init__(self, menu_name):
            self.menu_name = menu_name

        def render(self, context):
            current_path = template.resolve_variable('request.path', context)
            user = template.resolve_variable('request.user', context)
            context['menuitems'] = get_items(self.menu_name, current_path, user)
            return ''

    def build_sub_menu(parser, token):
        """
        {% submenu %}
        """
        return SubMenuObject()

    class SubMenuObject(template.Node):
        def __init__(self):
            pass

        def render(self, context):
            current_path = template.resolve_variable('request.path', context)
            user = template.resolve_variable('request.user', context)
            menu = False
            for m in Menu.objects.filter(base_url__isnull=False):
                if m.base_url and current_path.startswith(m.base_url):
                    menu = m

            if menu:
                context['submenu_items'] = get_items(menu.slug, current_path, user)
                context['submenu'] = menu
            else:
                context['submenu_items'] = context['submenu'] = None
            return ''

    def get_items(menu, current_path, user):
        menuitems = []
        for i in MenuItem.objects.filter(menu__slug=menu).order_by('order'):
            current = ( i.link_url != '/' and current_path.startswith(i.link_url)) or ( i.link_url == '/' and current_path == '/' )
            if not i.login_required or ( i.login_required and user.is_authenticated() ):
                menuitems.append({'url': i.link_url, 'title': i.title, 'current': current,})
        return menuitems

    register.tag('menu', build_menu)
    register.tag('submenu', build_sub_menu)

Using this menu system is relatively easy:

  1. Filesystem setup

    1. Create a directory called menu in your Python path
    2. Drop models.py (above) into the menu folder
    3. Create a directory called templatetags inside the menu folder
    4. Copy the menuubilder.py (above) into the templatetags folder
    5. Create a blank file called __init__.py and put a copy in each of your menu and templatetags folders
  2. Django setup

    1. Add menu to the INSTALLED_APPS list in your settings.py
    2. Run ./manage.py syncdb to create the relevant database tables
  3. Data setup (using Django's Admin tools)

    1. Add a Menu object for each menu set you wish to use. Give static menus (eg, those that are the same on each page) a slug such as main, footer or sidebar. For dynamic menus, that display different contents on different pages, add a base URL. A menu with a base URL or /about/ might contain links to your philosophy, your team photos, your history, and a few policies - but only when the user is visiting a page within /about/.
  4. Template setup

    1. In your template, wherever you want to use a particular named menu, add this code:
    <ul>{% load menubuilder %}{% menu main %}
    {% for item in menuitems %}<li><a href="{{ item.url }}" title="{{ item.title|escape }}"{% if item.current %} class='current'{% endif %}>{{ item.title }}</a></li>
    {% endfor %}
    </ul>
2. Replace **main** with the name of a static menu (eg **footer** or **sidebar** from the above example)
3. For dynamic URL-based menus, add this code:
    {% load menubuilder %}{% submenu %}{% if submenu %}
    <div id='subnav'>
    <h2>{{ submenu.name }}</h2>

    <ul>
    <li>» <a href="{{ submenu.base_url }}" title="{{ submenu.name|escape }}"{% ifequal submenu.base_url request.path %} class='current'{% endifequal %}>{{ submenu.name }}</a></li>
    {% for item in submenu_items %}<li>&raquo; <a href="{{ item.url }}" title="{{ item.title|escape }}"{% if item.current %} class='current'{% endif %}>{{ item.title }}</a></li>

    {% endfor %}
    </ul>
    </div>
    {% endif %}
4. If a menu has been set up with a BASE URI that the user is currently seeing, it will be displayed. If no such menu exists, nothing will be displayed.

I use named menus for link bars in header and footers. I use URI-based menus to display a sub-section navigation, for example within the about area on a website or within the products section. A URI-based menu will only be displayed with the user is within that URI.

So there you have it. Database driven menus that let you build easy static or URI-based menus and submenus. If you look carefully there is code to only display links to logged in users if required, and the page that is currently being viewed is tagged with the current CSS class so it can be easily styled.

If all of that is confusing, stay tuned for my Basic Django-powered website in a box that will pull together a number of elements such as this into a simple to install package you can use to get a website up and running quicksmart - with the fantasmical automated Admin screens to control it all.