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

Initial work on config contexts

Jeremy Stretch 7 лет назад
Родитель
Сommit
c13e4858d7

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

@@ -3,7 +3,6 @@ from __future__ import unicode_literals
 from collections import OrderedDict
 
 from django.conf import settings
-from django.db import transaction
 from django.http import HttpResponseBadRequest, HttpResponseForbidden
 from django.shortcuts import get_object_or_404
 from drf_yasg import openapi
@@ -233,6 +232,11 @@ class DeviceViewSet(CustomFieldModelViewSet):
     serializer_class = serializers.DeviceSerializer
     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')
     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 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 utilities.fields import ColorField, NullableCharField
 from utilities.managers import NaturalOrderByManager
@@ -1158,7 +1158,7 @@ class DeviceManager(NaturalOrderByManager):
 
 
 @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,
     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+)/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+)/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'),

+ 12 - 0
netbox/dcim/views.py

@@ -994,6 +994,18 @@ class DeviceConfigView(PermissionRequiredMixin, View):
         })
 
 
+class DeviceConfigContextView(View):
+
+    def get(self, request, pk):
+
+        device = get_object_or_404(Device, pk=pk)
+
+        return render(request, 'dcim/device_configcontext.html', {
+            'device': device,
+            'active_tab': 'config-context',
+        })
+
+
 class DeviceCreateView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.add_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 .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
 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
 #

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

@@ -4,10 +4,16 @@ from django.core.exceptions import ObjectDoesNotExist
 from rest_framework import serializers
 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 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 tenancy.api.serializers import NestedTenantSerializer
 from users.api.serializers import NestedUserSerializer
 from utilities.api import (
     ChoiceFieldSerializer, ContentTypeFieldSerializer, get_serializer_for_model, ValidatedModelSerializer,
@@ -121,6 +127,22 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
         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', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', 'data']
+
+
 #
 # Reports
 #

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

@@ -34,6 +34,9 @@ router.register(r'tags', views.TagViewSet)
 # Image attachments
 router.register(r'image-attachments', views.ImageAttachmentViewSet)
 
+# Config contexts
+router.register(r'config-contexts', views.ConfigContextViewSet)
+
 # Reports
 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.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 utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
@@ -132,6 +133,15 @@ class ImageAttachmentViewSet(ModelViewSet):
     serializer_class = serializers.ImageAttachmentSerializer
 
 
+#
+# Config contexts
+#
+
+class ConfigContextViewSet(ModelViewSet):
+    queryset = ConfigContext.objects.prefetch_related('regions', 'sites', 'roles', 'platforms', 'tenants')
+    serializer_class = serializers.ConfigContextSerializer
+
+
 #
 # Reports
 #

+ 18 - 2
netbox/extras/forms.py

@@ -5,14 +5,16 @@ from collections import OrderedDict
 from django import forms
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
+from mptt.forms import TreeNodeMultipleChoiceField
 from taggit.models import Tag
 
+from dcim.models import Region
 from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, SlugField
 from .constants import (
     CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
     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
 #
-#
 
 class TagForm(BootstrapMixin, forms.ModelForm):
     slug = SlugField()
@@ -184,6 +185,21 @@ class TagForm(BootstrapMixin, forms.ModelForm):
         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', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', 'data']
+
+
 #
 # Image attachments
 #

+ 44 - 0
netbox/extras/migrations/0014_config-contexts.py

@@ -0,0 +1,44 @@
+# Generated by Django 2.0.6 on 2018-06-27 17:45
+
+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)),
+                ('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'),
+        ),
+    ]

+ 88 - 0
netbox/extras/models.py

@@ -629,6 +629,94 @@ class ImageAttachment(models.Model):
             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
+    )
+    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()
+
+    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.
+        """
+
+        # `device_role` for Device; `role` for VirtualMachine
+        role = getattr(self, 'device_role', None) or self.role
+
+        # Gather all ConfigContexts orders by weight, name
+        contexts = ConfigContext.objects.filter(
+            Q(regions=self.site.region) | Q(regions=None),
+            Q(sites=self.site) | Q(sites=None),
+            Q(roles=role) | Q(roles=None),
+            Q(tenants=self.tenant) | Q(tenants=None),
+            Q(platforms=self.platform) | Q(platforms=None),
+            is_active=True,
+        ).order_by('weight', 'name')
+
+        # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
+        data = {}
+        for context in contexts:
+            data.update(context.data)
+
+        return data
+
+
 #
 # Report results
 #

+ 25 - 2
netbox/extras/tables.py

@@ -4,7 +4,7 @@ import django_tables2 as tables
 from taggit.models import Tag
 
 from utilities.tables import BaseTable, ToggleColumn
-from .models import ObjectChange
+from .models import ConfigContext, ObjectChange
 
 TAG_ACTIONS = """
 {% if perms.taggit.change_tag %}
@@ -15,6 +15,15 @@ TAG_ACTIONS = """
 {% 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 = """
 {% if record.action == 1 %}
     <span class="label label-success">Created</span>
@@ -44,7 +53,21 @@ class TagTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         model = Tag
-        fields = ('pk', 'name', 'items')
+        fields = ('pk', 'name', 'weight')
+
+
+class ConfigContextTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.LinkColumn()
+    actions = tables.TemplateColumn(
+        template_code=CONFIGCONTEXT_ACTIONS,
+        attrs={'td': {'class': 'text-right'}},
+        verbose_name=''
+    )
+
+    class Meta(BaseTable.Meta):
+        model = ConfigContext
+        fields = ('pk', 'name', 'weight', 'active')
 
 
 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/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
     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'),

+ 50 - 3
netbox/extras/views.py

@@ -14,10 +14,10 @@ from taggit.models import Tag
 from utilities.forms import ConfirmationForm
 from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
 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 .tables import ObjectChangeTable, TagTable
+from .tables import ConfigContextTable, ObjectChangeTable, TagTable
 
 
 #
@@ -53,6 +53,53 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     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'
+
+
 #
 # Change logging
 #

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

@@ -69,6 +69,9 @@
                 {% include 'dcim/inc/device_napalm_tabs.html' %}
             {% 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 %}>
             <a href="{% url 'dcim:device_changelog' pk=device.pk %}">Changelog</a>
         </li>

+ 18 - 0
netbox/templates/dcim/device_configcontext.html

@@ -0,0 +1,18 @@
+{% extends 'dcim/device.html' %}
+
+{% block title %}{{ device }} - Config Context{% endblock %}
+
+{% block content %}
+    <div class="row">
+        <div class="col-md-6">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Config Context</strong>
+                </div>
+                <div class="panel-body">
+                    <pre>{{ device.get_config_context }}</pre>
+                </div>
+            </div>
+        </div>
+    </div>
+{% endblock %}

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

@@ -0,0 +1,154 @@
+{% 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>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>
+                    <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 }}</pre>
+                </div>
+            </div>
+        </div>
+    </div>
+{% endblock %}

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

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

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

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

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

@@ -44,6 +44,9 @@
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ virtualmachine.get_absolute_url }}">Virtual Machine</a>
         </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 %}>
             <a href="{% url 'virtualization:virtualmachine_changelog' pk=virtualmachine.pk %}">Changelog</a>
         </li>

+ 18 - 0
netbox/templates/virtualization/virtualmachine_configcontext.html

@@ -0,0 +1,18 @@
+{% extends 'virtualization/virtualmachine.html' %}
+
+{% block title %}{{ virtualmachine }} - Config Context{% endblock %}
+
+{% block content %}
+    <div class="row">
+        <div class="col-md-6">
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Config Context</strong>
+                </div>
+                <div class="panel-body">
+                    <pre>{{ virtualmachine.get_config_context }}</pre>
+                </div>
+            </div>
+        </div>
+    </div>
+{% endblock %}

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

@@ -1,5 +1,9 @@
 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 extras.api.views import CustomFieldModelViewSet
 from utilities.api import FieldChoicesViewSet, ModelViewSet
@@ -49,6 +53,11 @@ class VirtualMachineViewSet(CustomFieldModelViewSet):
     serializer_class = serializers.VirtualMachineSerializer
     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):
     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 dcim.models import Device
-from extras.models import CustomFieldModel
+from extras.models import ConfigContextModel, CustomFieldModel
 from utilities.models import ChangeLoggedModel
 from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
 
@@ -168,7 +168,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
 #
 
 @python_2_unicode_compatible
-class VirtualMachine(ChangeLoggedModel, CustomFieldModel):
+class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     """
     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+)/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+)/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'),
 

+ 12 - 0
netbox/virtualization/views.py

@@ -269,6 +269,18 @@ class VirtualMachineView(View):
         })
 
 
+class VirtualMachineConfigContextView(View):
+
+    def get(self, request, pk):
+
+        virtualmachine = get_object_or_404(VirtualMachine, pk=pk)
+
+        return render(request, 'virtualization/virtualmachine_configcontext.html', {
+            'virtualmachine': virtualmachine,
+            'active_tab': 'config-context',
+        })
+
+
 class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'virtualization.add_virtualmachine'
     model = VirtualMachine