Initial work on config contexts

This commit is contained in:
Jeremy Stretch 2018-06-27 16:02:34 -04:00
parent ffcbc54522
commit c13e4858d7
26 changed files with 565 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.
"""

View File

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

View File

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