1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

implemnted #2392 - local config context for devices and VMs

This commit is contained in:
John Anderson
2018-09-16 00:25:20 -04:00
parent e965adad7c
commit 0da113b723
20 changed files with 232 additions and 7 deletions

View File

@ -1,3 +1,5 @@
# Contextual Configuration Data
Sometimes it is desirable to associate arbitrary data with a group of devices to aid in their configuration. For example, you might want to associate a set of syslog servers for all devices at a particular site. Context data enables the association of arbitrary data to devices and virtual machines grouped by region, site, role, platform, and/or tenant. Context data is arranged hierarchically, so that data with a higher weight can be entered to override more general lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object.
Devices and Virtual Machines may also have a local config context defined. This local context will always overwrite the rendered config context objects for the Device/VM. This is useful in situations were the device requires a one-off value different from the rest of the environment.

View File

@ -412,7 +412,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'created',
'last_updated',
'last_updated', 'local_config_context_data',
]
validators = []
@ -448,7 +448,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields',
'config_context', 'created', 'last_updated',
'config_context', 'created', 'last_updated', 'local_config_context_data',
]
def get_config_context(self, obj):

View File

@ -18,7 +18,7 @@ from utilities.forms import (
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm,
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
FlexibleModelChoiceField, JSONField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
)
from virtualization.models import Cluster
from .constants import (
@ -920,6 +920,16 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
self.initial['rack'] = self.instance.parent_bay.device.rack_id
class DeviceLocalConfigContextForm(BootstrapMixin, forms.ModelForm):
local_config_context_data = JSONField()
class Meta:
model = Device
fields = [
'local_config_context_data',
]
class BaseDeviceCSVForm(forms.ModelForm):
device_role = forms.ModelChoiceField(
queryset=DeviceRole.objects.all(),

View File

@ -0,0 +1,19 @@
# Generated by Django 2.0.8 on 2018-09-16 02:01
import django.contrib.postgres.fields.jsonb
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0062_interface_mtu'),
]
operations = [
migrations.AddField(
model_name='device',
name='local_config_context_data',
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True),
),
]

View File

@ -1287,6 +1287,10 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
images = GenericRelation(
to='extras.ImageAttachment'
)
local_config_context_data = JSONField(
blank=True,
null=True,
)
objects = DeviceManager()
tags = TaggableManager()

View File

@ -142,6 +142,8 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
url(r'^devices/(?P<pk>\d+)/config-context/$', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
url(r'^devices/(?P<pk>\d+)/config-context/edit-local/$', views.DeviceEditLocalConfigContextView.as_view(), name='device_edit_localconfigcontext'),
url(r'^devices/(?P<pk>\d+)/config-context/clear-local/$', views.DeviceClearLocalContextDataView.as_view(), name='device_delete_localconfigcontext'),
url(r'^devices/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
url(r'^devices/(?P<pk>\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'),

View File

@ -26,7 +26,7 @@ from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
ObjectDeleteView, ObjectEditView, ObjectListView,
ObjectDeleteView, ObjectEditView, ObjectListView, ObjectSetFieldNullView,
)
from virtualization.models import VirtualMachine
from . import filters, forms, tables
@ -983,6 +983,19 @@ class DeviceEditView(DeviceCreateView):
permission_required = 'dcim.change_device'
class DeviceEditLocalConfigContextView(DeviceCreateView):
permission_required = 'dcim.change_device'
model_form = forms.DeviceLocalConfigContextForm
template_name = 'dcim/device_edit_local_config_context.html'
class DeviceClearLocalContextDataView(ObjectSetFieldNullView):
permission_required = 'dcim.change_device'
model = Device
field = 'local_config_context_data'
field_human_friendly_name = 'local config context'
class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_device'
model = Device

View File

@ -716,6 +716,10 @@ class ConfigContextModel(models.Model):
for context in ConfigContext.objects.get_for_object(self):
data.update(context.data)
# If the object has local config context data defined, that data overwrites all rendered data
if self.local_config_context_data is not None:
data.update(self.local_config_context_data)
return data

View File

@ -106,9 +106,15 @@ class ObjectConfigContextView(View):
obj = get_object_or_404(self.object_class, pk=pk)
source_contexts = ConfigContext.objects.get_for_object(obj)
model_name = self.object_class._meta.model_name
app_label = self.object_class._meta.app_label
return render(request, 'extras/object_configcontext.html', {
self.object_class._meta.model_name: obj,
model_name: obj,
'obj': obj,
'perm_string': '{}.change_{}'.format(app_label, model_name),
'edit_url':'{}:{}_edit_localconfigcontext'.format(app_label, model_name),
'delete_url':'{}:{}_delete_localconfigcontext'.format(app_label, model_name),
'rendered_context': obj.get_config_context(),
'source_contexts': source_contexts,
'base_template': self.base_template,

View File

@ -0,0 +1,11 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Local Config Context Data</strong></div>
<div class="panel-body">
{% render_field form.local_config_context_data %}
</div>
</div>
{% endblock %}

View File

@ -16,6 +16,38 @@
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Local Context</strong>
</div>
<div class="panel-body">
{% if obj.local_config_context_data %}
<pre>{{ obj.local_config_context_data|render_json }}</pre>
{% else %}
<span class="text-muted">None</span>
{% endif %}
<span class="help-block">
<i class="fa fa-info-circle"></i>
The local config context overwrites all source contexts.
</span>
</div>
<div class="panel-footer">
{% if perm_string in perms %}
{% if obj.local_config_context_data %}
<a href="{% url edit_url pk=obj.pk %}?return_url={{ obj.get_absolute_url }}config-context/" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Edit
</a>
<a href="{% url delete_url pk=obj.pk %}?return_url={{ obj.get_absolute_url }}config-context/" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</a>
{% else %}
<a href="{% url edit_url pk=obj.pk %}?return_url={{ obj.get_absolute_url }}config-context/" class="btn btn-success btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add local context
</a>
{% endif %}
{% endif %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Source Contexts</strong>

View File

@ -0,0 +1,9 @@
{% extends 'utilities/confirmation_form.html' %}
{% load form_helpers %}
{% block title %}Clear {{ field_human_friendly_name }}?{% endblock %}
{% block message %}
<p>Are you sure you want to clear the <strong>{{ field_human_friendly_name }}</strong> on {{ obj_type }} <strong>{{ obj }}</strong>?</p>
{% block message_extra %}{% endblock %}
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Local Config Context Data</strong></div>
<div class="panel-body">
{% render_field form.local_config_context_data %}
</div>
</div>
{% endblock %}

View File

@ -844,6 +844,56 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
})
class ObjectSetFieldNullView(ObjectDeleteView):
"""
Given a field name, set it to None (null) and save the object.
field: The field to be nulled
field_friendly_name: Human friendly name for the field in the UI.
"""
template_name = 'utilities/object_set_field_null.html'
field_human_friendly_name = None
def get(self, request, **kwargs):
obj = self.get_object(kwargs)
form = ConfirmationForm(initial=request.GET)
return render(request, self.template_name, {
'obj': obj,
'form': form,
'obj_type': self.model._meta.verbose_name,
'field_human_friendly_name': self.field_human_friendly_name,
'return_url': self.get_return_url(request, obj),
})
def post(self, request, **kwargs):
obj = self.get_object(kwargs)
form = ConfirmationForm(request.POST)
if form.is_valid():
setattr(obj, self.field, None)
obj.save()
msg = 'Cleared {} on {} {}'.format(self.field_human_friendly_name, self.model._meta.verbose_name, obj)
messages.success(request, msg)
return_url = form.cleaned_data.get('return_url')
if return_url is not None and is_safe_url(url=return_url, host=request.get_host()):
return redirect(return_url)
else:
return redirect(self.get_return_url(request, obj))
return render(request, self.template_name, {
'obj': obj,
'form': form,
'obj_type': self.model._meta.verbose_name,
'field_human_friendly_name': self.field_human_friendly_name,
'return_url': self.get_return_url(request, obj),
})
@requires_csrf_token
def server_error(request, template_name=ERROR_500_TEMPLATE_NAME):
"""

View File

@ -107,6 +107,7 @@ class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
fields = [
'id', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4',
'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'local_config_context_data',
]
@ -117,6 +118,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
fields = [
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
'local_config_context_data',
]
def get_config_context(self, obj):

View File

@ -17,7 +17,8 @@ from tenancy.models import Tenant
from utilities.forms import (
AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, add_blank_choice
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea,
add_blank_choice
)
from .constants import VM_STATUS_CHOICES
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@ -302,6 +303,16 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
self.fields['primary_ip6'].widget.attrs['readonly'] = True
class VirtualMachineLocalConfigContextForm(BootstrapMixin, forms.ModelForm):
local_config_context_data = JSONField()
class Meta:
model = VirtualMachine
fields = [
'local_config_context_data',
]
class VirtualMachineCSVForm(forms.ModelForm):
status = CSVChoiceField(
choices=VM_STATUS_CHOICES,

View File

@ -0,0 +1,19 @@
# Generated by Django 2.0.8 on 2018-09-16 02:01
import django.contrib.postgres.fields.jsonb
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('virtualization', '0007_change_logging'),
]
operations = [
migrations.AddField(
model_name='virtualmachine',
name='local_config_context_data',
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True),
),
]

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
@ -244,6 +245,10 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
local_config_context_data = JSONField(
blank=True,
null=True,
)
tags = TaggableManager()

View File

@ -49,6 +49,8 @@ urlpatterns = [
url(r'^virtual-machines/(?P<pk>\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'),
url(r'^virtual-machines/(?P<pk>\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
url(r'^virtual-machines/(?P<pk>\d+)/config-context/$', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'),
url(r'^virtual-machines/(?P<pk>\d+)/config-context/edit-local/$', views.VirtualMachineEditLocalConfigContextView.as_view(), name='virtualmachine_edit_localconfigcontext'),
url(r'^virtual-machines/(?P<pk>\d+)/config-context/clear-local/$', views.VirtualMachineClearLocalContextDataView.as_view(), name='virtualmachine_delete_localconfigcontext'),
url(r'^virtual-machines/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
url(r'^virtual-machines/(?P<virtualmachine>\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'),

View File

@ -14,7 +14,7 @@ from extras.views import ObjectConfigContextView
from ipam.models import Service
from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
ObjectEditView, ObjectListView,
ObjectEditView, ObjectListView, ObjectSetFieldNullView,
)
from . import filters, forms, tables
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@ -285,6 +285,19 @@ class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView):
default_return_url = 'virtualization:virtualmachine_list'
class VirtualMachineEditLocalConfigContextView(VirtualMachineCreateView):
permission_required = 'virtualization.change_device'
model_form = forms.VirtualMachineLocalConfigContextForm
template_name = 'virtualization/virtualmachine_edit_local_config_context.html'
class VirtualMachineClearLocalContextDataView(ObjectSetFieldNullView):
permission_required = 'virtualization.change_virtualmachine'
model = VirtualMachine
field = 'local_config_context_data'
field_human_friendly_name = 'local config context'
class VirtualMachineEditView(VirtualMachineCreateView):
permission_required = 'virtualization.change_virtualmachine'