Skip to content

Commit 767311a

Browse files
Add tutorial to documentation (#212)
1 parent 8075694 commit 767311a

File tree

2 files changed

+332
-0
lines changed

2 files changed

+332
-0
lines changed

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ For [Jinja2](usage.md#jinja2) support, see the setup guide.
2424
:maxdepth: 3
2525
2626
getting-started.md
27+
tutorial.md
2728
usage.md
2829
reference.md
2930
changelog.md

docs/tutorial.md

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
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

Comments
 (0)