Skip to content

Commit a30ee8c

Browse files
Add Jinja2 support (#170)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent fc43861 commit a30ee8c

File tree

10 files changed

+289
-18
lines changed

10 files changed

+289
-18
lines changed

AUTHORS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@
22

33
- Josh Thomas <josh@joshthomas.dev>
44
- Jeff Triplett [@jefftriplett](https://github.com/jefftriplett)
5+
- HiPhish [@hiphish](https://github.com/hiphish)

README.md

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@
5050
```
5151
5252
If you do not add `django.contrib.auth` to your `INSTALLED_APPS` and you define any permissions for your navigation items, `django-simple-nav` will simply ignore the permissions and render all items regardless of whether the permission check is `True` or `False.`
53+
54+
1. **Add the template function to your Jinja environment**
55+
56+
If you want to use Jinja 2 templates you will need to add the `django_simple_nav` function to your Jinja environment.
57+
Example:
58+
59+
```python
60+
from jinja2 import Environment
61+
from jinja2 import FileSystemLoader
62+
63+
from django_simple_nav.jinja2 import django_simple_nav
64+
65+
environment = Environment()
66+
environment.globals.update({"django_simple_nav": django_simple_nav})
67+
```
68+
5369
<!-- getting-started-end -->
5470
5571
## Getting Started
@@ -143,7 +159,7 @@
143159

144160
2. **Create a template for the navigation.**
145161

146-
Create a template to render the navigation structure. This is just a standard Django template so you can use any Django template features you like.
162+
Create a template to render the navigation structure. This is a standard Django or Jinja 2 template so you can use any template features you like.
147163

148164
The template will be passed an `items` variable in the context representing the structure of the navigation, containing the `NavItem` and `NavGroup` objects defined in your navigation.
149165

@@ -177,9 +193,37 @@
177193
</ul>
178194
```
179195
196+
The same template in Jinja would be written as follows:
197+
198+
```html
199+
<!-- main_nav.html.j2 -->
200+
<ul>
201+
{% for item in items %}
202+
<li>
203+
<a href="{{ item.url }}"{% if item.active %} class="active"{% endif %}{% if item.baz %} data-baz="{{ item.baz }}"{% endif %}>
204+
{{ item.title }}
205+
</a>
206+
{% if item['items'] %}
207+
<ul>
208+
{% for subitem in item['items'] %}
209+
<li>
210+
<a href="{{ subitem.url }}"{% if subitem.active %} class="active"{% endif %}{% if item.foo %} data-foo="{{ item.foo }}"{% endif %}>
211+
{{ subitem.title }}
212+
</a>
213+
</li>
214+
{% endfor %}
215+
</ul>
216+
{% endif %}
217+
</li>
218+
{% endfor %}
219+
</ul>
220+
```
221+
222+
Note that unlike in Django templates we need to index the `items` field as a string in Jinja.
223+
180224
1. **Integrate navigation in templates.**:
181225
182-
Use the `django_simple_nav` template tag in your Django templates where you want to display the navigation.
226+
Use the `django_simple_nav` template tag in your Django templates (the `django_simple_nav` function in Jinja) where you want to display the navigation.
183227
184228
For example:
185229
@@ -194,6 +238,17 @@
194238
{% endblock navigation %}
195239
```
196240
241+
For Jinja:
242+
243+
```html
244+
<!-- base.html.j2 -->
245+
{% block navigation %}
246+
<nav>
247+
{{ django_simple_nav("path.to.MainNav") }}
248+
</nav>
249+
{% endblock navigation %}
250+
```
251+
197252
The template tag can either take a string representing the import path to your navigation definition or an instance of your navigation class:
198253
199254
```python
@@ -217,6 +272,17 @@
217272
{% endblock navigation %}
218273
```
219274
275+
```html
276+
<!-- example_app/example_template.html.j2 -->
277+
{% extends "base.html" %}
278+
279+
{% block navigation %}
280+
<nav>
281+
{{ django_simple_nav(nav) }}
282+
</nav>
283+
{% endblock navigation %}
284+
```
285+
220286
Additionally, the template tag can take a second argument to specify the template to use for rendering the navigation. This is useful if you want to use the same navigation structure in multiple places but render it differently.
221287
222288
```htmldjango
@@ -228,6 +294,14 @@
228294
</footer>
229295
```
230296
297+
```html
298+
<!-- base.html.j2 -->
299+
300+
<footer>
301+
{{ django_simple_nav("path.to.MainNav", "footer_nav.html.j2") }}
302+
</footer>
303+
```
304+
231305
After configuring your navigation, you can use it across your Django project by calling the `django_simple_nav` template tag in your templates. This tag dynamically renders navigation based on your defined structure, ensuring a consistent and flexible navigation experience throughout your application.
232306
<!-- usage-end -->
233307

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ docs = [
4646
"sphinx-copybutton>=0.5.2",
4747
"sphinx-inline-tabs>=2023.4.21"
4848
]
49+
jinja2 = [
50+
"jinja2"
51+
]
4952
tests = [
5053
"faker>=30.3.0",
5154
"model-bakery>=1.20.0",

src/django_simple_nav/jinja2.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from __future__ import annotations
2+
3+
from typing import cast
4+
5+
from django.utils.module_loading import import_string
6+
from jinja2 import TemplateRuntimeError
7+
from jinja2 import pass_context
8+
from jinja2.runtime import Context
9+
10+
from django_simple_nav.nav import Nav
11+
12+
13+
@pass_context
14+
def django_simple_nav(
15+
context: Context, nav: str | Nav, template_name: str | None = None
16+
) -> str:
17+
"""Jinja binding for `django_simple_nav`"""
18+
if (loader := context.environment.loader) is None:
19+
raise TemplateRuntimeError("No template loader in Jinja2 environment")
20+
21+
if type(nav) is str:
22+
try:
23+
nav = import_string(nav)()
24+
except ImportError as err:
25+
raise TemplateRuntimeError(str(err)) from err
26+
27+
try:
28+
if template_name is None:
29+
template_name = cast(Nav, nav).template_name
30+
if template_name is None:
31+
raise TemplateRuntimeError("Navigation object has no template")
32+
request = context["request"]
33+
new_context = {"request": request, **cast(Nav, nav).get_context_data(request)}
34+
except Exception as err:
35+
raise TemplateRuntimeError(str(err)) from err
36+
37+
return loader.load(context.environment, template_name).render(new_context)

tests/jinja2/environment.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
"""Sets up a reasonably minimal Jinja2 environment for testing"""
2+
3+
from __future__ import annotations
4+
5+
from jinja2 import Environment
6+
from jinja2 import FileSystemLoader
7+
8+
from django_simple_nav.jinja2 import django_simple_nav
9+
10+
# Ensure the same template paths are valid for both Jinja2 and Django templates
11+
loader = FileSystemLoader("tests/jinja2/")
12+
13+
environment = Environment(loader=loader, trim_blocks=True)
14+
environment.globals.update({"django_simple_nav": django_simple_nav})

tests/jinja2/tests/dummy_nav.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<ul>
2+
{% for item in items recursive %}
3+
<li>
4+
<a href="{{ item.url }}">{{ item.title }}</a>
5+
{% if item['items'] %}
6+
<ul>
7+
{{ loop(item['items']) }}
8+
</ul>
9+
{% endif %}
10+
</li>
11+
{% endfor %}
12+
</ul>

tests/templates/tests/jinja2/dummy_nav.html

Lines changed: 0 additions & 10 deletions
This file was deleted.

tests/test_jinja.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
from django.contrib.auth import get_user_model
5+
from django.contrib.auth.models import AnonymousUser
6+
from jinja2 import TemplateRuntimeError
7+
from model_bakery import baker
8+
9+
from django_simple_nav.nav import NavItem
10+
from tests.jinja2.environment import environment
11+
from tests.navs import DummyNav
12+
from tests.utils import count_anchors
13+
14+
pytestmark = pytest.mark.django_db
15+
16+
17+
def test_django_simple_nav_templatetag(req):
18+
template = environment.from_string('{{ django_simple_nav("tests.navs.DummyNav") }}')
19+
req.user = AnonymousUser()
20+
rendered_template = template.render(request=req)
21+
assert count_anchors(rendered_template) == 7
22+
23+
24+
def test_templatetag_with_template_name(req):
25+
template = environment.from_string(
26+
"{{ django_simple_nav('tests.navs.DummyNav', 'tests/alternate.html') }}"
27+
)
28+
req.user = AnonymousUser()
29+
rendered_template = template.render({"request": req})
30+
assert "This is an alternate template." in rendered_template
31+
32+
33+
def test_templatetag_with_nav_instance(req):
34+
class PlainviewNav(DummyNav):
35+
items = [
36+
NavItem(title="I drink your milkshake!", url="/milkshake/"),
37+
]
38+
39+
template = environment.from_string("{{ django_simple_nav(new_nav) }}")
40+
req.user = baker.make(get_user_model(), first_name="Daniel", last_name="Plainview")
41+
rendered_template = template.render({"request": req, "new_nav": PlainviewNav()})
42+
assert "I drink your milkshake!" in rendered_template
43+
44+
45+
def test_templatetag_with_nav_instance_and_template_name(req):
46+
class DeadParrotNav(DummyNav):
47+
items = [
48+
NavItem(title="He's pinin' for the fjords!", url="/notlob/"),
49+
]
50+
51+
template = environment.from_string(
52+
"{{ django_simple_nav(new_nav, 'tests/alternate.html') }}"
53+
)
54+
req.user = baker.make(get_user_model(), first_name="Norwegian", last_name="Blue")
55+
rendered_template = template.render({"request": req, "new_nav": DeadParrotNav()})
56+
assert "He's pinin' for the fjords!" in rendered_template
57+
assert "This is an alternate template." in rendered_template
58+
59+
60+
def test_templatetag_with_template_name_on_nav_instance(req):
61+
class PinkmanNav(DummyNav):
62+
template_name = "tests/alternate.html"
63+
items = [
64+
NavItem(title="Yeah Mr. White! Yeah science!", url="/science/"),
65+
]
66+
67+
template = environment.from_string("{{ django_simple_nav(new_nav) }}")
68+
req.user = baker.make(get_user_model(), first_name="Jesse", last_name="Pinkman")
69+
rendered_template = template.render({"request": req, "new_nav": PinkmanNav()})
70+
assert "Yeah Mr. White! Yeah science!" in rendered_template
71+
assert "This is an alternate template." in rendered_template
72+
73+
74+
def test_templatetag_with_no_arguments(req):
75+
req.user = AnonymousUser()
76+
with pytest.raises(TypeError):
77+
template = environment.from_string("{{ django_simple_nav() }}")
78+
template.render({"request": req})
79+
80+
81+
def test_templatetag_with_missing_variable(req):
82+
req.user = AnonymousUser()
83+
template = environment.from_string("{{ django_simple_nav(missing_nav) }}")
84+
with pytest.raises(TemplateRuntimeError):
85+
template.render({"request": req})
86+
87+
88+
def test_nested_templatetag(req):
89+
# called twice to simulate a nested call
90+
template = environment.from_string(
91+
"{{ django_simple_nav('tests.navs.DummyNav') }}"
92+
"{{ django_simple_nav('tests.navs.DummyNav') }}"
93+
)
94+
req.user = AnonymousUser()
95+
rendered_template = template.render({"request": req})
96+
assert count_anchors(rendered_template) == 14
97+
98+
99+
def test_invalid_dotted_string(req):
100+
template = environment.from_string(
101+
"{{ django_simple_nav('path.to.DoesNotExist') }}"
102+
)
103+
104+
with pytest.raises(TemplateRuntimeError):
105+
template.render({"request": req})
106+
107+
108+
class InvalidNav: ...
109+
110+
111+
def test_invalid_nav_instance(req):
112+
template = environment.from_string(
113+
"{{ django_simple_nav('tests.test_templatetags.InvalidNav') }}"
114+
)
115+
with pytest.raises(TemplateRuntimeError):
116+
template.render({"request": req})
117+
118+
119+
def test_template_name_variable_does_not_exist(req):
120+
template = environment.from_string(
121+
"{{ django_simple_nav('tests.navs.DummyNav', nonexistent_template_name_variable) }}"
122+
)
123+
with pytest.raises(TemplateRuntimeError):
124+
template.render({"request": req})
125+
126+
127+
def test_request_not_in_context():
128+
template = environment.from_string(
129+
" {{ django_simple_nav('tests.navs.DummyNav') }}"
130+
)
131+
132+
with pytest.raises(TemplateRuntimeError):
133+
template.render()
134+
135+
136+
def test_invalid_request():
137+
class InvalidRequest: ...
138+
139+
template = environment.from_string("{{ django_simple_nav('tests.navs.DummyNav') }}")
140+
141+
with pytest.raises(TemplateRuntimeError):
142+
template.render({"request": InvalidRequest()})

tests/test_nav.py

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,14 +156,12 @@ class GetItemsNav(Nav):
156156
)
157157
def test_get_template_engines(engine, expected):
158158
class TemplateEngineNav(Nav):
159-
template_name = (
160-
"tests/dummy_nav.html"
161-
if engine.endswith("DjangoTemplates")
162-
else "tests/jinja2/dummy_nav.html"
163-
)
159+
template_name = "tests/dummy_nav.html"
164160
items = [...]
165161

166-
with override_settings(TEMPLATES=[dict(settings.TEMPLATES[0], BACKEND=engine)]):
162+
with override_settings(
163+
TEMPLATES=[dict(settings.TEMPLATES[0], BACKEND=engine, DIRS=[])]
164+
):
167165
template = TemplateEngineNav().get_template()
168166

169167
assert isinstance(template, expected)

0 commit comments

Comments
 (0)