Browse Source

Initial work on virtualization support (#142)

Jeremy Stretch 7 years ago
parent
commit
d06813f528

+ 8 - 82
netbox/dcim/views.py

@@ -23,7 +23,8 @@ from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_S
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.views import (
-    BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+    BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView,
+    ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 from . import filters, forms, tables
 from .models import (
@@ -60,87 +61,6 @@ def expand_pattern(string):
             yield "{0}{1}".format(lead, i)
 
 
-class ComponentCreateView(View):
-    parent_model = None
-    parent_field = None
-    model = None
-    form = None
-    model_form = None
-
-    def get(self, request, pk):
-
-        parent = get_object_or_404(self.parent_model, pk=pk)
-        form = self.form(parent, initial=request.GET)
-
-        return render(request, 'dcim/device_component_add.html', {
-            'parent': parent,
-            'component_type': self.model._meta.verbose_name,
-            'form': form,
-            'return_url': parent.get_absolute_url(),
-        })
-
-    def post(self, request, pk):
-
-        parent = get_object_or_404(self.parent_model, pk=pk)
-
-        form = self.form(parent, request.POST)
-        if form.is_valid():
-
-            new_components = []
-            data = deepcopy(form.cleaned_data)
-
-            for name in form.cleaned_data['name_pattern']:
-                component_data = {
-                    self.parent_field: parent.pk,
-                    'name': name,
-                }
-                # Replace objects with their primary key to keep component_form.clean() happy
-                for k, v in data.items():
-                    if hasattr(v, 'pk'):
-                        component_data[k] = v.pk
-                    else:
-                        component_data[k] = v
-                component_form = self.model_form(component_data)
-                if component_form.is_valid():
-                    new_components.append(component_form.save(commit=False))
-                else:
-                    for field, errors in component_form.errors.as_data().items():
-                        # Assign errors on the child form's name field to name_pattern on the parent form
-                        if field == 'name':
-                            field = 'name_pattern'
-                        for e in errors:
-                            form.add_error(field, '{}: {}'.format(name, ', '.join(e)))
-
-            if not form.errors:
-                self.model.objects.bulk_create(new_components)
-                messages.success(request, "Added {} {} to {}.".format(
-                    len(new_components), self.model._meta.verbose_name_plural, parent
-                ))
-                if '_addanother' in request.POST:
-                    return redirect(request.path)
-                else:
-                    return redirect(parent.get_absolute_url())
-
-        return render(request, 'dcim/device_component_add.html', {
-            'parent': parent,
-            'component_type': self.model._meta.verbose_name,
-            'form': form,
-            'return_url': parent.get_absolute_url(),
-        })
-
-
-class ComponentEditView(ObjectEditView):
-
-    def get_return_url(self, request, obj):
-        return obj.device.get_absolute_url()
-
-
-class ComponentDeleteView(ObjectDeleteView):
-
-    def get_return_url(self, request, obj):
-        return obj.device.get_absolute_url()
-
-
 class BulkDisconnectView(View):
     """
     An extendable view for disconnection console/power/interface components in bulk.
@@ -662,6 +582,7 @@ class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
     model = ConsolePortTemplate
     form = forms.ConsolePortTemplateCreateForm
     model_form = forms.ConsolePortTemplateForm
+    template_name = 'dcim/device_component_add.html'
 
 
 class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -680,6 +601,7 @@ class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCrea
     model = ConsoleServerPortTemplate
     form = forms.ConsoleServerPortTemplateCreateForm
     model_form = forms.ConsoleServerPortTemplateForm
+    template_name = 'dcim/device_component_add.html'
 
 
 class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -696,6 +618,7 @@ class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     model = PowerPortTemplate
     form = forms.PowerPortTemplateCreateForm
     model_form = forms.PowerPortTemplateForm
+    template_name = 'dcim/device_component_add.html'
 
 
 class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -712,6 +635,7 @@ class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
     model = PowerOutletTemplate
     form = forms.PowerOutletTemplateCreateForm
     model_form = forms.PowerOutletTemplateForm
+    template_name = 'dcim/device_component_add.html'
 
 
 class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -728,6 +652,7 @@ class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     model = InterfaceTemplate
     form = forms.InterfaceTemplateCreateForm
     model_form = forms.InterfaceTemplateForm
+    template_name = 'dcim/device_component_add.html'
 
 
 class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
@@ -752,6 +677,7 @@ class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     model = DeviceBayTemplate
     form = forms.DeviceBayTemplateCreateForm
     model_form = forms.DeviceBayTemplateForm
+    template_name = 'dcim/device_component_add.html'
 
 
 class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):

+ 1 - 0
netbox/netbox/settings.py

@@ -145,6 +145,7 @@ INSTALLED_APPS = (
     'tenancy',
     'users',
     'utilities',
+    'virtualization',
 )
 
 # Middleware

+ 1 - 0
netbox/netbox/urls.py

@@ -32,6 +32,7 @@ _patterns = [
     url(r'^secrets/', include('secrets.urls')),
     url(r'^tenancy/', include('tenancy.urls')),
     url(r'^user/', include('users.urls')),
+    url(r'^virtualization/', include('virtualization.urls')),
 
     # API
     url(r'^api/$', APIRootView.as_view(), name='api-root'),

+ 28 - 0
netbox/templates/_base.html

@@ -202,6 +202,34 @@
                             {% endif %}
                         </ul>
                     </li>
+                    <li class="dropdown{% if request.path|contains:'/virtualization/' %} active{% endif %}">
+                        <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Virtualization <span class="caret"></span></a>
+                        <ul class="dropdown-menu">
+                            <li><a href="{% url 'virtualization:cluster_list' %}"><strong>Clusters</strong></a></li>
+                            {% if perms.virtualization.add_cluster %}
+                                <li class="subnav"><a href="{% url 'virtualization:cluster_add' %}"><i class="fa fa-plus"></i> Add a Cluster</a></li>
+                                <li class="subnav"><a href="{% url 'virtualization:cluster_import' %}"><i class="fa fa-download"></i> Import Clusters</a></li>
+                            {% endif %}
+                            {% if perms.virtualization.add_cluster or perms.virtualization.add_virtualmachine %}
+                                <li class="divider"></li>
+                            {% endif %}
+                            <li><a href="{% url 'virtualization:virtualmachine_list' %}"><strong>Virtual Machines</strong></a></li>
+                            {% if perms.virtualization.add_virtualmachine %}
+                                <li class="subnav"><a href="{% url 'virtualization:virtualmachine_add' %}"><i class="fa fa-plus"></i> Add a Virtual Machine</a></li>
+                                <li class="subnav"><a href="{% url 'virtualization:virtualmachine_import' %}"><i class="fa fa-download"></i> Import Virtual Machines</a></li>
+                            {% endif %}
+                            <li class="divider"></li>
+                            <li><a href="{% url 'virtualization:clustertype_list' %}"><strong>Cluster Types</strong></a></li>
+                            {% if perms.virtualization.add_clustertype %}
+                                <li class="subnav"><a href="{% url 'virtualization:clustertype_add' %}"><i class="fa fa-plus"></i> Add a Cluster Type</a></li>
+                            {% endif %}
+                            <li class="divider"></li>
+                            <li><a href="{% url 'virtualization:clustergroup_list' %}"><strong>Cluster Groups</strong></a></li>
+                            {% if perms.virtualization.add_clustergroup %}
+                                <li class="subnav"><a href="{% url 'virtualization:clustergroup_add' %}"><i class="fa fa-plus"></i> Add a Cluster Group</a></li>
+                            {% endif %}
+                        </ul>
+                    </li>
                     <li class="dropdown{% if request.path|contains:'/circuits/' %} active{% endif %}">
                         <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Circuits <span class="caret"></span></a>
                         <ul class="dropdown-menu">

+ 92 - 0
netbox/templates/virtualization/cluster.html

@@ -0,0 +1,92 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block content %}
+<div class="row">
+    <div class="col-sm-8 col-md-9">
+    </div>
+    <div class="col-sm-4 col-md-3">
+        <form action="{% url 'virtualization:cluster_list' %}" method="get">
+            <div class="input-group">
+                <input type="text" name="q" class="form-control" placeholder="Search clusters" />
+                <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.virtualization.change_cluster %}
+		<a href="{% url 'virtualization:cluster_edit' pk=cluster.pk %}" class="btn btn-warning">
+			<span class="fa fa-pencil" aria-hidden="true"></span>
+			Edit this cluster
+		</a>
+    {% endif %}
+    {% if perms.dcim.delete_cluster %}
+		<a href="{% url 'virtualization:cluster_delete' pk=cluster.pk %}" class="btn btn-danger">
+			<span class="fa fa-trash" aria-hidden="true"></span>
+			Delete this cluster
+		</a>
+    {% endif %}
+</div>
+<h1>{% block title %}{{ cluster }}{% endblock %}</h1>
+{% include 'inc/created_updated.html' with obj=cluster %}
+<div class="row">
+	<div class="col-md-5">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Cluster</strong>
+            </div>
+            <table class="table table-hover panel-body attr-table">
+                <tr>
+                    <td>Name</td>
+                    <td>{{ cluster.name }}</td>
+                </tr>
+                <tr>
+                    <td>Type</td>
+                    <td><a href="{{ cluster.type.get_absolute_url }}">{{ cluster.type }}</a></td>
+                </tr>
+                <tr>
+                    <td>Group</td>
+                    <td>
+                        {% if cluster.group %}
+                            <a href="{{ cluster.group.get_absolute_url }}">{{ cluster.group }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Virtual Machines</td>
+                    <td><a href="{% url 'virtualization:virtualmachine_list' %}?cluster={{ cluster.pk }}">{{ cluster.virtual_machines.count }}</a></td>
+                </tr>
+            </table>
+        </div>
+        {% include 'inc/custom_fields_panel.html' with custom_fields=cluster.get_custom_fields %}
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Comments</strong>
+            </div>
+            <div class="panel-body">
+                {% if cluster.comments %}
+                    {{ cluster.comments|gfm }}
+                {% else %}
+                    <span class="text-muted">None</span>
+                {% endif %}
+            </div>
+        </div>
+    </div>
+    <div class="col-md-7">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Devices</strong>
+            </div>
+            <div class="panel-body">
+            </div>
+        </div>
+	</div>
+</div>
+{% endblock %}

+ 26 - 0
netbox/templates/virtualization/cluster_list.html

@@ -0,0 +1,26 @@
+{% extends '_base.html' %}
+
+{% block content %}
+<div class="pull-right">
+    {% if perms.virtualization.add_cluster %}
+		<a href="{% url 'virtualization:cluster_add' %}" class="btn btn-primary">
+			<span class="fa fa-plus" aria-hidden="true"></span>
+			Add a cluster
+		</a>
+        <a href="{% url 'virtualization:cluster_import' %}" class="btn btn-info">
+            <span class="fa fa-download" aria-hidden="true"></span>
+            Import clusters
+        </a>
+    {% endif %}
+    {% include 'inc/export_button.html' with obj_type='clusters' %}
+</div>
+<h1>{% block title %}Clusters{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' %}
+    </div>
+    <div class="col-md-3">
+		{% include 'inc/search_panel.html' %}
+    </div>
+</div>
+{% endblock %}

+ 19 - 0
netbox/templates/virtualization/clustergroup_list.html

@@ -0,0 +1,19 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block content %}
+<div class="pull-right">
+    {% if perms.virtualization.add_clustergroup %}
+        <a href="{% url 'virtualization:clustergroup_add' %}" class="btn btn-primary">
+            <span class="fa fa-plus" aria-hidden="true"></span>
+            Add a cluster group
+        </a>
+    {% endif %}
+</div>
+<h1>{% block title %}Cluster Groups{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-12">
+        {% include 'utilities/obj_table.html' with bulk_delete_url='virtualization:clustergroup_bulk_delete' %}
+    </div>
+</div>
+{% endblock %}

+ 19 - 0
netbox/templates/virtualization/clustertype_list.html

@@ -0,0 +1,19 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block content %}
+<div class="pull-right">
+    {% if perms.virtualization.add_clustertype %}
+        <a href="{% url 'virtualization:clustertype_add' %}" class="btn btn-primary">
+            <span class="fa fa-plus" aria-hidden="true"></span>
+            Add a cluster type
+        </a>
+    {% endif %}
+</div>
+<h1>{% block title %}Cluster Types{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-12">
+        {% include 'utilities/obj_table.html' with bulk_delete_url='virtualization:clustertype_bulk_delete' %}
+    </div>
+</div>
+{% endblock %}

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

@@ -0,0 +1,77 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block content %}
+<div class="row">
+    <div class="col-sm-8 col-md-9">
+        <ol class="breadcrumb">
+            {% if vm.cluster %}
+                <li><a href="{{ vm.cluster.get_absolute_url }}">{{ vm.cluster }}</a></li>
+            {% endif %}
+            <li>{{ vm }}</li>
+        </ol>
+    </div>
+    <div class="col-sm-4 col-md-3">
+        <form action="{% url 'virtualization:virtualmachine_list' %}" method="get">
+            <div class="input-group">
+                <input type="text" name="q" class="form-control" placeholder="Search virtual machines" />
+                <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.virtualization.change_virtualmachine %}
+		<a href="{% url 'virtualization:virtualmachine_edit' pk=vm.pk %}" class="btn btn-warning">
+			<span class="fa fa-pencil" aria-hidden="true"></span>
+			Edit this VM
+		</a>
+    {% endif %}
+    {% if perms.virtualization.delete_virtualmachine %}
+		<a href="{% url 'virtualization:virtualmachine_delete' pk=vm.pk %}" class="btn btn-danger">
+			<span class="fa fa-trash" aria-hidden="true"></span>
+			Delete this VM
+		</a>
+    {% endif %}
+</div>
+<h1>{% block title %}{{ vm }}{% endblock %}</h1>
+{% include 'inc/created_updated.html' with obj=vm %}
+<div class="row">
+	<div class="col-md-7">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Virtual Machine</strong>
+            </div>
+            <table class="table table-hover panel-body attr-table">
+                <tr>
+                    <td>Name</td>
+                    <td>{{ vm.name }}</td>
+                </tr>
+                <tr>
+                    <td>Cluster</td>
+                    <td><a href="{{ vm.cluster.get_absolute_url }}">{{ vm.cluster }}</a></td>
+                </tr>
+            </table>
+        </div>
+        {% include 'inc/custom_fields_panel.html' with custom_fields=vm.get_custom_fields %}
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Comments</strong>
+            </div>
+            <div class="panel-body">
+                {% if vm.comments %}
+                    {{ vm.comments|gfm }}
+                {% else %}
+                    <span class="text-muted">None</span>
+                {% endif %}
+            </div>
+        </div>
+    </div>
+    <div class="col-md-5">
+	</div>
+</div>
+{% endblock %}

+ 39 - 0
netbox/templates/virtualization/virtualmachine_edit.html

@@ -0,0 +1,39 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Virtual Machine</strong></div>
+        <div class="panel-body">
+            {% render_field form.name %}
+            {% render_field form.platform %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Cluster</strong></div>
+        <div class="panel-body">
+            {% render_field form.cluster %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Tenancy</strong></div>
+        <div class="panel-body">
+            {% render_field form.tenant_group %}
+            {% render_field form.tenant %}
+        </div>
+    </div>
+    {% if form.custom_fields %}
+        <div class="panel panel-default">
+            <div class="panel-heading"><strong>Custom Fields</strong></div>
+            <div class="panel-body">
+                {% render_custom_fields form %}
+            </div>
+        </div>
+    {% endif %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Comments</strong></div>
+        <div class="panel-body">
+            {% render_field form.comments %}
+        </div>
+    </div>
+{% endblock %}

+ 26 - 0
netbox/templates/virtualization/virtualmachine_list.html

@@ -0,0 +1,26 @@
+{% extends '_base.html' %}
+
+{% block content %}
+<div class="pull-right">
+    {% if perms.virtualization.add_virtualmachine %}
+		<a href="{% url 'virtualization:virtualmachine_add' %}" class="btn btn-primary">
+			<span class="fa fa-plus" aria-hidden="true"></span>
+			Add a virtual machine
+		</a>
+        <a href="{% url 'virtualization:virtualmachine_import' %}" class="btn btn-info">
+            <span class="fa fa-download" aria-hidden="true"></span>
+            Import virtual machines
+        </a>
+    {% endif %}
+    {% include 'inc/export_button.html' with obj_type='virtualmachines' %}
+</div>
+<h1>{% block title %}Virtual Machines{% endblock %}</h1>
+<div class="row">
+	<div class="col-md-9">
+        {% include 'utilities/obj_table.html' %}
+    </div>
+    <div class="col-md-3">
+		{% include 'inc/search_panel.html' %}
+    </div>
+</div>
+{% endblock %}

+ 87 - 0
netbox/utilities/views.py

@@ -1,5 +1,6 @@
 from __future__ import unicode_literals
 from collections import OrderedDict
+from copy import deepcopy
 
 from django_tables2 import RequestConfig
 
@@ -697,3 +698,89 @@ class BulkDeleteView(View):
         if self.form:
             return self.form
         return BulkDeleteForm
+
+
+#
+# Device/VirtualMachine components
+#
+
+class ComponentCreateView(View):
+    parent_model = None
+    parent_field = None
+    model = None
+    form = None
+    model_form = None
+    template_name = None
+
+    def get(self, request, pk):
+
+        parent = get_object_or_404(self.parent_model, pk=pk)
+        form = self.form(parent, initial=request.GET)
+
+        return render(request, self.template_name, {
+            'parent': parent,
+            'component_type': self.model._meta.verbose_name,
+            'form': form,
+            'return_url': parent.get_absolute_url(),
+        })
+
+    def post(self, request, pk):
+
+        parent = get_object_or_404(self.parent_model, pk=pk)
+
+        form = self.form(parent, request.POST)
+        if form.is_valid():
+
+            new_components = []
+            data = deepcopy(form.cleaned_data)
+
+            for name in form.cleaned_data['name_pattern']:
+                component_data = {
+                    self.parent_field: parent.pk,
+                    'name': name,
+                }
+                # Replace objects with their primary key to keep component_form.clean() happy
+                for k, v in data.items():
+                    if hasattr(v, 'pk'):
+                        component_data[k] = v.pk
+                    else:
+                        component_data[k] = v
+                component_form = self.model_form(component_data)
+                if component_form.is_valid():
+                    new_components.append(component_form.save(commit=False))
+                else:
+                    for field, errors in component_form.errors.as_data().items():
+                        # Assign errors on the child form's name field to name_pattern on the parent form
+                        if field == 'name':
+                            field = 'name_pattern'
+                        for e in errors:
+                            form.add_error(field, '{}: {}'.format(name, ', '.join(e)))
+
+            if not form.errors:
+                self.model.objects.bulk_create(new_components)
+                messages.success(request, "Added {} {} to {}.".format(
+                    len(new_components), self.model._meta.verbose_name_plural, parent
+                ))
+                if '_addanother' in request.POST:
+                    return redirect(request.path)
+                else:
+                    return redirect(parent.get_absolute_url())
+
+        return render(request, self.template_name, {
+            'parent': parent,
+            'component_type': self.model._meta.verbose_name,
+            'form': form,
+            'return_url': parent.get_absolute_url(),
+        })
+
+
+class ComponentEditView(ObjectEditView):
+
+    def get_return_url(self, request, obj):
+        return obj.device.get_absolute_url()
+
+
+class ComponentDeleteView(ObjectDeleteView):
+
+    def get_return_url(self, request, obj):
+        return obj.device.get_absolute_url()

+ 0 - 0
netbox/virtualization/__init__.py


+ 7 - 0
netbox/virtualization/apps.py

@@ -0,0 +1,7 @@
+from __future__ import unicode_literals
+
+from django.apps import AppConfig
+
+
+class VirtualizationConfig(AppConfig):
+    name = 'virtualization'

+ 101 - 0
netbox/virtualization/forms.py

@@ -0,0 +1,101 @@
+from __future__ import unicode_literals
+
+from django import forms
+
+from extras.forms import CustomFieldBulkEditForm, CustomFieldForm
+from tenancy.forms import TenancyForm
+from tenancy.models import Tenant
+from utilities.forms import BootstrapMixin, SlugField
+from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+
+
+#
+# Cluster types
+#
+
+class ClusterTypeForm(BootstrapMixin, forms.ModelForm):
+    slug = SlugField()
+
+    class Meta:
+        model = ClusterType
+        fields = ['name', 'slug']
+
+
+#
+# Cluster groups
+#
+
+class ClusterGroupForm(BootstrapMixin, forms.ModelForm):
+    slug = SlugField()
+
+    class Meta:
+        model = ClusterGroup
+        fields = ['name', 'slug']
+
+
+#
+# Clusters
+#
+
+class ClusterForm(BootstrapMixin, CustomFieldForm):
+
+    class Meta:
+        model = Cluster
+        fields = ['name', 'type', 'group']
+
+
+class ClusterCSVForm(forms.ModelForm):
+    type = forms.ModelChoiceField(
+        queryset=ClusterType.objects.all(),
+        to_field_name='name',
+        help_text='Name of cluster type',
+        error_messages={
+            'invalid_choice': 'Invalid cluster type name.',
+        }
+    )
+    group = forms.ModelChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Name of cluster group',
+        error_messages={
+            'invalid_choice': 'Invalid cluster group name.',
+        }
+    )
+
+    class Meta:
+        fields = ['name', 'type', 'group']
+
+
+#
+# Virtual Machines
+#
+
+class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+
+    class Meta:
+        model = VirtualMachine
+        fields = ['name', 'cluster', 'tenant', 'platform', 'comments']
+
+
+class VirtualMachineCSVForm(forms.ModelForm):
+    cluster = forms.ModelChoiceField(
+        queryset=Cluster.objects.all(),
+        to_field_name='name',
+        help_text='Name of parent cluster',
+        error_messages={
+            'invalid_choice': 'Invalid cluster name.',
+        }
+    )
+
+    class Meta:
+        fields = ['cluster', 'name', 'tenant']
+
+
+class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput)
+    cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False, label='Cluster')
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
+
+    class Meta:
+        nullable_fields = ['tenant']

+ 107 - 0
netbox/virtualization/migrations/0001_initial.py

@@ -0,0 +1,107 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.3 on 2017-08-04 20:51
+from __future__ import unicode_literals
+
+import dcim.fields
+from django.db import migrations, models
+import django.db.models.deletion
+import extras.models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('ipam', '0018_remove_service_uniqueness_constraint'),
+        ('tenancy', '0003_unicode_literals'),
+        ('dcim', '0041_napalm_integration'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Cluster',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created', models.DateField(auto_now_add=True)),
+                ('last_updated', models.DateTimeField(auto_now=True)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('comments', models.TextField(blank=True)),
+                ('devices', models.ManyToManyField(to='dcim.Device')),
+            ],
+            options={
+                'ordering': ['name'],
+            },
+            bases=(models.Model, extras.models.CustomFieldModel),
+        ),
+        migrations.CreateModel(
+            name='ClusterGroup',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=50, unique=True)),
+                ('slug', models.SlugField(unique=True)),
+            ],
+            options={
+                'ordering': ['name'],
+            },
+        ),
+        migrations.CreateModel(
+            name='ClusterType',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=50, unique=True)),
+                ('slug', models.SlugField(unique=True)),
+            ],
+            options={
+                'ordering': ['name'],
+            },
+        ),
+        migrations.CreateModel(
+            name='VirtualMachine',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('created', models.DateField(auto_now_add=True)),
+                ('last_updated', models.DateTimeField(auto_now=True)),
+                ('name', models.CharField(max_length=64, unique=True)),
+                ('comments', models.TextField(blank=True)),
+                ('cluster', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.Cluster')),
+                ('platform', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='virtual_machines', to='dcim.Platform')),
+                ('primary_ip4', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.IPAddress', verbose_name='Primary IPv4')),
+                ('primary_ip6', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.IPAddress', verbose_name='Primary IPv6')),
+                ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='tenancy.Tenant')),
+            ],
+            options={
+                'ordering': ['name'],
+            },
+            bases=(models.Model, extras.models.CustomFieldModel),
+        ),
+        migrations.CreateModel(
+            name='VMInterface',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=30)),
+                ('enabled', models.BooleanField(default=True)),
+                ('mac_address', dcim.fields.MACAddressField(blank=True, null=True, verbose_name='MAC Address')),
+                ('mtu', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU')),
+                ('description', models.CharField(blank=True, max_length=100)),
+                ('virtual_machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine')),
+            ],
+            options={
+                'ordering': ['virtual_machine', 'name'],
+            },
+        ),
+        migrations.AddField(
+            model_name='cluster',
+            name='group',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='virtualization.ClusterGroup'),
+        ),
+        migrations.AddField(
+            model_name='cluster',
+            name='type',
+            field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='clusters', to='virtualization.ClusterType'),
+        ),
+        migrations.AlterUniqueTogether(
+            name='vminterface',
+            unique_together=set([('virtual_machine', 'name')]),
+        ),
+    ]

+ 0 - 0
netbox/virtualization/migrations/__init__.py


+ 218 - 0
netbox/virtualization/models.py

@@ -0,0 +1,218 @@
+from __future__ import unicode_literals
+
+from django.contrib.contenttypes.fields import GenericRelation
+from django.db import models
+from django.urls import reverse
+from django.utils.encoding import python_2_unicode_compatible
+
+from dcim.fields import MACAddressField
+from extras.models import CustomFieldModel, CustomFieldValue
+from utilities.models import CreatedUpdatedModel
+
+
+#
+# Cluster types
+#
+
+@python_2_unicode_compatible
+class ClusterType(models.Model):
+    """
+    A type of Cluster.
+    """
+    name = models.CharField(
+        max_length=50,
+        unique=True
+    )
+    slug = models.SlugField(
+        unique=True
+    )
+
+    class Meta:
+        ordering = ['name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return "{}?type={}".format(reverse('virtualization:cluster_list'), self.slug)
+
+
+#
+# Cluster groups
+#
+
+@python_2_unicode_compatible
+class ClusterGroup(models.Model):
+    """
+    An organizational group of Clusters.
+    """
+    name = models.CharField(
+        max_length=50,
+        unique=True
+    )
+    slug = models.SlugField(
+        unique=True
+    )
+
+    class Meta:
+        ordering = ['name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return "{}?group={}".format(reverse('virtualization:cluster_list'), self.slug)
+
+
+#
+# Clusters
+#
+
+@python_2_unicode_compatible
+class Cluster(CreatedUpdatedModel, CustomFieldModel):
+    """
+    A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
+    """
+    name = models.CharField(
+        max_length=100,
+        unique=True
+    )
+    type = models.ForeignKey(
+        to=ClusterType,
+        on_delete=models.PROTECT,
+        related_name='clusters'
+    )
+    group = models.ForeignKey(
+        to=ClusterGroup,
+        on_delete=models.PROTECT,
+        related_name='clusters',
+        blank=True,
+        null=True
+    )
+    devices = models.ManyToManyField(
+        to='dcim.Device'
+    )
+    comments = models.TextField(
+        blank=True
+    )
+    custom_field_values = GenericRelation(
+        to=CustomFieldValue,
+        content_type_field='obj_type',
+        object_id_field='obj_id'
+    )
+
+    class Meta:
+        ordering = ['name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('virtualization:cluster', args=[self.pk])
+
+
+#
+# Virtual machines
+#
+
+@python_2_unicode_compatible
+class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
+    """
+    A virtual machine which runs inside a Cluster.
+    """
+    cluster = models.ForeignKey(
+        to=Cluster,
+        on_delete=models.PROTECT,
+        related_name='virtual_machines'
+    )
+    tenant = models.ForeignKey(
+        to='tenancy.Tenant',
+        on_delete=models.PROTECT,
+        related_name='virtual_machines',
+        blank=True,
+        null=True
+    )
+    platform = models.ForeignKey(
+        to='dcim.Platform',
+        on_delete=models.SET_NULL,
+        related_name='virtual_machines',
+        blank=True,
+        null=True
+    )
+    name = models.CharField(
+        max_length=64,
+        unique=True
+    )
+    primary_ip4 = models.OneToOneField(
+        to='ipam.IPAddress',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True,
+        verbose_name='Primary IPv4'
+    )
+    primary_ip6 = models.OneToOneField(
+        to='ipam.IPAddress',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True,
+        verbose_name='Primary IPv6'
+    )
+    comments = models.TextField(
+        blank=True
+    )
+    custom_field_values = GenericRelation(
+        to=CustomFieldValue,
+        content_type_field='obj_type',
+        object_id_field='obj_id'
+    )
+
+    class Meta:
+        ordering = ['name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('virtualization:virtualmachine', args=[self.pk])
+
+
+@python_2_unicode_compatible
+class VMInterface(models.Model):
+    """
+    A virtual interface which belongs to a VirtualMachine. Like the dcim.Interface model, IPAddresses can be assigned to
+    VMInterfaces.
+    """
+    virtual_machine = models.ForeignKey(
+        to=VirtualMachine,
+        on_delete=models.CASCADE,
+        related_name='interfaces'
+    )
+    name = models.CharField(
+        max_length=30
+    )
+    enabled = models.BooleanField(
+        default=True
+    )
+    mac_address = MACAddressField(
+        blank=True,
+        null=True,
+        verbose_name='MAC Address'
+    )
+    mtu = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        verbose_name='MTU'
+    )
+    description = models.CharField(
+        max_length=100,
+        blank=True
+    )
+
+    class Meta:
+        ordering = ['virtual_machine', 'name']
+        unique_together = ['virtual_machine', 'name']
+
+    def __str__(self):
+        return self.name

+ 84 - 0
netbox/virtualization/tables.py

@@ -0,0 +1,84 @@
+from __future__ import unicode_literals
+
+import django_tables2 as tables
+from django_tables2.utils import Accessor
+
+from utilities.tables import BaseTable, ToggleColumn
+from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+
+
+CLUSTERTYPE_ACTIONS = """
+{% if perms.virtualization.change_clustertype %}
+    <a href="{% url 'virtualization:clustertype_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+{% endif %}
+"""
+
+CLUSTERGROUP_ACTIONS = """
+{% if perms.virtualization.change_clustergroup %}
+    <a href="{% url 'virtualization:clustergroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
+{% endif %}
+"""
+
+
+#
+# Cluster types
+#
+
+class ClusterTypeTable(BaseTable):
+    pk = ToggleColumn()
+    cluster_count = tables.Column(verbose_name='Clusters')
+    actions = tables.TemplateColumn(
+        template_code=CLUSTERTYPE_ACTIONS,
+        attrs={'td': {'class': 'text-right'}},
+        verbose_name=''
+    )
+
+    class Meta(BaseTable.Meta):
+        model = ClusterType
+        fields = ('pk', 'name', 'cluster_count', 'actions')
+
+
+#
+# Cluster groups
+#
+
+class ClusterGroupTable(BaseTable):
+    pk = ToggleColumn()
+    cluster_count = tables.Column(verbose_name='Clusters')
+    actions = tables.TemplateColumn(
+        template_code=CLUSTERGROUP_ACTIONS,
+        attrs={'td': {'class': 'text-right'}},
+        verbose_name=''
+    )
+
+    class Meta(BaseTable.Meta):
+        model = ClusterGroup
+        fields = ('pk', 'name', 'cluster_count', 'actions')
+
+
+#
+# Clusters
+#
+
+class ClusterTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.LinkColumn()
+    vm_count = tables.Column(verbose_name='VMs')
+
+    class Meta(BaseTable.Meta):
+        model = Cluster
+        fields = ('pk', 'name', 'type', 'group', 'vm_count')
+
+
+#
+# Virtual machines
+#
+
+class VirtualMachineTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.LinkColumn()
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+
+    class Meta(BaseTable.Meta):
+        model = VirtualMachine
+        fields = ('pk', 'name', 'tenant')

+ 41 - 0
netbox/virtualization/urls.py

@@ -0,0 +1,41 @@
+from __future__ import unicode_literals
+
+from django.conf.urls import url
+
+from . import views
+
+
+app_name = 'virtualization'
+urlpatterns = [
+
+    # Cluster types
+    url(r'^cluster-types/$', views.ClusterTypeListView.as_view(), name='clustertype_list'),
+    url(r'^cluster-types/add/$', views.ClusterTypeCreateView.as_view(), name='clustertype_add'),
+    url(r'^cluster-types/delete/$', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
+    url(r'^cluster-types/(?P<slug>[\w-]+)/edit/$', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
+
+    # Cluster groups
+    url(r'^cluster-groups/$', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
+    url(r'^cluster-groups/add/$', views.ClusterGroupCreateView.as_view(), name='clustergroup_add'),
+    url(r'^cluster-groups/delete/$', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
+    url(r'^cluster-groups/(?P<slug>[\w-]+)/edit/$', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
+
+    # Clusters
+    url(r'^clusters/$', views.ClusterListView.as_view(), name='cluster_list'),
+    url(r'^clusters/add/$', views.ClusterCreateView.as_view(), name='cluster_add'),
+    url(r'^clusters/import/$', views.ClusterBulkImportView.as_view(), name='cluster_import'),
+    # url(r'^clusters/edit/$', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'),
+    url(r'^clusters/(?P<pk>\d+)/$', views.ClusterView.as_view(), name='cluster'),
+    url(r'^clusters/(?P<pk>\d+)/edit/$', views.ClusterEditView.as_view(), name='cluster_edit'),
+    url(r'^clusters/(?P<pk>\d+)/delete/$', views.ClusterDeleteView.as_view(), name='cluster_delete'),
+
+    # Virtual machines
+    url(r'^virtual-machines/$', views.VirtualMachineListView.as_view(), name='virtualmachine_list'),
+    url(r'^virtual-machines/add/$', views.VirtualMachineCreateView.as_view(), name='virtualmachine_add'),
+    url(r'^virtual-machines/import/$', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'),
+    # url(r'^virtual-machines/edit/$', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'),
+    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'),
+
+]

+ 229 - 0
netbox/virtualization/views.py

@@ -0,0 +1,229 @@
+from __future__ import unicode_literals
+
+from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.db.models import Count
+from django.shortcuts import get_object_or_404, render
+from django.urls import reverse
+from django.views.generic import View
+
+from utilities.views import (
+    BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView,
+    ObjectDeleteView, ObjectEditView, ObjectListView,
+)
+from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
+import forms
+import tables
+
+
+#
+# Cluster types
+#
+
+class ClusterTypeListView(ObjectListView):
+    queryset = ClusterType.objects.annotate(cluster_count=Count('clusters'))
+    table = tables.ClusterTypeTable
+    template_name = 'virtualization/clustertype_list.html'
+
+
+class ClusterTypeCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'virtualization.add_clustertype'
+    model = ClusterType
+    form_class = forms.ClusterTypeForm
+
+    def get_return_url(self, request, obj):
+        return reverse('virtualization:clustertype_list')
+
+
+class ClusterTypeEditView(ClusterTypeCreateView):
+    permission_required = 'virtualization.change_clustertype'
+
+
+class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'virtualization.delete_clustertype'
+    cls = ClusterType
+    queryset = ClusterType.objects.annotate(cluster_count=Count('clusters'))
+    table = tables.ClusterTypeTable
+    default_return_url = 'virtualization:clustertype_list'
+
+
+#
+# Cluster groups
+#
+
+class ClusterGroupListView(ObjectListView):
+    queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters'))
+    table = tables.ClusterGroupTable
+    template_name = 'virtualization/clustergroup_list.html'
+
+
+class ClusterGroupCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'virtualization.add_clustergroup'
+    model = ClusterGroup
+    form_class = forms.ClusterGroupForm
+
+    def get_return_url(self, request, obj):
+        return reverse('virtualization:clustergroup_list')
+
+
+class ClusterGroupEditView(ClusterGroupCreateView):
+    permission_required = 'virtualization.change_clustergroup'
+
+
+class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'virtualization.delete_clustergroup'
+    cls = ClusterGroup
+    queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters'))
+    table = tables.ClusterGroupTable
+    default_return_url = 'virtualization:clustergroup_list'
+
+
+#
+# Clusters
+#
+
+class ClusterListView(ObjectListView):
+    queryset = Cluster.objects.annotate(vm_count=Count('virtual_machines'))
+    table = tables.ClusterTable
+    template_name = 'virtualization/cluster_list.html'
+
+
+class ClusterView(View):
+
+    def get(self, request, pk):
+
+        cluster = get_object_or_404(Cluster, pk=pk)
+
+        return render(request, 'virtualization/cluster.html', {
+            'cluster': cluster,
+        })
+
+
+class ClusterCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'virtualization.add_cluster'
+    model = Cluster
+    form_class = forms.ClusterForm
+
+    def get_return_url(self, request, obj):
+        return reverse('virtualization:cluster_list')
+
+
+class ClusterEditView(ClusterCreateView):
+    permission_required = 'virtualization.change_cluster'
+
+
+class ClusterDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'virtualization.delete_cluster'
+    model = Cluster
+    default_return_url = 'virtualization:cluster_list'
+
+
+class ClusterBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'virtualization.add_cluster'
+    model_form = forms.ClusterCSVForm
+    table = tables.ClusterTable
+    default_return_url = 'virtualization:cluster_list'
+
+
+class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'virtualization.delete_cluster'
+    cls = Cluster
+    queryset = Cluster.objects.annotate(vm_count=Count('virtual_machines'))
+    table = tables.ClusterTable
+    default_return_url = 'virtualization:cluster_list'
+
+
+#
+# Virtual machines
+#
+
+class VirtualMachineListView(ObjectListView):
+    queryset = VirtualMachine.objects.select_related('tenant')
+    # filter = filters.VirtualMachineFilter
+    # filter_form = forms.VirtualMachineFilterForm
+    table = tables.VirtualMachineTable
+    template_name = 'virtualization/virtualmachine_list.html'
+
+
+class VirtualMachineView(View):
+
+    def get(self, request, pk):
+
+        vm = get_object_or_404(VirtualMachine.objects.select_related('tenant__group'), pk=pk)
+
+        return render(request, 'virtualization/virtualmachine.html', {
+            'vm': vm,
+        })
+
+
+class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'virtualization.add_virtualmachine'
+    model = VirtualMachine
+    form_class = forms.VirtualMachineForm
+    template_name = 'virtualization/virtualmachine_edit.html'
+    default_return_url = 'virtualization:virtualmachine_list'
+
+
+class VirtualMachineEditView(VirtualMachineCreateView):
+    permission_required = 'virtualization.change_virtualmachine'
+
+
+class VirtualMachineDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'virtualization.delete_virtualmachine'
+    model = VirtualMachine
+    default_return_url = 'virtualization:virtualmachine_list'
+
+
+class VirtualMachineBulkImportView(PermissionRequiredMixin, BulkImportView):
+    permission_required = 'virtualization.add_virtualmachine'
+    model_form = forms.VirtualMachineCSVForm
+    table = tables.VirtualMachineTable
+    default_return_url = 'virtualization:virtualmachine_list'
+
+
+class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'virtualization.change_virtualmachine'
+    cls = VirtualMachine
+    queryset = VirtualMachine.objects.select_related('tenant')
+    # filter = filters.VirtualMachineFilter
+    table = tables.VirtualMachineTable
+    form = forms.VirtualMachineBulkEditForm
+    default_return_url = 'virtualization:virtualmachine_list'
+
+
+#
+# VM interfaces
+#
+
+# class VMInterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
+#     permission_required = 'virtualization.add_vminterface'
+#     parent_model = VirtualMachine
+#     parent_field = 'vm'
+#     model = VMInterface
+#     form = forms.VMInterfaceCreateForm
+#     model_form = forms.VMInterfaceForm
+#
+#
+# class VMInterfaceEditView(PermissionRequiredMixin, ComponentEditView):
+#     permission_required = 'virtualization.change_vminterface'
+#     model = VMInterface
+#     form_class = forms.VMInterfaceForm
+#
+#
+# class VMInterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
+#     permission_required = 'virtualization.delete_vminterface'
+#     model = VMInterface
+#
+#
+# class VMInterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
+#     permission_required = 'virtualization.change_vminterface'
+#     cls = VMInterface
+#     parent_cls = VirtualMachine
+#     table = tables.VMInterfaceTable
+#     form = forms.VMInterfaceBulkEditForm
+#
+#
+# class VMInterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+#     permission_required = 'virtualization.delete_vminterface'
+#     cls = VMInterface
+#     parent_cls = VirtualMachine
+#     table = tables.VMInterfaceTable