diff --git a/docs/additional-features/context-data.md b/docs/additional-features/context-data.md index cd9f1ceaa..465b4d2dc 100644 --- a/docs/additional-features/context-data.md +++ b/docs/additional-features/context-data.md @@ -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. diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 0478932f7..3acafda8b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -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): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 4e201639c..e5d64ff5d 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -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(), diff --git a/netbox/dcim/migrations/0063_device_local_config_context_data.py b/netbox/dcim/migrations/0063_device_local_config_context_data.py new file mode 100644 index 000000000..cbadde2ca --- /dev/null +++ b/netbox/dcim/migrations/0063_device_local_config_context_data.py @@ -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), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 19c75bdb9..bc3677b6e 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -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() diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 7345cdacd..51cedaa20 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -142,6 +142,8 @@ urlpatterns = [ url(r'^devices/(?P\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'), url(r'^devices/(?P\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'), url(r'^devices/(?P\d+)/config-context/$', views.DeviceConfigContextView.as_view(), name='device_configcontext'), + url(r'^devices/(?P\d+)/config-context/edit-local/$', views.DeviceEditLocalConfigContextView.as_view(), name='device_edit_localconfigcontext'), + url(r'^devices/(?P\d+)/config-context/clear-local/$', views.DeviceClearLocalContextDataView.as_view(), name='device_delete_localconfigcontext'), url(r'^devices/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), url(r'^devices/(?P\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'), url(r'^devices/(?P\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index eb7f71a25..42106b060 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -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 diff --git a/netbox/extras/models.py b/netbox/extras/models.py index ad4fcdb18..467a96f64 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -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 diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 90d0d698d..7be652ca0 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -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, diff --git a/netbox/templates/dcim/device_edit_local_config_context.html b/netbox/templates/dcim/device_edit_local_config_context.html new file mode 100644 index 000000000..de9c1c76f --- /dev/null +++ b/netbox/templates/dcim/device_edit_local_config_context.html @@ -0,0 +1,11 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Local Config Context Data
+
+ {% render_field form.local_config_context_data %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/object_configcontext.html b/netbox/templates/extras/object_configcontext.html index 81f8e1780..eab3df10b 100644 --- a/netbox/templates/extras/object_configcontext.html +++ b/netbox/templates/extras/object_configcontext.html @@ -16,6 +16,38 @@
+
+
+ Local Context +
+
+ {% if obj.local_config_context_data %} +
{{ obj.local_config_context_data|render_json }}
+ {% else %} + None + {% endif %} + + + The local config context overwrites all source contexts. + +
+ +
Source Contexts diff --git a/netbox/templates/utilities/object_set_field_null.html b/netbox/templates/utilities/object_set_field_null.html new file mode 100644 index 000000000..d1d58a9ed --- /dev/null +++ b/netbox/templates/utilities/object_set_field_null.html @@ -0,0 +1,9 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Clear {{ field_human_friendly_name }}?{% endblock %} + +{% block message %} +

Are you sure you want to clear the {{ field_human_friendly_name }} on {{ obj_type }} {{ obj }}?

+ {% block message_extra %}{% endblock %} +{% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine_edit_local_config_context.html b/netbox/templates/virtualization/virtualmachine_edit_local_config_context.html new file mode 100644 index 000000000..de9c1c76f --- /dev/null +++ b/netbox/templates/virtualization/virtualmachine_edit_local_config_context.html @@ -0,0 +1,11 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Local Config Context Data
+
+ {% render_field form.local_config_context_data %} +
+
+{% endblock %} diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index e11d681ef..40ef04dd4 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -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): """ diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 9dff223d3..faa2f3161 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -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): diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 10833234b..686864820 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -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, diff --git a/netbox/virtualization/migrations/0008_virtualmachine_local_config_context_data.py b/netbox/virtualization/migrations/0008_virtualmachine_local_config_context_data.py new file mode 100644 index 000000000..e6f4b2bbf --- /dev/null +++ b/netbox/virtualization/migrations/0008_virtualmachine_local_config_context_data.py @@ -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), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 119c9ee4f..9a55c10fd 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -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() diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index b03b3bc0a..0af76bba2 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -49,6 +49,8 @@ urlpatterns = [ url(r'^virtual-machines/(?P\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), url(r'^virtual-machines/(?P\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), url(r'^virtual-machines/(?P\d+)/config-context/$', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), + url(r'^virtual-machines/(?P\d+)/config-context/edit-local/$', views.VirtualMachineEditLocalConfigContextView.as_view(), name='virtualmachine_edit_localconfigcontext'), + url(r'^virtual-machines/(?P\d+)/config-context/clear-local/$', views.VirtualMachineClearLocalContextDataView.as_view(), name='virtualmachine_delete_localconfigcontext'), url(r'^virtual-machines/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}), url(r'^virtual-machines/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index d4728da45..34423f012 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -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'