This commit is contained in:
jeremystretch 2023-02-09 16:32:32 -05:00
parent d6c41fb45f
commit f5e09c6f48
20 changed files with 455 additions and 4 deletions

View File

@ -383,6 +383,27 @@ class ConfigContextSerializer(ValidatedModelSerializer):
]
#
# Config templates
#
class ConfigTemplateSerializer(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', 'created', 'last_updated',
]
#
# Job Results
#

View File

@ -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)

View File

@ -157,6 +157,27 @@ 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'])
def render(self, request, pk):
"""
Render a ConfigTemplate using the context data provided (if any).
"""
configtemplate = self.get_object()
output = configtemplate.render(context=request.data)
# TODO: Create a proper serializer
return Response({"output": output})
#
# Reports
#

View File

@ -4,7 +4,7 @@ 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
@ -13,9 +13,9 @@ from virtualization.models import Cluster, ClusterGroup, ClusterType
from .choices import *
from .models import *
__all__ = (
'ConfigContextFilterSet',
'ConfigTemplateFilterSet',
'ContentTypeFilterSet',
'CustomFieldFilterSet',
'CustomLinkFilterSet',
@ -454,6 +454,33 @@ 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)'),
)
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
#

View File

@ -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(),

View File

@ -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',
)
class SavedFilterImportForm(CSVModelForm):
content_types = CSVMultipleContentTypeField(
queryset=ContentType.objects.all(),

View File

@ -20,6 +20,7 @@ from .mixins import SavedFiltersMixin
__all__ = (
'ConfigContextFilterForm',
'ConfigTemplateFilterForm',
'CustomFieldFilterForm',
'CustomLinkFilterForm',
'ExportTemplateFilterForm',
@ -364,6 +365,26 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
)
class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id')),
('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'
}
)
class LocalConfigContextFilterForm(forms.Form):
local_context_data = forms.NullBooleanField(
required=False,

View File

@ -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 = (
('Export 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:

View File

@ -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)

View File

@ -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:

View File

@ -0,0 +1,34 @@
# Generated by Django 4.1.6 on 2023-02-09 19:33
from django.db import migrations, models
import django.db.models.deletion
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')),
],
options={
'ordering': ('name',),
},
),
]

View File

@ -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',

View File

@ -3,15 +3,20 @@ 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.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
from utilities.jinja2 import ConfigTemplateLoader
from utilities.utils import deepmerge
__all__ = (
'ConfigContext',
'ConfigContextModel',
'ConfigTemplate',
)
@ -182,3 +187,59 @@ 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, 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 {}
template_code = self.template_code
# output = render_jinja2(template_code, context)
environment = SandboxedEnvironment(
loader=ConfigTemplateLoader(data_source=self.data_source)
)
environment.filters.update(get_config().JINJA2_FILTERS)
output = environment.from_string(source=template_code).render(**context)
# Replace CRLF-style line terminators
output = output.replace('\r\n', '\n')
return output

View File

@ -8,6 +8,7 @@ from .template_code import *
__all__ = (
'ConfigContextTable',
'ConfigTemplateTable',
'CustomFieldTable',
'CustomLinkTable',
'ExportTemplateTable',
@ -223,6 +224,30 @@ 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'
)
class Meta(NetBoxTable.Meta):
model = ConfigTemplate
fields = (
'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'description', 'is_synced',
)
class ObjectChangeTable(NetBoxTable):
time = tables.DateTimeColumn(
linkify=True,

View File

@ -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'))),

View File

@ -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
#

View File

@ -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']),
),
),
),

View File

@ -0,0 +1,76 @@
{% 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>
{% 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 %}

View 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 %}

View File

@ -0,0 +1,27 @@
import os
from django.apps import apps
from jinja2 import BaseLoader, TemplateNotFound
__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
def get_source(self, environment, template):
DataFile = apps.get_model('core', 'DataFile')
try:
datafile = DataFile.objects.get(source=self.data_source, path=template)
template_source = datafile.data_as_string
except DataFile.DoesNotExist:
raise TemplateNotFound(template)
return template_source, template, True