mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 01:41:22 -06:00
* WIP * Add config_template field to Device * Pre-fetch referenced templates * Correct up_to_date callable * Add config_template FK to Device * Update & merge migrations * Add config_template FK to Platform * Add tagging support for ConfigTemplate * Catch exceptions when rendering device templates in UI * Refactor ConfigTemplate.render() * Add support for returning plain text content * Add ConfigTemplate model documentation * Add feature documentation for config rendering
This commit is contained in:
parent
db4e00d394
commit
73a7a2d27a
38
docs/features/configuration-rendering.md
Normal file
38
docs/features/configuration-rendering.md
Normal file
@ -0,0 +1,38 @@
|
||||
# Configuration Rendering
|
||||
|
||||
One of the critical aspects of operating a network is ensuring that every network node is configured correctly. By leveraging configuration templates and [context data](./context-data.md), NetBox can render complete configuration files for each device on your network.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
ConfigContext & ConfigTemplate --> Config{{Rendered configuration}}
|
||||
|
||||
click ConfigContext "../../models/extras/configcontext/"
|
||||
click ConfigTemplate "../../models/extras/configtemplate/"
|
||||
```
|
||||
|
||||
## Configuration Templates
|
||||
|
||||
Configuration templates are written in the [Jinja2 templating language](https://jinja.palletsprojects.com/), and may be automatically populated from remote data sources. Context data is applied to a template during rendering to output a complete configuration file. Below is an example template.
|
||||
|
||||
```jinja2
|
||||
{% extends 'base.j2' %}
|
||||
|
||||
{% block content %}
|
||||
system {
|
||||
host-name {{ device.name }};
|
||||
domain-name example.com;
|
||||
time-zone UTC;
|
||||
authentication-order [ password radius ];
|
||||
ntp {
|
||||
{% for server in ntp_servers %}
|
||||
server {{ server }};
|
||||
{% endfor %}
|
||||
}
|
||||
}
|
||||
{% for interface in device.interfaces.all() %}
|
||||
{% include 'common/interface.j2' %}
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
```
|
||||
|
||||
When rendered for a specific NetBox device, the template's `device` variable will be populated with the device instance, and `ntp_servers` will be pulled from the device's available context data. The resulting output will be a valid configuration segment that can be applied directly to a compatible network device.
|
@ -11,6 +11,8 @@ Configuration context data (or "config contexts" for short) is a powerful featur
|
||||
}
|
||||
```
|
||||
|
||||
Context data can be consumed by remote API clients, or it can be employed natively to render [configuration templates](./configuration-rendering.md).
|
||||
|
||||
Config contexts can be computed for objects based on the following criteria:
|
||||
|
||||
| Type | Devices | Virtual Machines |
|
||||
|
@ -72,6 +72,10 @@ The device's operational status.
|
||||
|
||||
A device may be associated with a particular [platform](./platform.md) to indicate its operating system. Note that only platforms assigned to the associated manufacturer (or to no manufacturer) will be available for selection.
|
||||
|
||||
### Configuration Template
|
||||
|
||||
The [configuration template](../extras/configtemplate.md) from which the configuration for this device can be rendered. If set, this will override any config template referenced by the device's role or platform.
|
||||
|
||||
### Primary IPv4 & IPv6 Addresses
|
||||
|
||||
Each device may designate one primary IPv4 address and/or one primary IPv6 address for management purposes.
|
||||
|
@ -19,3 +19,7 @@ The color used when displaying the role in the NetBox UI.
|
||||
### VM Role
|
||||
|
||||
If selected, this role may be assigned to [virtual machines](../virtualization/virtualmachine.md)
|
||||
|
||||
### Configuration Template
|
||||
|
||||
The default [configuration template](../extras/configtemplate.md) for devices assigned to this role.
|
||||
|
@ -22,6 +22,10 @@ A unique URL-friendly identifier. (This value can be used for filtering.)
|
||||
|
||||
If designated, this platform will be available for use only to devices assigned to this [manufacturer](./manufacturer.md). This can be handy e.g. for limiting network operating systems to use on hardware produced by the relevant vendor. However, it should not be used when defining general-purpose software platforms.
|
||||
|
||||
### Configuration Template
|
||||
|
||||
The default [configuration template](../extras/configtemplate.md) for devices assigned to this platform.
|
||||
|
||||
### NAPALM Driver
|
||||
|
||||
The [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html) associated with this platform.
|
||||
|
29
docs/models/extras/configtemplate.md
Normal file
29
docs/models/extras/configtemplate.md
Normal file
@ -0,0 +1,29 @@
|
||||
# Configuration Templates
|
||||
|
||||
Configuration templates can be used to render [devices](../dcim/device.md) configurations from [context data](../../features/context-data.md). Templates are written in the [Jinja2 language](https://jinja.palletsprojects.com/) and can be associated with devices roles, platforms, and/or individual devices.
|
||||
|
||||
Context data is made available to [devices](../dcim/device.md) and/or [virtual machines](../virtualization/virtualmachine.md) based on their relationships to other objects in NetBox. For example, context data can be associated only with devices assigned to a particular site, or only to virtual machines in a certain cluster.
|
||||
|
||||
See the [configuration rendering documentation](../../features/configuration-rendering.md) for more information.
|
||||
|
||||
## Fields
|
||||
|
||||
### Name
|
||||
|
||||
A unique human-friendly name.
|
||||
|
||||
### Weight
|
||||
|
||||
A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight.
|
||||
|
||||
### Data File
|
||||
|
||||
Template code may optionally be sourced from a remote [data file](../core/datafile.md), which is synchronized from a remote data source. When designating a data file, there is no need to specify template code: It will be populated automatically from the data file.
|
||||
|
||||
### Template Code
|
||||
|
||||
Jinja2 template code, if being defined locally rather than replicated from a data file.
|
||||
|
||||
### Environment Parameters
|
||||
|
||||
A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior.
|
@ -74,6 +74,7 @@ nav:
|
||||
- Contacts: 'features/contacts.md'
|
||||
- Search: 'features/search.md'
|
||||
- Context Data: 'features/context-data.md'
|
||||
- Configuration Rendering: 'features/configuration-rendering.md'
|
||||
- Change Logging: 'features/change-logging.md'
|
||||
- Journaling: 'features/journaling.md'
|
||||
- Auth & Permissions: 'features/authentication-permissions.md'
|
||||
@ -196,6 +197,7 @@ nav:
|
||||
- Extras:
|
||||
- Branch: 'models/extras/branch.md'
|
||||
- ConfigContext: 'models/extras/configcontext.md'
|
||||
- ConfigTemplate: 'models/extras/configtemplate.md'
|
||||
- CustomField: 'models/extras/customfield.md'
|
||||
- CustomLink: 'models/extras/customlink.md'
|
||||
- ExportTemplate: 'models/extras/exporttemplate.md'
|
||||
|
@ -9,6 +9,7 @@ from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from extras.api.nested_serializers import NestedConfigTemplateSerializer
|
||||
from ipam.api.nested_serializers import (
|
||||
NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer,
|
||||
NestedVRFSerializer,
|
||||
@ -605,8 +606,8 @@ class DeviceRoleSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'tags', 'custom_fields',
|
||||
'created', 'last_updated', 'device_count', 'virtualmachine_count',
|
||||
'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
|
||||
]
|
||||
|
||||
|
||||
@ -619,8 +620,8 @@ class PlatformSerializer(NetBoxModelSerializer):
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description',
|
||||
'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
|
||||
'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'config_template', 'napalm_driver', 'napalm_args',
|
||||
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count',
|
||||
]
|
||||
|
||||
|
||||
@ -651,6 +652,7 @@ class DeviceSerializer(NetBoxModelSerializer):
|
||||
cluster = NestedClusterSerializer(required=False, allow_null=True)
|
||||
virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True, default=None)
|
||||
vc_position = serializers.IntegerField(allow_null=True, max_value=255, min_value=0, default=None)
|
||||
config_template = NestedConfigTemplateSerializer(required=False, allow_null=True, default=None)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
@ -658,7 +660,7 @@ class DeviceSerializer(NetBoxModelSerializer):
|
||||
'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip',
|
||||
'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description',
|
||||
'comments', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=NestedDeviceSerializer)
|
||||
|
@ -366,7 +366,7 @@ class InventoryItemTemplateViewSet(NetBoxModelViewSet):
|
||||
#
|
||||
|
||||
class DeviceRoleViewSet(NetBoxModelViewSet):
|
||||
queryset = DeviceRole.objects.prefetch_related('tags').annotate(
|
||||
queryset = DeviceRole.objects.prefetch_related('config_template', 'tags').annotate(
|
||||
device_count=count_related(Device, 'device_role'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'role')
|
||||
)
|
||||
@ -379,7 +379,7 @@ class DeviceRoleViewSet(NetBoxModelViewSet):
|
||||
#
|
||||
|
||||
class PlatformViewSet(NetBoxModelViewSet):
|
||||
queryset = Platform.objects.prefetch_related('tags').annotate(
|
||||
queryset = Platform.objects.prefetch_related('config_template', 'tags').annotate(
|
||||
device_count=count_related(Device, 'platform'),
|
||||
virtualmachine_count=count_related(VirtualMachine, 'platform')
|
||||
)
|
||||
|
@ -3,6 +3,7 @@ from django.contrib.auth.models import User
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.filtersets import LocalConfigContextFilterSet
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.models import ASN, L2VPN, IPAddress, VRF
|
||||
from netbox.filtersets import (
|
||||
BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet,
|
||||
@ -776,6 +777,10 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo
|
||||
|
||||
|
||||
class DeviceRoleFilterSet(OrganizationalModelFilterSet):
|
||||
config_template_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
label=_('Config template (ID)'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
@ -794,6 +799,10 @@ class PlatformFilterSet(OrganizationalModelFilterSet):
|
||||
to_field_name='slug',
|
||||
label=_('Manufacturer (slug)'),
|
||||
)
|
||||
config_template_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
label=_('Config template (ID)'),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
@ -936,6 +945,10 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
method='_virtual_chassis_member',
|
||||
label=_('Is a virtual chassis member')
|
||||
)
|
||||
config_template_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
label=_('Config template (ID)'),
|
||||
)
|
||||
console_ports = django_filters.BooleanFilter(
|
||||
method='_console_ports',
|
||||
label=_('Has console ports'),
|
||||
|
@ -6,6 +6,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, VLAN, VLANGroup, VRF
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
@ -454,6 +455,10 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
widget=BulkEditNullBooleanSelect,
|
||||
label=_('VM role')
|
||||
)
|
||||
config_template = DynamicModelChoiceField(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
required=False
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=200,
|
||||
required=False
|
||||
@ -461,9 +466,9 @@ class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = DeviceRole
|
||||
fieldsets = (
|
||||
(None, ('color', 'vm_role', 'description')),
|
||||
(None, ('color', 'vm_role', 'config_template', 'description')),
|
||||
)
|
||||
nullable_fields = ('color', 'description')
|
||||
nullable_fields = ('color', 'config_template', 'description')
|
||||
|
||||
|
||||
class PlatformBulkEditForm(NetBoxModelBulkEditForm):
|
||||
@ -475,7 +480,10 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
|
||||
max_length=50,
|
||||
required=False
|
||||
)
|
||||
# TODO: Bulk edit support for napalm_args
|
||||
config_template = DynamicModelChoiceField(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
required=False
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=200,
|
||||
required=False
|
||||
@ -483,9 +491,9 @@ class PlatformBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = Platform
|
||||
fieldsets = (
|
||||
(None, ('manufacturer', 'napalm_driver', 'description')),
|
||||
(None, ('manufacturer', 'config_template', 'napalm_driver', 'description')),
|
||||
)
|
||||
nullable_fields = ('manufacturer', 'napalm_driver', 'description')
|
||||
nullable_fields = ('manufacturer', 'config_template', 'napalm_driver', 'description')
|
||||
|
||||
|
||||
class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
@ -540,6 +548,10 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
max_length=200,
|
||||
required=False
|
||||
)
|
||||
config_template = DynamicModelChoiceField(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
@ -550,6 +562,7 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
('Device', ('device_role', 'status', 'tenant', 'platform', 'description')),
|
||||
('Location', ('site', 'location')),
|
||||
('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')),
|
||||
('Configuration', ('config_template',)),
|
||||
)
|
||||
nullable_fields = (
|
||||
'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments',
|
||||
|
@ -8,6 +8,7 @@ from django.utils.translation import gettext as _
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from extras.models import ConfigTemplate
|
||||
from ipam.models import VRF
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from tenancy.models import Tenant
|
||||
@ -307,11 +308,17 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
|
||||
|
||||
|
||||
class DeviceRoleImportForm(NetBoxModelImportForm):
|
||||
config_template = CSVModelChoiceField(
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False,
|
||||
help_text=_('Config template')
|
||||
)
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
fields = ('name', 'slug', 'color', 'vm_role', 'description', 'tags')
|
||||
fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
}
|
||||
@ -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):
|
||||
|
@ -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',
|
||||
|
@ -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"),
|
||||
|
28
netbox/dcim/migrations/0170_configtemplate.py
Normal file
28
netbox/dcim/migrations/0170_configtemplate.py
Normal file
@ -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'),
|
||||
),
|
||||
]
|
@ -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.
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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(),
|
||||
|
@ -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(),
|
||||
|
@ -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,
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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:
|
||||
|
34
netbox/extras/migrations/0086_configtemplate.py
Normal file
34
netbox/extras/migrations/0086_configtemplate.py
Normal file
@ -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',),
|
||||
},
|
||||
),
|
||||
]
|
@ -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',
|
||||
|
@ -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
|
@ -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,
|
||||
|
@ -64,6 +64,14 @@ urlpatterns = [
|
||||
path('config-contexts/sync/', views.ConfigContextBulkSyncDataView.as_view(), name='configcontext_bulk_sync'),
|
||||
path('config-contexts/<int:pk>/', include(get_model_urls('extras', 'configcontext'))),
|
||||
|
||||
# Config templates
|
||||
path('config-templates/', views.ConfigTemplateListView.as_view(), name='configtemplate_list'),
|
||||
path('config-templates/add/', views.ConfigTemplateEditView.as_view(), name='configtemplate_add'),
|
||||
path('config-templates/edit/', views.ConfigTemplateBulkEditView.as_view(), name='configtemplate_bulk_edit'),
|
||||
path('config-templates/delete/', views.ConfigTemplateBulkDeleteView.as_view(), name='configtemplate_bulk_delete'),
|
||||
path('config-templates/sync/', views.ConfigTemplateBulkSyncDataView.as_view(), name='configtemplate_bulk_sync'),
|
||||
path('config-templates/<int:pk>/', include(get_model_urls('extras', 'configtemplate'))),
|
||||
|
||||
# Image attachments
|
||||
path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'),
|
||||
path('image-attachments/<int:pk>/', include(get_model_urls('extras', 'imageattachment'))),
|
||||
|
@ -452,6 +452,58 @@ class ObjectConfigContextView(generic.ObjectView):
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Config templates
|
||||
#
|
||||
|
||||
class ConfigTemplateListView(generic.ObjectListView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
filterset = filtersets.ConfigTemplateFilterSet
|
||||
filterset_form = forms.ConfigTemplateFilterForm
|
||||
table = tables.ConfigTemplateTable
|
||||
template_name = 'extras/configtemplate_list.html'
|
||||
actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete', 'bulk_sync')
|
||||
|
||||
|
||||
@register_model_view(ConfigTemplate)
|
||||
class ConfigTemplateView(generic.ObjectView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
|
||||
|
||||
@register_model_view(ConfigTemplate, 'edit')
|
||||
class ConfigTemplateEditView(generic.ObjectEditView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
form = forms.ConfigTemplateForm
|
||||
|
||||
|
||||
@register_model_view(ConfigTemplate, 'delete')
|
||||
class ConfigTemplateDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
|
||||
|
||||
class ConfigTemplateBulkImportView(generic.BulkImportView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
model_form = forms.ConfigTemplateImportForm
|
||||
table = tables.ConfigTemplateTable
|
||||
|
||||
|
||||
class ConfigTemplateBulkEditView(generic.BulkEditView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
filterset = filtersets.ConfigTemplateFilterSet
|
||||
table = tables.ConfigTemplateTable
|
||||
form = forms.ConfigTemplateBulkEditForm
|
||||
|
||||
|
||||
class ConfigTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
filterset = filtersets.ConfigTemplateFilterSet
|
||||
table = tables.ConfigTemplateTable
|
||||
|
||||
|
||||
class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# Change logging
|
||||
#
|
||||
|
@ -1,4 +1,9 @@
|
||||
from rest_framework.renderers import BrowsableAPIRenderer
|
||||
from rest_framework.renderers import BaseRenderer, BrowsableAPIRenderer
|
||||
|
||||
__all__ = (
|
||||
'FormlessBrowsableAPIRenderer',
|
||||
'TextRenderer',
|
||||
)
|
||||
|
||||
|
||||
class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer):
|
||||
@ -10,3 +15,14 @@ class FormlessBrowsableAPIRenderer(BrowsableAPIRenderer):
|
||||
|
||||
def get_filter_form(self, data, view, request):
|
||||
return None
|
||||
|
||||
|
||||
class TextRenderer(BaseRenderer):
|
||||
"""
|
||||
Return raw data as plain text.
|
||||
"""
|
||||
media_type = 'text/plain'
|
||||
format = 'txt'
|
||||
|
||||
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||
return str(data)
|
||||
|
@ -311,6 +311,7 @@ OTHER_MENU = Menu(
|
||||
items=(
|
||||
get_model_item('extras', 'tag', 'Tags'),
|
||||
get_model_item('extras', 'configcontext', _('Config Contexts'), actions=['add']),
|
||||
get_model_item('extras', 'configtemplate', _('Config Templates'), actions=['add']),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -9,9 +9,7 @@
|
||||
<div class="row">
|
||||
<div class="col col-12 col-xl-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Device
|
||||
</h5>
|
||||
<h5 class="card-header">Device</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
@ -111,6 +109,10 @@
|
||||
<th scope="row">Asset Tag</th>
|
||||
<td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Config Template</th>
|
||||
<td>{{ object.config_template|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
47
netbox/templates/dcim/device/render_config.html
Normal file
47
netbox/templates/dcim/device/render_config.html
Normal file
@ -0,0 +1,47 @@
|
||||
{% extends 'dcim/device/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ object }} - Config{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-5">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Config Template</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Config Template</th>
|
||||
<td>{{ config_template|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Data Source</th>
|
||||
<td>{{ config_template.data_file.source|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Data File</th>
|
||||
<td>{{ config_template.data_file|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Context Data</h5>
|
||||
<pre class="card-body">{{ context_data|pprint }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
{% if config_template %}
|
||||
<pre class="card-body">{{ rendered_config }}</pre>
|
||||
{% else %}
|
||||
<div class="card-body text-muted">No configuration template found</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -65,6 +65,7 @@
|
||||
</div>
|
||||
{% render_field form.status %}
|
||||
{% render_field form.platform %}
|
||||
{% render_field form.config_template %}
|
||||
{% if object.pk %}
|
||||
{% render_field form.primary_ip4 %}
|
||||
{% render_field form.primary_ip6 %}
|
||||
|
@ -42,6 +42,10 @@
|
||||
<th scope="row">VM Role</th>
|
||||
<td>{% checkmark object.vm_role %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Config Template</th>
|
||||
<td>{{ object.config_template|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -39,6 +39,10 @@
|
||||
<th scope="row">Manufacturer</th>
|
||||
<td>{{ object.manufacturer|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Config Template</th>
|
||||
<td>{{ object.config_template|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">NAPALM Driver</th>
|
||||
<td>{{ object.napalm_driver|placeholder }}</td>
|
||||
|
77
netbox/templates/extras/configtemplate.html
Normal file
77
netbox/templates/extras/configtemplate.html
Normal file
@ -0,0 +1,77 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-5">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Config Template</h5>
|
||||
<div class="card-body">
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">Name</th>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Description</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Data Source</th>
|
||||
<td>
|
||||
{% if object.data_source %}
|
||||
<a href="{{ object.data_source.get_absolute_url }}">{{ object.data_source }}</a>
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Data File</th>
|
||||
<td>
|
||||
{% if object.data_file %}
|
||||
<a href="{{ object.data_file.get_absolute_url }}">{{ object.data_file }}</a>
|
||||
{% elif object.data_path %}
|
||||
<div class="float-end text-warning">
|
||||
<i class="mdi mdi-alert" title="The data file associated with this object has been deleted."></i>
|
||||
</div>
|
||||
{{ object.data_path }}
|
||||
{% else %}
|
||||
{{ ''|placeholder }}
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Data Synced</th>
|
||||
<td>{{ object.data_synced|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'inc/panels/tags.html' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-7">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Environment Parameters</h5>
|
||||
<div class="card-body">
|
||||
<pre>{{ object.environment_params }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Template</h5>
|
||||
<div class="card-body">
|
||||
{% include 'inc/sync_warning.html' %}
|
||||
<pre>{{ object.template_code }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% plugin_full_width_page object %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
10
netbox/templates/extras/configtemplate_list.html
Normal file
10
netbox/templates/extras/configtemplate_list.html
Normal file
@ -0,0 +1,10 @@
|
||||
{% extends 'generic/object_list.html' %}
|
||||
|
||||
{% block bulk_buttons %}
|
||||
{% if perms.extras.sync_configtemplate %}
|
||||
<button type="submit" name="_sync" formaction="{% url 'extras:configtemplate_bulk_sync' %}" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-sync" aria-hidden="true"></i> Sync Data
|
||||
</button>
|
||||
{% endif %}
|
||||
{{ block.super }}
|
||||
{% endblock %}
|
37
netbox/utilities/jinja2.py
Normal file
37
netbox/utilities/jinja2.py
Normal file
@ -0,0 +1,37 @@
|
||||
from django.apps import apps
|
||||
from jinja2 import BaseLoader, TemplateNotFound
|
||||
from jinja2.meta import find_referenced_templates
|
||||
|
||||
__all__ = (
|
||||
'ConfigTemplateLoader',
|
||||
)
|
||||
|
||||
|
||||
class ConfigTemplateLoader(BaseLoader):
|
||||
"""
|
||||
Custom Jinja2 loader to facilitate populating template content from DataFiles.
|
||||
"""
|
||||
def __init__(self, data_source):
|
||||
self.data_source = data_source
|
||||
self._template_cache = {}
|
||||
|
||||
def get_source(self, environment, template):
|
||||
DataFile = apps.get_model('core', 'DataFile')
|
||||
|
||||
# Retrieve template content from cache
|
||||
try:
|
||||
template_source = self._template_cache[template]
|
||||
except KeyError:
|
||||
raise TemplateNotFound(template)
|
||||
|
||||
# Find and pre-fetch referenced templates
|
||||
if referenced_templates := find_referenced_templates(environment.parse(template_source)):
|
||||
self.cache_templates({
|
||||
df.path: df.data_as_string for df in
|
||||
DataFile.objects.filter(source=self.data_source, path__in=referenced_templates)
|
||||
})
|
||||
|
||||
return template_source, template, lambda: True
|
||||
|
||||
def cache_templates(self, templates):
|
||||
self._template_cache.update(templates)
|
Loading…
Reference in New Issue
Block a user