Kaynağa Gözat

Closes #22351: Add jinja2_filters plugin hook and get_jinja2_context() for config template extensibility (#22363)

bctiemann 1 ay önce
ebeveyn
işleme
5b6d7887f2

+ 3 - 0
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

+ 115 - 0
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.

+ 1 - 0
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`)                                           |

+ 1 - 0
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'

+ 15 - 0
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)
 

+ 20 - 0
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)

+ 24 - 0
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

+ 3 - 0
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()
 

+ 8 - 0
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,
+}

+ 79 - 0
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):
 

+ 8 - 3
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: