00ff00
)')),
}
@@ -325,10 +332,18 @@ class PlatformImportForm(NetBoxModelImportForm):
to_field_name='name',
help_text=_('Limit platform assignments to this manufacturer')
)
+ config_template = CSVModelChoiceField(
+ queryset=ConfigTemplate.objects.all(),
+ to_field_name='name',
+ required=False,
+ help_text=_('Config template')
+ )
class Meta:
model = Platform
- fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags')
+ fields = (
+ 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
+ )
class BaseDeviceImportForm(NetBoxModelImportForm):
@@ -434,12 +449,18 @@ class DeviceImportForm(BaseDeviceImportForm):
required=False,
help_text=_('Airflow direction')
)
+ config_template = CSVModelChoiceField(
+ queryset=ConfigTemplate.objects.all(),
+ to_field_name='name',
+ required=False,
+ help_text=_('Config template')
+ )
class Meta(BaseDeviceImportForm.Meta):
fields = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis',
- 'vc_position', 'vc_priority', 'cluster', 'description', 'comments', 'tags',
+ 'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments', 'tags',
]
def __init__(self, data=None, *args, **kwargs):
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index b5a6cd53b..4ccc2fe54 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -6,6 +6,7 @@ from dcim.choices import *
from dcim.constants import *
from dcim.models import *
from extras.forms import LocalConfigContextFilterForm
+from extras.models import ConfigTemplate
from ipam.models import ASN, L2VPN, VRF
from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
@@ -568,6 +569,11 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
model = DeviceRole
+ config_template_id = DynamicModelMultipleChoiceField(
+ queryset=ConfigTemplate.objects.all(),
+ required=False,
+ label=_('Config template')
+ )
tag = TagFilterField(model)
@@ -578,6 +584,11 @@ class PlatformFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Manufacturer')
)
+ config_template_id = DynamicModelMultipleChoiceField(
+ queryset=ConfigTemplate.objects.all(),
+ required=False,
+ label=_('Config template')
+ )
tag = TagFilterField(model)
@@ -598,7 +609,7 @@ class DeviceFilterForm(
('Components', (
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
)),
- ('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'local_context_data'))
+ ('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data'))
)
region_id = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
@@ -680,6 +691,11 @@ class DeviceFilterForm(
required=False,
label='MAC address'
)
+ config_template_id = DynamicModelMultipleChoiceField(
+ queryset=ConfigTemplate.objects.all(),
+ required=False,
+ label=_('Config template')
+ )
has_primary_ip = forms.NullBooleanField(
required=False,
label='Has a primary IP',
diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
index 8bac5d342..2e7ca0d4b 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/netbox/dcim/forms/model_forms.py
@@ -7,6 +7,7 @@ from timezone_field import TimeZoneFormField
from dcim.choices import *
from dcim.constants import *
from dcim.models import *
+from extras.models import ConfigTemplate
from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
@@ -416,18 +417,22 @@ class ModuleTypeForm(NetBoxModelForm):
class DeviceRoleForm(NetBoxModelForm):
+ config_template = DynamicModelChoiceField(
+ queryset=ConfigTemplate.objects.all(),
+ required=False
+ )
slug = SlugField()
fieldsets = (
('Device Role', (
- 'name', 'slug', 'color', 'vm_role', 'description', 'tags',
+ 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
)),
)
class Meta:
model = DeviceRole
fields = [
- 'name', 'slug', 'color', 'vm_role', 'description', 'tags',
+ 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
]
@@ -436,13 +441,17 @@ class PlatformForm(NetBoxModelForm):
queryset=Manufacturer.objects.all(),
required=False
)
+ config_template = DynamicModelChoiceField(
+ queryset=ConfigTemplate.objects.all(),
+ required=False
+ )
slug = SlugField(
max_length=64
)
fieldsets = (
('Platform', (
- 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
+ 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
)),
)
@@ -450,7 +459,7 @@ class PlatformForm(NetBoxModelForm):
class Meta:
model = Platform
fields = [
- 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags',
+ 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args', 'description', 'tags',
]
widgets = {
'napalm_args': forms.Textarea(),
@@ -565,6 +574,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
label=_('Priority'),
help_text=_("The priority of the device in the virtual chassis")
)
+ config_template = DynamicModelChoiceField(
+ queryset=ConfigTemplate.objects.all(),
+ required=False
+ )
class Meta:
model = Device
@@ -572,7 +585,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6',
'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority',
- 'description', 'comments', 'tags', 'local_context_data'
+ 'description', 'config_template', 'comments', 'tags', 'local_context_data'
]
help_texts = {
'device_role': _("The function this device serves"),
diff --git a/netbox/dcim/migrations/0170_configtemplate.py b/netbox/dcim/migrations/0170_configtemplate.py
new file mode 100644
index 000000000..b1aac0ad2
--- /dev/null
+++ b/netbox/dcim/migrations/0170_configtemplate.py
@@ -0,0 +1,28 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0086_configtemplate'),
+ ('dcim', '0169_devicetype_default_platform'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='device',
+ name='config_template',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='extras.configtemplate'),
+ ),
+ migrations.AddField(
+ model_name='devicerole',
+ name='config_template',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='device_roles', to='extras.configtemplate'),
+ ),
+ migrations.AddField(
+ model_name='platform',
+ name='config_template',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='platforms', to='extras.configtemplate'),
+ ),
+ ]
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index 94f61aba7..7ce1a2388 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -410,6 +410,13 @@ class DeviceRole(OrganizationalModel):
verbose_name='VM Role',
help_text=_('Virtual machines may be assigned to this role')
)
+ config_template = models.ForeignKey(
+ to='extras.ConfigTemplate',
+ on_delete=models.PROTECT,
+ related_name='device_roles',
+ blank=True,
+ null=True
+ )
def get_absolute_url(self):
return reverse('dcim:devicerole', args=[self.pk])
@@ -429,6 +436,13 @@ class Platform(OrganizationalModel):
null=True,
help_text=_('Optionally limit this platform to devices of a certain manufacturer')
)
+ config_template = models.ForeignKey(
+ to='extras.ConfigTemplate',
+ on_delete=models.PROTECT,
+ related_name='platforms',
+ blank=True,
+ null=True
+ )
napalm_driver = models.CharField(
max_length=50,
blank=True,
@@ -590,6 +604,13 @@ class Device(PrimaryModel, ConfigContextModel):
null=True,
validators=[MaxValueValidator(255)]
)
+ config_template = models.ForeignKey(
+ to='extras.ConfigTemplate',
+ on_delete=models.PROTECT,
+ related_name='devices',
+ blank=True,
+ null=True
+ )
# Generic relations
contacts = GenericRelation(
@@ -862,6 +883,17 @@ class Device(PrimaryModel, ConfigContextModel):
def interfaces_count(self):
return self.vc_interfaces().count()
+ def get_config_template(self):
+ """
+ Return the appropriate ConfigTemplate (if any) for this Device.
+ """
+ if self.config_template:
+ return self.config_template
+ if self.device_role.config_template:
+ return self.device_role.config_template
+ if self.platform and self.platform.config_template:
+ return self.platform.config_template
+
def get_vc_master(self):
"""
If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index 904e96b83..f68960965 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -86,6 +86,9 @@ class DeviceRoleTable(NetBoxTable):
)
color = columns.ColorColumn()
vm_role = columns.BooleanColumn()
+ config_template = tables.Column(
+ linkify=True
+ )
tags = columns.TagColumn(
url_name='dcim:devicerole_list'
)
@@ -93,8 +96,8 @@ class DeviceRoleTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = models.DeviceRole
fields = (
- 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags',
- 'actions', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template', 'description',
+ 'slug', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description')
@@ -110,6 +113,9 @@ class PlatformTable(NetBoxTable):
manufacturer = tables.Column(
linkify=True
)
+ config_template = tables.Column(
+ linkify=True
+ )
device_count = columns.LinkedCountColumn(
viewname='dcim:device_list',
url_params={'platform_id': 'pk'},
@@ -127,8 +133,8 @@ class PlatformTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = models.Platform
fields = (
- 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
- 'description', 'tags', 'actions', 'created', 'last_updated',
+ 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'config_template', 'napalm_driver',
+ 'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description',
@@ -203,6 +209,9 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
vc_priority = tables.Column(
verbose_name='VC Priority'
)
+ config_template = tables.Column(
+ linkify=True
+ )
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:device_list'
@@ -214,7 +223,7 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type',
'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face',
'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
- 'vc_priority', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
+ 'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 095314e7b..62359553d 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -1,3 +1,5 @@
+import traceback
+
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger
@@ -10,10 +12,11 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from django.views.generic import View
+from jinja2.exceptions import TemplateError
from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView
-from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup
+from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
from ipam.tables import InterfaceVLANTable
from netbox.views import generic
from utilities.forms import ConfirmationForm
@@ -1997,6 +2000,39 @@ class DeviceInventoryView(DeviceComponentsView):
)
+@register_model_view(Device, 'render-config')
+class DeviceRenderConfigView(generic.ObjectView):
+ queryset = Device.objects.all()
+ template_name = 'dcim/device/render_config.html'
+ tab = ViewTab(
+ label=_('Render Config'),
+ permission='extras.view_configtemplate',
+ weight=2000
+ )
+
+ def get_extra_context(self, request, instance):
+ # Compile context data
+ context_data = {
+ 'device': instance,
+ }
+ context_data.update(**instance.get_config_context())
+
+ # Render the config template
+ rendered_config = None
+ if config_template := instance.get_config_template():
+ try:
+ rendered_config = config_template.render(context=context_data)
+ except TemplateError as e:
+ messages.error(request, f"An error occurred while rendering the template: {e}")
+ rendered_config = traceback.format_exc()
+
+ return {
+ 'config_template': config_template,
+ 'context_data': context_data,
+ 'rendered_config': rendered_config,
+ }
+
+
@register_model_view(Device, 'configcontext', path='config-context')
class DeviceConfigContextView(ObjectConfigContextView):
queryset = Device.objects.annotate_config_context_data()
@@ -2004,7 +2040,7 @@ class DeviceConfigContextView(ObjectConfigContextView):
tab = ViewTab(
label=_('Config Context'),
permission='extras.view_configcontext',
- weight=2000
+ weight=2100
)
diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py
index 5644b0b4e..dab0798fe 100644
--- a/netbox/extras/api/nested_serializers.py
+++ b/netbox/extras/api/nested_serializers.py
@@ -7,6 +7,7 @@ from users.api.nested_serializers import NestedUserSerializer
__all__ = [
'NestedConfigContextSerializer',
+ 'NestedConfigTemplateSerializer',
'NestedCustomFieldSerializer',
'NestedCustomLinkSerializer',
'NestedExportTemplateSerializer',
@@ -51,6 +52,14 @@ class NestedConfigContextSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'display', 'name']
+class NestedConfigTemplateSerializer(WritableNestedSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail')
+
+ class Meta:
+ model = models.ConfigTemplate
+ fields = ['id', 'url', 'display', 'name']
+
+
class NestedExportTemplateSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py
index 6a8248548..5e0a484f8 100644
--- a/netbox/extras/api/serializers.py
+++ b/netbox/extras/api/serializers.py
@@ -16,6 +16,7 @@ from extras.utils import FeatureQuery
from netbox.api.exceptions import SerializerNotFound
from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
+from netbox.api.serializers.features import TaggableModelSerializer
from netbox.constants import NESTED_SERIALIZER_PREFIX
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.models import Tenant, TenantGroup
@@ -29,6 +30,7 @@ from .nested_serializers import *
__all__ = (
'ConfigContextSerializer',
+ 'ConfigTemplateSerializer',
'ContentTypeSerializer',
'CustomFieldSerializer',
'CustomLinkSerializer',
@@ -383,6 +385,27 @@ class ConfigContextSerializer(ValidatedModelSerializer):
]
+#
+# Config templates
+#
+
+class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer):
+ url = serializers.HyperlinkedIdentityField(view_name='extras-api:configtemplate-detail')
+ data_source = NestedDataSourceSerializer(
+ required=False
+ )
+ data_file = NestedDataFileSerializer(
+ read_only=True
+ )
+
+ class Meta:
+ model = ConfigTemplate
+ fields = [
+ 'id', 'url', 'display', 'name', 'description', 'environment_params', 'template_code', 'data_source',
+ 'data_path', 'data_file', 'data_synced', 'tags', 'created', 'last_updated',
+ ]
+
+
#
# Job Results
#
diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py
index 91067d40d..f01cdcd00 100644
--- a/netbox/extras/api/urls.py
+++ b/netbox/extras/api/urls.py
@@ -14,6 +14,7 @@ router.register('tags', views.TagViewSet)
router.register('image-attachments', views.ImageAttachmentViewSet)
router.register('journal-entries', views.JournalEntryViewSet)
router.register('config-contexts', views.ConfigContextViewSet)
+router.register('config-templates', views.ConfigTemplateViewSet)
router.register('reports', views.ReportViewSet, basename='report')
router.register('scripts', views.ScriptViewSet, basename='script')
router.register('object-changes', views.ObjectChangeViewSet)
diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py
index 190b32f53..75f0eb464 100644
--- a/netbox/extras/api/views.py
+++ b/netbox/extras/api/views.py
@@ -5,6 +5,7 @@ from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.permissions import IsAuthenticated
+from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.routers import APIRootView
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
@@ -19,10 +20,12 @@ from extras.scripts import get_script, get_scripts, run_script
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.features import SyncedDataMixin
from netbox.api.metadata import ContentTypeMetadata
+from netbox.api.renderers import TextRenderer
from netbox.api.viewsets import NetBoxModelViewSet
from utilities.exceptions import RQWorkerNotRunningException
from utilities.utils import copy_safe_request, count_related
from . import serializers
+from .nested_serializers import NestedConfigTemplateSerializer
class ExtrasRootView(APIRootView):
@@ -157,6 +160,35 @@ class ConfigContextViewSet(SyncedDataMixin, NetBoxModelViewSet):
filterset_class = filtersets.ConfigContextFilterSet
+#
+# Config templates
+#
+
+class ConfigTemplateViewSet(SyncedDataMixin, NetBoxModelViewSet):
+ queryset = ConfigTemplate.objects.prefetch_related('data_source', 'data_file')
+ serializer_class = serializers.ConfigTemplateSerializer
+ filterset_class = filtersets.ConfigTemplateFilterSet
+
+ @action(detail=True, methods=['post'], renderer_classes=[JSONRenderer, TextRenderer])
+ def render(self, request, pk):
+ """
+ Render a ConfigTemplate using the context data provided (if any). If the client requests "text/plain" data,
+ return the raw rendered content, rather than serialized JSON.
+ """
+ configtemplate = self.get_object()
+ output = configtemplate.render(context=request.data)
+
+ # If the client has requested "text/plain", return the raw content.
+ if request.accepted_renderer.format == 'txt':
+ return Response(output)
+
+ template_serializer = NestedConfigTemplateSerializer(configtemplate, context={'request': request})
+ return Response({
+ 'configtemplate': template_serializer.data,
+ 'content': output
+ })
+
+
#
# Reports
#
diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py
index f7f34e17a..816406647 100644
--- a/netbox/extras/filtersets.py
+++ b/netbox/extras/filtersets.py
@@ -4,18 +4,19 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from django.utils.translation import gettext as _
-from core.models import DataFile, DataSource
+from core.models import DataSource
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from tenancy.models import Tenant, TenantGroup
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
from virtualization.models import Cluster, ClusterGroup, ClusterType
from .choices import *
+from .filters import TagFilter
from .models import *
-
__all__ = (
'ConfigContextFilterSet',
+ 'ConfigTemplateFilterSet',
'ContentTypeFilterSet',
'CustomFieldFilterSet',
'CustomLinkFilterSet',
@@ -454,6 +455,34 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
)
+class ConfigTemplateFilterSet(BaseFilterSet):
+ q = django_filters.CharFilter(
+ method='search',
+ label=_('Search'),
+ )
+ data_source_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=DataSource.objects.all(),
+ label=_('Data source (ID)'),
+ )
+ data_file_id = django_filters.ModelMultipleChoiceFilter(
+ queryset=DataSource.objects.all(),
+ label=_('Data file (ID)'),
+ )
+ tag = TagFilter()
+
+ class Meta:
+ model = ConfigTemplate
+ fields = ['id', 'name', 'description', 'data_synced']
+
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ return queryset.filter(
+ Q(name__icontains=value) |
+ Q(description__icontains=value)
+ )
+
+
#
# Filter for Local Config Context Data
#
diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py
index 47a529772..bba585591 100644
--- a/netbox/extras/forms/bulk_edit.py
+++ b/netbox/extras/forms/bulk_edit.py
@@ -9,6 +9,7 @@ from utilities.forms import (
__all__ = (
'ConfigContextBulkEditForm',
+ 'ConfigTemplateBulkEditForm',
'CustomFieldBulkEditForm',
'CustomLinkBulkEditForm',
'ExportTemplateBulkEditForm',
@@ -201,6 +202,19 @@ class ConfigContextBulkEditForm(BulkEditForm):
nullable_fields = ('description',)
+class ConfigTemplateBulkEditForm(BulkEditForm):
+ pk = forms.ModelMultipleChoiceField(
+ queryset=ConfigTemplate.objects.all(),
+ widget=forms.MultipleHiddenInput
+ )
+ description = forms.CharField(
+ max_length=200,
+ required=False
+ )
+
+ nullable_fields = ('description',)
+
+
class JournalEntryBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=JournalEntry.objects.all(),
diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py
index cf723c4f7..b035c2579 100644
--- a/netbox/extras/forms/bulk_import.py
+++ b/netbox/extras/forms/bulk_import.py
@@ -10,6 +10,7 @@ from extras.utils import FeatureQuery
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
__all__ = (
+ 'ConfigTemplateImportForm',
'CustomFieldImportForm',
'CustomLinkImportForm',
'ExportTemplateImportForm',
@@ -83,6 +84,15 @@ class ExportTemplateImportForm(CSVModelForm):
)
+class ConfigTemplateImportForm(CSVModelForm):
+
+ class Meta:
+ model = ConfigTemplate
+ fields = (
+ 'name', 'description', 'environment_params', 'template_code', 'tags',
+ )
+
+
class SavedFilterImportForm(CSVModelForm):
content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(),
diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py
index 4a92ff606..114eb1a59 100644
--- a/netbox/extras/forms/filtersets.py
+++ b/netbox/extras/forms/filtersets.py
@@ -20,6 +20,7 @@ from .mixins import SavedFiltersMixin
__all__ = (
'ConfigContextFilterForm',
+ 'ConfigTemplateFilterForm',
'CustomFieldFilterForm',
'CustomLinkFilterForm',
'ExportTemplateFilterForm',
@@ -358,6 +359,27 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
)
+class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
+ fieldsets = (
+ (None, ('q', 'filter_id', 'tag')),
+ ('Data', ('data_source_id', 'data_file_id')),
+ )
+ data_source_id = DynamicModelMultipleChoiceField(
+ queryset=DataSource.objects.all(),
+ required=False,
+ label=_('Data source')
+ )
+ data_file_id = DynamicModelMultipleChoiceField(
+ queryset=DataFile.objects.all(),
+ required=False,
+ label=_('Data file'),
+ query_params={
+ 'source_id': '$data_source_id'
+ }
+ )
+ tag = TagFilterField(ConfigTemplate)
+
+
class LocalConfigContextFilterForm(forms.Form):
local_context_data = forms.NullBooleanField(
required=False,
diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py
index 69c124ee2..4ce81c01b 100644
--- a/netbox/extras/forms/model_forms.py
+++ b/netbox/extras/forms/model_forms.py
@@ -18,6 +18,7 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
__all__ = (
'ConfigContextForm',
+ 'ConfigTemplateForm',
'CustomFieldForm',
'CustomLinkForm',
'ExportTemplateForm',
@@ -269,6 +270,34 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
return self.cleaned_data
+class ConfigTemplateForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
+ tags = DynamicModelMultipleChoiceField(
+ queryset=Tag.objects.all(),
+ required=False
+ )
+ template_code = forms.CharField(
+ required=False,
+ widget=forms.Textarea(attrs={'class': 'font-monospace'})
+ )
+
+ fieldsets = (
+ ('Config Template', ('name', 'description', 'environment_params', 'tags')),
+ ('Content', ('data_source', 'data_file', 'template_code',)),
+ )
+
+ class Meta:
+ model = ConfigTemplate
+ fields = '__all__'
+
+ def clean(self):
+ super().clean()
+
+ if not self.cleaned_data.get('template_code') and not self.cleaned_data.get('data_file'):
+ raise forms.ValidationError("Must specify either local content or a data file")
+
+ return self.cleaned_data
+
+
class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
class Meta:
diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py
index 0c3113879..3e116023f 100644
--- a/netbox/extras/graphql/schema.py
+++ b/netbox/extras/graphql/schema.py
@@ -8,6 +8,9 @@ class ExtrasQuery(graphene.ObjectType):
config_context = ObjectField(ConfigContextType)
config_context_list = ObjectListField(ConfigContextType)
+ config_template = ObjectField(ConfigTemplateType)
+ config_template_list = ObjectListField(ConfigTemplateType)
+
custom_field = ObjectField(CustomFieldType)
custom_field_list = ObjectListField(CustomFieldType)
diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py
index b5d4dffce..ba16ccd3e 100644
--- a/netbox/extras/graphql/types.py
+++ b/netbox/extras/graphql/types.py
@@ -4,6 +4,7 @@ from netbox.graphql.types import BaseObjectType, ObjectType
__all__ = (
'ConfigContextType',
+ 'ConfigTemplateType',
'CustomFieldType',
'CustomLinkType',
'ExportTemplateType',
@@ -24,6 +25,14 @@ class ConfigContextType(ObjectType):
filterset_class = filtersets.ConfigContextFilterSet
+class ConfigTemplateType(ObjectType):
+
+ class Meta:
+ model = models.ConfigTemplate
+ fields = '__all__'
+ filterset_class = filtersets.ConfigTemplateFilterSet
+
+
class CustomFieldType(ObjectType):
class Meta:
diff --git a/netbox/extras/migrations/0086_configtemplate.py b/netbox/extras/migrations/0086_configtemplate.py
new file mode 100644
index 000000000..bd47254e9
--- /dev/null
+++ b/netbox/extras/migrations/0086_configtemplate.py
@@ -0,0 +1,34 @@
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0001_initial'),
+ ('extras', '0085_synced_data'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ConfigTemplate',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('created', models.DateTimeField(auto_now_add=True, null=True)),
+ ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+ ('data_path', models.CharField(blank=True, editable=False, max_length=1000)),
+ ('data_synced', models.DateTimeField(blank=True, editable=False, null=True)),
+ ('name', models.CharField(max_length=100)),
+ ('description', models.CharField(blank=True, max_length=200)),
+ ('template_code', models.TextField()),
+ ('environment_params', models.JSONField(blank=True, null=True)),
+ ('data_file', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='core.datafile')),
+ ('data_source', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='core.datasource')),
+ ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+ ],
+ options={
+ 'ordering': ('name',),
+ },
+ ),
+ ]
diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py
index 9b5c660c4..33936cc4f 100644
--- a/netbox/extras/models/__init__.py
+++ b/netbox/extras/models/__init__.py
@@ -1,5 +1,5 @@
from .change_logging import ObjectChange
-from .configcontexts import ConfigContext, ConfigContextModel
+from .configs import *
from .customfields import CustomField
from .models import *
from .search import *
@@ -12,6 +12,7 @@ __all__ = (
'ConfigContext',
'ConfigContextModel',
'ConfigRevision',
+ 'ConfigTemplate',
'CustomField',
'CustomLink',
'ExportTemplate',
diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configs.py
similarity index 68%
rename from netbox/extras/models/configcontexts.py
rename to netbox/extras/models/configs.py
index eed8babcd..f2b50f161 100644
--- a/netbox/extras/models/configcontexts.py
+++ b/netbox/extras/models/configs.py
@@ -3,15 +3,21 @@ from django.core.validators import ValidationError
from django.db import models
from django.urls import reverse
from django.utils import timezone
+from django.utils.translation import gettext as _
+from jinja2.loaders import BaseLoader
+from jinja2.sandbox import SandboxedEnvironment
from extras.querysets import ConfigContextQuerySet
+from netbox.config import get_config
from netbox.models import ChangeLoggedModel
-from netbox.models.features import SyncedDataMixin
+from netbox.models.features import ExportTemplatesMixin, SyncedDataMixin, TagsMixin
+from utilities.jinja2 import ConfigTemplateLoader
from utilities.utils import deepmerge
__all__ = (
'ConfigContext',
'ConfigContextModel',
+ 'ConfigTemplate',
)
@@ -182,3 +188,77 @@ class ConfigContextModel(models.Model):
raise ValidationError(
{'local_context_data': 'JSON data must be in object form. Example: {"foo": 123}'}
)
+
+
+#
+# Config templates
+#
+
+class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
+ name = models.CharField(
+ max_length=100
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
+ template_code = models.TextField(
+ help_text=_('Jinja2 template code.')
+ )
+ environment_params = models.JSONField(
+ blank=True,
+ null=True
+ )
+
+ class Meta:
+ ordering = ('name',)
+
+ def __str__(self):
+ return self.name
+
+ def get_absolute_url(self):
+ return reverse('extras:configtemplate', args=[self.pk])
+
+ def sync_data(self):
+ """
+ Synchronize template content from the designated DataFile (if any).
+ """
+ self.template_code = self.data_file.data_as_string
+ self.data_synced = timezone.now()
+
+ def render(self, context=None):
+ """
+ Render the contents of the template.
+ """
+ context = context or {}
+
+ # Initialize the Jinja2 environment and instantiate the Template
+ environment = self._get_environment()
+ if self.data_file:
+ template = environment.get_template(self.data_file.path)
+ else:
+ template = environment.from_string(self.template_code)
+
+ output = template.render(**context)
+
+ # Replace CRLF-style line terminators
+ return output.replace('\r\n', '\n')
+
+ def _get_environment(self):
+ """
+ Instantiate and return a Jinja2 environment suitable for rendering the ConfigTemplate.
+ """
+ # Initialize the template loader & cache the base template code (if applicable)
+ if self.data_file:
+ loader = ConfigTemplateLoader(data_source=self.data_source)
+ loader.cache_templates({
+ self.data_file.path: self.template_code
+ })
+ else:
+ loader = BaseLoader()
+
+ # Initialize the environment
+ environment = SandboxedEnvironment(loader=loader)
+ environment.filters.update(get_config().JINJA2_FILTERS)
+
+ return environment
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index 6b2f34de4..5991203f2 100644
--- a/netbox/extras/tables/tables.py
+++ b/netbox/extras/tables/tables.py
@@ -8,6 +8,7 @@ from .template_code import *
__all__ = (
'ConfigContextTable',
+ 'ConfigTemplateTable',
'CustomFieldTable',
'CustomLinkTable',
'ExportTemplateTable',
@@ -223,6 +224,34 @@ class ConfigContextTable(NetBoxTable):
default_columns = ('pk', 'name', 'weight', 'is_active', 'is_synced', 'description')
+class ConfigTemplateTable(NetBoxTable):
+ name = tables.Column(
+ linkify=True
+ )
+ data_source = tables.Column(
+ linkify=True
+ )
+ data_file = tables.Column(
+ linkify=True
+ )
+ is_synced = columns.BooleanColumn(
+ verbose_name='Synced'
+ )
+ tags = columns.TagColumn(
+ url_name='extras:configtemplate_list'
+ )
+
+ class Meta(NetBoxTable.Meta):
+ model = ConfigTemplate
+ fields = (
+ 'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
+ 'tags',
+ )
+ default_columns = (
+ 'pk', 'name', 'description', 'is_synced',
+ )
+
+
class ObjectChangeTable(NetBoxTable):
time = tables.DateTimeColumn(
linkify=True,
diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py
index dabb9f977..dfbaa1bc6 100644
--- a/netbox/extras/urls.py
+++ b/netbox/extras/urls.py
@@ -64,6 +64,14 @@ urlpatterns = [
path('config-contexts/sync/', views.ConfigContextBulkSyncDataView.as_view(), name='configcontext_bulk_sync'),
path('config-contexts/Asset Tag | {{ object.asset_tag|placeholder }} |
---|---|
Config Template | +{{ object.config_template|linkify|placeholder }} | +
Config Template | +{{ config_template|linkify|placeholder }} | +
---|---|
Data Source | +{{ config_template.data_file.source|linkify|placeholder }} | +
Data File | +{{ config_template.data_file|linkify|placeholder }} | +
{{ context_data|pprint }}+
{{ rendered_config }}+ {% else %} +
Name | +{{ object.name }} | +
---|---|
Description | +{{ object.description|placeholder }} | +
Data Source | ++ {% if object.data_source %} + {{ object.data_source }} + {% else %} + {{ ''|placeholder }} + {% endif %} + | +
Data File | +
+ {% if object.data_file %}
+ {{ object.data_file }}
+ {% elif object.data_path %}
+
+
+
+ {{ object.data_path }}
+ {% else %}
+ {{ ''|placeholder }}
+ {% endif %}
+ |
+
Data Synced | +{{ object.data_synced|placeholder }} | +
{{ object.environment_params }}+
{{ object.template_code }}+