Parcourir la source

Merge branch 'virtualization' into develop-2.2

Jeremy Stretch il y a 7 ans
Parent
commit
00024240bb
62 fichiers modifiés avec 3011 ajouts et 275 suppressions
  1. 1 1
      docs/data-model/ipam.md
  2. 29 0
      docs/data-model/virtualization.md
  3. 1 0
      mkdocs.yml
  4. 12 1
      netbox/dcim/api/serializers.py
  5. 6 4
      netbox/dcim/constants.py
  6. 5 0
      netbox/dcim/filters.py
  7. 17 26
      netbox/dcim/forms.py
  8. 32 0
      netbox/dcim/migrations/0044_virtualization.py
  9. 41 5
      netbox/dcim/models.py
  10. 1 1
      netbox/dcim/urls.py
  11. 28 82
      netbox/dcim/views.py
  12. 4 2
      netbox/ipam/api/serializers.py
  13. 5 1
      netbox/ipam/api/views.py
  14. 18 18
      netbox/ipam/fixtures/ipam.json
  15. 37 65
      netbox/ipam/forms.py
  16. 31 0
      netbox/ipam/migrations/0019_virtualization.py
  17. 53 12
      netbox/ipam/models.py
  18. 5 5
      netbox/ipam/tables.py
  19. 28 8
      netbox/ipam/views.py
  20. 1 0
      netbox/netbox/settings.py
  21. 2 0
      netbox/netbox/urls.py
  22. 30 9
      netbox/netbox/views.py
  23. 28 0
      netbox/templates/_base.html
  24. 2 2
      netbox/templates/dcim/device.html
  25. 1 1
      netbox/templates/dcim/inc/interface.html
  26. 28 11
      netbox/templates/home.html
  27. 3 3
      netbox/templates/dcim/inc/service.html
  28. 2 2
      netbox/templates/ipam/ipaddress.html
  29. 19 11
      netbox/templates/ipam/ipaddress_edit.html
  30. 14 5
      netbox/templates/ipam/service_edit.html
  31. 4 0
      netbox/templates/tenancy/tenant.html
  32. 38 0
      netbox/templates/utilities/obj_bulk_remove.html
  33. 117 0
      netbox/templates/virtualization/cluster.html
  34. 44 0
      netbox/templates/virtualization/cluster_add_devices.html
  35. 26 0
      netbox/templates/virtualization/cluster_list.html
  36. 19 0
      netbox/templates/virtualization/clustergroup_list.html
  37. 19 0
      netbox/templates/virtualization/clustertype_list.html
  38. 70 0
      netbox/templates/virtualization/inc/interface.html
  39. 277 0
      netbox/templates/virtualization/virtualmachine.html
  40. 44 0
      netbox/templates/virtualization/virtualmachine_component_add.html
  41. 43 0
      netbox/templates/virtualization/virtualmachine_edit.html
  42. 55 0
      netbox/templates/virtualization/virtualmachine_list.html
  43. 2 0
      netbox/tenancy/views.py
  44. 22 0
      netbox/utilities/forms.py
  45. 16 0
      netbox/utilities/templatetags/helpers.py
  46. 89 0
      netbox/utilities/views.py
  47. 0 0
      netbox/virtualization/__init__.py
  48. 0 0
      netbox/virtualization/api/__init__.py
  49. 142 0
      netbox/virtualization/api/serializers.py
  50. 29 0
      netbox/virtualization/api/urls.py
  51. 48 0
      netbox/virtualization/api/views.py
  52. 7 0
      netbox/virtualization/apps.py
  53. 99 0
      netbox/virtualization/filters.py
  54. 269 0
      netbox/virtualization/forms.py
  55. 89 0
      netbox/virtualization/migrations/0001_virtualization.py
  56. 0 0
      netbox/virtualization/migrations/__init__.py
  57. 189 0
      netbox/virtualization/models.py
  58. 97 0
      netbox/virtualization/tables.py
  59. 0 0
      netbox/virtualization/tests/__init__.py
  60. 297 0
      netbox/virtualization/tests/test_api.py
  61. 53 0
      netbox/virtualization/urls.py
  62. 323 0
      netbox/virtualization/views.py

+ 1 - 1
docs/data-model/ipam.md

@@ -96,4 +96,4 @@ VLAN groups can be employed for administrative organization within NetBox. Each
 
 
 # Services
 # Services
 
 
-A service represents a TCP or UDP service available on a device. Each service must be defined with a name, protocol, and port number; for example, "SSH (TCP/22)." A service may optionally be bound to one or more specific IP addresses belonging to a device. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.)
+A service represents a TCP or UDP service available on a device or virtual machine. Each service must be defined with a name, protocol, and port number; for example, "SSH (TCP/22)." A service may optionally be bound to one or more specific IP addresses belonging to its parent. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.)

+ 29 - 0
docs/data-model/virtualization.md

@@ -0,0 +1,29 @@
+NetBox supports the definition of virtual machines arranged in clusters. A cluster can optionally have physical host devices associated with it.
+
+# Clusters
+
+A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type, and may optionally be assigned an organizational group.
+
+Physical devices (from NetBox's DCIM component) may be associated with clusters as hosts. This allows users to track on which host(s) a particular VM may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device.
+
+### Cluster Types
+
+A cluster type represents a technology or mechanism by which a cluster is formed. For example, you might create a cluster type named "VMware vSphere" for a locally hosted cluster or "DigitalOcean NYC3" for one hosted by a cloud provider.
+
+### Cluster Groups
+
+Cluster groups may be created for the purpose of organizing clusters.
+
+---
+
+# Virtual Machines
+
+A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be associated with exactly one cluster.
+
+Like devices, each VM can have interfaces created on it. These behave similarly to device interfaces, and can be assigned IP addresses, however given their virtual nature they cannot be connected to other interfaces. VMs can also be assigned layer four services. Unlike physical devices, VMs cannot be assigned console or power ports, or device bays.
+
+The following resources can be defined for each VM:
+
+* vCPU count
+* Memory (MB)
+* Disk space (GB)

+ 1 - 0
mkdocs.yml

@@ -18,6 +18,7 @@ pages:
         - 'IPAM': 'data-model/ipam.md'
         - 'IPAM': 'data-model/ipam.md'
         - 'Secrets': 'data-model/secrets.md'
         - 'Secrets': 'data-model/secrets.md'
         - 'Tenancy': 'data-model/tenancy.md'
         - 'Tenancy': 'data-model/tenancy.md'
+        - 'Virtualization': 'data-model/virtualization.md'
         - 'Extras': 'data-model/extras.md'
         - 'Extras': 'data-model/extras.md'
     - 'API':
     - 'API':
         - 'Overview': 'api/overview.md'
         - 'Overview': 'api/overview.md'

+ 12 - 1
netbox/dcim/api/serializers.py

@@ -16,6 +16,7 @@ from dcim.models import (
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from tenancy.api.serializers import NestedTenantSerializer
 from tenancy.api.serializers import NestedTenantSerializer
 from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
 from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
+from virtualization.models import Cluster
 
 
 
 
 #
 #
@@ -446,6 +447,15 @@ class DeviceIPAddressSerializer(serializers.ModelSerializer):
         fields = ['id', 'url', 'family', 'address']
         fields = ['id', 'url', 'family', 'address']
 
 
 
 
+# Cannot import virtualization.api.NestedClusterSerializer due to circular dependency
+class NestedClusterSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
+
+    class Meta:
+        model = Cluster
+        fields = ['id', 'url', 'name']
+
+
 class DeviceSerializer(CustomFieldModelSerializer):
 class DeviceSerializer(CustomFieldModelSerializer):
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
     device_role = NestedDeviceRoleSerializer()
     device_role = NestedDeviceRoleSerializer()
@@ -459,13 +469,14 @@ class DeviceSerializer(CustomFieldModelSerializer):
     primary_ip4 = DeviceIPAddressSerializer()
     primary_ip4 = DeviceIPAddressSerializer()
     primary_ip6 = DeviceIPAddressSerializer()
     primary_ip6 = DeviceIPAddressSerializer()
     parent_device = serializers.SerializerMethodField()
     parent_device = serializers.SerializerMethodField()
+    cluster = NestedClusterSerializer()
 
 
     class Meta:
     class Meta:
         model = Device
         model = Device
         fields = [
         fields = [
             'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
             'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
             'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
-            'comments', 'custom_fields',
+            'cluster', 'comments', 'custom_fields',
         ]
         ]
 
 
     def get_parent_device(self, obj):
     def get_parent_device(self, obj):

+ 6 - 4
netbox/dcim/constants.py

@@ -93,13 +93,15 @@ IFACE_FF_JUNIPER_VCP = 5200
 # Other
 # Other
 IFACE_FF_OTHER = 32767
 IFACE_FF_OTHER = 32767
 
 
+VIFACE_FF_CHOICES = [
+    [IFACE_FF_VIRTUAL, 'Virtual'],
+    [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
+]
+
 IFACE_FF_CHOICES = [
 IFACE_FF_CHOICES = [
     [
     [
         'Virtual interfaces',
         'Virtual interfaces',
-        [
-            [IFACE_FF_VIRTUAL, 'Virtual'],
-            [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
-        ]
+        VIFACE_FF_CHOICES,
     ],
     ],
     [
     [
         'Ethernet (fixed)',
         'Ethernet (fixed)',

+ 5 - 0
netbox/dcim/filters.py

@@ -10,6 +10,7 @@ from django.db.models import Q
 from extras.filters import CustomFieldFilterSet
 from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.filters import NullableCharFieldFilter, NullableModelMultipleChoiceFilter, NumericInFilter
 from utilities.filters import NullableCharFieldFilter, NullableModelMultipleChoiceFilter, NumericInFilter
+from virtualization.models import Cluster
 from .models import (
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection,
     DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection,
@@ -408,6 +409,10 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         label='Rack (ID)',
         label='Rack (ID)',
     )
     )
+    cluster_id = NullableModelMultipleChoiceFilter(
+        queryset=Cluster.objects.all(),
+        label='VM cluster (ID)',
+    )
     model = django_filters.ModelMultipleChoiceFilter(
     model = django_filters.ModelMultipleChoiceFilter(
         name='device_type__slug',
         name='device_type__slug',
         queryset=DeviceType.objects.all(),
         queryset=DeviceType.objects.all(),

+ 17 - 26
netbox/dcim/forms.py

@@ -13,9 +13,9 @@ from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
-    ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ConfirmationForm, CSVChoiceField, ExpandableNameField,
-    FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField,
-    FilterTreeNodeMultipleChoiceField,
+    ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField,
+    ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
+    SlugField, FilterTreeNodeMultipleChoiceField,
 )
 )
 from .formfields import MACAddressFormField
 from .formfields import MACAddressFormField
 from .models import (
 from .models import (
@@ -49,15 +49,6 @@ def get_device_by_name_or_pk(name):
     return device
     return device
 
 
 
 
-class DeviceComponentForm(BootstrapMixin, forms.Form):
-    """
-    Allow inclusion of the parent device as context for limiting field choices.
-    """
-    def __init__(self, device, *args, **kwargs):
-        self.device = device
-        super(DeviceComponentForm, self).__init__(*args, **kwargs)
-
-
 #
 #
 # Regions
 # Regions
 #
 #
@@ -452,7 +443,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
         }
         }
 
 
 
 
-class ConsolePortTemplateCreateForm(DeviceComponentForm):
+class ConsolePortTemplateCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
@@ -466,7 +457,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
         }
         }
 
 
 
 
-class ConsoleServerPortTemplateCreateForm(DeviceComponentForm):
+class ConsoleServerPortTemplateCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
@@ -480,7 +471,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
         }
         }
 
 
 
 
-class PowerPortTemplateCreateForm(DeviceComponentForm):
+class PowerPortTemplateCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
@@ -494,7 +485,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
         }
         }
 
 
 
 
-class PowerOutletTemplateCreateForm(DeviceComponentForm):
+class PowerOutletTemplateCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
@@ -508,7 +499,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
         }
         }
 
 
 
 
-class InterfaceTemplateCreateForm(DeviceComponentForm):
+class InterfaceTemplateCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
     form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
     form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
     mgmt_only = forms.BooleanField(required=False, label='OOB Management')
     mgmt_only = forms.BooleanField(required=False, label='OOB Management')
@@ -533,7 +524,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
         }
         }
 
 
 
 
-class DeviceBayTemplateCreateForm(DeviceComponentForm):
+class DeviceBayTemplateCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
@@ -933,7 +924,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
         }
         }
 
 
 
 
-class ConsolePortCreateForm(DeviceComponentForm):
+class ConsolePortCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
@@ -1102,7 +1093,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
         }
         }
 
 
 
 
-class ConsoleServerPortCreateForm(DeviceComponentForm):
+class ConsoleServerPortCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
@@ -1194,7 +1185,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
         }
         }
 
 
 
 
-class PowerPortCreateForm(DeviceComponentForm):
+class PowerPortCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
@@ -1363,7 +1354,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
         }
         }
 
 
 
 
-class PowerOutletCreateForm(DeviceComponentForm):
+class PowerOutletCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 
@@ -1468,7 +1459,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
             )
             )
 
 
 
 
-class InterfaceCreateForm(DeviceComponentForm):
+class InterfaceCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
     form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
     form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
     enabled = forms.BooleanField(required=False)
     enabled = forms.BooleanField(required=False)
@@ -1487,9 +1478,9 @@ class InterfaceCreateForm(DeviceComponentForm):
         super(InterfaceCreateForm, self).__init__(*args, **kwargs)
         super(InterfaceCreateForm, self).__init__(*args, **kwargs)
 
 
         # Limit LAG choices to interfaces belonging to this device
         # Limit LAG choices to interfaces belonging to this device
-        if self.device is not None:
+        if self.parent is not None:
             self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
             self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
-                device=self.device, form_factor=IFACE_FF_LAG
+                device=self.parent, form_factor=IFACE_FF_LAG
             )
             )
         else:
         else:
             self.fields['lag'].queryset = Interface.objects.none()
             self.fields['lag'].queryset = Interface.objects.none()
@@ -1715,7 +1706,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
         }
         }
 
 
 
 
-class DeviceBayCreateForm(DeviceComponentForm):
+class DeviceBayCreateForm(ComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     name_pattern = ExpandableNameField(label='Name')
 
 
 
 

+ 32 - 0
netbox/dcim/migrations/0044_virtualization.py

@@ -0,0 +1,32 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.4 on 2017-08-31 14:15
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('virtualization', '0001_virtualization'),
+        ('dcim', '0043_device_component_name_lengths'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='device',
+            name='cluster',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='virtualization.Cluster'),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='virtual_machine',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine'),
+        ),
+        migrations.AlterField(
+            model_name='interface',
+            name='device',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.Device'),
+        ),
+    ]

+ 41 - 5
netbox/dcim/models.py

@@ -808,6 +808,13 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
         'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True,
         'ipam.IPAddress', related_name='primary_ip6_for', on_delete=models.SET_NULL, blank=True, null=True,
         verbose_name='Primary IPv6'
         verbose_name='Primary IPv6'
     )
     )
+    cluster = models.ForeignKey(
+        to='virtualization.Cluster',
+        on_delete=models.SET_NULL,
+        related_name='devices',
+        blank=True,
+        null=True
+    )
     comments = models.TextField(blank=True)
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     images = GenericRelation(ImageAttachment)
     images = GenericRelation(ImageAttachment)
@@ -1145,13 +1152,26 @@ class PowerOutlet(models.Model):
 @python_2_unicode_compatible
 @python_2_unicode_compatible
 class Interface(models.Model):
 class Interface(models.Model):
     """
     """
-    A physical data interface within a Device. An Interface can connect to exactly one other Interface via the creation
-    of an InterfaceConnection.
+    A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
+    Interface via the creation of an InterfaceConnection.
     """
     """
-    device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
+    device = models.ForeignKey(
+        to='Device',
+        on_delete=models.CASCADE,
+        related_name='interfaces',
+        null=True,
+        blank=True
+    )
+    virtual_machine = models.ForeignKey(
+        to='virtualization.VirtualMachine',
+        on_delete=models.CASCADE,
+        related_name='interfaces',
+        null=True,
+        blank=True
+    )
     lag = models.ForeignKey(
     lag = models.ForeignKey(
-        'self',
-        models.SET_NULL,
+        to='self',
+        on_delete=models.SET_NULL,
         related_name='member_interfaces',
         related_name='member_interfaces',
         null=True,
         null=True,
         blank=True,
         blank=True,
@@ -1180,6 +1200,18 @@ class Interface(models.Model):
 
 
     def clean(self):
     def clean(self):
 
 
+        # An Interface must belong to a Device *or* to a VirtualMachine
+        if self.device and self.virtual_machine:
+            raise ValidationError("An interface cannot belong to both a device and a virtual machine.")
+        if not self.device and not self.virtual_machine:
+            raise ValidationError("An interface must belong to either a device or a virtual machine.")
+
+        # VM interfaces must be virtual
+        if self.virtual_machine and self.form_factor not in VIRTUAL_IFACE_TYPES:
+            raise ValidationError({
+                'form_factor': "Virtual machines cannot have physical interfaces."
+            })
+
         # Virtual interfaces cannot be connected
         # Virtual interfaces cannot be connected
         if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.is_connected:
         if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.is_connected:
             raise ValidationError({
             raise ValidationError({
@@ -1210,6 +1242,10 @@ class Interface(models.Model):
             })
             })
 
 
     @property
     @property
+    def parent(self):
+        return self.device or self.virtual_machine
+
+    @property
     def is_virtual(self):
     def is_virtual(self):
         return self.form_factor in VIRTUAL_IFACE_TYPES
         return self.form_factor in VIRTUAL_IFACE_TYPES
 
 

+ 1 - 1
netbox/dcim/urls.py

@@ -126,7 +126,7 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     url(r'^devices/(?P<pk>\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'),
     url(r'^devices/(?P<pk>\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'),
     url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
     url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
-    url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'),
+    url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='device_service_assign'),
     url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
     url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
 
 
     # Console ports
     # Console ports

+ 28 - 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.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator
 from utilities.paginator import EnhancedPaginator
 from utilities.views import (
 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 . import filters, forms, tables
 from .models import (
 from .models import (
@@ -60,87 +61,6 @@ def expand_pattern(string):
             yield "{0}{1}".format(lead, i)
             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):
 class BulkDisconnectView(View):
     """
     """
     An extendable view for disconnection console/power/interface components in bulk.
     An extendable view for disconnection console/power/interface components in bulk.
@@ -662,6 +582,7 @@ class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
     model = ConsolePortTemplate
     model = ConsolePortTemplate
     form = forms.ConsolePortTemplateCreateForm
     form = forms.ConsolePortTemplateCreateForm
     model_form = forms.ConsolePortTemplateForm
     model_form = forms.ConsolePortTemplateForm
+    template_name = 'dcim/device_component_add.html'
 
 
 
 
 class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -680,6 +601,7 @@ class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCrea
     model = ConsoleServerPortTemplate
     model = ConsoleServerPortTemplate
     form = forms.ConsoleServerPortTemplateCreateForm
     form = forms.ConsoleServerPortTemplateCreateForm
     model_form = forms.ConsoleServerPortTemplateForm
     model_form = forms.ConsoleServerPortTemplateForm
+    template_name = 'dcim/device_component_add.html'
 
 
 
 
 class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -696,6 +618,7 @@ class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     model = PowerPortTemplate
     model = PowerPortTemplate
     form = forms.PowerPortTemplateCreateForm
     form = forms.PowerPortTemplateCreateForm
     model_form = forms.PowerPortTemplateForm
     model_form = forms.PowerPortTemplateForm
+    template_name = 'dcim/device_component_add.html'
 
 
 
 
 class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -712,6 +635,7 @@ class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
     model = PowerOutletTemplate
     model = PowerOutletTemplate
     form = forms.PowerOutletTemplateCreateForm
     form = forms.PowerOutletTemplateCreateForm
     model_form = forms.PowerOutletTemplateForm
     model_form = forms.PowerOutletTemplateForm
+    template_name = 'dcim/device_component_add.html'
 
 
 
 
 class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -728,6 +652,7 @@ class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     model = InterfaceTemplate
     model = InterfaceTemplate
     form = forms.InterfaceTemplateCreateForm
     form = forms.InterfaceTemplateCreateForm
     model_form = forms.InterfaceTemplateForm
     model_form = forms.InterfaceTemplateForm
+    template_name = 'dcim/device_component_add.html'
 
 
 
 
 class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
 class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
@@ -752,6 +677,7 @@ class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
     model = DeviceBayTemplate
     model = DeviceBayTemplate
     form = forms.DeviceBayTemplateCreateForm
     form = forms.DeviceBayTemplateCreateForm
     model_form = forms.DeviceBayTemplateForm
     model_form = forms.DeviceBayTemplateForm
+    template_name = 'dcim/device_component_add.html'
 
 
 
 
 class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -1035,6 +961,7 @@ class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
     model = ConsolePort
     model = ConsolePort
     form = forms.ConsolePortCreateForm
     form = forms.ConsolePortCreateForm
     model_form = forms.ConsolePortForm
     model_form = forms.ConsolePortForm
+    template_name = 'dcim/device_component_add.html'
 
 
 
 
 @permission_required('dcim.change_consoleport')
 @permission_required('dcim.change_consoleport')
@@ -1116,12 +1043,14 @@ def consoleport_disconnect(request, pk):
 class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView):
 class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_consoleport'
     permission_required = 'dcim.change_consoleport'
     model = ConsolePort
     model = ConsolePort
+    parent_field = 'device'
     form_class = forms.ConsolePortForm
     form_class = forms.ConsolePortForm
 
 
 
 
 class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
 class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_consoleport'
     permission_required = 'dcim.delete_consoleport'
     model = ConsolePort
     model = ConsolePort
+    parent_field = 'device'
 
 
 
 
 class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -1149,6 +1078,7 @@ class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     model = ConsoleServerPort
     model = ConsoleServerPort
     form = forms.ConsoleServerPortCreateForm
     form = forms.ConsoleServerPortCreateForm
     model_form = forms.ConsoleServerPortForm
     model_form = forms.ConsoleServerPortForm
+    template_name = 'dcim/device_component_add.html'
 
 
 
 
 @permission_required('dcim.change_consoleserverport')
 @permission_required('dcim.change_consoleserverport')
@@ -1233,12 +1163,14 @@ def consoleserverport_disconnect(request, pk):
 class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView):
 class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_consoleserverport'
     permission_required = 'dcim.change_consoleserverport'
     model = ConsoleServerPort
     model = ConsoleServerPort
+    parent_field = 'device'
     form_class = forms.ConsoleServerPortForm
     form_class = forms.ConsoleServerPortForm
 
 
 
 
 class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
 class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_consoleserverport'
     permission_required = 'dcim.delete_consoleserverport'
     model = ConsoleServerPort
     model = ConsoleServerPort
+    parent_field = 'device'
 
 
 
 
 class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
 class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
@@ -1268,6 +1200,7 @@ class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
     model = PowerPort
     model = PowerPort
     form = forms.PowerPortCreateForm
     form = forms.PowerPortCreateForm
     model_form = forms.PowerPortForm
     model_form = forms.PowerPortForm
+    template_name = 'dcim/device_component_add.html'
 
 
 
 
 @permission_required('dcim.change_powerport')
 @permission_required('dcim.change_powerport')
@@ -1349,12 +1282,14 @@ def powerport_disconnect(request, pk):
 class PowerPortEditView(PermissionRequiredMixin, ComponentEditView):
 class PowerPortEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_powerport'
     permission_required = 'dcim.change_powerport'
     model = PowerPort
     model = PowerPort
+    parent_field = 'device'
     form_class = forms.PowerPortForm
     form_class = forms.PowerPortForm
 
 
 
 
 class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
 class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_powerport'
     permission_required = 'dcim.delete_powerport'
     model = PowerPort
     model = PowerPort
+    parent_field = 'device'
 
 
 
 
 class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
@@ -1382,6 +1317,7 @@ class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
     model = PowerOutlet
     model = PowerOutlet
     form = forms.PowerOutletCreateForm
     form = forms.PowerOutletCreateForm
     model_form = forms.PowerOutletForm
     model_form = forms.PowerOutletForm
+    template_name = 'dcim/device_component_add.html'
 
 
 
 
 @permission_required('dcim.change_poweroutlet')
 @permission_required('dcim.change_poweroutlet')
@@ -1466,12 +1402,14 @@ def poweroutlet_disconnect(request, pk):
 class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView):
 class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_poweroutlet'
     permission_required = 'dcim.change_poweroutlet'
     model = PowerOutlet
     model = PowerOutlet
+    parent_field = 'device'
     form_class = forms.PowerOutletForm
     form_class = forms.PowerOutletForm
 
 
 
 
 class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView):
 class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_poweroutlet'
     permission_required = 'dcim.delete_poweroutlet'
     model = PowerOutlet
     model = PowerOutlet
+    parent_field = 'device'
 
 
 
 
 class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
 class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
@@ -1503,17 +1441,20 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
     model = Interface
     model = Interface
     form = forms.InterfaceCreateForm
     form = forms.InterfaceCreateForm
     model_form = forms.InterfaceForm
     model_form = forms.InterfaceForm
+    template_name = 'dcim/device_component_add.html'
 
 
 
 
 class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
 class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_interface'
     permission_required = 'dcim.change_interface'
     model = Interface
     model = Interface
+    parent_field = 'device'
     form_class = forms.InterfaceForm
     form_class = forms.InterfaceForm
 
 
 
 
 class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
 class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_interface'
     permission_required = 'dcim.delete_interface'
     model = Interface
     model = Interface
+    parent_field = 'device'
 
 
 
 
 class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
 class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
@@ -1554,17 +1495,20 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
     model = DeviceBay
     model = DeviceBay
     form = forms.DeviceBayCreateForm
     form = forms.DeviceBayCreateForm
     model_form = forms.DeviceBayForm
     model_form = forms.DeviceBayForm
+    template_name = 'dcim/device_component_add.html'
 
 
 
 
 class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView):
 class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_devicebay'
     permission_required = 'dcim.change_devicebay'
     model = DeviceBay
     model = DeviceBay
+    parent_field = 'device'
     form_class = forms.DeviceBayForm
     form_class = forms.DeviceBayForm
 
 
 
 
 class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView):
 class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_devicebay'
     permission_required = 'dcim.delete_devicebay'
     model = DeviceBay
     model = DeviceBay
+    parent_field = 'device'
 
 
 
 
 @permission_required('dcim.change_devicebay')
 @permission_required('dcim.change_devicebay')
@@ -1871,6 +1815,7 @@ class InterfaceConnectionsListView(ObjectListView):
 class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
 class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
     permission_required = 'dcim.change_inventoryitem'
     permission_required = 'dcim.change_inventoryitem'
     model = InventoryItem
     model = InventoryItem
+    parent_field = 'device'
     form_class = forms.InventoryItemForm
     form_class = forms.InventoryItemForm
 
 
     def alter_obj(self, obj, request, url_args, url_kwargs):
     def alter_obj(self, obj, request, url_args, url_kwargs):
@@ -1882,3 +1827,4 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView):
 class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView):
 class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView):
     permission_required = 'dcim.delete_inventoryitem'
     permission_required = 'dcim.delete_inventoryitem'
     model = InventoryItem
     model = InventoryItem
+    parent_field = 'device'

+ 4 - 2
netbox/ipam/api/serializers.py

@@ -12,6 +12,7 @@ from ipam.models import (
 )
 )
 from tenancy.api.serializers import NestedTenantSerializer
 from tenancy.api.serializers import NestedTenantSerializer
 from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
 from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
+from virtualization.api.serializers import NestedVirtualMachineSerializer
 
 
 
 
 #
 #
@@ -295,12 +296,13 @@ class AvailableIPSerializer(serializers.Serializer):
 
 
 class ServiceSerializer(serializers.ModelSerializer):
 class ServiceSerializer(serializers.ModelSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
+    virtual_machine = NestedVirtualMachineSerializer()
     protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES)
     protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES)
     ipaddresses = NestedIPAddressSerializer(many=True)
     ipaddresses = NestedIPAddressSerializer(many=True)
 
 
     class Meta:
     class Meta:
         model = Service
         model = Service
-        fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
+        fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
 
 
 
 
 # TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
 # TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
@@ -308,4 +310,4 @@ class WritableServiceSerializer(serializers.ModelSerializer):
 
 
     class Meta:
     class Meta:
         model = Service
         model = Service
-        fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description']
+        fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']

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

@@ -134,7 +134,11 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 #
 #
 
 
 class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
 class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
-    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
+    queryset = IPAddress.objects.select_related(
+        'vrf__tenant', 'tenant', 'nat_inside'
+    ).prefetch_related(
+        'interface__device'
+    )
     serializer_class = serializers.IPAddressSerializer
     serializer_class = serializers.IPAddressSerializer
     write_serializer_class = serializers.WritableIPAddressSerializer
     write_serializer_class = serializers.WritableIPAddressSerializer
     filter_class = filters.IPAddressFilter
     filter_class = filters.IPAddressFilter

+ 18 - 18
netbox/ipam/fixtures/ipam.json

@@ -70,7 +70,7 @@
         "family": 4,
         "family": 4,
         "address": "10.0.255.1/32",
         "address": "10.0.255.1/32",
         "vrf": null,
         "vrf": null,
-        "interface": 3,
+        "interface_id": 3,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -84,7 +84,7 @@
         "family": 4,
         "family": 4,
         "address": "169.254.254.1/31",
         "address": "169.254.254.1/31",
         "vrf": null,
         "vrf": null,
-        "interface": 4,
+        "interface_id": 4,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -98,7 +98,7 @@
         "family": 4,
         "family": 4,
         "address": "10.0.255.2/32",
         "address": "10.0.255.2/32",
         "vrf": null,
         "vrf": null,
-        "interface": 185,
+        "interface_id": 185,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -112,7 +112,7 @@
         "family": 4,
         "family": 4,
         "address": "169.254.1.1/31",
         "address": "169.254.1.1/31",
         "vrf": null,
         "vrf": null,
-        "interface": 213,
+        "interface_id": 213,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -126,7 +126,7 @@
         "family": 4,
         "family": 4,
         "address": "10.0.254.1/24",
         "address": "10.0.254.1/24",
         "vrf": null,
         "vrf": null,
-        "interface": 12,
+        "interface_id": 12,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -140,7 +140,7 @@
         "family": 4,
         "family": 4,
         "address": "10.15.21.1/31",
         "address": "10.15.21.1/31",
         "vrf": null,
         "vrf": null,
-        "interface": 218,
+        "interface_id": 218,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -154,7 +154,7 @@
         "family": 4,
         "family": 4,
         "address": "10.15.21.2/31",
         "address": "10.15.21.2/31",
         "vrf": null,
         "vrf": null,
-        "interface": 9,
+        "interface_id": 9,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -168,7 +168,7 @@
         "family": 4,
         "family": 4,
         "address": "10.15.22.1/31",
         "address": "10.15.22.1/31",
         "vrf": null,
         "vrf": null,
-        "interface": 8,
+        "interface_id": 8,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -182,7 +182,7 @@
         "family": 4,
         "family": 4,
         "address": "10.15.20.1/31",
         "address": "10.15.20.1/31",
         "vrf": null,
         "vrf": null,
-        "interface": 7,
+        "interface_id": 7,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -196,7 +196,7 @@
         "family": 4,
         "family": 4,
         "address": "10.16.20.1/31",
         "address": "10.16.20.1/31",
         "vrf": null,
         "vrf": null,
-        "interface": 216,
+        "interface_id": 216,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -210,7 +210,7 @@
         "family": 4,
         "family": 4,
         "address": "10.15.22.2/31",
         "address": "10.15.22.2/31",
         "vrf": null,
         "vrf": null,
-        "interface": 206,
+        "interface_id": 206,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -224,7 +224,7 @@
         "family": 4,
         "family": 4,
         "address": "10.16.22.1/31",
         "address": "10.16.22.1/31",
         "vrf": null,
         "vrf": null,
-        "interface": 217,
+        "interface_id": 217,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -238,7 +238,7 @@
         "family": 4,
         "family": 4,
         "address": "10.16.22.2/31",
         "address": "10.16.22.2/31",
         "vrf": null,
         "vrf": null,
-        "interface": 205,
+        "interface_id": 205,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -252,7 +252,7 @@
         "family": 4,
         "family": 4,
         "address": "10.16.20.2/31",
         "address": "10.16.20.2/31",
         "vrf": null,
         "vrf": null,
-        "interface": 211,
+        "interface_id": 211,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -266,7 +266,7 @@
         "family": 4,
         "family": 4,
         "address": "10.15.22.2/31",
         "address": "10.15.22.2/31",
         "vrf": null,
         "vrf": null,
-        "interface": 212,
+        "interface_id": 212,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -280,7 +280,7 @@
         "family": 4,
         "family": 4,
         "address": "10.0.254.2/32",
         "address": "10.0.254.2/32",
         "vrf": null,
         "vrf": null,
-        "interface": 188,
+        "interface_id": 188,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -294,7 +294,7 @@
         "family": 4,
         "family": 4,
         "address": "169.254.1.1/31",
         "address": "169.254.1.1/31",
         "vrf": null,
         "vrf": null,
-        "interface": 200,
+        "interface_id": 200,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }
@@ -308,7 +308,7 @@
         "family": 4,
         "family": 4,
         "address": "169.254.1.2/31",
         "address": "169.254.1.2/31",
         "vrf": null,
         "vrf": null,
-        "interface": 194,
+        "interface_id": 194,
         "nat_inside": null,
         "nat_inside": null,
         "description": ""
         "description": ""
     }
     }

+ 37 - 65
netbox/ipam/forms.py

@@ -377,50 +377,9 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
 #
 #
 
 
 class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
 class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm):
-    interface_site = forms.ModelChoiceField(
-        queryset=Site.objects.all(),
-        required=False,
-        label='Site',
-        widget=forms.Select(
-            attrs={'filter-for': 'interface_rack'}
-        )
-    )
-    interface_rack = ChainedModelChoiceField(
-        queryset=Rack.objects.all(),
-        chains=(
-            ('site', 'interface_site'),
-        ),
-        required=False,
-        label='Rack',
-        widget=APISelect(
-            api_url='/api/dcim/racks/?site_id={{interface_site}}',
-            display_field='display_name',
-            attrs={'filter-for': 'interface_device', 'nullable': 'true'}
-        )
-    )
-    interface_device = ChainedModelChoiceField(
-        queryset=Device.objects.all(),
-        chains=(
-            ('site', 'interface_site'),
-            ('rack', 'interface_rack'),
-        ),
-        required=False,
-        label='Device',
-        widget=APISelect(
-            api_url='/api/dcim/devices/?site_id={{interface_site}}&rack_id={{interface_rack}}',
-            display_field='display_name',
-            attrs={'filter-for': 'interface'}
-        )
-    )
-    interface = ChainedModelChoiceField(
+    interface = forms.ModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
-        chains=(
-            ('device', 'interface_device'),
-        ),
-        required=False,
-        widget=APISelect(
-            api_url='/api/dcim/interfaces/?device_id={{interface_device}}'
-        )
+        required=False
     )
     )
     nat_site = forms.ModelChoiceField(
     nat_site = forms.ModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
@@ -479,13 +438,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
             obj_label='address'
             obj_label='address'
         )
         )
     )
     )
-    primary_for_device = forms.BooleanField(required=False, label='Make this the primary IP for the device')
+    primary_for_parent = forms.BooleanField(required=False, label='Make this the primary IP for the device/VM')
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
         fields = [
         fields = [
-            'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack',
-            'nat_inside', 'tenant_group', 'tenant',
+            'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site',
+            'nat_rack', 'nat_inside', 'tenant_group', 'tenant',
         ]
         ]
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -493,10 +452,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
         # Initialize helper selectors
         # Initialize helper selectors
         instance = kwargs.get('instance')
         instance = kwargs.get('instance')
         initial = kwargs.get('initial', {}).copy()
         initial = kwargs.get('initial', {}).copy()
-        if instance and instance.interface is not None:
-            initial['interface_site'] = instance.interface.device.site
-            initial['interface_rack'] = instance.interface.device.rack
-            initial['interface_device'] = instance.interface.device
         if instance and instance.nat_inside and instance.nat_inside.device is not None:
         if instance and instance.nat_inside and instance.nat_inside.device is not None:
             initial['nat_site'] = instance.nat_inside.device.site
             initial['nat_site'] = instance.nat_inside.device.site
             initial['nat_rack'] = instance.nat_inside.device.rack
             initial['nat_rack'] = instance.nat_inside.device.rack
@@ -507,22 +462,30 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
 
 
         self.fields['vrf'].empty_label = 'Global'
         self.fields['vrf'].empty_label = 'Global'
 
 
-        # Initialize primary_for_device if IP address is already assigned
-        if self.instance.interface is not None:
-            device = self.instance.interface.device
+        # Limit interface selections to those belonging to the parent device/VM
+        if self.instance and self.instance.interface:
+            self.fields['interface'].queryset = Interface.objects.filter(
+                device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine
+            )
+        else:
+            self.fields['interface'].choices = []
+
+        # Initialize primary_for_parent if IP address is already assigned
+        if self.instance.pk and self.instance.interface is not None:
+            parent = self.instance.interface.parent
             if (
             if (
-                self.instance.address.version == 4 and device.primary_ip4 == self.instance or
-                self.instance.address.version == 6 and device.primary_ip6 == self.instance
+                self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or
+                self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk
             ):
             ):
-                self.initial['primary_for_device'] = True
+                self.initial['primary_for_parent'] = True
 
 
     def clean(self):
     def clean(self):
         super(IPAddressForm, self).clean()
         super(IPAddressForm, self).clean()
 
 
         # Primary IP assignment is only available if an interface has been assigned.
         # Primary IP assignment is only available if an interface has been assigned.
-        if self.cleaned_data.get('primary_for_device') and not self.cleaned_data.get('interface'):
+        if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'):
             self.add_error(
             self.add_error(
-                'primary_for_device', "Only IP addresses assigned to an interface can be designated as primary IPs."
+                'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
             )
             )
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
@@ -530,13 +493,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
         ipaddress = super(IPAddressForm, self).save(*args, **kwargs)
         ipaddress = super(IPAddressForm, self).save(*args, **kwargs)
 
 
         # Assign this IPAddress as the primary for the associated Device.
         # Assign this IPAddress as the primary for the associated Device.
-        if self.cleaned_data['primary_for_device']:
-            device = self.cleaned_data['interface'].device
+        if self.cleaned_data['primary_for_parent']:
+            parent = self.cleaned_data['interface'].parent
             if ipaddress.address.version == 4:
             if ipaddress.address.version == 4:
-                device.primary_ip4 = ipaddress
+                parent.primary_ip4 = ipaddress
             else:
             else:
-                device.primary_ip6 = ipaddress
-            device.save()
+                parent.primary_ip6 = ipaddress
+            parent.save()
 
 
         # Clear assignment as primary for device if set.
         # Clear assignment as primary for device if set.
         else:
         else:
@@ -898,5 +861,14 @@ class ServiceForm(BootstrapMixin, forms.ModelForm):
 
 
         super(ServiceForm, self).__init__(*args, **kwargs)
         super(ServiceForm, self).__init__(*args, **kwargs)
 
 
-        # Limit IP address choices to those assigned to interfaces of the parent device
-        self.fields['ipaddresses'].queryset = IPAddress.objects.filter(interface__device=self.instance.device)
+        # Limit IP address choices to those assigned to interfaces of the parent device/VM
+        if self.instance.device:
+            self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
+                interface__device=self.instance.device
+            )
+        elif self.instance.virtual_machine:
+            self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
+                interface__virtual_machine=self.instance.virtual_machine
+            )
+        else:
+            self.fields['ipaddresses'].choices = []

+ 31 - 0
netbox/ipam/migrations/0019_virtualization.py

@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.4 on 2017-08-31 15:44
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('virtualization', '0001_virtualization'),
+        ('ipam', '0018_remove_service_uniqueness_constraint'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='service',
+            options={'ordering': ['protocol', 'port']},
+        ),
+        migrations.AddField(
+            model_name='service',
+            name='virtual_machine',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='virtualization.VirtualMachine'),
+        ),
+        migrations.AlterField(
+            model_name='service',
+            name='device',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name='device'),
+        ),
+    ]

+ 53 - 12
netbox/ipam/models.py

@@ -2,10 +2,12 @@ from __future__ import unicode_literals
 import netaddr
 import netaddr
 
 
 from django.conf import settings
 from django.conf import settings
-from django.contrib.contenttypes.fields import GenericRelation
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
+from django.db.models import Q
 from django.db.models.expressions import RawSQL
 from django.db.models.expressions import RawSQL
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.encoding import python_2_unicode_compatible
 from django.utils.encoding import python_2_unicode_compatible
@@ -586,20 +588,59 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel):
 @python_2_unicode_compatible
 @python_2_unicode_compatible
 class Service(CreatedUpdatedModel):
 class Service(CreatedUpdatedModel):
     """
     """
-    A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied
-    to one or more specific IPAddresses belonging to the Device.
+    A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
+    optionally be tied to one or more specific IPAddresses belonging to its parent.
     """
     """
-    device = models.ForeignKey('dcim.Device', related_name='services', on_delete=models.CASCADE, verbose_name='device')
-    name = models.CharField(max_length=30)
-    protocol = models.PositiveSmallIntegerField(choices=IP_PROTOCOL_CHOICES)
-    port = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)],
-                                       verbose_name='Port number')
-    ipaddresses = models.ManyToManyField('ipam.IPAddress', related_name='services', blank=True,
-                                         verbose_name='IP addresses')
-    description = models.CharField(max_length=100, blank=True)
+    device = models.ForeignKey(
+        to='dcim.Device',
+        on_delete=models.CASCADE,
+        related_name='services',
+        verbose_name='device',
+        null=True,
+        blank=True
+    )
+    virtual_machine = models.ForeignKey(
+        to='virtualization.VirtualMachine',
+        on_delete=models.CASCADE,
+        related_name='services',
+        null=True,
+        blank=True
+    )
+    name = models.CharField(
+        max_length=30
+    )
+    protocol = models.PositiveSmallIntegerField(
+        choices=IP_PROTOCOL_CHOICES
+    )
+    port = models.PositiveIntegerField(
+        validators=[MinValueValidator(1), MaxValueValidator(65535)],
+        verbose_name='Port number'
+    )
+    ipaddresses = models.ManyToManyField(
+        to='ipam.IPAddress',
+        related_name='services',
+        blank=True,
+        verbose_name='IP addresses'
+    )
+    description = models.CharField(
+        max_length=100,
+        blank=True
+    )
 
 
     class Meta:
     class Meta:
-        ordering = ['device', 'protocol', 'port']
+        ordering = ['protocol', 'port']
 
 
     def __str__(self):
     def __str__(self):
         return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
         return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display())
+
+    @property
+    def parent(self):
+        return self.device or self.virtual_machine
+
+    def clean(self):
+
+        # A Service must belong to a Device *or* to a VirtualMachine
+        if self.device and self.virtual_machine:
+            raise ValidationError("A service cannot be associated with both a device and a virtual machine.")
+        if not self.device and not self.virtual_machine:
+            raise ValidationError("A service must be associated with either a device or a virtual machine.")

+ 5 - 5
netbox/ipam/tables.py

@@ -77,9 +77,9 @@ IPADDRESS_LINK = """
 {% endif %}
 {% endif %}
 """
 """
 
 
-IPADDRESS_DEVICE = """
+IPADDRESS_PARENT = """
 {% if record.interface %}
 {% if record.interface %}
-    <a href="{{ record.interface.device.get_absolute_url }}">{{ record.interface.device }}</a>
+    <a href="{{ record.interface.parent.get_absolute_url }}">{{ record.interface.parent }}</a>
 {% else %}
 {% else %}
     &mdash;
     &mdash;
 {% endif %}
 {% endif %}
@@ -265,12 +265,12 @@ class IPAddressTable(BaseTable):
     status = tables.TemplateColumn(STATUS_LABEL)
     status = tables.TemplateColumn(STATUS_LABEL)
     vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
     vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
     tenant = tables.TemplateColumn(TENANT_LINK)
     tenant = tables.TemplateColumn(TENANT_LINK)
-    device = tables.TemplateColumn(IPADDRESS_DEVICE, orderable=False)
+    parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False)
     interface = tables.Column(orderable=False)
     interface = tables.Column(orderable=False)
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = IPAddress
         model = IPAddress
-        fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'device', 'interface', 'description')
+        fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
         row_attrs = {
         row_attrs = {
             'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
             'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
         }
         }
@@ -283,7 +283,7 @@ class IPAddressDetailTable(IPAddressTable):
 
 
     class Meta(IPAddressTable.Meta):
     class Meta(IPAddressTable.Meta):
         fields = (
         fields = (
-            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'device', 'interface', 'description',
+            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'description',
         )
         )
 
 
 
 

+ 28 - 8
netbox/ipam/views.py

@@ -10,11 +10,12 @@ from django.shortcuts import get_object_or_404, render
 from django.urls import reverse
 from django.urls import reverse
 from django.views.generic import View
 from django.views.generic import View
 
 
-from dcim.models import Device
+from dcim.models import Device, Interface
 from utilities.paginator import EnhancedPaginator
 from utilities.paginator import EnhancedPaginator
 from utilities.views import (
 from utilities.views import (
     BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
     BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 )
+from virtualization.models import VirtualMachine
 from . import filters, forms, tables
 from . import filters, forms, tables
 from .constants import IPADDRESS_ROLE_ANYCAST
 from .constants import IPADDRESS_ROLE_ANYCAST
 from .models import (
 from .models import (
@@ -595,7 +596,11 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 #
 
 
 class IPAddressListView(ObjectListView):
 class IPAddressListView(ObjectListView):
-    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside')
+    queryset = IPAddress.objects.select_related(
+        'vrf__tenant', 'tenant', 'nat_inside'
+    ).prefetch_related(
+        'interface__device', 'interface__virtual_machine'
+    )
     filter = filters.IPAddressFilter
     filter = filters.IPAddressFilter
     filter_form = forms.IPAddressFilterForm
     filter_form = forms.IPAddressFilterForm
     table = tables.IPAddressDetailTable
     table = tables.IPAddressDetailTable
@@ -606,7 +611,7 @@ class IPAddressView(View):
 
 
     def get(self, request, pk):
     def get(self, request, pk):
 
 
-        ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk)
+        ipaddress = get_object_or_404(IPAddress.objects.select_related('vrf__tenant', 'tenant'), pk=pk)
 
 
         # Parent prefixes table
         # Parent prefixes table
         parent_prefixes = Prefix.objects.filter(
         parent_prefixes = Prefix.objects.filter(
@@ -623,7 +628,9 @@ class IPAddressView(View):
         ).exclude(
         ).exclude(
             pk=ipaddress.pk
             pk=ipaddress.pk
         ).select_related(
         ).select_related(
-            'interface__device', 'nat_inside'
+            'nat_inside'
+        ).prefetch_related(
+            'interface__device'
         )
         )
         # Exclude anycast IPs if this IP is anycast
         # Exclude anycast IPs if this IP is anycast
         if ipaddress.role == IPADDRESS_ROLE_ANYCAST:
         if ipaddress.role == IPADDRESS_ROLE_ANYCAST:
@@ -631,7 +638,7 @@ class IPAddressView(View):
         duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
         duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
 
 
         # Related IP table
         # Related IP table
-        related_ips = IPAddress.objects.select_related(
+        related_ips = IPAddress.objects.prefetch_related(
             'interface__device'
             'interface__device'
         ).exclude(
         ).exclude(
             address=str(ipaddress.address)
             address=str(ipaddress.address)
@@ -655,6 +662,17 @@ class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView):
     template_name = 'ipam/ipaddress_edit.html'
     template_name = 'ipam/ipaddress_edit.html'
     default_return_url = 'ipam:ipaddress_list'
     default_return_url = 'ipam:ipaddress_list'
 
 
+    def alter_obj(self, obj, request, url_args, url_kwargs):
+
+        interface_id = request.GET.get('interface')
+        if interface_id:
+            try:
+                obj.interface = Interface.objects.get(pk=interface_id)
+            except (ValueError, Interface.DoesNotExist):
+                pass
+
+        return obj
+
 
 
 class IPAddressEditView(IPAddressCreateView):
 class IPAddressEditView(IPAddressCreateView):
     permission_required = 'ipam.change_ipaddress'
     permission_required = 'ipam.change_ipaddress'
@@ -685,7 +703,7 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
 class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
 class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
     permission_required = 'ipam.change_ipaddress'
     permission_required = 'ipam.change_ipaddress'
     cls = IPAddress
     cls = IPAddress
-    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
+    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
     filter = filters.IPAddressFilter
     filter = filters.IPAddressFilter
     table = tables.IPAddressTable
     table = tables.IPAddressTable
     form = forms.IPAddressBulkEditForm
     form = forms.IPAddressBulkEditForm
@@ -695,7 +713,7 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
 class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'ipam.delete_ipaddress'
     permission_required = 'ipam.delete_ipaddress'
     cls = IPAddress
     cls = IPAddress
-    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device')
+    queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
     filter = filters.IPAddressFilter
     filter = filters.IPAddressFilter
     table = tables.IPAddressTable
     table = tables.IPAddressTable
     default_return_url = 'ipam:ipaddress_list'
     default_return_url = 'ipam:ipaddress_list'
@@ -821,10 +839,12 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
     def alter_obj(self, obj, request, url_args, url_kwargs):
     def alter_obj(self, obj, request, url_args, url_kwargs):
         if 'device' in url_kwargs:
         if 'device' in url_kwargs:
             obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
             obj.device = get_object_or_404(Device, pk=url_kwargs['device'])
+        elif 'virtualmachine' in url_kwargs:
+            obj.virtual_machine = get_object_or_404(VirtualMachine, pk=url_kwargs['virtualmachine'])
         return obj
         return obj
 
 
     def get_return_url(self, request, obj):
     def get_return_url(self, request, obj):
-        return obj.device.get_absolute_url()
+        return obj.parent.get_absolute_url()
 
 
 
 
 class ServiceEditView(ServiceCreateView):
 class ServiceEditView(ServiceCreateView):

+ 1 - 0
netbox/netbox/settings.py

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

+ 2 - 0
netbox/netbox/urls.py

@@ -32,6 +32,7 @@ _patterns = [
     url(r'^secrets/', include('secrets.urls')),
     url(r'^secrets/', include('secrets.urls')),
     url(r'^tenancy/', include('tenancy.urls')),
     url(r'^tenancy/', include('tenancy.urls')),
     url(r'^user/', include('users.urls')),
     url(r'^user/', include('users.urls')),
+    url(r'^virtualization/', include('virtualization.urls')),
 
 
     # API
     # API
     url(r'^api/$', APIRootView.as_view(), name='api-root'),
     url(r'^api/$', APIRootView.as_view(), name='api-root'),
@@ -41,6 +42,7 @@ _patterns = [
     url(r'^api/ipam/', include('ipam.api.urls')),
     url(r'^api/ipam/', include('ipam.api.urls')),
     url(r'^api/secrets/', include('secrets.api.urls')),
     url(r'^api/secrets/', include('secrets.api.urls')),
     url(r'^api/tenancy/', include('tenancy.api.urls')),
     url(r'^api/tenancy/', include('tenancy.api.urls')),
+    url(r'^api/virtualization/', include('virtualization.api.urls')),
     url(r'^api/docs/', swagger_view, name='api_docs'),
     url(r'^api/docs/', swagger_view, name='api_docs'),
 
 
     # Serving static media in Django to pipe it through LoginRequiredMiddleware
     # Serving static media in Django to pipe it through LoginRequiredMiddleware

+ 30 - 9
netbox/netbox/views.py

@@ -25,6 +25,9 @@ from secrets.tables import SecretTable
 from tenancy.filters import TenantFilter
 from tenancy.filters import TenantFilter
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from tenancy.tables import TenantTable
 from tenancy.tables import TenantTable
+from virtualization.filters import ClusterFilter, VirtualMachineFilter
+from virtualization.models import Cluster, VirtualMachine
+from virtualization.tables import ClusterTable, VirtualMachineTable
 from .forms import SearchForm
 from .forms import SearchForm
 
 
 
 
@@ -90,7 +93,7 @@ SEARCH_TYPES = OrderedDict((
         'url': 'ipam:prefix_list',
         'url': 'ipam:prefix_list',
     }),
     }),
     ('ipaddress', {
     ('ipaddress', {
-        'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'),
+        'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant'),
         'filter': IPAddressFilter,
         'filter': IPAddressFilter,
         'table': IPAddressTable,
         'table': IPAddressTable,
         'url': 'ipam:ipaddress_list',
         'url': 'ipam:ipaddress_list',
@@ -115,6 +118,19 @@ SEARCH_TYPES = OrderedDict((
         'table': TenantTable,
         'table': TenantTable,
         'url': 'tenancy:tenant_list',
         'url': 'tenancy:tenant_list',
     }),
     }),
+    # Virtualization
+    ('cluster', {
+        'queryset': Cluster.objects.all(),
+        'filter': ClusterFilter,
+        'table': ClusterTable,
+        'url': 'virtualization:cluster_list',
+    }),
+    ('virtualmachine', {
+        'queryset': VirtualMachine.objects.select_related('cluster', 'tenant', 'platform'),
+        'filter': VirtualMachineFilter,
+        'table': VirtualMachineTable,
+        'url': 'virtualization:virtualmachine_list',
+    }),
 ))
 ))
 
 
 
 
@@ -150,6 +166,10 @@ class HomeView(View):
             # Secrets
             # Secrets
             'secret_count': Secret.objects.count(),
             'secret_count': Secret.objects.count(),
 
 
+            # Virtualization
+            'cluster_count': Cluster.objects.count(),
+            'virtualmachine_count': VirtualMachine.objects.count(),
+
         }
         }
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
@@ -216,14 +236,15 @@ class APIRootView(APIView):
 
 
     def get(self, request, format=None):
     def get(self, request, format=None):
 
 
-        return Response({
-            'circuits': reverse('circuits-api:api-root', request=request, format=format),
-            'dcim': reverse('dcim-api:api-root', request=request, format=format),
-            'extras': reverse('extras-api:api-root', request=request, format=format),
-            'ipam': reverse('ipam-api:api-root', request=request, format=format),
-            'secrets': reverse('secrets-api:api-root', request=request, format=format),
-            'tenancy': reverse('tenancy-api:api-root', request=request, format=format),
-        })
+        return Response(OrderedDict((
+            ('circuits', reverse('circuits-api:api-root', request=request, format=format)),
+            ('dcim', reverse('dcim-api:api-root', request=request, format=format)),
+            ('extras', reverse('extras-api:api-root', request=request, format=format)),
+            ('ipam', reverse('ipam-api:api-root', request=request, format=format)),
+            ('secrets', reverse('secrets-api:api-root', request=request, format=format)),
+            ('tenancy', reverse('tenancy-api:api-root', request=request, format=format)),
+            ('virtualization', reverse('virtualization-api:api-root', request=request, format=format)),
+        )))
 
 
 
 
 def handle_500(request):
 def handle_500(request):

+ 28 - 0
netbox/templates/_base.html

@@ -202,6 +202,34 @@
                             {% endif %}
                             {% endif %}
                         </ul>
                         </ul>
                     </li>
                     </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 %}">
                     <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>
                         <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">
                         <ul class="dropdown-menu">

+ 2 - 2
netbox/templates/dcim/device.html

@@ -196,7 +196,7 @@
             {% if services %}
             {% if services %}
                 <table class="table table-hover panel-body">
                 <table class="table table-hover panel-body">
                     {% for service in services %}
                     {% for service in services %}
-                        {% include 'dcim/inc/service.html' %}
+                        {% include 'ipam/inc/service.html' %}
                     {% endfor %}
                     {% endfor %}
                 </table>
                 </table>
             {% else %}
             {% else %}
@@ -206,7 +206,7 @@
             {% endif %}
             {% endif %}
             {% if perms.ipam.add_service %}
             {% if perms.ipam.add_service %}
                 <div class="panel-footer text-right">
                 <div class="panel-footer text-right">
-                    <a href="{% url 'dcim:service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
+                    <a href="{% url 'dcim:device_service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
                         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
                         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
                     </a>
                     </a>
                 </div>
                 </div>

+ 1 - 1
netbox/templates/dcim/inc/interface.html

@@ -64,7 +64,7 @@
             {% endif %}
             {% endif %}
         {% endif %}
         {% endif %}
         {% if perms.ipam.add_ipaddress %}
         {% if perms.ipam.add_ipaddress %}
-            <a href="{% url 'ipam:ipaddress_add' %}?interface_site={{ device.site.pk }}&interface_rack={{ device.rack.pk }}&interface_device={{ device.pk }}&interface={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address">
+            <a href="{% url 'ipam:ipaddress_add' %}?interface={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address">
                 <i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
                 <i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
             </a>
             </a>
         {% endif %}
         {% endif %}

+ 28 - 11
netbox/templates/home.html

@@ -55,20 +55,23 @@
                 </div>
                 </div>
             </div>
             </div>
         </div>
         </div>
-        {% if perms.secrets %}
-            <div class="panel panel-default">
-                <div class="panel-heading">
-                    <strong>Secrets</strong>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Virtualization</strong>
+            </div>
+            <div class="list-group">
+                <div class="list-group-item">
+                    <span class="badge pull-right">{{ stats.cluster_count }}</span>
+                    <h4 class="list-group-item-heading"><a href="{% url 'virtualization:cluster_list' %}">Clusters</a></h4>
+                    <p class="list-group-item-text text-muted">Clusters of physical hosts in which VMs reside</p>
                 </div>
                 </div>
-                <div class="list-group">
-                    <div class="list-group-item">
-                        <span class="badge pull-right">{{ stats.secret_count }}</span>
-                        <h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
-                        <p class="list-group-item-text text-muted">Sensitive data (such as passwords) which has been stored securely</p>
-                    </div>
+                <div class="list-group-item">
+                    <span class="badge pull-right">{{ stats.virtualmachine_count }}</span>
+                    <h4 class="list-group-item-heading"><a href="{% url 'virtualization:virtualmachine_list' %}">Virtual Machines</a></h4>
+                    <p class="list-group-item-text text-muted">Virtual compute instances running inside clusters</p>
                 </div>
                 </div>
             </div>
             </div>
-        {% endif %}
+        </div>
     </div>
     </div>
     <div class="col-sm-6 col-md-4">
     <div class="col-sm-6 col-md-4">
         <div class="panel panel-default">
         <div class="panel panel-default">
@@ -120,6 +123,20 @@
                 </div>
                 </div>
             </div>
             </div>
         </div>
         </div>
+        {% if perms.secrets %}
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>Secrets</strong>
+                </div>
+                <div class="list-group">
+                    <div class="list-group-item">
+                        <span class="badge pull-right">{{ stats.secret_count }}</span>
+                        <h4 class="list-group-item-heading"><a href="{% url 'secrets:secret_list' %}">Secrets</a></h4>
+                        <p class="list-group-item-text text-muted">Sensitive data (such as passwords) which has been stored securely</p>
+                    </div>
+                </div>
+            </div>
+        {% endif %}
     </div>
     </div>
     <div class="col-sm-6 col-md-4">
     <div class="col-sm-6 col-md-4">
         <div class="panel panel-default">
         <div class="panel panel-default">

+ 3 - 3
netbox/templates/dcim/inc/service.html

@@ -14,12 +14,12 @@
     <td class="text-right">
     <td class="text-right">
         {% if perms.ipam.change_service %}
         {% if perms.ipam.change_service %}
             <a href="{% url 'ipam:service_edit' pk=service.pk %}" class="btn btn-info btn-xs" title="Edit service">
             <a href="{% url 'ipam:service_edit' pk=service.pk %}" class="btn btn-info btn-xs" title="Edit service">
-                <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
+                <i class="glyphicon glyphicon-pencil"></i>
             </a>
             </a>
         {% endif %}
         {% endif %}
         {% if perms.ipam.delete_service %}
         {% if perms.ipam.delete_service %}
-            <a href="{% url 'ipam:service_delete' pk=service.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
-                <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete service"></i>
+            <a href="{% url 'ipam:service_delete' pk=service.pk %}?return_url={{ service.parent.get_absolute_url }}" class="btn btn-danger btn-xs">
+                <i class="glyphicon glyphicon-trash" title="Delete service"></i>
             </a>
             </a>
         {% endif %}
         {% endif %}
     </td>
     </td>

+ 2 - 2
netbox/templates/ipam/ipaddress.html

@@ -104,7 +104,7 @@
                     <td>Assignment</td>
                     <td>Assignment</td>
                     <td>
                     <td>
                         {% if ipaddress.interface %}
                         {% if ipaddress.interface %}
-                            <span><a href="{% url 'dcim:device' pk=ipaddress.interface.device.pk %}">{{ ipaddress.interface.device }}</a> ({{ ipaddress.interface }})</span>
+                            <span><a href="{{ ipaddress.interface.parent.get_absolute_url }}">{{ ipaddress.interface.parent }}</a> ({{ ipaddress.interface }})</span>
                         {% else %}
                         {% else %}
                             <span class="text-muted">None</span>
                             <span class="text-muted">None</span>
                         {% endif %}
                         {% endif %}
@@ -116,7 +116,7 @@
                         {% if ipaddress.nat_inside %}
                         {% if ipaddress.nat_inside %}
                             <a href="{% url 'ipam:ipaddress' pk=ipaddress.nat_inside.pk %}">{{ ipaddress.nat_inside }}</a>
                             <a href="{% url 'ipam:ipaddress' pk=ipaddress.nat_inside.pk %}">{{ ipaddress.nat_inside }}</a>
                             {% if ipaddress.nat_inside.interface %}
                             {% if ipaddress.nat_inside.interface %}
-                                (<a href="{% url 'dcim:device' pk=ipaddress.nat_inside.interface.device.pk %}">{{ ipaddress.nat_inside.interface.device }}</a>)
+                                (<a href="{{ ipaddress.nat_inside.interface.parent.get_absolute_url }}">{{ ipaddress.nat_inside.interface.parent }}</a>)
                             {% endif %}
                             {% endif %}
                         {% else %}
                         {% else %}
                             <span class="text-muted">None</span>
                             <span class="text-muted">None</span>

+ 19 - 11
netbox/templates/ipam/ipaddress_edit.html

@@ -1,6 +1,7 @@
 {% extends 'utilities/obj_edit.html' %}
 {% extends 'utilities/obj_edit.html' %}
 {% load static from staticfiles %}
 {% load static from staticfiles %}
 {% load form_helpers %}
 {% load form_helpers %}
+{% load helpers %}
 
 
 {% block tabs %}
 {% block tabs %}
     {% if not obj.pk %}
     {% if not obj.pk %}
@@ -26,18 +27,25 @@
             {% render_field form.tenant %}
             {% render_field form.tenant %}
         </div>
         </div>
     </div>
     </div>
-    <div class="panel panel-default">
-        <div class="panel-heading">
-            <strong>Interface Assignment</strong>
-        </div>
-        <div class="panel-body">
-            {% render_field form.interface_site %}
-            {% render_field form.interface_rack %}
-            {% render_field form.interface_device %}
-            {% render_field form.interface %}
-            {% render_field form.primary_for_device %}
+    {% if obj.interface %}
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Interface Assignment</strong>
+            </div>
+            <div class="panel-body">
+                <div class="form-group">
+                    <label class="col-md-3 control-label">{{ obj.interface.parent|model_name|bettertitle }}</label>
+                    <div class="col-md-9">
+                        <p class="form-control-static">
+                            <a href="{{ obj.interface.parent.get_absolute_url }}">{{ obj.interface.parent }}</a>
+                        </p>
+                    </div>
+                </div>
+                {% render_field form.interface %}
+                {% render_field form.primary_for_parent %}
+            </div>
         </div>
         </div>
-    </div>
+    {% endif %}
     <div class="panel panel-default">
     <div class="panel panel-default">
         <div class="panel-heading"><strong>NAT IP (Inside)</strong></div>
         <div class="panel-heading"><strong>NAT IP (Inside)</strong></div>
         <div class="panel-body">
         <div class="panel-body">

+ 14 - 5
netbox/templates/ipam/service_edit.html

@@ -5,12 +5,21 @@
     <div class="panel panel-default">
     <div class="panel panel-default">
         <div class="panel-heading"><strong>Service</strong></div>
         <div class="panel-heading"><strong>Service</strong></div>
         <div class="panel-body">
         <div class="panel-body">
-            <div class="form-group">
-                <label class="col-md-3 control-label">Device</label>
-                <div class="col-md-9">
-                    <p class="form-control-static">{{ obj.device }}</p>
+            {% if obj.device %}
+                <div class="form-group">
+                    <label class="col-md-3 control-label required">Device</label>
+                    <div class="col-md-9">
+                        <p class="form-control-static">{{ obj.device }}</p>
+                    </div>
                 </div>
                 </div>
-            </div>
+            {% else %}
+                <div class="form-group">
+                    <label class="col-md-3 control-label required">Virtual Machine</label>
+                    <div class="col-md-9">
+                        <p class="form-control-static">{{ obj.virtual_machine }}</p>
+                    </div>
+                </div>
+            {% endif %}
             {% render_field form.name %}
             {% render_field form.name %}
             <div class="form-group form-inline">
             <div class="form-group form-inline">
                 <label class="col-md-3 control-label required">Port</label>
                 <label class="col-md-3 control-label required">Port</label>

+ 4 - 0
netbox/templates/tenancy/tenant.html

@@ -124,6 +124,10 @@
                     <h2><a href="{% url 'circuits:circuit_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.circuit_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.circuit_count }}</a></h2>
                     <h2><a href="{% url 'circuits:circuit_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.circuit_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.circuit_count }}</a></h2>
                     <p>Circuits</p>
                     <p>Circuits</p>
                 </div>
                 </div>
+                <div class="col-md-4 text-center">
+                    <h2><a href="{% url 'virtualization:virtualmachine_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.virtualmachine_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.virtualmachine_count }}</a></h2>
+                    <p>Virtual machines</p>
+                </div>
             </div>
             </div>
         </div>
         </div>
     </div>
     </div>

+ 38 - 0
netbox/templates/utilities/obj_bulk_remove.html

@@ -0,0 +1,38 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block title %}Remove {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?{% endblock %}
+
+{% block content %}
+    <div class="row">
+        <div class="col-md-8 col-md-offset-2">
+            <div class="panel panel-danger">
+                <div class="panel-heading"><strong>Confirm Bulk Removal</strong></div>
+                <div class="panel-body">
+                    <strong>Warning:</strong> The following operation will remove {{ table.rows|length }} {{ obj_type_plural }} from {{ parent_obj }}. Please carefully review the {{ obj_type_plural }} to be removed and confirm below.
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="row">
+        <div class="col-md-8 col-md-offset-2">
+            <div class="panel panel-default">
+                {% include 'inc/table.html' %}
+            </div>
+        </div>
+    </div>
+    <div class="row">
+        <div class="col-md-6 col-md-offset-3">
+            <form action="." method="post" class="form">
+                {% csrf_token %}
+                {% for field in form.hidden_fields %}
+                    {{ field }}
+                {% endfor %}
+                <div class="text-center">
+                    <button type="submit" name="_confirm" class="btn btn-danger">Delete these {{ table.rows|length }} {{ obj_type_plural }}</button>
+                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+                </div>
+            </form>
+        </div>
+    </div>
+{% endblock %}

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

@@ -0,0 +1,117 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block content %}
+<div class="row" xmlns="http://www.w3.org/1999/html">
+    <div class="col-sm-8 col-md-9">
+        <ol class="breadcrumb">
+            <li><a href="{{ cluster.type.get_absolute_url }}">{{ cluster.type }}</a></li>
+            {% if cluster.group %}
+                <li><a href="{{ cluster.group.get_absolute_url }}">{{ cluster.group }}</a></li>
+            {% endif %}
+            <li>{{ cluster }}</li>
+        </ol>
+    </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>Host Devices</strong>
+            </div>
+            {% if perms.virtualization.change_cluster %}
+                <form action="{% url 'virtualization:cluster_remove_devices' pk=cluster.pk %}" method="post">
+                {% csrf_token %}
+            {% endif %}
+            {% include 'responsive_table.html' with table=device_table %}
+            {% if perms.virtualization.change_cluster %}
+                <div class="panel-footer">
+                    <div class="pull-right">
+                        <a href="{% url 'virtualization:cluster_add_devices' pk=cluster.pk %}" class="btn btn-primary btn-xs">
+                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
+                            Add devices
+                        </a>
+                    </div>
+                    <button type="submit" name="_remove" class="btn btn-danger primary btn-xs">
+                        <span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
+                        Remove devices
+                    </button>
+                </div>
+                </form>
+            {% endif %}
+        </div>
+	</div>
+</div>
+{% endblock %}

+ 44 - 0
netbox/templates/virtualization/cluster_add_devices.html

@@ -0,0 +1,44 @@
+{% extends '_base.html' %}
+{% load static from staticfiles %}
+{% load form_helpers %}
+
+{% block content %}
+    <form action="." method="post" class="form form-horizontal">
+        {% csrf_token %}
+        {% for field in form.hidden_fields %}
+            {{ field }}
+        {% endfor %}
+        <div class="row">
+            <div class="col-md-6 col-md-offset-3">
+                <h3>{% block title %}Add Devices to Cluster {{ cluster }}{% endblock %}</h3>
+                {% if form.non_field_errors %}
+                    <div class="panel panel-danger">
+                        <div class="panel-heading"><strong>Errors</strong></div>
+                        <div class="panel-body">
+                            {{ form.non_field_errors }}
+                        </div>
+                    </div>
+                {% endif %}
+                <div class="panel panel-default">
+                    <div class="panel-heading"><strong>Devices</strong></div>
+                    <div class="panel-body">
+                        {% render_field form.region %}
+                        {% render_field form.site %}
+                        {% render_field form.rack %}
+                        {% render_field form.devices %}
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div class="row">
+            <div class="col-md-6 col-md-offset-3 text-right">
+                <button type="submit" name="_add" class="btn btn-primary">Add Devices</button>
+                <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+            </div>
+        </div>
+    </form>
+{% endblock %}
+
+{% block javascript %}
+    <script src="{% static 'js/livesearch.js' %}?v{{ settings.VERSION }}"></script>
+{% 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 %}

+ 70 - 0
netbox/templates/virtualization/inc/interface.html

@@ -0,0 +1,70 @@
+<tr class="interface{% if not iface.enabled %} danger{% endif %}">
+    {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
+        <td class="pk">
+            <input name="pk" type="checkbox" value="{{ iface.pk }}" />
+        </td>
+    {% endif %}
+    <td>
+        <i class="fa fa-fw fa-square"></i> <span>{{ iface.name }}</span>
+        {% if iface.description %}
+            <i class="fa fa-fw fa-comment-o" title="{{ iface.description }}"></i>
+        {% endif %}
+    </td>
+    <td>{{ iface.mtu|default:"" }}</td>
+    <td>{{ iface.mac_address|default:"" }}</td>
+    <td class="text-right">
+        {% if perms.ipam.add_ipaddress %}
+            <a href="{% url 'ipam:ipaddress_add' %}?interface={{ iface.pk }}&return_url={{ vm.get_absolute_url }}" class="btn btn-xs btn-success" title="Add IP address">
+                <i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
+            </a>
+        {% endif %}
+        {% if perms.dcim.change_interface %}
+            <a href="{% url 'virtualization:interface_edit' pk=iface.pk %}" class="btn btn-info btn-xs" title="Edit interface">
+                <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
+            </a>
+        {% endif %}
+        {% if perms.dcim.delete_interface %}
+            <a href="{% url 'virtualization:interface_delete' pk=iface.pk %}" class="btn btn-danger btn-xs" title="Delete interface">
+                <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
+            </a>
+        {% endif %}
+    </td>
+</tr>
+{% for ip in iface.ip_addresses.all %}
+    <tr class="ipaddress">
+        {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %}
+            <td></td>
+        {% endif %}
+        <td>
+            <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
+            {% if ip.description %}
+                <i class="fa fa-fw fa-comment-o" title="{{ ip.description }}"></i>
+            {% endif %}
+            {% if vm.primary_ip4 == ip or vm.primary_ip6 == ip %}
+                <span class="label label-success">Primary</span>
+            {% endif %}
+        </td>
+        <td class="text-right">
+            {% if ip.vrf %}
+                <a href="{% url 'ipam:vrf' pk=ip.vrf.pk %}">{{ ip.vrf }}</a>
+            {% else %}
+                <span class="text-muted">Global</span>
+            {% endif %}
+        </td>
+        <td>
+            <span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
+        </td>
+        <td class="text-right">
+            {% if perms.ipam.change_ipaddress %}
+                <a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ vm.get_absolute_url }}" class="btn btn-info btn-xs">
+                    <i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i>
+                </a>
+            {% endif %}
+            {% if perms.ipam.delete_ipaddress %}
+                <a href="{% url 'ipam:ipaddress_delete' pk=ip.pk %}?return_url={{ vm.get_absolute_url }}" class="btn btn-danger btn-xs">
+                    <i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete IP address"></i>
+                </a>
+            {% endif %}
+        </td>
+    </tr>
+{% endfor %}

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

@@ -0,0 +1,277 @@
+{% 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"></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"></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"></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-5">
+        <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>
+                        {% if vm.cluster.group %}
+                            <a href="{{ vm.cluster.group.get_absolute_url }}">{{ vm.cluster.group }}</a>
+                            <i class="fa fa-angle-right"></i>
+                        {% endif %}
+                        <a href="{{ vm.cluster.get_absolute_url }}">{{ vm.cluster }}</a>
+                    </td>
+                </tr>
+                <tr>
+                    <td>Cluster Type</td>
+                    <td>{{ vm.cluster.type }}</td>
+                </tr>
+                <tr>
+                    <td>Platform</td>
+                    <td>
+                        {% if vm.platform %}
+                            <span>{{ vm.platform }}</span>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if vm.tenant %}
+                            {% if vm.tenant.group %}
+                                <a href="{{ vm.tenant.group.get_absolute_url }}">{{ vm.tenant.group.name }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endif %}
+                            <a href="{{ vm.tenant.get_absolute_url }}">{{ vm.tenant }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Primary IPv4</td>
+                    <td>
+                        {% if vm.primary_ip4 %}
+                            <a href="{% url 'ipam:ipaddress' pk=vm.primary_ip4.pk %}">{{ vm.primary_ip4.address.ip }}</a>
+                            {% if vm.primary_ip4.nat_inside %}
+                                <span>(NAT for {{ vm.primary_ip4.nat_inside.address.ip }})</span>
+                            {% elif vm.primary_ip4.nat_outside %}
+                                <span>(NAT: {{ vm.primary_ip4.nat_outside.address.ip }})</span>
+                            {% endif %}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Primary IPv6</td>
+                    <td>
+                        {% if vm.primary_ip6 %}
+                            <a href="{% url 'ipam:ipaddress' pk=vm.primary_ip6.pk %}">{{ vm.primary_ip6.address.ip }}</a>
+                            {% if vm.primary_ip6.nat_inside %}
+                                <span>(NAT for {{ vm.primary_ip6.nat_inside.address.ip }})</span>
+                            {% elif vm.primary_ip6.nat_outside %}
+                                <span>(NAT: {{ vm.primary_ip6.nat_outside.address.ip }})</span>
+                            {% endif %}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+            </table>
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Resources</strong>
+            </div>
+            <table class="table table-hover panel-body attr-table">
+                <tr>
+                    <td><i class="fa fa-tachometer"></i> Virtual CPUs</td>
+                    <td>
+                        {% if vm.vcpus %}
+                            {{ vm.vcpus }}
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td><i class="fa fa-microchip"></i> Memory</td>
+                    <td>
+                        {% if vm.memory %}
+                            {{ vm.memory }} MB
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td><i class="fa fa-hdd-o"></i> Disk Space</td>
+                    <td>
+                        {% if vm.disk %}
+                            {{ vm.disk }} GB
+                        {% else %}
+                            <span class="text-muted">N/A</span>
+                        {% endif %}
+                    </td>
+                </tr>
+            </table>
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Services</strong>
+            </div>
+            {% if services %}
+                <table class="table table-hover panel-body">
+                    {% for service in services %}
+                        {% include 'ipam/inc/service.html' %}
+                    {% endfor %}
+                </table>
+            {% else %}
+                <div class="panel-body text-muted">
+                    None
+                </div>
+            {% endif %}
+            {% if perms.ipam.add_service %}
+                <div class="panel-footer text-right">
+                    <a href="{% url 'virtualization:virtualmachine_service_assign' virtualmachine=vm.pk %}" class="btn btn-xs btn-primary">
+                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
+                    </a>
+                </div>
+            {% endif %}
+        </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-7">
+        {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
+            <form method="post">
+            {% csrf_token %}
+            <input type="hidden" name="virtual_machine" value="{{ vm.pk }}" />
+        {% endif %}
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Interfaces</strong>
+                <div class="pull-right">
+                    <button class="btn btn-default btn-xs toggle-ips" selected="selected">
+                        <span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
+                    </button>
+                    {% if perms.dcim.change_interface and interfaces|length > 1 %}
+                        <button class="btn btn-default btn-xs toggle">
+                            <span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
+                        </button>
+                    {% endif %}
+                    {% if perms.dcim.add_interface and interfaces|length > 10 %}
+                        <a href="{% url 'virtualization:interface_add' pk=vm.pk %}" class="btn btn-primary btn-xs">
+                            <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
+                        </a>
+                    {% endif %}
+                </div>
+            </div>
+            <table id="interfaces_table" class="table table-hover panel-body component-list">
+                {% for iface in interfaces %}
+                    {% include 'virtualization/inc/interface.html' with selectable=True %}
+                {% empty %}
+                    <tr>
+                        <td colspan="4">No interfaces defined</td>
+                    </tr>
+                {% endfor %}
+            </table>
+            {% if perms.dcim.add_interface or perms.dcim.delete_interface %}
+                <div class="panel-footer">
+                    {% if interfaces and perms.dcim.change_interface %}
+                        <button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=vm.pk %}" class="btn btn-warning btn-xs">
+                            <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
+                        </button>
+                    {% endif %}
+                    {% if interfaces and perms.dcim.delete_interface %}
+                        <button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' pk=vm.pk %}" class="btn btn-danger btn-xs">
+                            <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
+                        </button>
+                    {% endif %}
+                    {% if perms.dcim.add_interface %}
+                        <div class="pull-right">
+                            <a href="{% url 'virtualization:interface_add' pk=vm.pk %}" class="btn btn-primary btn-xs">
+                                <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
+                            </a>
+                        </div>
+                        <div class="clearfix"></div>
+                    {% endif %}
+                 </div>
+            {% endif %}
+        </div>
+        {% if perms.dcim.delete_interface %}
+            </form>
+        {% endif %}
+	</div>
+</div>
+{% endblock %}
+
+{% block javascript %}
+<script type="text/javascript">
+// Toggle the display of IP addresses under interfaces
+$('button.toggle-ips').click(function() {
+    var selected = $(this).attr('selected');
+    if (selected) {
+        $('#interfaces_table tr.ipaddress').hide();
+    } else {
+        $('#interfaces_table tr.ipaddress').show();
+    }
+    $(this).attr('selected', !selected);
+    $(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
+    return false;
+});
+</script>
+{% endblock %}

+ 44 - 0
netbox/templates/virtualization/virtualmachine_component_add.html

@@ -0,0 +1,44 @@
+{% extends '_base.html' %}
+{% load helpers %}
+{% load form_helpers %}
+
+{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
+
+{% block content %}
+<form action="." method="post" class="form form-horizontal">
+    {% csrf_token %}
+    <div class="row">
+        <div class="col-md-6 col-md-offset-3">
+            {% if form.non_field_errors %}
+                <div class="panel panel-danger">
+                    <div class="panel-heading"><strong>Errors</strong></div>
+                    <div class="panel-body">
+                        {{ form.non_field_errors }}
+                    </div>
+                </div>
+            {% endif %}
+            <div class="panel panel-default">
+                <div class="panel-heading">
+                    <strong>{{ component_type|bettertitle }}</strong>
+                </div>
+                <div class="panel-body">
+                    <div class="form-group">
+                        <label class="col-md-3 control-label required">Virtual Machine</label>
+                        <div class="col-md-9">
+                            <p class="form-control-static">{{ parent }}</p>
+                        </div>
+                    </div>
+                    {% render_form form %}
+                </div>
+            </div>
+		    <div class="form-group">
+                <div class="col-md-9 col-md-offset-3">
+                    <button type="submit" name="_create" class="btn btn-primary">Create</button>
+                    <button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
+                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
+                </div>
+		    </div>
+        </div>
+    </div>
+</form>
+{% endblock %}

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

@@ -0,0 +1,43 @@
+{% 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.cluster_group %}
+            {% render_field form.cluster %}
+            {% render_field form.platform %}
+        </div>
+    </div>
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>Resources</strong></div>
+        <div class="panel-body">
+            {% render_field form.vcpus %}
+            {% render_field form.memory %}
+            {% render_field form.disk %}
+        </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 %}

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

@@ -0,0 +1,55 @@
+{% 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='virtual machines' %}
+</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 %}
+
+{% block javascript %}
+<script type="text/javascript">
+$(document).ready(function() {
+
+    var cluster_group_list = $('#id_cluster_group');
+    var cluster_list = $('#id_cluster_id');
+
+    // Update cluster options based on selected group
+    cluster_group_list.change(function() {
+        var selected_groups = $(this).val();
+        if (selected_groups) {
+            cluster_list.empty();
+            $.ajax({
+                url: netbox_api_path + 'virtualization/clusters/?limit=500&group=' + selected_groups.join('&group='),
+                dataType: 'json',
+                success: function (response, status) {
+                    $.each(response["results"], function (index, cluster) {
+                        var option = $("<option></option>").attr("value", cluster.id).text(cluster.name);
+                        cluster_list.append(option);
+                    });
+                }
+            });
+        }
+    });
+
+});
+</script>
+{% endblock %}

+ 2 - 0
netbox/tenancy/views.py

@@ -12,6 +12,7 @@ from ipam.models import IPAddress, Prefix, VLAN, VRF
 from utilities.views import (
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 )
+from virtualization.models import VirtualMachine
 from .models import Tenant, TenantGroup
 from .models import Tenant, TenantGroup
 from . import filters, forms, tables
 from . import filters, forms, tables
 
 
@@ -79,6 +80,7 @@ class TenantView(View):
             ).count(),
             ).count(),
             'vlan_count': VLAN.objects.filter(tenant=tenant).count(),
             'vlan_count': VLAN.objects.filter(tenant=tenant).count(),
             'circuit_count': Circuit.objects.filter(tenant=tenant).count(),
             'circuit_count': Circuit.objects.filter(tenant=tenant).count(),
+            'virtualmachine_count': VirtualMachine.objects.filter(tenant=tenant).count(),
         }
         }
 
 
         return render(request, 'tenancy/tenant.html', {
         return render(request, 'tenancy/tenant.html', {

+ 22 - 0
netbox/utilities/forms.py

@@ -188,6 +188,10 @@ class APISelect(SelectWithDisabled):
             self.attrs['disabled-indicator'] = disabled_indicator
             self.attrs['disabled-indicator'] = disabled_indicator
 
 
 
 
+class APISelectMultiple(APISelect):
+    allow_multiple_selected = True
+
+
 class Livesearch(forms.TextInput):
 class Livesearch(forms.TextInput):
     """
     """
     A text widget that carries a few extra bits of data for use in AJAX-powered autocomplete search
     A text widget that carries a few extra bits of data for use in AJAX-powered autocomplete search
@@ -386,6 +390,15 @@ class ChainedModelChoiceField(forms.ModelChoiceField):
         super(ChainedModelChoiceField, self).__init__(*args, **kwargs)
         super(ChainedModelChoiceField, self).__init__(*args, **kwargs)
 
 
 
 
+class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField):
+    """
+    See ChainedModelChoiceField
+    """
+    def __init__(self, chains=None, *args, **kwargs):
+        self.chains = chains
+        super(ChainedModelMultipleChoiceField, self).__init__(*args, **kwargs)
+
+
 class SlugField(forms.SlugField):
 class SlugField(forms.SlugField):
 
 
     def __init__(self, slug_source='name', *args, **kwargs):
     def __init__(self, slug_source='name', *args, **kwargs):
@@ -508,6 +521,15 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm):
     confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
     confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
 
 
 
 
+class ComponentForm(BootstrapMixin, forms.Form):
+    """
+    Allow inclusion of the parent Device/VirtualMachine as context for limiting field choices.
+    """
+    def __init__(self, parent, *args, **kwargs):
+        self.parent = parent
+        super(ComponentForm, self).__init__(*args, **kwargs)
+
+
 class BulkEditForm(forms.Form):
 class BulkEditForm(forms.Form):
 
 
     def __init__(self, model, *args, **kwargs):
     def __init__(self, model, *args, **kwargs):

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

@@ -47,6 +47,22 @@ def gfm(value):
 
 
 
 
 @register.filter()
 @register.filter()
+def model_name(obj):
+    """
+    Return the name of the model of the given object
+    """
+    return obj._meta.verbose_name
+
+
+@register.filter()
+def model_name_plural(obj):
+    """
+    Return the plural name of the model of the given object
+    """
+    return obj._meta.verbose_name_plural
+
+
+@register.filter()
 def contains(value, arg):
 def contains(value, arg):
     """
     """
     Test whether a value contains any of a given set of strings. `arg` should be a comma-separated list of strings.
     Test whether a value contains any of a given set of strings. `arg` should be a comma-separated list of strings.

+ 89 - 0
netbox/utilities/views.py

@@ -1,5 +1,6 @@
 from __future__ import unicode_literals
 from __future__ import unicode_literals
 from collections import OrderedDict
 from collections import OrderedDict
+from copy import deepcopy
 
 
 from django_tables2 import RequestConfig
 from django_tables2 import RequestConfig
 
 
@@ -697,3 +698,91 @@ class BulkDeleteView(View):
         if self.form:
         if self.form:
             return self.form
             return self.form
         return BulkDeleteForm
         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):
+    parent_field = None
+
+    def get_return_url(self, request, obj):
+        return getattr(obj, self.parent_field).get_absolute_url()
+
+
+class ComponentDeleteView(ObjectDeleteView):
+    parent_field = None
+
+    def get_return_url(self, request, obj):
+        return getattr(obj, self.parent_field).get_absolute_url()

+ 0 - 0
netbox/virtualization/__init__.py


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


+ 142 - 0
netbox/virtualization/api/serializers.py

@@ -0,0 +1,142 @@
+from __future__ import unicode_literals
+
+from rest_framework import serializers
+
+from dcim.api.serializers import NestedPlatformSerializer
+from dcim.constants import VIFACE_FF_CHOICES
+from dcim.models import Interface
+from extras.api.customfields import CustomFieldModelSerializer
+from tenancy.api.serializers import NestedTenantSerializer
+from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
+from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+
+
+#
+# Cluster types
+#
+
+class ClusterTypeSerializer(ValidatedModelSerializer):
+
+    class Meta:
+        model = ClusterType
+        fields = ['id', 'name', 'slug']
+
+
+class NestedClusterTypeSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail')
+
+    class Meta:
+        model = ClusterType
+        fields = ['id', 'url', 'name', 'slug']
+
+
+#
+# Cluster groups
+#
+
+class ClusterGroupSerializer(ValidatedModelSerializer):
+
+    class Meta:
+        model = ClusterGroup
+        fields = ['id', 'name', 'slug']
+
+
+class NestedClusterGroupSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail')
+
+    class Meta:
+        model = ClusterGroup
+        fields = ['id', 'url', 'name', 'slug']
+
+
+#
+# Clusters
+#
+
+class ClusterSerializer(CustomFieldModelSerializer):
+    type = NestedClusterTypeSerializer()
+    group = NestedClusterGroupSerializer()
+
+    class Meta:
+        model = Cluster
+        fields = ['id', 'name', 'type', 'group', 'comments', 'custom_fields']
+
+
+class NestedClusterSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
+
+    class Meta:
+        model = Cluster
+        fields = ['id', 'url', 'name']
+
+
+class WritableClusterSerializer(CustomFieldModelSerializer):
+
+    class Meta:
+        model = Cluster
+        fields = ['id', 'name', 'type', 'group', 'comments', 'custom_fields']
+
+
+#
+# Virtual machines
+#
+
+class VirtualMachineSerializer(CustomFieldModelSerializer):
+    cluster = NestedClusterSerializer()
+    tenant = NestedTenantSerializer()
+    platform = NestedPlatformSerializer()
+
+    class Meta:
+        model = VirtualMachine
+        fields = [
+            'id', 'name', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields',
+        ]
+
+
+class NestedVirtualMachineSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
+
+    class Meta:
+        model = VirtualMachine
+        fields = ['id', 'url', 'name']
+
+
+class WritableVirtualMachineSerializer(CustomFieldModelSerializer):
+
+    class Meta:
+        model = VirtualMachine
+        fields = [
+            'id', 'name', 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'comments', 'custom_fields',
+        ]
+
+
+#
+# VM interfaces
+#
+
+class InterfaceSerializer(serializers.ModelSerializer):
+    virtual_machine = NestedVirtualMachineSerializer()
+    form_factor = ChoiceFieldSerializer(choices=VIFACE_FF_CHOICES)
+
+    class Meta:
+        model = Interface
+        fields = [
+            'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description',
+        ]
+
+
+class NestedInterfaceSerializer(serializers.ModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail')
+
+    class Meta:
+        model = Interface
+        fields = ['id', 'url', 'name']
+
+
+class WritableInterfaceSerializer(ValidatedModelSerializer):
+
+    class Meta:
+        model = Interface
+        fields = [
+            'id', 'name', 'virtual_machine', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description',
+        ]

+ 29 - 0
netbox/virtualization/api/urls.py

@@ -0,0 +1,29 @@
+from __future__ import unicode_literals
+
+from rest_framework import routers
+
+from . import views
+
+
+class VirtualizationRootView(routers.APIRootView):
+    """
+    Virtualization API root view
+    """
+    def get_view_name(self):
+        return 'Virtualization'
+
+
+router = routers.DefaultRouter()
+router.APIRootView = VirtualizationRootView
+
+# Clusters
+router.register(r'cluster-types', views.ClusterTypeViewSet)
+router.register(r'cluster-groups', views.ClusterGroupViewSet)
+router.register(r'clusters', views.ClusterViewSet)
+
+# VirtualMachines
+router.register(r'virtual-machines', views.VirtualMachineViewSet)
+router.register(r'interfaces', views.InterfaceViewSet)
+
+app_name = 'virtualization-api'
+urlpatterns = router.urls

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

@@ -0,0 +1,48 @@
+from __future__ import unicode_literals
+
+from rest_framework.viewsets import ModelViewSet
+
+from dcim.models import Interface
+from extras.api.views import CustomFieldModelViewSet
+from utilities.api import WritableSerializerMixin
+from virtualization import filters
+from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from . import serializers
+
+
+#
+# Clusters
+#
+
+class ClusterTypeViewSet(ModelViewSet):
+    queryset = ClusterType.objects.all()
+    serializer_class = serializers.ClusterTypeSerializer
+
+
+class ClusterGroupViewSet(ModelViewSet):
+    queryset = ClusterGroup.objects.all()
+    serializer_class = serializers.ClusterGroupSerializer
+
+
+class ClusterViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+    queryset = Cluster.objects.select_related('type', 'group')
+    serializer_class = serializers.ClusterSerializer
+    write_serializer_class = serializers.WritableClusterSerializer
+    filter_class = filters.ClusterFilter
+
+
+#
+# Virtual machines
+#
+
+class VirtualMachineViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
+    queryset = VirtualMachine.objects.all()
+    serializer_class = serializers.VirtualMachineSerializer
+    write_serializer_class = serializers.WritableVirtualMachineSerializer
+    filter_class = filters.VirtualMachineFilter
+
+
+class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
+    queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine')
+    serializer_class = serializers.InterfaceSerializer
+    write_serializer_class = serializers.WritableInterfaceSerializer

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

+ 99 - 0
netbox/virtualization/filters.py

@@ -0,0 +1,99 @@
+from __future__ import unicode_literals
+
+import django_filters
+from django.db.models import Q
+
+from dcim.models import Platform
+from extras.filters import CustomFieldFilterSet
+from tenancy.models import Tenant
+from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter
+from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+
+
+class ClusterFilter(CustomFieldFilterSet):
+    id__in = NumericInFilter(name='id', lookup_expr='in')
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    group_id = NullableModelMultipleChoiceFilter(
+        queryset=ClusterGroup.objects.all(),
+        label='Parent group (ID)',
+    )
+    group = NullableModelMultipleChoiceFilter(
+        queryset=ClusterGroup.objects.all(),
+        to_field_name='slug',
+        label='Parent group (slug)',
+    )
+    type_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ClusterType.objects.all(),
+        label='Cluster type (ID)',
+    )
+    type = django_filters.ModelMultipleChoiceFilter(
+        name='type__slug',
+        queryset=ClusterType.objects.all(),
+        to_field_name='slug',
+        label='Cluster type (slug)',
+    )
+
+    class Meta:
+        model = Cluster
+        fields = ['name']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(comments__icontains=value)
+        )
+
+
+class VirtualMachineFilter(CustomFieldFilterSet):
+    id__in = NumericInFilter(name='id', lookup_expr='in')
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    cluster_group_id = NullableModelMultipleChoiceFilter(
+        name='cluster__group',
+        queryset=ClusterGroup.objects.all(),
+        label='Cluster group (ID)',
+    )
+    cluster_group = NullableModelMultipleChoiceFilter(
+        name='cluster__group__slug',
+        queryset=ClusterGroup.objects.all(),
+        to_field_name='slug',
+        label='Cluster group (slug)',
+    )
+    tenant_id = NullableModelMultipleChoiceFilter(
+        queryset=Tenant.objects.all(),
+        label='Tenant (ID)',
+    )
+    tenant = NullableModelMultipleChoiceFilter(
+        queryset=Tenant.objects.all(),
+        to_field_name='slug',
+        label='Tenant (slug)',
+    )
+    platform_id = NullableModelMultipleChoiceFilter(
+        queryset=Platform.objects.all(),
+        label='Platform (ID)',
+    )
+    platform = NullableModelMultipleChoiceFilter(
+        name='platform',
+        queryset=Platform.objects.all(),
+        to_field_name='slug',
+        label='Platform (slug)',
+    )
+
+    class Meta:
+        model = VirtualMachine
+        fields = ['name', 'cluster']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(name__icontains=value) |
+            Q(comments__icontains=value)
+        )

+ 269 - 0
netbox/virtualization/forms.py

@@ -0,0 +1,269 @@
+from __future__ import unicode_literals
+
+from mptt.forms import TreeNodeChoiceField
+
+from django import forms
+from django.db.models import Count
+
+from dcim.constants import VIFACE_FF_CHOICES
+from dcim.formfields import MACAddressFormField
+from dcim.models import Device, Interface, Rack, Region, Site
+from extras.forms import CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
+from tenancy.forms import TenancyForm
+from tenancy.models import Tenant
+from utilities.forms import (
+    APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin,
+    ChainedModelChoiceField, ChainedModelMultipleChoiceField, ComponentForm, ConfirmationForm, ExpandableNameField,
+    FilterChoiceField, 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']
+
+
+class ClusterFilterForm(BootstrapMixin, CustomFieldFilterForm):
+    model = Cluster
+    q = forms.CharField(required=False, label='Search')
+    group = FilterChoiceField(
+        queryset=ClusterGroup.objects.annotate(filter_count=Count('clusters')),
+        to_field_name='slug',
+        null_option=(0, 'None'),
+        required=False,
+    )
+    type = FilterChoiceField(
+        queryset=ClusterType.objects.annotate(filter_count=Count('clusters')),
+        to_field_name='slug',
+        required=False,
+    )
+
+
+class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
+    region = TreeNodeChoiceField(
+        queryset=Region.objects.all(),
+        required=False,
+        widget=forms.Select(
+            attrs={'filter-for': 'site', 'nullable': 'true'}
+        )
+    )
+    site = ChainedModelChoiceField(
+        queryset=Site.objects.all(),
+        chains=(
+            ('region', 'region'),
+        ),
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/sites/?region_id={{region}}',
+            attrs={'filter-for': 'rack'}
+        )
+    )
+    rack = ChainedModelChoiceField(
+        queryset=Rack.objects.all(),
+        chains=(
+            ('site', 'site'),
+        ),
+        required=False,
+        widget=APISelect(
+            api_url='/api/dcim/racks/?site_id={{site}}',
+            attrs={'filter-for': 'devices', 'nullable': 'true'}
+        )
+    )
+    devices = ChainedModelMultipleChoiceField(
+        queryset=Device.objects.filter(cluster__isnull=True),
+        chains=(
+            ('site', 'site'),
+            ('rack', 'rack'),
+        ),
+        label='Device',
+        required=False,
+        widget=APISelectMultiple(
+            api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
+            display_field='display_name',
+            disabled_indicator='cluster'
+        )
+    )
+
+    class Meta:
+        fields = ['region', 'site', 'rack', 'devices']
+
+    def __init__(self, *args, **kwargs):
+
+        super(ClusterAddDevicesForm, self).__init__(*args, **kwargs)
+
+        self.fields['devices'].choices = []
+
+
+class ClusterRemoveDevicesForm(ConfirmationForm):
+    pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
+
+
+#
+# Virtual Machines
+#
+
+class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
+    cluster_group = forms.ModelChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        required=False,
+        widget=forms.Select(
+            attrs={'filter-for': 'cluster', 'nullable': 'true'}
+        )
+    )
+    cluster = ChainedModelChoiceField(
+        queryset=Cluster.objects.all(),
+        chains=(
+            ('group', 'cluster_group'),
+        ),
+        widget=APISelect(
+            api_url='/api/virtualization/clusters/?group_id={{cluster_group}}'
+        )
+    )
+
+    class Meta:
+        model = VirtualMachine
+        fields = ['name', 'cluster_group', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments']
+
+    def __init__(self, *args, **kwargs):
+
+        # Initialize helper selector
+        instance = kwargs.get('instance')
+        if instance.pk and instance.cluster is not None:
+            initial = kwargs.get('initial', {}).copy()
+            initial['cluster_group'] = instance.cluster.group
+            kwargs['initial'] = initial
+
+        super(VirtualMachineForm, self).__init__(*args, **kwargs)
+
+
+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', 'platform', 'vcpus', 'memory', 'disk', 'comments']
+
+
+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']
+
+
+class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
+    model = VirtualMachine
+    q = forms.CharField(required=False, label='Search')
+    cluster_group = FilterChoiceField(
+        queryset=ClusterGroup.objects.all(),
+        to_field_name='slug',
+        null_option=(0, 'None'),
+    )
+    cluster_id = FilterChoiceField(
+        queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')),
+        label='Cluster'
+    )
+
+
+#
+# VM interfaces
+#
+
+class InterfaceForm(BootstrapMixin, forms.ModelForm):
+
+    class Meta:
+        model = Interface
+        fields = ['virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description']
+        widgets = {
+            'virtual_machine': forms.HiddenInput(),
+        }
+
+
+class InterfaceCreateForm(ComponentForm):
+    name_pattern = ExpandableNameField(label='Name')
+    form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES)
+    enabled = forms.BooleanField(required=False)
+    mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
+    mac_address = MACAddressFormField(required=False, label='MAC Address')
+    description = forms.CharField(max_length=100, required=False)
+
+    def __init__(self, *args, **kwargs):
+
+        # Set interfaces enabled by default
+        kwargs['initial'] = kwargs.get('initial', {}).copy()
+        kwargs['initial'].update({'enabled': True})
+
+        super(InterfaceCreateForm, self).__init__(*args, **kwargs)
+
+
+class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
+    virtual_machine = forms.ModelChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.HiddenInput)
+    enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect)
+    mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
+    description = forms.CharField(max_length=100, required=False)
+
+    class Meta:
+        nullable_fields = ['mtu', 'description']

+ 89 - 0
netbox/virtualization/migrations/0001_virtualization.py

@@ -0,0 +1,89 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.4 on 2017-08-31 14:15
+from __future__ import unicode_literals
+
+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'),
+        ('dcim', '0043_device_component_name_lengths'),
+        ('tenancy', '0003_unicode_literals'),
+    ]
+
+    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)),
+            ],
+            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)),
+                ('vcpus', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='vCPUs')),
+                ('memory', models.PositiveIntegerField(blank=True, null=True, verbose_name='Memory (MB)')),
+                ('disk', models.PositiveIntegerField(blank=True, null=True, verbose_name='Disk (GB)')),
+                ('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.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'),
+        ),
+    ]

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


+ 189 - 0
netbox/virtualization/models.py

@@ -0,0 +1,189 @@
+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 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
+    )
+    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'
+    )
+    vcpus = models.PositiveSmallIntegerField(
+        blank=True,
+        null=True,
+        verbose_name='vCPUs'
+    )
+    memory = models.PositiveIntegerField(
+        blank=True,
+        null=True,
+        verbose_name='Memory (MB)'
+    )
+    disk = models.PositiveIntegerField(
+        blank=True,
+        null=True,
+        verbose_name='Disk (GB)'
+    )
+    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])

+ 97 - 0
netbox/virtualization/tables.py

@@ -0,0 +1,97 @@
+from __future__ import unicode_literals
+
+import django_tables2 as tables
+from django_tables2.utils import Accessor
+
+from dcim.models import Interface
+from utilities.tables import BaseTable, ToggleColumn
+from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+
+
+CLUSTERTYPE_ACTIONS = """
+{% if perms.virtualization.change_clustertype %}
+    <a href="{% url 'virtualization:clustertype_edit' slug=record.slug %}" 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' slug=record.slug %}" 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()
+    cluster = tables.LinkColumn('virtualization:cluster', args=[Accessor('cluster.pk')])
+    tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
+
+    class Meta(BaseTable.Meta):
+        model = VirtualMachine
+        fields = ('pk', 'name', 'cluster', 'tenant', 'vcpus', 'memory', 'disk')
+
+
+#
+# VM components
+#
+
+class InterfaceTable(BaseTable):
+
+    class Meta(BaseTable.Meta):
+        model = Interface
+        fields = ('name', 'enabled', 'description')

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


+ 297 - 0
netbox/virtualization/tests/test_api.py

@@ -0,0 +1,297 @@
+from __future__ import unicode_literals
+
+from rest_framework import status
+from rest_framework.test import APITestCase
+
+from django.contrib.auth.models import User
+from django.urls import reverse
+
+from users.models import Token
+from utilities.tests import HttpStatusMixin
+from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+
+
+class ClusterTypeTest(HttpStatusMixin, APITestCase):
+
+    def setUp(self):
+
+        user = User.objects.create(username='testuser', is_superuser=True)
+        token = Token.objects.create(user=user)
+        self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+
+        self.clustertype1 = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
+        self.clustertype2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
+        self.clustertype3 = ClusterType.objects.create(name='Test Cluster Type 3', slug='test-cluster-type-3')
+
+    def test_get_clustertype(self):
+
+        url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['name'], self.clustertype1.name)
+
+    def test_list_clustertypes(self):
+
+        url = reverse('virtualization-api:clustertype-list')
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['count'], 3)
+
+    def test_create_clustertype(self):
+
+        data = {
+            'name': 'Test Cluster Type 4',
+            'slug': 'test-cluster-type-4',
+        }
+
+        url = reverse('virtualization-api:clustertype-list')
+        response = self.client.post(url, data, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(ClusterType.objects.count(), 4)
+        clustertype4 = ClusterType.objects.get(pk=response.data['id'])
+        self.assertEqual(clustertype4.name, data['name'])
+        self.assertEqual(clustertype4.slug, data['slug'])
+
+    def test_update_clustertype(self):
+
+        data = {
+            'name': 'Test Cluster Type X',
+            'slug': 'test-cluster-type-x',
+        }
+
+        url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
+        response = self.client.put(url, data, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(ClusterType.objects.count(), 3)
+        clustertype1 = ClusterType.objects.get(pk=response.data['id'])
+        self.assertEqual(clustertype1.name, data['name'])
+        self.assertEqual(clustertype1.slug, data['slug'])
+
+    def test_delete_clustertype(self):
+
+        url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
+        response = self.client.delete(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+        self.assertEqual(ClusterType.objects.count(), 2)
+
+
+class ClusterGroupTest(HttpStatusMixin, APITestCase):
+
+    def setUp(self):
+
+        user = User.objects.create(username='testuser', is_superuser=True)
+        token = Token.objects.create(user=user)
+        self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+
+        self.clustergroup1 = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
+        self.clustergroup2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2')
+        self.clustergroup3 = ClusterGroup.objects.create(name='Test Cluster Group 3', slug='test-cluster-group-3')
+
+    def test_get_clustergroup(self):
+
+        url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['name'], self.clustergroup1.name)
+
+    def test_list_clustergroups(self):
+
+        url = reverse('virtualization-api:clustergroup-list')
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['count'], 3)
+
+    def test_create_clustergroup(self):
+
+        data = {
+            'name': 'Test Cluster Group 4',
+            'slug': 'test-cluster-group-4',
+        }
+
+        url = reverse('virtualization-api:clustergroup-list')
+        response = self.client.post(url, data, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(ClusterGroup.objects.count(), 4)
+        clustergroup4 = ClusterGroup.objects.get(pk=response.data['id'])
+        self.assertEqual(clustergroup4.name, data['name'])
+        self.assertEqual(clustergroup4.slug, data['slug'])
+
+    def test_update_clustergroup(self):
+
+        data = {
+            'name': 'Test Cluster Group X',
+            'slug': 'test-cluster-group-x',
+        }
+
+        url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
+        response = self.client.put(url, data, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(ClusterGroup.objects.count(), 3)
+        clustergroup1 = ClusterGroup.objects.get(pk=response.data['id'])
+        self.assertEqual(clustergroup1.name, data['name'])
+        self.assertEqual(clustergroup1.slug, data['slug'])
+
+    def test_delete_clustergroup(self):
+
+        url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
+        response = self.client.delete(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+        self.assertEqual(ClusterGroup.objects.count(), 2)
+
+
+class ClusterTest(HttpStatusMixin, APITestCase):
+
+    def setUp(self):
+
+        user = User.objects.create(username='testuser', is_superuser=True)
+        token = Token.objects.create(user=user)
+        self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+
+        cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
+        cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
+
+        self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
+        self.cluster2 = Cluster.objects.create(name='Test Cluster 2', type=cluster_type, group=cluster_group)
+        self.cluster3 = Cluster.objects.create(name='Test Cluster 3', type=cluster_type, group=cluster_group)
+
+    def test_get_cluster(self):
+
+        url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['name'], self.cluster1.name)
+
+    def test_list_clusters(self):
+
+        url = reverse('virtualization-api:cluster-list')
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['count'], 3)
+
+    def test_create_cluster(self):
+
+        data = {
+            'name': 'Test Cluster 4',
+            'type': ClusterType.objects.first().pk,
+            'group': ClusterGroup.objects.first().pk,
+        }
+
+        url = reverse('virtualization-api:cluster-list')
+        response = self.client.post(url, data, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(Cluster.objects.count(), 4)
+        cluster4 = Cluster.objects.get(pk=response.data['id'])
+        self.assertEqual(cluster4.name, data['name'])
+        self.assertEqual(cluster4.type.pk, data['type'])
+        self.assertEqual(cluster4.group.pk, data['group'])
+
+    def test_update_cluster(self):
+
+        cluster_type2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
+        cluster_group2 = ClusterGroup.objects.create(name='Test Cluster Group 2', slug='test-cluster-group-2')
+        data = {
+            'name': 'Test Cluster X',
+            'type': cluster_type2.pk,
+            'group': cluster_group2.pk,
+        }
+
+        url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
+        response = self.client.put(url, data, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(Cluster.objects.count(), 3)
+        cluster1 = Cluster.objects.get(pk=response.data['id'])
+        self.assertEqual(cluster1.name, data['name'])
+        self.assertEqual(cluster1.type.pk, data['type'])
+        self.assertEqual(cluster1.group.pk, data['group'])
+
+    def test_delete_cluster(self):
+
+        url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
+        response = self.client.delete(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+        self.assertEqual(Cluster.objects.count(), 2)
+
+
+class VirtualMachineTest(HttpStatusMixin, APITestCase):
+
+    def setUp(self):
+
+        user = User.objects.create(username='testuser', is_superuser=True)
+        token = Token.objects.create(user=user)
+        self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)}
+
+        cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
+        cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
+        cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
+
+        self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=cluster)
+        self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=cluster)
+        self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=cluster)
+
+    def test_get_virtualmachine(self):
+
+        url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['name'], self.virtualmachine1.name)
+
+    def test_list_virtualmachines(self):
+
+        url = reverse('virtualization-api:virtualmachine-list')
+        response = self.client.get(url, **self.header)
+
+        self.assertEqual(response.data['count'], 3)
+
+    def test_create_virtualmachine(self):
+
+        data = {
+            'name': 'Test Virtual Machine 4',
+            'cluster': Cluster.objects.first().pk,
+        }
+
+        url = reverse('virtualization-api:virtualmachine-list')
+        response = self.client.post(url, data, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(VirtualMachine.objects.count(), 4)
+        virtualmachine4 = VirtualMachine.objects.get(pk=response.data['id'])
+        self.assertEqual(virtualmachine4.name, data['name'])
+        self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
+
+    def test_update_virtualmachine(self):
+
+        cluster2 = Cluster.objects.create(
+            name='Test Cluster 2',
+            type=ClusterType.objects.first(),
+            group=ClusterGroup.objects.first()
+        )
+        data = {
+            'name': 'Test Virtual Machine X',
+            'cluster': cluster2.pk,
+        }
+
+        url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
+        response = self.client.put(url, data, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(VirtualMachine.objects.count(), 3)
+        virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id'])
+        self.assertEqual(virtualmachine1.name, data['name'])
+        self.assertEqual(virtualmachine1.cluster.pk, data['cluster'])
+
+    def test_delete_virtualmachine(self):
+
+        url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
+        response = self.client.delete(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+        self.assertEqual(VirtualMachine.objects.count(), 2)

+ 53 - 0
netbox/virtualization/urls.py

@@ -0,0 +1,53 @@
+from __future__ import unicode_literals
+
+from django.conf.urls import url
+
+from ipam.views import ServiceCreateView
+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'),
+    url(r'^clusters/(?P<pk>\d+)/devices/add/$', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'),
+    url(r'^clusters/(?P<pk>\d+)/devices/remove/$', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'),
+
+    # 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'),
+    url(r'^virtual-machines/(?P<virtualmachine>\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'),
+
+    # VM interfaces
+    # url(r'^virtual-machines/interfaces/add/$', views.VMBulkAddInterfaceView.as_view(), name='vm_bulk_add_interface'),
+    url(r'^virtual-machines/(?P<pk>\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'),
+    url(r'^virtual-machines/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
+    url(r'^virtual-machines/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
+    url(r'^vm-interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
+    url(r'^vm-interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
+
+]

+ 323 - 0
netbox/virtualization/views.py

@@ -0,0 +1,323 @@
+from __future__ import unicode_literals
+
+from django.contrib import messages
+from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.db.models import Count
+from django.shortcuts import get_object_or_404, redirect, render
+from django.urls import reverse
+from django.views.generic import View
+
+from dcim.models import Device, Interface
+from dcim.tables import DeviceTable
+from ipam.models import Service
+from utilities.views import (
+    BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, ComponentEditView,
+    ObjectDeleteView, ObjectEditView, ObjectListView,
+)
+from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from . import filters
+from . import forms
+from . 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
+    filter = filters.ClusterFilter
+    filter_form = forms.ClusterFilterForm
+    template_name = 'virtualization/cluster_list.html'
+
+
+class ClusterView(View):
+
+    def get(self, request, pk):
+
+        cluster = get_object_or_404(Cluster, pk=pk)
+        devices = Device.objects.filter(cluster=cluster).select_related(
+            'site', 'rack', 'tenant', 'device_type__manufacturer'
+        )
+        device_table = DeviceTable(list(devices), orderable=False)
+        if request.user.has_perm('virtualization:change_cluster'):
+            device_table.columns.show('pk')
+
+        return render(request, 'virtualization/cluster.html', {
+            'cluster': cluster,
+            'device_table': device_table,
+        })
+
+
+class ClusterCreateView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'virtualization.add_cluster'
+    model = Cluster
+    form_class = forms.ClusterForm
+
+
+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'
+
+
+class ClusterAddDevicesView(PermissionRequiredMixin, View):
+    permission_required = 'virtualization.change_cluster'
+    form = forms.ClusterAddDevicesForm
+    template_name = 'virtualization/cluster_add_devices.html'
+
+    def get(self, request, pk):
+
+        cluster = get_object_or_404(Cluster, pk=pk)
+        form = self.form()
+
+        return render(request, self.template_name, {
+            'cluster': cluster,
+            'form': form,
+            'return_url': reverse('virtualization:cluster', kwargs={'pk': pk}),
+        })
+
+    def post(self, request, pk):
+
+        cluster = get_object_or_404(Cluster, pk=pk)
+        form = self.form(request.POST)
+
+        if form.is_valid():
+
+            # Assign the selected Devices to the Cluster
+            devices = form.cleaned_data['devices']
+            Device.objects.filter(pk__in=devices).update(cluster=cluster)
+
+            messages.success(request, "Added {} devices to cluster {}".format(
+                len(devices), cluster
+            ))
+            return redirect(cluster.get_absolute_url())
+
+        return render(request, self.template_name, {
+            'cluser': cluster,
+            'form': form,
+            'return_url': cluster.get_absolute_url(),
+        })
+
+
+class ClusterRemoveDevicesView(PermissionRequiredMixin, View):
+    permission_required = 'virtualization.change_cluster'
+    form = forms.ClusterRemoveDevicesForm
+    template_name = 'utilities/obj_bulk_remove.html'
+
+    def post(self, request, pk):
+
+        cluster = get_object_or_404(Cluster, pk=pk)
+
+        if '_confirm' in request.POST:
+            form = self.form(request.POST)
+            if form.is_valid():
+
+                # Remove the selected Devices from the Cluster
+                devices = form.cleaned_data['pk']
+                Device.objects.filter(pk__in=devices).update(cluster=None)
+
+                messages.success(request, "Removed {} devices from cluster {}".format(
+                    len(devices), cluster
+                ))
+                return redirect(cluster.get_absolute_url())
+
+        else:
+            form = self.form(initial={'pk': request.POST.getlist('pk')})
+
+        selected_objects = Device.objects.filter(pk__in=form.initial['pk'])
+        device_table = DeviceTable(list(selected_objects), orderable=False)
+
+        return render(request, self.template_name, {
+            'form': form,
+            'parent_obj': cluster,
+            'table': device_table,
+            'obj_type_plural': 'devices',
+            'return_url': cluster.get_absolute_url(),
+        })
+
+
+#
+# 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)
+        interfaces = Interface.objects.filter(virtual_machine=vm)
+        services = Service.objects.filter(virtual_machine=vm)
+
+        return render(request, 'virtualization/virtualmachine.html', {
+            'vm': vm,
+            'interfaces': interfaces,
+            'services': services,
+        })
+
+
+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 InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
+    permission_required = 'dcim.add_interface'
+    parent_model = VirtualMachine
+    parent_field = 'virtual_machine'
+    model = Interface
+    form = forms.InterfaceCreateForm
+    model_form = forms.InterfaceForm
+    template_name = 'virtualization/virtualmachine_component_add.html'
+
+
+class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
+    permission_required = 'dcim.change_interface'
+    model = Interface
+    parent_field = 'virtual_machine'
+    form_class = forms.InterfaceForm
+
+
+class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
+    permission_required = 'dcim.delete_interface'
+    model = Interface
+    parent_field = 'virtual_machine'
+
+
+class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
+    permission_required = 'dcim.change_interface'
+    cls = Interface
+    parent_cls = VirtualMachine
+    table = tables.InterfaceTable
+    form = forms.InterfaceBulkEditForm
+
+
+class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+    permission_required = 'dcim.delete_interface'
+    cls = Interface
+    parent_cls = VirtualMachine
+    table = tables.InterfaceTable