diff --git a/docs/configuration/system.md b/docs/configuration/system.md index 6951e911d..efc983407 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -190,6 +190,9 @@ For example, given `JINJA_ENVIRONMENT_PARAMS = ['WEBHOOK_TOKEN_*']`, a Jinja2 te Authorization: Bearer {{ 'WEBHOOK_TOKEN_3' | env }} ``` +!!! tip "Plugin-provided filters" + Plugins can also register Jinja2 filters without requiring instance configuration. See [Jinja2 Config Templates](../plugins/development/config-templates.md) in the plugin development documentation. Instance-level `JINJA2_FILTERS` always takes precedence over plugin-registered filters of the same name. + --- ## LOGGING diff --git a/docs/plugins/development/config-templates.md b/docs/plugins/development/config-templates.md new file mode 100644 index 000000000..91476a865 --- /dev/null +++ b/docs/plugins/development/config-templates.md @@ -0,0 +1,115 @@ +# Jinja2 Config Templates + +NetBox uses [Jinja2](https://jinja.palletsprojects.com/) to render [configuration templates](../../features/config-templates.md). Plugins can extend this rendering pipeline in two complementary ways: + +1. **Register custom filters** — make new template filters available by name in every config template. +2. **Inject context variables** — add extra variables that are available inside every config template render. + +--- + +## Registering Jinja2 Filters + +### Via `jinja2_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. + +```python title="my_plugin/jinja2_env.py" +def prefix_list(device): + """Return all prefixes assigned to a device's interfaces.""" + return [ + str(ip.address) + for iface in device.interfaces.all() + for ip in iface.ip_addresses.all() + ] + +filters = { + 'prefix_list': prefix_list, +} +``` + +The filter is then available in any config template: + +```jinja2 +{% for prefix in device | prefix_list %} + network {{ prefix }} +{% endfor %} +``` + +### Via `register_jinja2_filters()` + +You can also register filters programmatically inside your plugin's `ready()` method: + +```python title="my_plugin/__init__.py" +from netbox.plugins import PluginConfig + +class MyPluginConfig(PluginConfig): + name = 'my_plugin' + # ... + + def ready(self): + super().ready() + from netbox.plugins.registration import register_jinja2_filters + from .jinja2_env import filters + register_jinja2_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. + +### Precedence + +The full filter precedence from lowest to highest is: **NetBox built-in filters** (e.g. `env`) → **plugin-registered filters** → **instance [`JINJA2_FILTERS`](../../configuration/system.md#jinja2_filters)**. Instance-level filters always win, so site admins can override anything without touching a plugin. + +If two plugins register a filter with the same name, the later-loaded plugin's version wins and NetBox will log a warning. + +For example, if `my_plugin` registers a `prefix_list` filter but a site needs different behaviour, the operator can replace it in `configuration.py` without touching the plugin: + +```python title="configuration.py" +def prefix_list(device): + # Site-local override: include only loopback prefixes + return [ + str(ip.address) + for iface in device.interfaces.filter(type='loopback') + for ip in iface.ip_addresses.all() + ] + +JINJA2_FILTERS = { + 'prefix_list': prefix_list, +} +``` + +--- + +## Injecting Context Variables + +Override `get_jinja2_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 + +class MyPluginConfig(PluginConfig): + name = 'my_plugin' + # ... + + def get_jinja2_context(self): + from .utils import MyNamespace + return { + 'my_plugin': MyNamespace(), + } +``` + +The returned dict is merged into the template context, so `my_plugin` becomes available by name inside every config template: + +```jinja2 +{% set records = my_plugin.lookup(device.name) %} +``` + +!!! 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. + +!!! 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. + +!!! 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. diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index b94639300..b7ce27ef6 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -119,6 +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`) | | `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`) | diff --git a/mkdocs.yml b/mkdocs.yml index 36a1abcaf..164993b71 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -147,6 +147,7 @@ nav: - UI Components: 'plugins/development/ui-components.md' - Navigation: 'plugins/development/navigation.md' - Templates: 'plugins/development/templates.md' + - Config Templates: 'plugins/development/config-templates.md' - Tables: 'plugins/development/tables.md' - Forms: 'plugins/development/forms.md' - Filters & Filter Sets: 'plugins/development/filtersets.md' diff --git a/netbox/extras/models/mixins.py b/netbox/extras/models/mixins.py index 7eee9ac3c..2b293d148 100644 --- a/netbox/extras/models/mixins.py +++ b/netbox/extras/models/mixins.py @@ -1,5 +1,6 @@ import importlib.abc import importlib.util +import logging import os import sys from collections import defaultdict @@ -21,6 +22,8 @@ __all__ = ( 'RenderTemplateMixin', ) +logger = logging.getLogger(__name__) + class CustomStoragesLoader(importlib.abc.Loader): """ @@ -123,6 +126,10 @@ class RenderTemplateMixin(models.Model): abstract = True def get_context(self, context=None, queryset=None): + from django.apps import apps as django_apps + + from netbox.plugins import PluginConfig + _context = defaultdict(dict) # Populate all public models for reference within the template @@ -130,6 +137,14 @@ class RenderTemplateMixin(models.Model): if model := object_type.model_class(): _context[object_type.app_label][model.__name__] = model + # Allow plugins to inject additional context (e.g. friendly-named namespaces) + for app_config in django_apps.get_app_configs(): + if isinstance(app_config, PluginConfig): + try: + _context.update(app_config.get_jinja2_context()) + except Exception: + logger.exception("Plugin %r raised an exception in get_jinja2_context()", app_config.name) + if context is not None: _context.update(context) diff --git a/netbox/netbox/plugins/__init__.py b/netbox/netbox/plugins/__init__.py index 51d88aefb..d5671f17a 100644 --- a/netbox/netbox/plugins/__init__.py +++ b/netbox/netbox/plugins/__init__.py @@ -20,6 +20,7 @@ from .utils import * registry['plugins'].update({ 'installed': [], 'graphql_schemas': [], + 'jinja2_filters': {}, 'menus': [], 'menu_items': {}, 'preferences': {}, @@ -30,6 +31,7 @@ DEFAULT_RESOURCE_PATHS = { 'search_indexes': 'search.indexes', 'data_backends': 'data_backends.backends', 'graphql_schema': 'graphql.schema', + 'jinja2_filters': 'jinja2_env.filters', 'menu': 'navigation.menu', 'menu_items': 'navigation.menu_items', 'template_extensions': 'template_content.template_extensions', @@ -78,6 +80,7 @@ class PluginConfig(AppConfig): search_indexes = None data_backends = None graphql_schema = None + jinja2_filters = None menu = None menu_items = None serializer_resolver = None @@ -85,6 +88,19 @@ class PluginConfig(AppConfig): user_preferences = None events_pipeline = [] + def get_jinja2_context(self): + """ + Return a dict of additional variables to inject into the Jinja2 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. + + The returned dict is merged into the template context after the standard + ObjectType-based model population, so keys here can shadow the auto-populated + entries if needed. + """ + return {} + def _load_resource(self, name): # Import from the configured path, if defined. if path := getattr(self, name, None): @@ -117,6 +133,10 @@ class PluginConfig(AppConfig): 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 template content (if defined) if template_extensions := self._load_resource('template_extensions'): register_template_extensions(template_extensions) diff --git a/netbox/netbox/plugins/registration.py b/netbox/netbox/plugins/registration.py index b0aa4a912..b8d28ab51 100644 --- a/netbox/netbox/plugins/registration.py +++ b/netbox/netbox/plugins/registration.py @@ -1,4 +1,5 @@ import inspect +import logging from django.utils.translation import gettext_lazy as _ @@ -7,8 +8,11 @@ from netbox.registry import registry from .navigation import PluginMenu, PluginMenuButton, PluginMenuItem from .templates import PluginTemplateExtension +logger = logging.getLogger(__name__) + __all__ = ( 'register_graphql_schema', + 'register_jinja2_filters', 'register_menu', 'register_menu_items', 'register_serializer_resolver', @@ -17,6 +21,26 @@ __all__ = ( ) +def register_jinja2_filters(filters): + """ + Register a dict of Jinja2 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 JINJA2_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")) + 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']: + logger.warning( + "Jinja2 filter '%s' registered by a plugin is being overridden by a later-loaded plugin", + name, + ) + registry['plugins']['jinja2_filters'].update(filters) + + def register_template_extensions(class_list): """ Register a list of PluginTemplateExtension classes diff --git a/netbox/netbox/tests/dummy_plugin/__init__.py b/netbox/netbox/tests/dummy_plugin/__init__.py index 4c2be48ad..606ae7a56 100644 --- a/netbox/netbox/tests/dummy_plugin/__init__.py +++ b/netbox/netbox/tests/dummy_plugin/__init__.py @@ -21,6 +21,9 @@ class DummyPluginConfig(PluginConfig): 'netbox.tests.dummy_plugin.events.process_events_queue' ] + def get_jinja2_context(self): + return {'dummy_plugin_var': 'hello_from_dummy'} + def ready(self): super().ready() diff --git a/netbox/netbox/tests/dummy_plugin/jinja2_env.py b/netbox/netbox/tests/dummy_plugin/jinja2_env.py new file mode 100644 index 000000000..847589f0e --- /dev/null +++ b/netbox/netbox/tests/dummy_plugin/jinja2_env.py @@ -0,0 +1,8 @@ +def dummy_upper(value): + """Test Jinja2 filter: uppercases a string.""" + return str(value).upper() + + +filters = { + 'dummy_upper': dummy_upper, +} diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index 1292d5ad1..aa652c36d 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -238,6 +238,85 @@ class PluginTestCase(TestCase): """ self.assertIn(set_context, registry['webhook_callbacks']) + def test_jinja2_filters_registered(self): + """ + Check that Jinja2 filters exported by the dummy plugin are registered in + registry['plugins']['jinja2_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) + + def test_jinja2_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): + """ + Variables returned by a plugin's get_jinja2_context() must appear in the + context produced by RenderTemplateMixin.get_context(). + """ + from extras.models import ConfigTemplate + ct = ConfigTemplate(name='jinja2-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): + """ + A non-dict return from get_jinja2_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'): + ctx = ct.get_context() + self.assertNotIn('dummy_plugin_var', ctx) + + def test_instance_jinja2_filters_override_plugin_filters(self): + """ + Instance-level JINJA2_FILTERS must take precedence over plugin-registered filters + of the same name. + """ + from utilities.jinja2 import render_jinja2 + override = {'dummy_upper': lambda v: 'overridden'} + with self.settings(JINJA2_FILTERS=override): + result = render_jinja2("{{ 'hello' | dummy_upper }}", {}) + self.assertEqual(result, 'overridden') + + +@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS") +class PluginJinja2RegistrationTest(TestCase): + """ + Tests for the register_jinja2_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 + with self.assertRaises(TypeError): + register_jinja2_filters([('my_filter', lambda v: v)]) + + def test_register_jinja2_filters_rejects_non_callable_value(self): + from netbox.plugins.registration import register_jinja2_filters + with self.assertRaises(TypeError): + register_jinja2_filters({'my_filter': 'not_a_function'}) + + def test_register_jinja2_filters_merges_into_registry(self): + from netbox.plugins.registration import register_jinja2_filters + fn = lambda v: v # noqa: E731 + register_jinja2_filters({'_test_temp_filter': fn}) + try: + self.assertIs(registry['plugins']['jinja2_filters']['_test_temp_filter'], fn) + finally: + del registry['plugins']['jinja2_filters']['_test_temp_filter'] + class PluginNavigationTestCase(TestCase): diff --git a/netbox/utilities/jinja2.py b/netbox/utilities/jinja2.py index 4263c69e2..a7a4ded01 100644 --- a/netbox/utilities/jinja2.py +++ b/netbox/utilities/jinja2.py @@ -7,6 +7,7 @@ from jinja2.meta import find_referenced_templates from jinja2.sandbox import SandboxedEnvironment from netbox.config import get_config +from netbox.registry import registry __all__ = ( 'DEFAULT_JINJA2_FILTERS', @@ -97,9 +98,13 @@ def render_jinja2(template_code, context, environment_params=None, data_file=Non environment = SandboxedEnvironment(**environment_params) - # Register default filters, then apply any user-defined filters. User-defined entries take precedence so that - # existing JINJA2_FILTERS configurations are never overridden. - filters = {**DEFAULT_JINJA2_FILTERS, **get_config().JINJA2_FILTERS} + # Build filter table: default < plugin-registered < instance JINJA2_FILTERS. + # Instance-level config always wins so site admins can override anything. + filters = { + **DEFAULT_JINJA2_FILTERS, + **registry['plugins'].get('jinja2_filters', {}), + **get_config().JINJA2_FILTERS, + } environment.filters.update(filters) if data_file: