diff --git a/AUTHORS.md b/AUTHORS.md index 898e4db..3053322 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -2,3 +2,4 @@ - Josh Thomas - Jeff Triplett [@jefftriplett](https://github.com/jefftriplett) +- HiPhish [@hiphish](https://github.com/hiphish) diff --git a/README.md b/README.md index b19acbf..230e392 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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. @@ -177,9 +193,37 @@ ``` + The same template in Jinja would be written as follows: + + ```html + + + ``` + + 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: @@ -194,6 +238,17 @@ {% endblock navigation %} ``` + For Jinja: + + ```html + + {% block navigation %} + + {% 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 @@ -217,6 +272,17 @@ {% endblock navigation %} ``` + ```html + + {% extends "base.html" %} + + {% block navigation %} + + {% 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 @@ -228,6 +294,14 @@ ``` + ```html + + + + ``` + 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. diff --git a/pyproject.toml b/pyproject.toml index 626953c..24f71bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/django_simple_nav/jinja2.py b/src/django_simple_nav/jinja2.py new file mode 100644 index 0000000..e04ad4b --- /dev/null +++ b/src/django_simple_nav/jinja2.py @@ -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) diff --git a/tests/jinja2/environment.py b/tests/jinja2/environment.py new file mode 100644 index 0000000..a67b585 --- /dev/null +++ b/tests/jinja2/environment.py @@ -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}) diff --git a/tests/templates/tests/jinja2/alternate.html b/tests/jinja2/tests/alternate.html similarity index 100% rename from tests/templates/tests/jinja2/alternate.html rename to tests/jinja2/tests/alternate.html diff --git a/tests/jinja2/tests/dummy_nav.html b/tests/jinja2/tests/dummy_nav.html new file mode 100644 index 0000000..3f87663 --- /dev/null +++ b/tests/jinja2/tests/dummy_nav.html @@ -0,0 +1,12 @@ + diff --git a/tests/templates/tests/jinja2/dummy_nav.html b/tests/templates/tests/jinja2/dummy_nav.html deleted file mode 100644 index 5bf1572..0000000 --- a/tests/templates/tests/jinja2/dummy_nav.html +++ /dev/null @@ -1,10 +0,0 @@ - diff --git a/tests/test_jinja.py b/tests/test_jinja.py new file mode 100644 index 0000000..963f418 --- /dev/null +++ b/tests/test_jinja.py @@ -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()}) diff --git a/tests/test_nav.py b/tests/test_nav.py index aab48d1..8c77bd6 100644 --- a/tests/test_nav.py +++ b/tests/test_nav.py @@ -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)