diff --git a/docs/additional-features/graphs.md b/docs/additional-features/graphs.md index b20a6b4248..264b7f1b77 100644 --- a/docs/additional-features/graphs.md +++ b/docs/additional-features/graphs.md @@ -8,6 +8,11 @@ NetBox does not have the ability to generate graphs natively, but this feature a * **Source URL:** The source of the image to be embedded. The associated object will be available as a template variable named `obj`. * **Link URL (optional):** A URL to which the graph will be linked. The associated object will be available as a template variable named `obj`. +Graph names and links can be rendered using the Django or Jinja2 template languages. + +!!! warning + Support for the Django templating language will be removed in NetBox v2.8. Jinja2 is recommended. + ## Examples You only need to define one graph object for each graph you want to include when viewing an object. For example, if you want to include a graph of traffic through an interface over the past five minutes, your graph source might looks like this: diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 356b91b606..3d7505f2df 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -227,6 +227,7 @@ PATCH) to maintain backward compatibility. This behavior will be discontinued be * [#2669](https://github.com/digitalocean/netbox/issues/2669) - Relax uniqueness constraint on device and VM names * [#2902](https://github.com/digitalocean/netbox/issues/2902) - Replace `supervisord` with `systemd` * [#3455](https://github.com/digitalocean/netbox/issues/3455) - Add tenant assignment to cluster +* [#3520](https://github.com/digitalocean/netbox/issues/3520) - Add Jinja2 template support for Graphs * [#3564](https://github.com/digitalocean/netbox/issues/3564) - Add list views for device components * [#3538](https://github.com/digitalocean/netbox/issues/3538) - Introduce a REST API endpoint for executing custom scripts @@ -256,6 +257,7 @@ PATCH) to maintain backward compatibility. This behavior will be discontinued be * dcim.PowerOutlet: Added field `type` * dcim.PowerOutletTemplate: Added field `type` * dcim.RackRole: Added field `description` +* extras.Graph: Added field `template_language` (to indicate `django` or `jinja2`) * extras.Graph: The `type` field has been changed to a content type foreign key. Models are specified as `.`; e.g. `dcim.site`. * ipam.Role: Added field `description` diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 61bedd1892..2a39c207e3 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -131,10 +131,10 @@ class CustomLinkAdmin(admin.ModelAdmin): @admin.register(Graph, site=admin_site) class GraphAdmin(admin.ModelAdmin): list_display = [ - 'name', 'type', 'weight', 'source', + 'name', 'type', 'weight', 'template_language', 'source', ] list_filter = [ - 'type', + 'type', 'template_language', ] diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index f167dee5ce..7dd745513f 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -34,7 +34,7 @@ class GraphSerializer(ValidatedModelSerializer): class Meta: model = Graph - fields = ['id', 'type', 'weight', 'name', 'source', 'link'] + fields = ['id', 'type', 'weight', 'name', 'template_language', 'source', 'link'] class RenderedGraphSerializer(serializers.ModelSerializer): diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 1a90b317d7..aa1917f395 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -26,7 +26,7 @@ class ExtrasFieldChoicesViewSet(FieldChoicesViewSet): fields = ( (ExportTemplate, ['template_language']), - (Graph, ['type']), + (Graph, ['type', 'template_language']), (ObjectChange, ['action']), ) diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 792f8cee81..8a0d32b331 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -92,7 +92,7 @@ class GraphFilterSet(django_filters.FilterSet): class Meta: model = Graph - fields = ['type', 'name'] + fields = ['type', 'name', 'template_language'] class ExportTemplateFilterSet(django_filters.FilterSet): diff --git a/netbox/extras/migrations/0033_graph_type_to_fk.py b/netbox/extras/migrations/0033_graph_type_template_language.py similarity index 73% rename from netbox/extras/migrations/0033_graph_type_to_fk.py rename to netbox/extras/migrations/0033_graph_type_template_language.py index 6a0ee720a0..f5e4034cc8 100644 --- a/netbox/extras/migrations/0033_graph_type_to_fk.py +++ b/netbox/extras/migrations/0033_graph_type_template_language.py @@ -43,4 +43,17 @@ class Migration(migrations.Migration): to='contenttypes.ContentType' ), ), + + # Add the template_language field with an initial default of Django to preserve current behavior. Then, + # alter the field to set the default for any *new* Graphs to Jinja2. + migrations.AddField( + model_name='graph', + name='template_language', + field=models.CharField(default='django', max_length=50), + ), + migrations.AlterField( + model_name='graph', + name='template_language', + field=models.CharField(default='jinja2', max_length=50), + ), ] diff --git a/netbox/extras/migrations/0034_configcontext_tags.py b/netbox/extras/migrations/0034_configcontext_tags.py index 363572535d..e5076f43ce 100644 --- a/netbox/extras/migrations/0034_configcontext_tags.py +++ b/netbox/extras/migrations/0034_configcontext_tags.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ - ('extras', '0033_graph_type_to_fk'), + ('extras', '0033_graph_type_template_language'), ] operations = [ diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 6e1693dd4c..78ef0129a5 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -421,6 +421,11 @@ class Graph(models.Model): max_length=100, verbose_name='Name' ) + template_language = models.CharField( + max_length=50, + choices=ExportTemplateLanguageChoices, + default=ExportTemplateLanguageChoices.LANGUAGE_JINJA2 + ) source = models.CharField( max_length=500, verbose_name='Source URL' @@ -437,14 +442,29 @@ def __str__(self): return self.name def embed_url(self, obj): - template = Template(self.source) - return template.render(Context({'obj': obj})) + context = {'obj': obj} + + # TODO: Remove in v2.8 + if self.template_language == ExportTemplateLanguageChoices.LANGUAGE_DJANGO: + template = Template(self.source) + return template.render(Context(context)) + + elif self.template_language == ExportTemplateLanguageChoices.LANGUAGE_JINJA2: + return render_jinja2(self.source, context) def embed_link(self, obj): if self.link is None: return '' - template = Template(self.link) - return template.render(Context({'obj': obj})) + + context = {'obj': obj} + + # TODO: Remove in v2.8 + if self.template_language == ExportTemplateLanguageChoices.LANGUAGE_DJANGO: + template = Template(self.link) + return template.render(Context(context)) + + elif self.template_language == ExportTemplateLanguageChoices.LANGUAGE_JINJA2: + return render_jinja2(self.link, context) # diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index f1f5f0d882..b8637988d8 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -18,9 +18,9 @@ def setUpTestData(cls): content_types = ContentType.objects.filter(model__in=['site', 'device', 'interface']) graphs = ( - Graph(name='Graph 1', type=content_types[0], source='http://example.com/1'), - Graph(name='Graph 2', type=content_types[1], source='http://example.com/2'), - Graph(name='Graph 3', type=content_types[2], source='http://example.com/3'), + Graph(name='Graph 1', type=content_types[0], template_language=ExportTemplateLanguageChoices.LANGUAGE_DJANGO, source='http://example.com/1'), + Graph(name='Graph 2', type=content_types[1], template_language=ExportTemplateLanguageChoices.LANGUAGE_JINJA2, source='http://example.com/2'), + Graph(name='Graph 3', type=content_types[2], template_language=ExportTemplateLanguageChoices.LANGUAGE_JINJA2, source='http://example.com/3'), ) Graph.objects.bulk_create(graphs) @@ -32,6 +32,11 @@ def test_type(self): params = {'type': ContentType.objects.get(model='site').pk} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + # TODO: Remove in v2.8 + def test_template_language(self): + params = {'template_language': ExportTemplateLanguageChoices.LANGUAGE_JINJA2} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class ExportTemplateTestCase(TestCase): queryset = ExportTemplate.objects.all() diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py new file mode 100644 index 0000000000..6a74ef85f6 --- /dev/null +++ b/netbox/extras/tests/test_models.py @@ -0,0 +1,46 @@ +from django.contrib.contenttypes.models import ContentType +from django.test import TestCase + +from dcim.models import Site +from extras.choices import ExportTemplateLanguageChoices +from extras.models import Graph + + +class GraphTest(TestCase): + + def setUp(self): + + self.site = Site(name='Site 1', slug='site-1') + + def test_graph_render_django(self): + + # Using the pluralize filter as a sanity check (it's only available in Django) + TEMPLATE_TEXT = "{{ obj.name|lower }} thing{{ 2|pluralize }}" + RENDERED_TEXT = "site 1 things" + + graph = Graph( + type=ContentType.objects.get(app_label='dcim', model='site'), + name='Graph 1', + template_language=ExportTemplateLanguageChoices.LANGUAGE_DJANGO, + source=TEMPLATE_TEXT, + link=TEMPLATE_TEXT + ) + + self.assertEqual(graph.embed_url(self.site), RENDERED_TEXT) + self.assertEqual(graph.embed_link(self.site), RENDERED_TEXT) + + def test_graph_render_jinja2(self): + + TEMPLATE_TEXT = "{{ [obj.name, obj.slug]|join(',') }}" + RENDERED_TEXT = "Site 1,site-1" + + graph = Graph( + type=ContentType.objects.get(app_label='dcim', model='site'), + name='Graph 1', + template_language=ExportTemplateLanguageChoices.LANGUAGE_JINJA2, + source=TEMPLATE_TEXT, + link=TEMPLATE_TEXT + ) + + self.assertEqual(graph.embed_url(self.site), RENDERED_TEXT) + self.assertEqual(graph.embed_link(self.site), RENDERED_TEXT)