Просмотр исходного кода

Merge pull request #2208 from digitalocean/1349-config-contexts

1349 config contexts
Jeremy Stretch 7 лет назад
Родитель
Сommit
06dab9c468

+ 5 - 1
netbox/dcim/api/views.py

@@ -3,7 +3,6 @@ from __future__ import unicode_literals
 from collections import OrderedDict
 from collections import OrderedDict
 
 
 from django.conf import settings
 from django.conf import settings
-from django.db import transaction
 from django.http import HttpResponseBadRequest, HttpResponseForbidden
 from django.http import HttpResponseBadRequest, HttpResponseForbidden
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 from drf_yasg import openapi
 from drf_yasg import openapi
@@ -233,6 +232,11 @@ class DeviceViewSet(CustomFieldModelViewSet):
     serializer_class = serializers.DeviceSerializer
     serializer_class = serializers.DeviceSerializer
     filter_class = filters.DeviceFilter
     filter_class = filters.DeviceFilter
 
 
+    @detail_route(url_path='config-context')
+    def config_context(self, request, pk):
+        device = get_object_or_404(Device, pk=pk)
+        return Response(device.get_config_context())
+
     @detail_route(url_path='napalm')
     @detail_route(url_path='napalm')
     def napalm(self, request, pk):
     def napalm(self, request, pk):
         """
         """

+ 2 - 2
netbox/dcim/models.py

@@ -19,7 +19,7 @@ from timezone_field import TimeZoneField
 
 
 from circuits.models import Circuit
 from circuits.models import Circuit
 from extras.constants import OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
 from extras.constants import OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
-from extras.models import CustomFieldModel, ObjectChange
+from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange
 from extras.rpc import RPC_CLIENTS
 from extras.rpc import RPC_CLIENTS
 from utilities.fields import ColorField, NullableCharField
 from utilities.fields import ColorField, NullableCharField
 from utilities.managers import NaturalOrderByManager
 from utilities.managers import NaturalOrderByManager
@@ -1158,7 +1158,7 @@ class DeviceManager(NaturalOrderByManager):
 
 
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class Device(ChangeLoggedModel, CustomFieldModel):
+class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     """
     """
     A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
     A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
     DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.
     DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique.

+ 1 - 0
netbox/dcim/urls.py

@@ -141,6 +141,7 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/$', views.DeviceView.as_view(), name='device'),
     url(r'^devices/(?P<pk>\d+)/$', views.DeviceView.as_view(), name='device'),
     url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
     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+)/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+)/changelog/$', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
     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+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
     url(r'^devices/(?P<pk>\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'),
     url(r'^devices/(?P<pk>\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'),

+ 6 - 0
netbox/dcim/views.py

@@ -19,6 +19,7 @@ from natsort import natsorted
 
 
 from circuits.models import Circuit
 from circuits.models import Circuit
 from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
 from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
+from extras.views import ObjectConfigContextView
 from ipam.models import Prefix, Service, VLAN
 from ipam.models import Prefix, Service, VLAN
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.paginator import EnhancedPaginator
@@ -994,6 +995,11 @@ class DeviceConfigView(PermissionRequiredMixin, View):
         })
         })
 
 
 
 
+class DeviceConfigContextView(ObjectConfigContextView):
+    object_class = Device
+    base_template = 'dcim/device.html'
+
+
 class DeviceCreateView(PermissionRequiredMixin, ObjectEditView):
 class DeviceCreateView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.add_device'
     permission_required = 'dcim.add_device'
     model = Device
     model = Device

+ 11 - 1
netbox/extras/admin.py

@@ -7,7 +7,8 @@ from django.utils.safestring import mark_safe
 from utilities.forms import LaxURLField
 from utilities.forms import LaxURLField
 from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
 from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
 from .models import (
 from .models import (
-    CustomField, CustomFieldChoice, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction, Webhook,
+    ConfigContext, CustomField, CustomFieldChoice, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction,
+    Webhook,
 )
 )
 
 
 
 
@@ -125,6 +126,15 @@ class TopologyMapAdmin(admin.ModelAdmin):
     }
     }
 
 
 
 
+#
+# Config contexts
+#
+
+@admin.register(ConfigContext)
+class ConfigContextAdmin(admin.ModelAdmin):
+    list_display = ['name', 'weight']
+
+
 #
 #
 # Change logging
 # Change logging
 #
 #

+ 26 - 2
netbox/extras/api/serializers.py

@@ -4,10 +4,16 @@ from django.core.exceptions import ObjectDoesNotExist
 from rest_framework import serializers
 from rest_framework import serializers
 from taggit.models import Tag
 from taggit.models import Tag
 
 
-from dcim.api.serializers import NestedDeviceSerializer, NestedRackSerializer, NestedSiteSerializer
+from dcim.api.serializers import (
+    NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
+    NestedRegionSerializer, NestedSiteSerializer,
+)
 from dcim.models import Device, Rack, Site
 from dcim.models import Device, Rack, Site
-from extras.models import ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction
+from extras.models import (
+    ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction,
+)
 from extras.constants import *
 from extras.constants import *
+from tenancy.api.serializers import NestedTenantSerializer
 from users.api.serializers import NestedUserSerializer
 from users.api.serializers import NestedUserSerializer
 from utilities.api import (
 from utilities.api import (
     ChoiceFieldSerializer, ContentTypeFieldSerializer, get_serializer_for_model, ValidatedModelSerializer,
     ChoiceFieldSerializer, ContentTypeFieldSerializer, get_serializer_for_model, ValidatedModelSerializer,
@@ -121,6 +127,24 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
         return serializer(obj.parent, context={'request': self.context['request']}).data
         return serializer(obj.parent, context={'request': self.context['request']}).data
 
 
 
 
+#
+# Config contexts
+#
+
+class ConfigContextSerializer(ValidatedModelSerializer):
+    regions = NestedRegionSerializer(many=True)
+    sites = NestedSiteSerializer(many=True)
+    roles = NestedDeviceRoleSerializer(many=True)
+    platforms = NestedPlatformSerializer(many=True)
+    tenants = NestedTenantSerializer(many=True)
+
+    class Meta:
+        model = ConfigContext
+        fields = [
+            'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', 'data',
+        ]
+
+
 #
 #
 # Reports
 # Reports
 #
 #

+ 3 - 0
netbox/extras/api/urls.py

@@ -34,6 +34,9 @@ router.register(r'tags', views.TagViewSet)
 # Image attachments
 # Image attachments
 router.register(r'image-attachments', views.ImageAttachmentViewSet)
 router.register(r'image-attachments', views.ImageAttachmentViewSet)
 
 
+# Config contexts
+router.register(r'config-contexts', views.ConfigContextViewSet)
+
 # Reports
 # Reports
 router.register(r'reports', views.ReportViewSet, base_name='report')
 router.register(r'reports', views.ReportViewSet, base_name='report')
 
 

+ 11 - 1
netbox/extras/api/views.py

@@ -12,7 +12,8 @@ from taggit.models import Tag
 
 
 from extras import filters
 from extras import filters
 from extras.models import (
 from extras.models import (
-    CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, UserAction,
+    ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
+    UserAction,
 )
 )
 from extras.reports import get_report, get_reports
 from extras.reports import get_report, get_reports
 from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
 from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
@@ -132,6 +133,15 @@ class ImageAttachmentViewSet(ModelViewSet):
     serializer_class = serializers.ImageAttachmentSerializer
     serializer_class = serializers.ImageAttachmentSerializer
 
 
 
 
+#
+# Config contexts
+#
+
+class ConfigContextViewSet(ModelViewSet):
+    queryset = ConfigContext.objects.prefetch_related('regions', 'sites', 'roles', 'platforms', 'tenants')
+    serializer_class = serializers.ConfigContextSerializer
+
+
 #
 #
 # Reports
 # Reports
 #
 #

+ 20 - 2
netbox/extras/forms.py

@@ -5,14 +5,16 @@ from collections import OrderedDict
 from django import forms
 from django import forms
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from mptt.forms import TreeNodeMultipleChoiceField
 from taggit.models import Tag
 from taggit.models import Tag
 
 
+from dcim.models import Region
 from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, SlugField
 from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, SlugField
 from .constants import (
 from .constants import (
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
     OBJECTCHANGE_ACTION_CHOICES,
     OBJECTCHANGE_ACTION_CHOICES,
 )
 )
-from .models import CustomField, CustomFieldValue, ImageAttachment, ObjectChange
+from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange
 
 
 
 
 #
 #
@@ -174,7 +176,6 @@ class CustomFieldFilterForm(forms.Form):
 #
 #
 # Tags
 # Tags
 #
 #
-#
 
 
 class TagForm(BootstrapMixin, forms.ModelForm):
 class TagForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
     slug = SlugField()
@@ -184,6 +185,23 @@ class TagForm(BootstrapMixin, forms.ModelForm):
         fields = ['name', 'slug']
         fields = ['name', 'slug']
 
 
 
 
+#
+# Config contexts
+#
+
+class ConfigContextForm(BootstrapMixin, forms.ModelForm):
+    regions = TreeNodeMultipleChoiceField(
+        queryset=Region.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = ConfigContext
+        fields = [
+            'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', 'data',
+        ]
+
+
 #
 #
 # Image attachments
 # Image attachments
 #
 #

+ 45 - 0
netbox/extras/migrations/0014_configcontexts.py

@@ -0,0 +1,45 @@
+# Generated by Django 2.0.6 on 2018-06-29 13:34
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('tenancy', '0005_change_logging'),
+        ('dcim', '0060_change_logging'),
+        ('extras', '0013_objectchange'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ConfigContext',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('weight', models.PositiveSmallIntegerField(default=1000)),
+                ('is_active', models.BooleanField(default=True)),
+                ('description', models.CharField(blank=True, max_length=100)),
+                ('data', django.contrib.postgres.fields.jsonb.JSONField()),
+                ('platforms', models.ManyToManyField(blank=True, related_name='_configcontext_platforms_+', to='dcim.Platform')),
+                ('regions', models.ManyToManyField(blank=True, related_name='_configcontext_regions_+', to='dcim.Region')),
+                ('roles', models.ManyToManyField(blank=True, related_name='_configcontext_roles_+', to='dcim.DeviceRole')),
+                ('sites', models.ManyToManyField(blank=True, related_name='_configcontext_sites_+', to='dcim.Site')),
+                ('tenants', models.ManyToManyField(blank=True, related_name='_configcontext_tenants_+', to='tenancy.Tenant')),
+            ],
+            options={
+                'ordering': ['weight', 'name'],
+            },
+        ),
+        migrations.AlterField(
+            model_name='customfield',
+            name='obj_type',
+            field=models.ManyToManyField(help_text='The object(s) to which this field applies.', limit_choices_to={'model__in': ('provider', 'circuit', 'site', 'rack', 'devicetype', 'device', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', 'tenant', 'cluster', 'virtualmachine')}, related_name='custom_fields', to='contenttypes.ContentType', verbose_name='Object(s)'),
+        ),
+        migrations.AlterField(
+            model_name='webhook',
+            name='obj_type',
+            field=models.ManyToManyField(help_text='The object(s) to which this Webhook applies.', limit_choices_to={'model__in': ('provider', 'circuit', 'site', 'rack', 'rackgroup', 'device', 'interface', 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vlangroup', 'vrf', 'service', 'tenant', 'tenantgroup', 'cluster', 'clustergroup', 'virtualmachine')}, related_name='webhooks', to='contenttypes.ContentType', verbose_name='Object types'),
+        ),
+    ]

+ 82 - 5
netbox/extras/models.py

@@ -2,7 +2,6 @@ from __future__ import unicode_literals
 
 
 from collections import OrderedDict
 from collections import OrderedDict
 from datetime import date
 from datetime import date
-import json
 
 
 import graphviz
 import graphviz
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
@@ -21,6 +20,7 @@ from django.utils.safestring import mark_safe
 from dcim.constants import CONNECTION_STATUS_CONNECTED
 from dcim.constants import CONNECTION_STATUS_CONNECTED
 from utilities.utils import foreground_color
 from utilities.utils import foreground_color
 from .constants import *
 from .constants import *
+from .querysets import ConfigContextQuerySet
 
 
 
 
 #
 #
@@ -629,6 +629,87 @@ class ImageAttachment(models.Model):
             return None
             return None
 
 
 
 
+#
+# Config contexts
+#
+
+class ConfigContext(models.Model):
+    """
+    A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
+    qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
+    will be available to a Device in site A assigned to tenant B. Data is stored in JSON format.
+    """
+    name = models.CharField(
+        max_length=100,
+        unique=True
+    )
+    weight = models.PositiveSmallIntegerField(
+        default=1000
+    )
+    description = models.CharField(
+        max_length=100,
+        blank=True
+    )
+    is_active = models.BooleanField(
+        default=True,
+    )
+    regions = models.ManyToManyField(
+        to='dcim.Region',
+        related_name='+',
+        blank=True
+    )
+    sites = models.ManyToManyField(
+        to='dcim.Site',
+        related_name='+',
+        blank=True
+    )
+    roles = models.ManyToManyField(
+        to='dcim.DeviceRole',
+        related_name='+',
+        blank=True
+    )
+    platforms = models.ManyToManyField(
+        to='dcim.Platform',
+        related_name='+',
+        blank=True
+    )
+    tenants = models.ManyToManyField(
+        to='tenancy.Tenant',
+        related_name='+',
+        blank=True
+    )
+    data = JSONField()
+
+    objects = ConfigContextQuerySet.as_manager()
+
+    class Meta:
+        ordering = ['weight', 'name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('extras:configcontext', kwargs={'pk': self.pk})
+
+
+class ConfigContextModel(models.Model):
+
+    class Meta:
+        abstract = True
+
+    def get_config_context(self):
+        """
+        Return the rendered configuration context for a device or VM.
+        """
+
+        # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
+        data = OrderedDict()
+        for context in ConfigContext.objects.get_for_object(self):
+            data.update(context.data)
+
+        return data
+
+
 #
 #
 # Report results
 # Report results
 #
 #
@@ -766,10 +847,6 @@ class ObjectChange(models.Model):
             self.object_data,
             self.object_data,
         )
         )
 
 
-    @property
-    def object_data_pretty(self):
-        return json.dumps(self.object_data, indent=4, sort_keys=True)
-
 
 
 #
 #
 # User actions
 # User actions

+ 23 - 0
netbox/extras/querysets.py

@@ -0,0 +1,23 @@
+from __future__ import unicode_literals
+
+from django.db.models import Q, QuerySet
+
+
+class ConfigContextQuerySet(QuerySet):
+
+    def get_for_object(self, obj):
+        """
+        Return all applicable ConfigContexts for a given object. Only active ConfigContexts will be included.
+        """
+
+        # `device_role` for Device; `role` for VirtualMachine
+        role = getattr(obj, 'device_role', None) or obj.role
+
+        return self.filter(
+            Q(regions=obj.site.region) | Q(regions=None),
+            Q(sites=obj.site) | Q(sites=None),
+            Q(roles=role) | Q(roles=None),
+            Q(tenants=obj.tenant) | Q(tenants=None),
+            Q(platforms=obj.platform) | Q(platforms=None),
+            is_active=True,
+        ).order_by('weight', 'name')

+ 28 - 2
netbox/extras/tables.py

@@ -4,7 +4,7 @@ import django_tables2 as tables
 from taggit.models import Tag
 from taggit.models import Tag
 
 
 from utilities.tables import BaseTable, ToggleColumn
 from utilities.tables import BaseTable, ToggleColumn
-from .models import ObjectChange
+from .models import ConfigContext, ObjectChange
 
 
 TAG_ACTIONS = """
 TAG_ACTIONS = """
 {% if perms.taggit.change_tag %}
 {% if perms.taggit.change_tag %}
@@ -15,6 +15,15 @@ TAG_ACTIONS = """
 {% endif %}
 {% endif %}
 """
 """
 
 
+CONFIGCONTEXT_ACTIONS = """
+{% if perms.extras.change_configcontext %}
+    <a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+{% endif %}
+{% if perms.extras.delete_configcontext %}
+    <a href="{% url 'extras:configcontext_delete' pk=record.pk %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-trash" aria-hidden="true"></i></a>
+{% endif %}
+"""
+
 OBJECTCHANGE_ACTION = """
 OBJECTCHANGE_ACTION = """
 {% if record.action == 1 %}
 {% if record.action == 1 %}
     <span class="label label-success">Created</span>
     <span class="label label-success">Created</span>
@@ -44,7 +53,24 @@ class TagTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Tag
         model = Tag
-        fields = ('pk', 'name', 'items')
+        fields = ('pk', 'name', 'weight')
+
+
+class ConfigContextTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.LinkColumn()
+    is_active = tables.BooleanColumn(
+        verbose_name='Active'
+    )
+    actions = tables.TemplateColumn(
+        template_code=CONFIGCONTEXT_ACTIONS,
+        attrs={'td': {'class': 'text-right'}},
+        verbose_name=''
+    )
+
+    class Meta(BaseTable.Meta):
+        model = ConfigContext
+        fields = ('pk', 'name', 'weight', 'is_active', 'description', 'actions')
 
 
 
 
 class ObjectChangeTable(BaseTable):
 class ObjectChangeTable(BaseTable):

+ 8 - 0
netbox/extras/urls.py

@@ -13,6 +13,14 @@ urlpatterns = [
     url(r'^tags/(?P<slug>[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'),
     url(r'^tags/(?P<slug>[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'),
     url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
     url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
 
 
+    # Config contexts
+    url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'),
+    url(r'^config-contexts/add/$', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
+    url(r'^config-contexts/(?P<pk>\d+)/$', views.ConfigContextView.as_view(), name='configcontext'),
+    url(r'^config-contexts/(?P<pk>\d+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
+    url(r'^config-contexts/(?P<pk>\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
+    url(r'^config-contexts/delete/$', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
+
     # Image attachments
     # Image attachments
     url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
     url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
     url(r'^image-attachments/(?P<pk>\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
     url(r'^image-attachments/(?P<pk>\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),

+ 68 - 3
netbox/extras/views.py

@@ -14,10 +14,10 @@ from taggit.models import Tag
 from utilities.forms import ConfirmationForm
 from utilities.forms import ConfirmationForm
 from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
 from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
 from . import filters
 from . import filters
-from .forms import ObjectChangeFilterForm, ImageAttachmentForm, TagForm
-from .models import ImageAttachment, ObjectChange, ReportResult
+from .forms import ConfigContextForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm
+from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
 from .reports import get_report, get_reports
 from .reports import get_report, get_reports
-from .tables import ObjectChangeTable, TagTable
+from .tables import ConfigContextTable, ObjectChangeTable, TagTable
 
 
 
 
 #
 #
@@ -53,6 +53,71 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     default_return_url = 'extras:tag_list'
     default_return_url = 'extras:tag_list'
 
 
 
 
+#
+# Config contexts
+#
+
+class ConfigContextListView(ObjectListView):
+    queryset = ConfigContext.objects.all()
+    table = ConfigContextTable
+    template_name = 'extras/configcontext_list.html'
+
+
+class ConfigContextView(View):
+
+    def get(self, request, pk):
+
+        configcontext = get_object_or_404(ConfigContext, pk=pk)
+
+        return render(request, 'extras/configcontext.html', {
+            'configcontext': configcontext,
+        })
+
+
+class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'extras.add_configcontext'
+    model = ConfigContext
+    model_form = ConfigContextForm
+    default_return_url = 'extras:configcontext_list'
+    template_name = 'extras/configcontext_edit.html'
+
+
+class ConfigContextEditView(ConfigContextCreateView):
+    permission_required = 'extras.change_configcontext'
+
+
+class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'extras.delete_configcontext'
+    model = ConfigContext
+    default_return_url = 'extras:configcontext_list'
+
+
+class ConfigContextBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'extras.delete_cconfigcontext'
+    cls = ConfigContext
+    queryset = ConfigContext.objects.all()
+    table = ConfigContextTable
+    default_return_url = 'extras:configcontext_list'
+
+
+class ObjectConfigContextView(View):
+    object_class = None
+    base_template = None
+
+    def get(self, request, pk):
+
+        obj = get_object_or_404(self.object_class, pk=pk)
+        source_contexts = ConfigContext.objects.get_for_object(obj)
+
+        return render(request, 'extras/object_configcontext.html', {
+            self.object_class._meta.model_name: obj,
+            'rendered_context': obj.get_config_context(),
+            'source_contexts': source_contexts,
+            'base_template': self.base_template,
+            'active_tab': 'config-context',
+        })
+
+
 #
 #
 # Change logging
 # Change logging
 #
 #

+ 3 - 0
netbox/templates/dcim/device.html

@@ -69,6 +69,9 @@
                 {% include 'dcim/inc/device_napalm_tabs.html' %}
                 {% include 'dcim/inc/device_napalm_tabs.html' %}
             {% endif %}
             {% endif %}
         {% endif %}
         {% endif %}
+        <li role="presentation"{% if active_tab == 'config-context' %} class="active"{% endif %}>
+            <a href="{% url 'dcim:device_configcontext' pk=device.pk %}">Config Context</a>
+        </li>
         <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
         <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <a href="{% url 'dcim:device_changelog' pk=device.pk %}">Changelog</a>
             <a href="{% url 'dcim:device_changelog' pk=device.pk %}">Changelog</a>
         </li>
         </li>

+ 171 - 0
netbox/templates/extras/configcontext.html

@@ -0,0 +1,171 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block header %}
+    <div class="row">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'extras:configcontext_list' %}">Config Contexts</a></li>
+                <li>{{ configcontext }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'extras:configcontext_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
+    </div>
+    <div class="pull-right">
+        {% if perms.extras.change_configcontext %}
+            <a href="{% url 'extras:configcontext_edit' pk=configcontext.pk %}" class="btn btn-warning">
+                <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
+                Edit this config context
+            </a>
+        {% endif %}
+    </div>
+    <h1>{% block title %}{{ configcontext }}{% endblock %}</h1>
+{% endblock %}
+
+{% block content %}
+    <div class="row">
+        <div class="col-md-5">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Config Context</strong>
+                </div>
+                <table class="table table-hover panel-body attr-table">
+                    <tr>
+                        <td>Name</td>
+                        <td>
+                            {{ configcontext.name }}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Weight</td>
+                        <td>
+                            {{ configcontext.weight }}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Description</td>
+                        <td>
+                            {% if configcontext.description %}
+                                {{ configcontext.description }}
+                            {% else %}
+                                <span class="text-muted">N/A</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Active</td>
+                        <td>
+                            {% if configcontext.is_active %}
+                                <span class="text-success">
+                                    <i class="fa fa-check"></i>
+                                </span>
+                            {% else %}
+                                <span class="text-danger">
+                                    <i class="fa fa-close"></i>
+                                </span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                </table>
+            </div>
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Assignment</strong>
+                </div>
+                <table class="table table-hover panel-body attr-table">
+                    <tr>
+                        <td>Regions</td>
+                        <td>
+                            {% if configcontext.regions.all %}
+                                <ul>
+                                    {% for region in configcontext.regions.all %}
+                                        <li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li>
+                                    {% endfor %}
+                                </ul>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Sites</td>
+                        <td>
+                            {% if configcontext.sites.all %}
+                                <ul>
+                                    {% for site in configcontext.sites.all %}
+                                        <li><a href="{{ site.get_absolute_url }}">{{ site }}</a></li>
+                                    {% endfor %}
+                                </ul>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Roles</td>
+                        <td>
+                            {% if configcontext.roles.all %}
+                                <ul>
+                                    {% for role in configcontext.roles.all %}
+                                        <li><a href="{{ role.get_absolute_url }}">{{ role }}</a></li>
+                                    {% endfor %}
+                                </ul>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Platforms</td>
+                        <td>
+                            {% if configcontext.platforms.all %}
+                                <ul>
+                                    {% for platform in configcontext.platforms.all %}
+                                        <li><a href="{{ platform.get_absolute_url }}">{{ platform }}</a></li>
+                                    {% endfor %}
+                                </ul>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Tenants</td>
+                        <td>
+                            {% if configcontext.tenants.all %}
+                                <ul>
+                                    {% for tenant in configcontext.tenants.all %}
+                                        <li><a href="{{ tenant.get_absolute_url }}">{{ tenant }}</a></li>
+                                    {% endfor %}
+                                </ul>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                </table>
+            </div>
+        </div>
+        <div class="col-md-7">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Data</strong>
+                </div>
+                <div class="panel-body">
+                    <pre>{{ configcontext.data|render_json }}</pre>
+                </div>
+            </div>
+        </div>
+    </div>
+{% endblock %}

+ 30 - 0
netbox/templates/extras/configcontext_edit.html

@@ -0,0 +1,30 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Config Context</strong></div>
+        <div class="panel-body">
+            {% render_field form.name %}
+            {% render_field form.weight %}
+            {% render_field form.description %}
+            {% render_field form.is_active %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Assignment</strong></div>
+        <div class="panel-body">
+            {% render_field form.regions %}
+            {% render_field form.sites %}
+            {% render_field form.roles %}
+            {% render_field form.platforms %}
+            {% render_field form.tenants %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Data</strong></div>
+        <div class="panel-body">
+            {% render_field form.data %}
+        </div>
+    </div>
+{% endblock %}

+ 16 - 0
netbox/templates/extras/configcontext_list.html

@@ -0,0 +1,16 @@
+{% extends '_base.html' %}
+{% load buttons %}
+
+{% block content %}
+    <div class="pull-right">
+        {% if perms.extras.add_configcontext %}
+            {% add_button 'extras:configcontext_add' %}
+        {% endif %}
+    </div>
+    <h1>{% block title %}Config Contexts{% endblock %}</h1>
+    <div class="row">
+        <div class="col-md-12">
+            {% include 'utilities/obj_table.html' with bulk_delete_url='extras:configcontext_bulk_delete' %}
+        </div>
+    </div>
+{% endblock %}

+ 38 - 0
netbox/templates/extras/object_configcontext.html

@@ -0,0 +1,38 @@
+{% extends base_template %}
+{% load helpers %}
+
+{% block title %}{{ block.super }} - Config Context{% endblock %}
+
+{% block content %}
+    <div class="row">
+        <div class="col-md-6">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Rendered Context</strong>
+                </div>
+                <div class="panel-body">
+                    <pre>{{ rendered_context|render_json }}</pre>
+                </div>
+            </div>
+        </div>
+        <div class="col-md-6">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Source Contexts</strong>
+                </div>
+                {% for context in source_contexts %}
+                    <div class="panel-body">
+                        <div class="pull-right">
+                            <span class="text-muted">{{ context.weight }}</span>
+                        </div>
+                        <a href="{{ context.get_absolute_url }}"><strong>{{ context.name }}</strong></a>
+                        {% if context.description %}
+                            <br /><small>{{ context.description }}</small>
+                        {% endif %}
+                        <pre>{{ context.data|render_json }}</pre>
+                    </div>
+                {% endfor %}
+            </div>
+        </div>
+    </div>
+{% endblock %}

+ 1 - 1
netbox/templates/extras/objectchange.html

@@ -83,7 +83,7 @@
                     <strong>Object Data</strong>
                     <strong>Object Data</strong>
                 </div>
                 </div>
                 <div class="panel-body">
                 <div class="panel-body">
-                    <pre>{{ objectchange.object_data_pretty }}</pre>
+                    <pre>{{ objectchange.object_data|render_json }}</pre>
                 </div>
                 </div>
             </div>
             </div>
         </div>
         </div>

+ 3 - 0
netbox/templates/inc/nav_menu.html

@@ -63,6 +63,9 @@
                         <li>
                         <li>
                             <a href="{% url 'extras:tag_list' %}">Tags</a>
                             <a href="{% url 'extras:tag_list' %}">Tags</a>
                         </li>
                         </li>
+                        <li>
+                            <a href="{% url 'extras:configcontext_list' %}">Config Contexts</a>
+                        </li>
                         <li>
                         <li>
                             <a href="{% url 'extras:report_list' %}">Reports</a>
                             <a href="{% url 'extras:report_list' %}">Reports</a>
                         </li>
                         </li>

+ 3 - 0
netbox/templates/virtualization/virtualmachine.html

@@ -44,6 +44,9 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ virtualmachine.get_absolute_url }}">Virtual Machine</a>
             <a href="{{ virtualmachine.get_absolute_url }}">Virtual Machine</a>
         </li>
         </li>
+        <li role="presentation"{% if active_tab == 'config-context' %} class="active"{% endif %}>
+            <a href="{% url 'virtualization:virtualmachine_configcontext' pk=virtualmachine.pk %}">Config Context</a>
+        </li>
         <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
         <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
             <a href="{% url 'virtualization:virtualmachine_changelog' pk=virtualmachine.pk %}">Changelog</a>
             <a href="{% url 'virtualization:virtualmachine_changelog' pk=virtualmachine.pk %}">Changelog</a>
         </li>
         </li>

+ 9 - 0
netbox/utilities/templatetags/helpers.py

@@ -1,6 +1,7 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
 import datetime
 import datetime
+import json
 
 
 from django import template
 from django import template
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
@@ -46,6 +47,14 @@ def gfm(value):
     return mark_safe(html)
     return mark_safe(html)
 
 
 
 
+@register.filter()
+def render_json(value):
+    """
+    Render a dictionary as formatted JSON.
+    """
+    return json.dumps(value, indent=4, sort_keys=True)
+
+
 @register.filter()
 @register.filter()
 def model_name(obj):
 def model_name(obj):
     """
     """

+ 9 - 0
netbox/virtualization/api/views.py

@@ -1,5 +1,9 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 
 
+from django.shortcuts import get_object_or_404
+from rest_framework.decorators import detail_route
+from rest_framework.response import Response
+
 from dcim.models import Interface
 from dcim.models import Interface
 from extras.api.views import CustomFieldModelViewSet
 from extras.api.views import CustomFieldModelViewSet
 from utilities.api import FieldChoicesViewSet, ModelViewSet
 from utilities.api import FieldChoicesViewSet, ModelViewSet
@@ -49,6 +53,11 @@ class VirtualMachineViewSet(CustomFieldModelViewSet):
     serializer_class = serializers.VirtualMachineSerializer
     serializer_class = serializers.VirtualMachineSerializer
     filter_class = filters.VirtualMachineFilter
     filter_class = filters.VirtualMachineFilter
 
 
+    @detail_route(url_path='config-context')
+    def config_context(self, request, pk):
+        device = get_object_or_404(VirtualMachine, pk=pk)
+        return Response(device.get_config_context())
+
 
 
 class InterfaceViewSet(ModelViewSet):
 class InterfaceViewSet(ModelViewSet):
     queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine')
     queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine')

+ 2 - 2
netbox/virtualization/models.py

@@ -9,7 +9,7 @@ from django.utils.encoding import python_2_unicode_compatible
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
 from dcim.models import Device
 from dcim.models import Device
-from extras.models import CustomFieldModel
+from extras.models import ConfigContextModel, CustomFieldModel
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
 from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
 from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
 
 
@@ -168,7 +168,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
 #
 #
 
 
 @python_2_unicode_compatible
 @python_2_unicode_compatible
-class VirtualMachine(ChangeLoggedModel, CustomFieldModel):
+class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     """
     """
     A virtual machine which runs inside a Cluster.
     A virtual machine which runs inside a Cluster.
     """
     """

+ 1 - 0
netbox/virtualization/urls.py

@@ -48,6 +48,7 @@ urlpatterns = [
     url(r'^virtual-machines/(?P<pk>\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'),
     url(r'^virtual-machines/(?P<pk>\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'),
     url(r'^virtual-machines/(?P<pk>\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'),
     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+)/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+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
     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'),
     url(r'^virtual-machines/(?P<virtualmachine>\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'),
 
 

+ 6 - 0
netbox/virtualization/views.py

@@ -9,6 +9,7 @@ from django.views.generic import View
 
 
 from dcim.models import Device, Interface
 from dcim.models import Device, Interface
 from dcim.tables import DeviceTable
 from dcim.tables import DeviceTable
+from extras.views import ObjectConfigContextView
 from ipam.models import Service
 from ipam.models import Service
 from utilities.views import (
 from utilities.views import (
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
     BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
@@ -269,6 +270,11 @@ class VirtualMachineView(View):
         })
         })
 
 
 
 
+class VirtualMachineConfigContextView(ObjectConfigContextView):
+    object_class = VirtualMachine
+    base_template = 'virtualization/virtualmachine.html'
+
+
 class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView):
 class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'virtualization.add_virtualmachine'
     permission_required = 'virtualization.add_virtualmachine'
     model = VirtualMachine
     model = VirtualMachine