Skip to content

Add Jinja2 support #170

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 76 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@
```

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.`

1. **Add the template function to your Jinja environment**

If you want to use Jinja 2 templates you will need to add the `django_simple_nav` function to your Jinja environment.
Example:

```python
from jinja2 import Environment
from jinja2 import FileSystemLoader

from django_simple_nav.jinja2 import django_simple_nav

environment = Environment()
environment.globals.update({"django_simple_nav": django_simple_nav})
```

<!-- getting-started-end -->

## Getting Started
Expand Down Expand Up @@ -143,7 +159,7 @@

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

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.
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.

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.

Expand Down Expand Up @@ -177,9 +193,37 @@
</ul>
```

The same template in Jinja would be written as follows:

```html
<!-- main_nav.html.j2 -->
<ul>
{% for item in items %}
<li>
<a href="{{ item.url }}"{% if item.active %} class="active"{% endif %}{% if item.baz %} data-baz="{{ item.baz }}"{% endif %}>
{{ item.title }}
</a>
{% if item['items'] %}
<ul>
{% for subitem in item['items'] %}
<li>
<a href="{{ subitem.url }}"{% if subitem.active %} class="active"{% endif %}{% if item.foo %} data-foo="{{ item.foo }}"{% endif %}>
{{ subitem.title }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
```

Note that unlike in Django templates we need to index the `items` field as a string in Jinja.

1. **Integrate navigation in templates.**:

Use the `django_simple_nav` template tag in your Django templates where you want to display the navigation.
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.

For example:

Expand All @@ -194,6 +238,17 @@
{% endblock navigation %}
```

For Jinja:

```html
<!-- base.html.j2 -->
{% block navigation %}
<nav>
{{ django_simple_nav("path.to.MainNav") }}
</nav>
{% endblock navigation %}
```

The template tag can either take a string representing the import path to your navigation definition or an instance of your navigation class:

```python
Expand All @@ -217,6 +272,17 @@
{% endblock navigation %}
```

```html
<!-- example_app/example_template.html.j2 -->
{% extends "base.html" %}

{% block navigation %}
<nav>
{{ django_simple_nav(nav) }}
</nav>
{% endblock navigation %}
```

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.

```htmldjango
Expand All @@ -228,6 +294,14 @@
</footer>
```

```html
<!-- base.html.j2 -->

<footer>
{{ django_simple_nav("path.to.MainNav", "footer_nav.html.j2") }}
</footer>
```

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.
<!-- usage-end -->

Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ docs = [
"sphinx-copybutton>=0.5.2",
"sphinx-inline-tabs>=2023.4.21"
]
jinja2 = [
"jinja2"
]
tests = [
"faker>=30.3.0",
"model-bakery>=1.20.0",
Expand Down
37 changes: 37 additions & 0 deletions src/django_simple_nav/jinja2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
from __future__ import annotations

from typing import cast

from django.utils.module_loading import import_string
from jinja2 import TemplateRuntimeError
from jinja2 import pass_context
from jinja2.runtime import Context

from django_simple_nav.nav import Nav


@pass_context
def django_simple_nav(
context: Context, nav: str | Nav, template_name: str | None = None
) -> str:
"""Jinja binding for `django_simple_nav`"""
if (loader := context.environment.loader) is None:
raise TemplateRuntimeError("No template loader in Jinja2 environment")

if type(nav) is str:
try:
nav = import_string(nav)()
except ImportError as err:
raise TemplateRuntimeError(str(err)) from err

try:
if template_name is None:
template_name = cast(Nav, nav).template_name
if template_name is None:
raise TemplateRuntimeError("Navigation object has no template")
request = context["request"]
new_context = {"request": request, **cast(Nav, nav).get_context_data(request)}
except Exception as err:
raise TemplateRuntimeError(str(err)) from err

return loader.load(context.environment, template_name).render(new_context)
14 changes: 14 additions & 0 deletions tests/jinja2/environment.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Sets up a reasonably minimal Jinja2 environment for testing"""

from __future__ import annotations

from jinja2 import Environment
from jinja2 import FileSystemLoader

from django_simple_nav.jinja2 import django_simple_nav

# Ensure the same template paths are valid for both Jinja2 and Django templates
loader = FileSystemLoader("tests/jinja2/")

environment = Environment(loader=loader, trim_blocks=True)
environment.globals.update({"django_simple_nav": django_simple_nav})
12 changes: 12 additions & 0 deletions tests/jinja2/tests/dummy_nav.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<ul>
{% for item in items recursive %}
<li>
<a href="{{ item.url }}">{{ item.title }}</a>
{% if item['items'] %}
<ul>
{{ loop(item['items']) }}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
10 changes: 0 additions & 10 deletions tests/templates/tests/jinja2/dummy_nav.html

This file was deleted.

142 changes: 142 additions & 0 deletions tests/test_jinja.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
from __future__ import annotations

import pytest
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from jinja2 import TemplateRuntimeError
from model_bakery import baker

from django_simple_nav.nav import NavItem
from tests.jinja2.environment import environment
from tests.navs import DummyNav
from tests.utils import count_anchors

pytestmark = pytest.mark.django_db


def test_django_simple_nav_templatetag(req):
template = environment.from_string('{{ django_simple_nav("tests.navs.DummyNav") }}')
req.user = AnonymousUser()
rendered_template = template.render(request=req)
assert count_anchors(rendered_template) == 7


def test_templatetag_with_template_name(req):
template = environment.from_string(
"{{ django_simple_nav('tests.navs.DummyNav', 'tests/alternate.html') }}"
)
req.user = AnonymousUser()
rendered_template = template.render({"request": req})
assert "This is an alternate template." in rendered_template


def test_templatetag_with_nav_instance(req):
class PlainviewNav(DummyNav):
items = [
NavItem(title="I drink your milkshake!", url="/milkshake/"),
]

template = environment.from_string("{{ django_simple_nav(new_nav) }}")
req.user = baker.make(get_user_model(), first_name="Daniel", last_name="Plainview")
rendered_template = template.render({"request": req, "new_nav": PlainviewNav()})
assert "I drink your milkshake!" in rendered_template


def test_templatetag_with_nav_instance_and_template_name(req):
class DeadParrotNav(DummyNav):
items = [
NavItem(title="He's pinin' for the fjords!", url="/notlob/"),
]

template = environment.from_string(
"{{ django_simple_nav(new_nav, 'tests/alternate.html') }}"
)
req.user = baker.make(get_user_model(), first_name="Norwegian", last_name="Blue")
rendered_template = template.render({"request": req, "new_nav": DeadParrotNav()})
assert "He's pinin' for the fjords!" in rendered_template
assert "This is an alternate template." in rendered_template


def test_templatetag_with_template_name_on_nav_instance(req):
class PinkmanNav(DummyNav):
template_name = "tests/alternate.html"
items = [
NavItem(title="Yeah Mr. White! Yeah science!", url="/science/"),
]

template = environment.from_string("{{ django_simple_nav(new_nav) }}")
req.user = baker.make(get_user_model(), first_name="Jesse", last_name="Pinkman")
rendered_template = template.render({"request": req, "new_nav": PinkmanNav()})
assert "Yeah Mr. White! Yeah science!" in rendered_template
assert "This is an alternate template." in rendered_template


def test_templatetag_with_no_arguments(req):
req.user = AnonymousUser()
with pytest.raises(TypeError):
template = environment.from_string("{{ django_simple_nav() }}")
template.render({"request": req})


def test_templatetag_with_missing_variable(req):
req.user = AnonymousUser()
template = environment.from_string("{{ django_simple_nav(missing_nav) }}")
with pytest.raises(TemplateRuntimeError):
template.render({"request": req})


def test_nested_templatetag(req):
# called twice to simulate a nested call
template = environment.from_string(
"{{ django_simple_nav('tests.navs.DummyNav') }}"
"{{ django_simple_nav('tests.navs.DummyNav') }}"
)
req.user = AnonymousUser()
rendered_template = template.render({"request": req})
assert count_anchors(rendered_template) == 14


def test_invalid_dotted_string(req):
template = environment.from_string(
"{{ django_simple_nav('path.to.DoesNotExist') }}"
)

with pytest.raises(TemplateRuntimeError):
template.render({"request": req})


class InvalidNav: ...


def test_invalid_nav_instance(req):
template = environment.from_string(
"{{ django_simple_nav('tests.test_templatetags.InvalidNav') }}"
)
with pytest.raises(TemplateRuntimeError):
template.render({"request": req})


def test_template_name_variable_does_not_exist(req):
template = environment.from_string(
"{{ django_simple_nav('tests.navs.DummyNav', nonexistent_template_name_variable) }}"
)
with pytest.raises(TemplateRuntimeError):
template.render({"request": req})


def test_request_not_in_context():
template = environment.from_string(
" {{ django_simple_nav('tests.navs.DummyNav') }}"
)

with pytest.raises(TemplateRuntimeError):
template.render()


def test_invalid_request():
class InvalidRequest: ...

template = environment.from_string("{{ django_simple_nav('tests.navs.DummyNav') }}")

with pytest.raises(TemplateRuntimeError):
template.render({"request": InvalidRequest()})
10 changes: 4 additions & 6 deletions tests/test_nav.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,12 @@ class GetItemsNav(Nav):
)
def test_get_template_engines(engine, expected):
class TemplateEngineNav(Nav):
template_name = (
"tests/dummy_nav.html"
if engine.endswith("DjangoTemplates")
else "tests/jinja2/dummy_nav.html"
)
template_name = "tests/dummy_nav.html"
items = [...]

with override_settings(TEMPLATES=[dict(settings.TEMPLATES[0], BACKEND=engine)]):
with override_settings(
TEMPLATES=[dict(settings.TEMPLATES[0], BACKEND=engine, DIRS=[])]
):
template = TemplateEngineNav().get_template()

assert isinstance(template, expected)
Expand Down