|
| 1 | +# Tutorial |
| 2 | + |
| 3 | +In this tutorial, we'll add navigation to the Django polls app — first by hand, then by refactoring to use `django-simple-nav`. By the end, you'll have a navigation bar with links, active page highlighting, and permission-based visibility, all defined in Python. |
| 4 | + |
| 5 | +We're picking up where [Django's official polls tutorial](https://docs.djangoproject.com/en/stable/intro/tutorial01/) leaves off. If you haven't gone through it, you'll need a working polls app with these views: |
| 6 | + |
| 7 | +- **Polls list** — `polls:index` |
| 8 | +- **Poll detail** — `polls:detail` |
| 9 | +- **Poll results** — `polls:results` |
| 10 | + |
| 11 | +You'll also need `django.contrib.auth` set up so you can log in to the admin. |
| 12 | + |
| 13 | +## The starting point |
| 14 | + |
| 15 | +Right now the polls app doesn't have any navigation. We'll add a nav bar to every page with these links: |
| 16 | + |
| 17 | +- **Polls** — always visible, links to the polls list |
| 18 | +- **Admin** — only visible to staff users |
| 19 | +- **Log out** / **Log in** — changes based on whether the user is authenticated |
| 20 | + |
| 21 | +Let's start by doing it the way you would without any library. |
| 22 | + |
| 23 | +## Building navigation by hand |
| 24 | + |
| 25 | +### Create a base template |
| 26 | + |
| 27 | +We need a base template that every page extends. Create `templates/base.html`: |
| 28 | + |
| 29 | +```htmldjango |
| 30 | +<!DOCTYPE html> |
| 31 | +<html> |
| 32 | +<head> |
| 33 | + <title>{% block title %}Polls{% endblock %}</title> |
| 34 | + <style> |
| 35 | + nav { background: #333; padding: 10px; } |
| 36 | + nav a { color: white; margin-right: 15px; text-decoration: none; } |
| 37 | + nav a.active { font-weight: bold; text-decoration: underline; } |
| 38 | + </style> |
| 39 | +</head> |
| 40 | +<body> |
| 41 | + <nav> |
| 42 | + <a href="{% url 'polls:index' %}" |
| 43 | + {% if request.resolver_match.url_name == 'index' %}class="active"{% endif %}> |
| 44 | + Polls |
| 45 | + </a> |
| 46 | + {% if user.is_staff %} |
| 47 | + <a href="{% url 'admin:index' %}">Admin</a> |
| 48 | + {% endif %} |
| 49 | + {% if user.is_authenticated %} |
| 50 | + <a href="{% url 'admin:logout' %}">Log out</a> |
| 51 | + {% else %} |
| 52 | + <a href="{% url 'admin:login' %}">Log in</a> |
| 53 | + {% endif %} |
| 54 | + </nav> |
| 55 | +
|
| 56 | + <main> |
| 57 | + {% block content %}{% endblock %} |
| 58 | + </main> |
| 59 | +</body> |
| 60 | +</html> |
| 61 | +``` |
| 62 | + |
| 63 | +### Update the polls templates |
| 64 | + |
| 65 | +Now update each polls template to extend the base. Open `polls/templates/polls/index.html`: |
| 66 | + |
| 67 | +```htmldjango |
| 68 | +{% extends "base.html" %} |
| 69 | +
|
| 70 | +{% block title %}Polls{% endblock %} |
| 71 | +
|
| 72 | +{% block content %} |
| 73 | + {% if latest_question_list %} |
| 74 | + <ul> |
| 75 | + {% for question in latest_question_list %} |
| 76 | + <li><a href="{% url 'polls:detail' question.id %}">{{ question.question_text }}</a></li> |
| 77 | + {% endfor %} |
| 78 | + </ul> |
| 79 | + {% else %} |
| 80 | + <p>No polls are available.</p> |
| 81 | + {% endif %} |
| 82 | +{% endblock %} |
| 83 | +``` |
| 84 | + |
| 85 | +Do the same for `polls/templates/polls/detail.html` and `polls/templates/polls/results.html` — wrap the existing content in `{% extends "base.html" %}` and `{% block content %}...{% endblock %}`. |
| 86 | + |
| 87 | +### Check it in the browser |
| 88 | + |
| 89 | +Run the development server and visit `http://localhost:8000/polls/`. You should see the "Polls" link in the nav bar, with "Admin" and "Log out" / "Log in" appearing based on your login state. |
| 90 | + |
| 91 | +This works. But look at the nav markup in `base.html` — it mixes HTML structure, URL resolution, active state logic, and permission checks all in one place. If we wanted to add this same nav to a different template, we'd copy and paste the whole block. And every new link means more `{% if %}` / `{% url %}` logic woven into the HTML. |
| 92 | + |
| 93 | +Let's clean this up. |
| 94 | + |
| 95 | +## Refactoring with django-simple-nav |
| 96 | + |
| 97 | +### Install the package |
| 98 | + |
| 99 | +```bash |
| 100 | +uv add django-simple-nav |
| 101 | +# or |
| 102 | +python -m pip install django-simple-nav |
| 103 | +``` |
| 104 | + |
| 105 | +Add it to `INSTALLED_APPS` in your settings: |
| 106 | + |
| 107 | +```python |
| 108 | +INSTALLED_APPS = [ |
| 109 | + # ... |
| 110 | + "django_simple_nav", |
| 111 | + # ... |
| 112 | +] |
| 113 | +``` |
| 114 | + |
| 115 | +### Define the navigation |
| 116 | + |
| 117 | +Create a file called `polls/nav.py`: |
| 118 | + |
| 119 | +```python |
| 120 | +from django.http import HttpRequest |
| 121 | + |
| 122 | +from django_simple_nav.nav import Nav |
| 123 | +from django_simple_nav.nav import NavItem |
| 124 | + |
| 125 | + |
| 126 | +def is_anonymous(request: HttpRequest) -> bool: |
| 127 | + return not request.user.is_authenticated |
| 128 | + |
| 129 | + |
| 130 | +class PollsNav(Nav): |
| 131 | + template_name = "polls_nav.html" |
| 132 | + items = [ |
| 133 | + NavItem(title="Polls", url="polls:index"), |
| 134 | + NavItem(title="Admin", url="admin:index", permissions=["is_staff"]), |
| 135 | + NavItem( |
| 136 | + title="Log out", |
| 137 | + url="admin:logout", |
| 138 | + permissions=["is_authenticated"], |
| 139 | + ), |
| 140 | + NavItem( |
| 141 | + title="Log in", |
| 142 | + url="admin:login", |
| 143 | + permissions=[is_anonymous], |
| 144 | + ), |
| 145 | + ] |
| 146 | +``` |
| 147 | + |
| 148 | +Let's walk through what's happening here. |
| 149 | + |
| 150 | +The URLs are **named URL patterns** — `"polls:index"`, `"admin:index"`, and so on. In our hand-written template, we had to use `{% url 'polls:index' %}` to resolve these. Here, `django-simple-nav` resolves them automatically. If a string matches a named URL pattern, it becomes the resolved path. If it doesn't match (like a literal `"/about/"` or `"#"`), it's used as-is. |
| 151 | + |
| 152 | +The `permissions` argument controls who can see each item. When we pass `permissions=["is_staff"]`, `django-simple-nav` checks `request.user.is_staff` — if it's falsy, the item is filtered out before the template ever sees it. Same with `"is_authenticated"`. These string permissions work for any boolean attribute on the user object. |
| 153 | + |
| 154 | +For the "Log in" link, we need the opposite — show it only when the user is *not* authenticated. That's what the `is_anonymous` function above the class is for. It takes the request and returns `True` when the user isn't logged in. Any callable that accepts an `HttpRequest` and returns a `bool` works as a permission — this is the intended way to handle conditions that go beyond checking a user attribute. Inverted checks, feature flags, time-based conditions, whatever you need — write a function and pass it in. |
| 155 | + |
| 156 | +These are the three permission types: strings for user attributes (`"is_staff"`, `"is_superuser"`), strings for Django permissions (`"blog.change_post"`), and callables for custom logic. The [permissions guide](usage.md#permissions) goes deeper on all three. |
| 157 | + |
| 158 | +### Create the nav template |
| 159 | + |
| 160 | +Create `templates/polls_nav.html`: |
| 161 | + |
| 162 | +```htmldjango |
| 163 | +<nav> |
| 164 | + {% for item in items %} |
| 165 | + <a href="{{ item.url }}"{% if item.active %} class="active"{% endif %}> |
| 166 | + {{ item.title }} |
| 167 | + </a> |
| 168 | + {% endfor %} |
| 169 | +</nav> |
| 170 | +``` |
| 171 | + |
| 172 | +That's the entire nav template. No permission checks, no URL resolution — just a loop over items. `django-simple-nav` has already resolved the URLs and filtered out items the user can't see before the template renders. |
| 173 | + |
| 174 | +### Update the base template |
| 175 | + |
| 176 | +Now replace the hand-written nav in `templates/base.html`: |
| 177 | + |
| 178 | +```htmldjango |
| 179 | +{% load django_simple_nav %} |
| 180 | +
|
| 181 | +<!DOCTYPE html> |
| 182 | +<html> |
| 183 | +<head> |
| 184 | + <title>{% block title %}Polls{% endblock %}</title> |
| 185 | + <style> |
| 186 | + nav { background: #333; padding: 10px; } |
| 187 | + nav a { color: white; margin-right: 15px; text-decoration: none; } |
| 188 | + nav a.active { font-weight: bold; text-decoration: underline; } |
| 189 | + </style> |
| 190 | +</head> |
| 191 | +<body> |
| 192 | + {% django_simple_nav "polls.nav.PollsNav" %} |
| 193 | +
|
| 194 | + <main> |
| 195 | + {% block content %}{% endblock %} |
| 196 | + </main> |
| 197 | +</body> |
| 198 | +</html> |
| 199 | +``` |
| 200 | + |
| 201 | +The `{% django_simple_nav "polls.nav.PollsNav" %}` tag loads our `PollsNav` class by its import path and renders it. The hand-written `{% if %}` blocks and `{% url %}` tags are gone. |
| 202 | + |
| 203 | +### Try it out |
| 204 | + |
| 205 | +Reload the page. The nav should look and behave exactly as before — "Polls" is always visible, "Admin" appears for staff users, and "Log out" / "Log in" toggles based on authentication. |
| 206 | + |
| 207 | +But now the *what* (which links, which permissions) lives in `polls/nav.py`, and the *how it looks* lives in `polls_nav.html`. If you want a different nav on a different page, you define another `Nav` class. If you want the same nav with different markup, you pass a different template. |
| 208 | + |
| 209 | +## Adding a group |
| 210 | + |
| 211 | +Let's say we want to group some links together. We can add a "Results" dropdown under each poll. But for our simple polls nav, let's group the authentication links: |
| 212 | + |
| 213 | +Update `polls/nav.py`: |
| 214 | + |
| 215 | +```python |
| 216 | +from django.http import HttpRequest |
| 217 | + |
| 218 | +from django_simple_nav.nav import Nav |
| 219 | +from django_simple_nav.nav import NavGroup |
| 220 | +from django_simple_nav.nav import NavItem |
| 221 | + |
| 222 | + |
| 223 | +def is_anonymous(request: HttpRequest) -> bool: |
| 224 | + return not request.user.is_authenticated |
| 225 | + |
| 226 | + |
| 227 | +class PollsNav(Nav): |
| 228 | + template_name = "polls_nav.html" |
| 229 | + items = [ |
| 230 | + NavItem(title="Polls", url="polls:index"), |
| 231 | + NavItem(title="Admin", url="admin:index", permissions=["is_staff"]), |
| 232 | + NavGroup( |
| 233 | + title="Account", |
| 234 | + items=[ |
| 235 | + NavItem( |
| 236 | + title="Log out", |
| 237 | + url="admin:logout", |
| 238 | + permissions=["is_authenticated"], |
| 239 | + ), |
| 240 | + NavItem( |
| 241 | + title="Log in", |
| 242 | + url="admin:login", |
| 243 | + permissions=[is_anonymous], |
| 244 | + ), |
| 245 | + ], |
| 246 | + ), |
| 247 | + ] |
| 248 | +``` |
| 249 | + |
| 250 | +And update `templates/polls_nav.html` to handle groups: |
| 251 | + |
| 252 | +```htmldjango |
| 253 | +<nav> |
| 254 | + {% for item in items %} |
| 255 | + {% if item.items %} |
| 256 | + <span>{{ item.title }}: |
| 257 | + {% for subitem in item.items %} |
| 258 | + <a href="{{ subitem.url }}"{% if subitem.active %} class="active"{% endif %}> |
| 259 | + {{ subitem.title }} |
| 260 | + </a> |
| 261 | + {% endfor %} |
| 262 | + </span> |
| 263 | + {% else %} |
| 264 | + <a href="{{ item.url }}"{% if item.active %} class="active"{% endif %}> |
| 265 | + {{ item.title }} |
| 266 | + </a> |
| 267 | + {% endif %} |
| 268 | + {% endfor %} |
| 269 | +</nav> |
| 270 | +``` |
| 271 | + |
| 272 | +Reload and you'll see "Account:" followed by either "Log out" or "Log in" depending on your login state. A `NavGroup` that has no visible children hides itself automatically, so the "Account" label won't appear as an orphan. |
| 273 | + |
| 274 | +## What we built |
| 275 | + |
| 276 | +Starting from the Django polls tutorial, we: |
| 277 | + |
| 278 | +1. Built a navigation bar by hand — mixing URLs, permissions, and active state into template logic. |
| 279 | +2. Installed `django-simple-nav` and moved the navigation structure into a Python class. |
| 280 | +3. Replaced the hand-written template logic with a clean loop over pre-resolved, pre-filtered items. |
| 281 | +4. Added a `NavGroup` to organize related links. |
| 282 | + |
| 283 | +The navigation is now defined in one place, tested by one set of rules, and rendered by a template that only cares about markup. |
| 284 | + |
| 285 | +## Alternatives |
| 286 | + |
| 287 | +There are other ways to approach navigation in Django. Here's how they compare to what we just built. |
| 288 | + |
| 289 | +### `{% include %}` with context |
| 290 | + |
| 291 | +You can put nav HTML in a partial template and include it everywhere: |
| 292 | + |
| 293 | +```htmldjango |
| 294 | +{% include "nav.html" %} |
| 295 | +``` |
| 296 | + |
| 297 | +This avoids copy-pasting the nav markup, but the permission checks and active state logic still live in the template. As the nav grows, the template grows with it. There's no central Python definition of "what's in the nav." |
| 298 | + |
| 299 | +### Custom inclusion template tag |
| 300 | + |
| 301 | +You can write your own template tag that builds the nav data and renders a template: |
| 302 | + |
| 303 | +```python |
| 304 | +@register.inclusion_tag("nav.html", takes_context=True) |
| 305 | +def my_nav(context): |
| 306 | + request = context["request"] |
| 307 | + items = [...] |
| 308 | + return {"items": items} |
| 309 | +``` |
| 310 | + |
| 311 | +This is essentially what `django-simple-nav` does under the hood — but you'd be writing the URL resolution, active state detection, and permission filtering yourself. If that's all you need for a small project, it's a reasonable approach. `django-simple-nav` provides it as a tested, reusable package. |
| 312 | + |
| 313 | +### Context processor |
| 314 | + |
| 315 | +A context processor can inject nav data into every template: |
| 316 | + |
| 317 | +```python |
| 318 | +def nav_context(request): |
| 319 | + return {"nav_items": [...]} |
| 320 | +``` |
| 321 | + |
| 322 | +This makes nav data available globally, but it runs on every request — even ones that don't render a nav. It also doesn't give you a clean separation between nav structure and nav rendering. |
| 323 | + |
| 324 | +### Other packages |
| 325 | + |
| 326 | +The [Django Packages navigation grid](https://djangopackages.org/grids/g/navigation/) lists other options. A few worth noting: |
| 327 | + |
| 328 | +- [**django-simple-menu**](https://github.com/jazzband/django-simple-menu) — A well-established library that takes a class-based approach similar to `django-simple-nav`, with a focus on menu hierarchies and visibility conditions. It has been around longer and has a large user base. |
| 329 | +- [**django-navutils**](https://github.com/agateblue/django-navutils) — Provides breadcrumbs and menus with a node-based API. |
| 330 | + |
| 331 | +Each takes a slightly different approach to the same problem. Pick the one that fits how you think about navigation in your project. |
0 commit comments