Skip to content

Commit 2a7cfb4

Browse files
Add programmatic nav construction and callable factory support (#214)
1 parent c709f1d commit 2a7cfb4

File tree

9 files changed

+280
-12
lines changed

9 files changed

+280
-12
lines changed

docs/reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ This page documents the runtime behaviors that aren't captured in the API docs.
1313

1414
| Argument | Required | Description |
1515
|---|---|---|
16-
| `nav` | yes | A dotted import path string (e.g. `"config.nav.MainNav"`) or a `Nav` instance from the template context. |
16+
| `nav` | yes | A dotted import path string to a `Nav` class (e.g. `"config.nav.MainNav"`), a dotted path to a callable that accepts `request` and returns a `Nav` (e.g. `"config.nav.main_nav"`), or a `Nav` instance from the template context. See [Programmatic Navigation](usage.md#programmatic-navigation). |
1717
| `template_name` | no | Override the template used to render the navigation. |
1818

1919
Expects `request` in the template context.

docs/usage.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,50 @@ def example_view(request):
266266
</nav>
267267
```
268268

269+
## Programmatic Navigation
270+
271+
Instead of subclassing `Nav`, you can construct one directly. This is useful when you want to build the items list with conditionals or loops:
272+
273+
```python
274+
from django_simple_nav.nav import Nav, NavItem
275+
276+
items = [NavItem(title="Home", url="/")]
277+
if show_dashboard:
278+
items.append(NavItem(title="Dashboard", url="/dashboard/"))
279+
280+
main_nav = Nav(template_name="main_nav.html", items=items)
281+
```
282+
283+
### Factory functions
284+
285+
For conditions that depend on the request (user authentication, permissions, session data), define a callable that accepts the request and returns a `Nav`:
286+
287+
```python
288+
# config/nav.py
289+
from django.http import HttpRequest
290+
291+
from django_simple_nav.nav import Nav, NavItem
292+
293+
294+
def main_nav(request: HttpRequest) -> Nav:
295+
items = [NavItem(title="Home", url="/")]
296+
if request.user.is_authenticated:
297+
items.append(NavItem(title="Dashboard", url="/dashboard/"))
298+
if request.user.is_staff:
299+
items.append(NavItem(title="Admin", url="/admin/"))
300+
return Nav(template_name="main_nav.html", items=items)
301+
```
302+
303+
Use the dotted import path in the template tag, the same way you would with a `Nav` class:
304+
305+
```htmldjango
306+
{% load django_simple_nav %}
307+
308+
{% django_simple_nav "config.nav.main_nav" %}
309+
```
310+
311+
The callable receives the current `request` and its return value is rendered as the navigation. This works in both Django templates and Jinja2.
312+
269313
## Self-Rendering Items
270314

271315
Instead of writing the HTML for each item manually, you can use `{{ item }}` to let items render themselves:

src/django_simple_nav/jinja2.py

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from __future__ import annotations
22

3-
from typing import cast
3+
from collections.abc import Callable
44

55
from django.utils.module_loading import import_string
66
from jinja2 import TemplateRuntimeError
@@ -12,25 +12,52 @@
1212

1313
@pass_context
1414
def django_simple_nav(
15-
context: Context, nav: str | Nav, template_name: str | None = None
15+
context: Context,
16+
nav: str | Nav | Callable[..., Nav],
17+
template_name: str | None = None,
1618
) -> str:
1719
"""Jinja binding for `django_simple_nav`"""
1820
if (loader := context.environment.loader) is None:
1921
raise TemplateRuntimeError("No template loader in Jinja2 environment")
2022

21-
if type(nav) is str:
23+
request = context.get("request")
24+
if request is None:
25+
raise TemplateRuntimeError("`request` not found in Jinja2 context")
26+
27+
nav_instance: object
28+
if isinstance(nav, Nav):
29+
nav_instance = nav
30+
elif isinstance(nav, str):
2231
try:
23-
nav = import_string(nav)()
32+
resolved: object = import_string(nav)
2433
except ImportError as err:
2534
raise TemplateRuntimeError(str(err)) from err
2635

36+
if isinstance(resolved, type):
37+
nav_instance = resolved()
38+
elif callable(resolved):
39+
nav_instance = resolved(request)
40+
else:
41+
nav_instance = resolved
42+
elif callable(nav):
43+
nav_instance = nav(request)
44+
else:
45+
nav_instance = nav
46+
47+
if not isinstance(nav_instance, Nav):
48+
raise TemplateRuntimeError(f"Not a valid `Nav` instance: {nav_instance}")
49+
2750
try:
2851
if template_name is None:
29-
template_name = cast(Nav, nav).template_name
52+
template_name = nav_instance.template_name
3053
if template_name is None:
3154
raise TemplateRuntimeError("Navigation object has no template")
32-
request = context["request"]
33-
new_context = {"request": request, **cast(Nav, nav).get_context_data(request)}
55+
new_context = {
56+
"request": request,
57+
**nav_instance.get_context_data(request),
58+
}
59+
except TemplateRuntimeError:
60+
raise
3461
except Exception as err:
3562
raise TemplateRuntimeError(str(err)) from err
3663

src/django_simple_nav/nav.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,17 @@ class Nav:
9191
template_name: str | None = field(init=False, default=None)
9292
items: list[NavGroup | NavItem] | None = field(init=False, default=None)
9393

94+
def __init__(
95+
self,
96+
*,
97+
template_name: str | None = None,
98+
items: list[NavGroup | NavItem] | None = None,
99+
) -> None:
100+
if template_name is not None:
101+
object.__setattr__(self, "template_name", template_name)
102+
if items is not None:
103+
object.__setattr__(self, "items", items)
104+
94105
def render(self, request: HttpRequest, template_name: str | None = None) -> str:
95106
context = self.get_context_data(request)
96107
template = self.get_template(template_name)

src/django_simple_nav/templatetags/django_simple_nav.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,27 +37,39 @@ def __init__(self, nav: str, template_name: str | None) -> None:
3737

3838
@override
3939
def render(self, context: Context) -> str:
40-
nav = self.get_nav(context)
41-
template_name = self.get_template_name(context)
4240
request = self.get_request(context)
41+
nav = self.get_nav(context, request)
42+
template_name = self.get_template_name(context)
4343

4444
return nav.render(request, template_name)
4545

46-
def get_nav(self, context: Context) -> Nav:
46+
def get_nav(self, context: Context, request: HttpRequest) -> Nav:
4747
try:
4848
nav: str | Nav = self.nav.resolve(context)
4949
except template.VariableDoesNotExist as err:
5050
raise template.TemplateSyntaxError(
5151
f"Variable does not exist: {err}"
5252
) from err
5353

54+
if isinstance(nav, Nav):
55+
return nav
56+
5457
if isinstance(nav, str):
5558
try:
56-
nav_instance: object = import_string(nav)()
59+
imported: object = import_string(nav)
5760
except ImportError as err:
5861
raise template.TemplateSyntaxError(
5962
f"Failed to import from dotted string: {nav}"
6063
) from err
64+
65+
if isinstance(imported, type):
66+
# Class (Nav subclass or otherwise) - instantiate with no args
67+
nav_instance: object = imported()
68+
elif callable(imported):
69+
# Callable factory - call with request
70+
nav_instance = imported(request)
71+
else:
72+
nav_instance = imported
6173
else:
6274
nav_instance = nav
6375

tests/navs.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
from django.http import HttpRequest
4+
35
from django_simple_nav.nav import Nav
46
from django_simple_nav.nav import NavGroup
57
from django_simple_nav.nav import NavItem
@@ -49,3 +51,16 @@ class DummyNav(Nav):
4951
items=[NavItem(title="Test Item", url="#")],
5052
),
5153
]
54+
55+
56+
def dynamic_nav(request: HttpRequest) -> Nav:
57+
"""A callable nav factory for testing."""
58+
items = [NavItem(title="Home", url="/")]
59+
if hasattr(request, "user") and getattr(request.user, "is_authenticated", False):
60+
items.append(NavItem(title="Dashboard", url="/dashboard/"))
61+
return Nav(template_name="tests/dummy_nav.html", items=items)
62+
63+
64+
def bad_callable(request: HttpRequest) -> str:
65+
"""Returns something that is not a Nav (for error testing)."""
66+
return "not a Nav"

tests/test_jinja.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,55 @@ class InvalidRequest: ...
140140

141141
with pytest.raises(TemplateRuntimeError):
142142
template.render({"request": InvalidRequest()})
143+
144+
145+
def test_callable_dotted_string(req):
146+
template = environment.from_string(
147+
'{{ django_simple_nav("tests.navs.dynamic_nav") }}'
148+
)
149+
req.user = AnonymousUser()
150+
rendered_template = template.render({"request": req})
151+
152+
assert "Home" in rendered_template
153+
assert "Dashboard" not in rendered_template
154+
155+
156+
def test_callable_authenticated(req):
157+
template = environment.from_string(
158+
'{{ django_simple_nav("tests.navs.dynamic_nav") }}'
159+
)
160+
req.user = baker.make(get_user_model())
161+
rendered_template = template.render({"request": req})
162+
163+
assert "Home" in rendered_template
164+
assert "Dashboard" in rendered_template
165+
166+
167+
def test_callable_with_template_name(req):
168+
template = environment.from_string(
169+
"{{ django_simple_nav('tests.navs.dynamic_nav', 'tests/alternate.html') }}"
170+
)
171+
req.user = AnonymousUser()
172+
rendered_template = template.render({"request": req})
173+
174+
assert "This is an alternate template." in rendered_template
175+
176+
177+
def test_callable_variable(req):
178+
from tests.navs import dynamic_nav
179+
180+
template = environment.from_string("{{ django_simple_nav(nav_func) }}")
181+
req.user = AnonymousUser()
182+
rendered_template = template.render({"request": req, "nav_func": dynamic_nav})
183+
184+
assert "Home" in rendered_template
185+
186+
187+
def test_bad_callable(req):
188+
template = environment.from_string(
189+
'{{ django_simple_nav("tests.navs.bad_callable") }}'
190+
)
191+
req.user = AnonymousUser()
192+
193+
with pytest.raises(TemplateRuntimeError):
194+
template.render({"request": req})

tests/test_nav.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,3 +222,65 @@ class GetTemplateNameNav(Nav):
222222

223223
with pytest.raises(ImproperlyConfigured):
224224
GetTemplateNameNav().get_template_name()
225+
226+
227+
class TestNavConstructor:
228+
"""Tests for direct Nav() construction (programmatic usage)."""
229+
230+
def test_constructor_with_items_and_template_name(self, req):
231+
nav = Nav(
232+
template_name="tests/dummy_nav.html",
233+
items=[NavItem(title="Home", url="/")],
234+
)
235+
236+
rendered = nav.render(req)
237+
238+
assert "Home" in rendered
239+
assert count_anchors(rendered) == 1
240+
241+
def test_constructor_no_args(self):
242+
nav = Nav()
243+
244+
assert nav.template_name is None
245+
assert nav.items is None
246+
247+
def test_constructor_items_only(self, req):
248+
nav = Nav(items=[NavItem(title="Test", url="/test/")])
249+
250+
items = nav.get_items(req)
251+
252+
assert len(items) == 1
253+
254+
def test_constructor_template_name_only(self):
255+
nav = Nav(template_name="tests/dummy_nav.html")
256+
257+
assert nav.get_template_name() == "tests/dummy_nav.html"
258+
259+
def test_constructor_frozen(self):
260+
nav = Nav(
261+
template_name="tests/dummy_nav.html",
262+
items=[NavItem(title="Home", url="/")],
263+
)
264+
265+
with pytest.raises(AttributeError):
266+
nav.template_name = "other.html"
267+
268+
def test_constructor_keyword_only(self):
269+
with pytest.raises(TypeError):
270+
Nav("tests/dummy_nav.html", [])
271+
272+
def test_subclass_not_shadowed(self):
273+
"""Existing subclass pattern continues to work."""
274+
nav = DummyNav()
275+
276+
assert nav.template_name == "tests/dummy_nav.html"
277+
assert nav.items is not None
278+
assert len(nav.items) > 0
279+
280+
def test_constructor_with_conditional_items(self, req):
281+
items = [NavItem(title="Home", url="/")]
282+
items.append(NavItem(title="About", url="/about/"))
283+
284+
nav = Nav(template_name="tests/dummy_nav.html", items=items)
285+
286+
assert len(nav.get_items(req)) == 2

tests/test_templatetags.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,48 @@ class InvalidRequest: ...
165165

166166
with pytest.raises(TemplateSyntaxError):
167167
template.render(Context({"request": InvalidRequest()}))
168+
169+
170+
def test_templatetag_with_callable(req):
171+
template = Template(
172+
"{% load django_simple_nav %} {% django_simple_nav 'tests.navs.dynamic_nav' %}"
173+
)
174+
req.user = AnonymousUser()
175+
176+
rendered_template = template.render(Context({"request": req}))
177+
178+
assert "Home" in rendered_template
179+
assert "Dashboard" not in rendered_template
180+
181+
182+
def test_templatetag_with_callable_authenticated(req):
183+
template = Template(
184+
"{% load django_simple_nav %} {% django_simple_nav 'tests.navs.dynamic_nav' %}"
185+
)
186+
req.user = baker.make(get_user_model())
187+
188+
rendered_template = template.render(Context({"request": req}))
189+
190+
assert "Home" in rendered_template
191+
assert "Dashboard" in rendered_template
192+
193+
194+
def test_templatetag_with_callable_and_template_name(req):
195+
template = Template(
196+
"{% load django_simple_nav %} {% django_simple_nav 'tests.navs.dynamic_nav' 'tests/alternate.html' %}"
197+
)
198+
req.user = AnonymousUser()
199+
200+
rendered_template = template.render(Context({"request": req}))
201+
202+
assert "This is an alternate template." in rendered_template
203+
204+
205+
def test_templatetag_with_bad_callable(req):
206+
template = Template(
207+
"{% load django_simple_nav %} {% django_simple_nav 'tests.navs.bad_callable' %}"
208+
)
209+
req.user = AnonymousUser()
210+
211+
with pytest.raises(TemplateSyntaxError):
212+
template.render(Context({"request": req}))

0 commit comments

Comments
 (0)