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

View File

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

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+)/$', 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'),

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): class DeviceCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_device' permission_required = 'dcim.add_device'
model = Device model = Device

View File

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

View File

@ -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,22 @@ 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', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', 'data']
# #
# Reports # Reports
# #

View File

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

View File

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

View File

@ -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,21 @@ 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', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenants', 'data']
# #
# Image attachments # 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 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 # Report results
# #

View File

@ -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,21 @@ 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()
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): 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/(?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'),

View File

@ -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 .forms import ConfigContextForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm
from .models import ImageAttachment, ObjectChange, ReportResult 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,53 @@ 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'
# #
# Change logging # Change logging
# #

View File

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

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

View File

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

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

View File

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

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+)/$', 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'),

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): class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'virtualization.add_virtualmachine' permission_required = 'virtualization.add_virtualmachine'
model = VirtualMachine model = VirtualMachine