Skip to content
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
26 changes: 13 additions & 13 deletions docs/plugins/development/config-templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ NetBox uses [Jinja](https://jinja.palletsprojects.com/) to render [configuration

## Registering Jinja Filters

### Via `jinja2_env.py` (auto-discovery)
### Via `jinja_env.py` (auto-discovery)

Create a file named `jinja2_env.py` in your plugin root and expose a dict called `filters`. NetBox will auto-discover and register it when the plugin loads.
Create a file named `jinja_env.py` in your plugin root and expose a dict called `filters`. NetBox will auto-discover and register it when the plugin loads.

```python title="my_plugin/jinja2_env.py"
```python title="my_plugin/jinja_env.py"
def prefix_list(device):
"""Return all prefixes assigned to a device's interfaces."""
return [
Expand All @@ -35,7 +35,7 @@ The filter is then available in any config template:
{% endfor %}
```

### Via `register_jinja2_filters()`
### Via `register_jinja_filters()`

You can also register filters programmatically inside your plugin's `ready()` method:

Expand All @@ -48,12 +48,12 @@ class MyPluginConfig(PluginConfig):

def ready(self):
super().ready()
from netbox.plugins.registration import register_jinja2_filters
from .jinja2_env import filters
register_jinja2_filters(filters)
from netbox.plugins.registration import register_jinja_filters
from .jinja_env import filters
register_jinja_filters(filters)
```

`register_jinja2_filters()` accepts a `dict` mapping filter names to callables. It raises `TypeError` if passed a non-dict or if any value is not callable.
`register_jinja_filters()` accepts a `dict` mapping filter names to callables. It raises `TypeError` if passed a non-dict or if any value is not callable.

### Precedence

Expand Down Expand Up @@ -81,7 +81,7 @@ JINJA_FILTERS = {

## Injecting Context Variables

Override `get_jinja2_context()` in your `PluginConfig` subclass to inject additional variables into every config template render context.
Override `get_jinja_context()` in your `PluginConfig` subclass to inject additional variables into every config template render context.

```python title="my_plugin/__init__.py"
from netbox.plugins import PluginConfig
Expand All @@ -90,7 +90,7 @@ class MyPluginConfig(PluginConfig):
name = 'my_plugin'
# ...

def get_jinja2_context(self):
def get_jinja_context(self):
from .utils import MyNamespace
return {
'my_plugin': MyNamespace(),
Expand All @@ -104,12 +104,12 @@ The returned dict is merged into the template context, so `my_plugin` becomes av
```

!!! warning "Startup cost"
`get_jinja2_context()` is called on **every** config template render, not once at startup. Keep it fast. Defer expensive lookups to the object you return rather than performing them in `get_jinja2_context()` itself.
`get_jinja_context()` is called on **every** config template render, not once at startup. Keep it fast. Defer expensive lookups to the object you return rather than performing them in `get_jinja_context()` itself.

!!! note "Conflict avoidance"
Choose context variable names that are unlikely to collide with NetBox's built-in template variables (`device`, `queryset`, etc.) or with those contributed by other plugins. Prefixing with your plugin name is strongly recommended.

In addition, avoid top-level app-label names (`dcim`, `ipam`, `virtualization`, etc.). The auto-populated template context maps each app label to a dict of its public model classes; returning a key like `'dcim'` from `get_jinja2_context()` will silently replace that entire namespace.
In addition, avoid top-level app-label names (`dcim`, `ipam`, `virtualization`, etc.). The auto-populated template context maps each app label to a dict of its public model classes; returning a key like `'dcim'` from `get_jinja_context()` will silently replace that entire namespace.

!!! note "No per-render context"
`get_jinja2_context()` receives no arguments — it has no access to the object being rendered or the caller-supplied context. It is intended for plugin-global namespaces (e.g. a lazily-evaluated query helper). Per-object logic belongs in the template itself or in a custom filter.
`get_jinja_context()` receives no arguments — it has no access to the object being rendered or the caller-supplied context. It is intended for plugin-global namespaces (e.g. a lazily-evaluated query helper). Per-object logic belongs in the template itself or in a custom filter.
2 changes: 1 addition & 1 deletion docs/plugins/development/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
| `search_indexes` | The dotted path to the list of search index classes (default: `search.indexes`) |
| `data_backends` | The dotted path to the list of data source backend classes (default: `data_backends.backends`) |
| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) |
| `jinja2_filters` | The dotted path to a dict of custom Jinja2 filter functions for use in config templates (default: `jinja2_env.filters`) |
| `jinja_filters` | The dotted path to a dict of custom Jinja filter functions for use in config templates (default: `jinja_env.filters`) |
| `menu` | The dotted path to a top-level navigation menu provided by the plugin (default: `navigation.menu`) |
| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) |
| `graphql_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) |
Expand Down
4 changes: 2 additions & 2 deletions netbox/extras/models/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,9 @@ def get_context(self, context=None, queryset=None):
for app_config in django_apps.get_app_configs():
if isinstance(app_config, PluginConfig):
try:
_context.update(app_config.get_jinja2_context())
_context.update(app_config.get_jinja_context())
except Exception:
logger.exception("Plugin %r raised an exception in get_jinja2_context()", app_config.name)
logger.exception("Plugin %r raised an exception in get_jinja_context()", app_config.name)

if context is not None:
_context.update(context)
Expand Down
16 changes: 8 additions & 8 deletions netbox/netbox/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
registry['plugins'].update({
'installed': [],
'graphql_schemas': [],
'jinja2_filters': {},
'jinja_filters': {},
'menus': [],
'menu_items': {},
'preferences': {},
Expand All @@ -31,7 +31,7 @@
'search_indexes': 'search.indexes',
'data_backends': 'data_backends.backends',
'graphql_schema': 'graphql.schema',
'jinja2_filters': 'jinja2_env.filters',
'jinja_filters': 'jinja_env.filters',
'menu': 'navigation.menu',
'menu_items': 'navigation.menu_items',
'template_extensions': 'template_content.template_extensions',
Expand Down Expand Up @@ -80,17 +80,17 @@ class PluginConfig(AppConfig):
search_indexes = None
data_backends = None
graphql_schema = None
jinja2_filters = None
jinja_filters = None
menu = None
menu_items = None
serializer_resolver = None
template_extensions = None
user_preferences = None
events_pipeline = []

def get_jinja2_context(self):
def get_jinja_context(self):
"""
Return a dict of additional variables to inject into the Jinja2 template context
Return a dict of additional variables to inject into the Jinja template context
when rendering ConfigTemplates. Override this in a PluginConfig subclass to expose
plugin-managed data to config templates without requiring template authors to know
internal model names.
Expand Down Expand Up @@ -133,9 +133,9 @@ def ready(self):
for backend in data_backends:
register_data_backend()(backend)

# Register Jinja2 filters (if defined)
if jinja2_filters := self._load_resource('jinja2_filters'):
register_jinja2_filters(jinja2_filters)
# Register Jinja filters (if defined)
if jinja_filters := self._load_resource('jinja_filters'):
register_jinja_filters(jinja_filters)

# Register template content (if defined)
if template_extensions := self._load_resource('template_extensions'):
Expand Down
16 changes: 8 additions & 8 deletions netbox/netbox/plugins/registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

__all__ = (
'register_graphql_schema',
'register_jinja2_filters',
'register_jinja_filters',
'register_menu',
'register_menu_items',
'register_serializer_resolver',
Expand All @@ -21,24 +21,24 @@
)


def register_jinja2_filters(filters):
def register_jinja_filters(filters):
"""
Register a dict of Jinja2 filter functions provided by a plugin. Each key is the
Register a dict of Jinja filter functions provided by a plugin. Each key is the
filter name as it will appear in templates; the value is the callable implementing it.
Plugin-registered filters have lower precedence than instance-level JINJA_FILTERS
so that site admins can always override them in configuration.py.
"""
if not isinstance(filters, dict):
raise TypeError(_("jinja2_filters must be a dict mapping filter names to callables"))
raise TypeError(_("jinja_filters must be a dict mapping filter names to callables"))
for name, fn in filters.items():
if not callable(fn):
raise TypeError(_("Jinja2 filter '{name}' must be callable").format(name=name))
if name in registry['plugins']['jinja2_filters']:
raise TypeError(_("Jinja filter '{name}' must be callable").format(name=name))
if name in registry['plugins']['jinja_filters']:
logger.warning(
"Jinja2 filter '%s' registered by a plugin is being overridden by a later-loaded plugin",
"Jinja filter '%s' registered by a plugin is being overridden by a later-loaded plugin",
name,
)
registry['plugins']['jinja2_filters'].update(filters)
registry['plugins']['jinja_filters'].update(filters)


def register_template_extensions(class_list):
Expand Down
2 changes: 1 addition & 1 deletion netbox/netbox/tests/dummy_plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class DummyPluginConfig(PluginConfig):
'netbox.tests.dummy_plugin.events.process_events_queue'
]

def get_jinja2_context(self):
def get_jinja_context(self):
return {'dummy_plugin_var': 'hello_from_dummy'}

def ready(self):
Expand Down
54 changes: 27 additions & 27 deletions netbox/netbox/tests/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,48 +238,48 @@ def test_webhook_callbacks(self):
"""
self.assertIn(set_context, registry['webhook_callbacks'])

def test_jinja2_filters_registered(self):
def test_jinja_filters_registered(self):
"""
Check that Jinja2 filters exported by the dummy plugin are registered in
registry['plugins']['jinja2_filters'] after ready().
Check that Jinja filters exported by the dummy plugin are registered in
registry['plugins']['jinja_filters'] after ready().
"""
from netbox.tests.dummy_plugin.jinja2_env import dummy_upper
self.assertIn('dummy_upper', registry['plugins']['jinja2_filters'])
self.assertIs(registry['plugins']['jinja2_filters']['dummy_upper'], dummy_upper)
from netbox.tests.dummy_plugin.jinja_env import dummy_upper
self.assertIn('dummy_upper', registry['plugins']['jinja_filters'])
self.assertIs(registry['plugins']['jinja_filters']['dummy_upper'], dummy_upper)

def test_jinja2_filter_available_in_render(self):
def test_jinja_filter_available_in_render(self):
"""
Filters registered by a plugin must be usable inside render_jinja2().
"""
from utilities.jinja2 import render_jinja2
result = render_jinja2("{{ 'hello' | dummy_upper }}", {})
self.assertEqual(result, 'HELLO')

def test_get_jinja2_context_merged_into_render(self):
def test_get_jinja_context_merged_into_render(self):
"""
Variables returned by a plugin's get_jinja2_context() must appear in the
Variables returned by a plugin's get_jinja_context() must appear in the
context produced by RenderTemplateMixin.get_context().
"""
from extras.models import ConfigTemplate
ct = ConfigTemplate(name='jinja2-ctx-test', template_code='')
ct = ConfigTemplate(name='jinja-ctx-test', template_code='')
ctx = ct.get_context()
self.assertIn('dummy_plugin_var', ctx)
self.assertEqual(ctx['dummy_plugin_var'], 'hello_from_dummy')

def test_get_jinja2_context_bad_return_is_silenced(self):
def test_get_jinja_context_bad_return_is_silenced(self):
"""
A non-dict return from get_jinja2_context() must not crash the render.
A non-dict return from get_jinja_context() must not crash the render.
"""
from unittest.mock import patch

from extras.models import ConfigTemplate
from netbox.tests.dummy_plugin import DummyPluginConfig
ct = ConfigTemplate(name='bad-ctx-test', template_code='')
with patch.object(DummyPluginConfig, 'get_jinja2_context', return_value='not_a_dict'):
with patch.object(DummyPluginConfig, 'get_jinja_context', return_value='not_a_dict'):
ctx = ct.get_context()
self.assertNotIn('dummy_plugin_var', ctx)

def test_instance_jinja2_filters_override_plugin_filters(self):
def test_instance_jinja_filters_override_plugin_filters(self):
"""
Instance-level JINJA_FILTERS must take precedence over plugin-registered filters
of the same name.
Expand All @@ -292,30 +292,30 @@ def test_instance_jinja2_filters_override_plugin_filters(self):


@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
class PluginJinja2RegistrationTest(TestCase):
class PluginJinjaRegistrationTest(TestCase):
"""
Tests for the register_jinja2_filters() registration helper independent of
Tests for the register_jinja_filters() registration helper independent of
the dummy plugin's startup path.
"""

def test_register_jinja2_filters_rejects_non_dict(self):
from netbox.plugins.registration import register_jinja2_filters
def test_register_jinja_filters_rejects_non_dict(self):
from netbox.plugins.registration import register_jinja_filters
with self.assertRaises(TypeError):
register_jinja2_filters([('my_filter', lambda v: v)])
register_jinja_filters([('my_filter', lambda v: v)])

def test_register_jinja2_filters_rejects_non_callable_value(self):
from netbox.plugins.registration import register_jinja2_filters
def test_register_jinja_filters_rejects_non_callable_value(self):
from netbox.plugins.registration import register_jinja_filters
with self.assertRaises(TypeError):
register_jinja2_filters({'my_filter': 'not_a_function'})
register_jinja_filters({'my_filter': 'not_a_function'})

def test_register_jinja2_filters_merges_into_registry(self):
from netbox.plugins.registration import register_jinja2_filters
def test_register_jinja_filters_merges_into_registry(self):
from netbox.plugins.registration import register_jinja_filters
fn = lambda v: v # noqa: E731
register_jinja2_filters({'_test_temp_filter': fn})
register_jinja_filters({'_test_temp_filter': fn})
try:
self.assertIs(registry['plugins']['jinja2_filters']['_test_temp_filter'], fn)
self.assertIs(registry['plugins']['jinja_filters']['_test_temp_filter'], fn)
finally:
del registry['plugins']['jinja2_filters']['_test_temp_filter']
del registry['plugins']['jinja_filters']['_test_temp_filter']


class PluginNavigationTestCase(TestCase):
Expand Down
2 changes: 1 addition & 1 deletion netbox/utilities/jinja2.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def render_jinja2(template_code, context, environment_params=None, data_file=Non
# Instance-level config always wins so site admins can override anything.
filters = {
**DEFAULT_JINJA2_FILTERS,
**registry['plugins'].get('jinja2_filters', {}),
**registry['plugins'].get('jinja_filters', {}),
**get_config().JINJA_FILTERS,
}
environment.filters.update(filters)
Expand Down