Преглед изворни кода

Merge pull request #4781 from netbox-community/4721-virtualmachine-interface

#4721: Move VM interfaces to a separate model (WIP)
Jeremy Stretch пре 5 година
родитељ
комит
d60a2d3723
49 измењених фајлова са 1235 додато и 651 уклоњено
  1. 7 35
      netbox/dcim/forms.py
  2. 18 0
      netbox/dcim/migrations/0109_interface_remove_vm.py
  3. 3 2
      netbox/dcim/models/__init__.py
  4. 47 80
      netbox/dcim/models/device_components.py
  5. 5 20
      netbox/dcim/tables.py
  6. 2 2
      netbox/dcim/tests/test_filters.py
  7. 3 55
      netbox/dcim/views.py
  8. 21 4
      netbox/ipam/api/serializers.py
  9. 1 2
      netbox/ipam/api/views.py
  10. 7 0
      netbox/ipam/constants.py
  11. 42 19
      netbox/ipam/filters.py
  12. 77 54
      netbox/ipam/forms.py
  13. 40 0
      netbox/ipam/migrations/0037_ipaddress_assignment.py
  14. 43 49
      netbox/ipam/models.py
  15. 8 24
      netbox/ipam/tables.py
  16. 31 20
      netbox/ipam/tests/test_filters.py
  17. 0 1
      netbox/ipam/tests/test_views.py
  18. 17 16
      netbox/ipam/views.py
  19. 2 2
      netbox/templates/dcim/inc/interface.html
  20. 10 14
      netbox/templates/dcim/interface.html
  21. 8 0
      netbox/templates/inc/nav_menu.html
  22. 4 4
      netbox/templates/ipam/ipaddress.html
  23. 21 16
      netbox/templates/ipam/ipaddress_edit.html
  24. 0 0
      netbox/templates/utilities/obj_bulk_rename.html
  25. 141 0
      netbox/templates/virtualization/inc/vminterface.html
  26. 14 16
      netbox/templates/virtualization/virtualmachine.html
  27. 0 6
      netbox/templates/virtualization/virtualmachine_component_add.html
  28. 1 1
      netbox/templates/virtualization/virtualmachine_list.html
  29. 100 0
      netbox/templates/virtualization/vminterface.html
  30. 1 1
      netbox/templates/virtualization/vminterface_edit.html
  31. 0 6
      netbox/utilities/custom_inspectors.py
  32. 24 0
      netbox/utilities/forms.py
  33. 53 0
      netbox/utilities/views.py
  34. 3 3
      netbox/virtualization/api/nested_serializers.py
  35. 5 7
      netbox/virtualization/api/serializers.py
  36. 1 1
      netbox/virtualization/api/urls.py
  37. 6 13
      netbox/virtualization/api/views.py
  38. 0 14
      netbox/virtualization/choices.py
  39. 5 5
      netbox/virtualization/filters.py
  40. 71 36
      netbox/virtualization/forms.py
  41. 44 0
      netbox/virtualization/migrations/0015_vminterface.py
  42. 69 0
      netbox/virtualization/migrations/0016_replicate_interfaces.py
  43. 114 2
      netbox/virtualization/models.py
  44. 8 5
      netbox/virtualization/tables.py
  45. 34 39
      netbox/virtualization/tests/test_api.py
  46. 13 13
      netbox/virtualization/tests/test_filters.py
  47. 18 32
      netbox/virtualization/tests/test_views.py
  48. 12 7
      netbox/virtualization/urls.py
  49. 81 25
      netbox/virtualization/views.py

+ 7 - 35
netbox/dcim/forms.py

@@ -23,12 +23,12 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import (
 from utilities.forms import (
     APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
-    ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
+    BulkRenameForm, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
     DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
     DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
     NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
     NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
     BOOLEAN_WITH_BLANK_CHOICES,
     BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
-from virtualization.models import Cluster, ClusterGroup, VirtualMachine
+from virtualization.models import Cluster, ClusterGroup
 from .choices import *
 from .choices import *
 from .constants import *
 from .constants import *
 from .models import (
 from .models import (
@@ -150,30 +150,6 @@ class LabeledComponentForm(BootstrapMixin, forms.Form):
             }, code='label_pattern_mismatch')
             }, code='label_pattern_mismatch')
 
 
 
 
-class BulkRenameForm(forms.Form):
-    """
-    An extendable form to be used for renaming device components in bulk.
-    """
-    find = forms.CharField()
-    replace = forms.CharField()
-    use_regex = forms.BooleanField(
-        required=False,
-        initial=True,
-        label='Use regular expressions'
-    )
-
-    def clean(self):
-
-        # Validate regular expression in "find" field
-        if self.cleaned_data['use_regex']:
-            try:
-                re.compile(self.cleaned_data['find'])
-            except re.error:
-                raise forms.ValidationError({
-                    'find': "Invalid regular expression"
-                })
-
-
 #
 #
 # Fields
 # Fields
 #
 #
@@ -1816,18 +1792,20 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
                 ip_choices = [(None, '---------')]
                 ip_choices = [(None, '---------')]
 
 
                 # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member
                 # Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member
-                interface_ids = self.instance.vc_interfaces.values('pk')
+                interface_ids = self.instance.vc_interfaces.values_list('pk', flat=True)
 
 
                 # Collect interface IPs
                 # Collect interface IPs
                 interface_ips = IPAddress.objects.prefetch_related('interface').filter(
                 interface_ips = IPAddress.objects.prefetch_related('interface').filter(
-                    address__family=family, interface_id__in=interface_ids
+                    address__family=family,
+                    interface__in=interface_ids
                 )
                 )
                 if interface_ips:
                 if interface_ips:
                     ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
                     ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
                     ip_choices.append(('Interface IPs', ip_list))
                     ip_choices.append(('Interface IPs', ip_list))
                 # Collect NAT IPs
                 # Collect NAT IPs
                 nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
                 nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
-                    address__family=family, nat_inside__interface__in=interface_ids
+                    address__family=family,
+                    nat_inside__interface__in=interface_ids
                 )
                 )
                 if nat_ips:
                 if nat_ips:
                     ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips]
                     ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips]
@@ -2961,12 +2939,6 @@ class InterfaceBulkDisconnectForm(ConfirmationForm):
 class InterfaceCSVForm(CSVModelForm):
 class InterfaceCSVForm(CSVModelForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
-        required=False,
-        to_field_name='name'
-    )
-    virtual_machine = CSVModelChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        required=False,
         to_field_name='name'
         to_field_name='name'
     )
     )
     lag = CSVModelChoiceField(
     lag = CSVModelChoiceField(

+ 18 - 0
netbox/dcim/migrations/0109_interface_remove_vm.py

@@ -0,0 +1,18 @@
+# Generated by Django 3.0.6 on 2020-06-22 16:03
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0108_add_tags'),
+        ('virtualization', '0016_replicate_interfaces'),
+    ]
+
+    operations = [
+        migrations.RemoveField(
+            model_name='interface',
+            name='virtual_machine',
+        ),
+    ]

+ 3 - 2
netbox/dcim/models/__init__.py

@@ -35,11 +35,12 @@ from .device_component_templates import (
     PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
     PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
 )
 )
 from .device_components import (
 from .device_components import (
-    CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, PowerOutlet,
-    PowerPort, RearPort,
+    BaseInterface, CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem,
+    PowerOutlet, PowerPort, RearPort,
 )
 )
 
 
 __all__ = (
 __all__ = (
+    'BaseInterface',
     'Cable',
     'Cable',
     'CableTermination',
     'CableTermination',
     'ConsolePort',
     'ConsolePort',

+ 47 - 80
netbox/dcim/models/device_components.py

@@ -19,7 +19,6 @@ from utilities.ordering import naturalize_interface
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from utilities.query_functions import CollateAsChar
 from utilities.query_functions import CollateAsChar
 from utilities.utils import serialize_object
 from utilities.utils import serialize_object
-from virtualization.choices import VMInterfaceTypeChoices
 
 
 
 
 __all__ = (
 __all__ = (
@@ -53,18 +52,12 @@ class ComponentModel(models.Model):
         return self.name
         return self.name
 
 
     def to_objectchange(self, action):
     def to_objectchange(self, action):
-        # Annotate the parent Device/VM
-        try:
-            parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None)
-        except ObjectDoesNotExist:
-            # The parent device/VM has already been deleted
-            parent = None
-
+        # Annotate the parent Device
         return ObjectChange(
         return ObjectChange(
             changed_object=self,
             changed_object=self,
             object_repr=str(self),
             object_repr=str(self),
             action=action,
             action=action,
-            related_object=parent,
+            related_object=self.device,
             object_data=serialize_object(self)
             object_data=serialize_object(self)
         )
         )
 
 
@@ -592,11 +585,44 @@ class PowerOutlet(CableTermination, ComponentModel):
 # Interfaces
 # Interfaces
 #
 #
 
 
+class BaseInterface(models.Model):
+    name = models.CharField(
+        max_length=64
+    )
+    _name = NaturalOrderingField(
+        target_field='name',
+        naturalize_function=naturalize_interface,
+        max_length=100,
+        blank=True
+    )
+    enabled = models.BooleanField(
+        default=True
+    )
+    mac_address = MACAddressField(
+        null=True,
+        blank=True,
+        verbose_name='MAC Address'
+    )
+    mtu = models.PositiveIntegerField(
+        blank=True,
+        null=True,
+        validators=[MinValueValidator(1), MaxValueValidator(65536)],
+        verbose_name='MTU'
+    )
+    mode = models.CharField(
+        max_length=50,
+        choices=InterfaceModeChoices,
+        blank=True
+    )
+
+    class Meta:
+        abstract = True
+
+
 @extras_features('graphs', 'export_templates', 'webhooks')
 @extras_features('graphs', 'export_templates', 'webhooks')
-class Interface(CableTermination, ComponentModel):
+class Interface(CableTermination, ComponentModel, BaseInterface):
     """
     """
-    A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
-    Interface.
+    A network interface within a Device. A physical Interface can connect to exactly one other Interface.
     """
     """
     device = models.ForeignKey(
     device = models.ForeignKey(
         to='Device',
         to='Device',
@@ -605,22 +631,6 @@ class Interface(CableTermination, ComponentModel):
         null=True,
         null=True,
         blank=True
         blank=True
     )
     )
-    virtual_machine = models.ForeignKey(
-        to='virtualization.VirtualMachine',
-        on_delete=models.CASCADE,
-        related_name='interfaces',
-        null=True,
-        blank=True
-    )
-    name = models.CharField(
-        max_length=64
-    )
-    _name = NaturalOrderingField(
-        target_field='name',
-        naturalize_function=naturalize_interface,
-        max_length=100,
-        blank=True
-    )
     label = models.CharField(
     label = models.CharField(
         max_length=64,
         max_length=64,
         blank=True,
         blank=True,
@@ -656,30 +666,11 @@ class Interface(CableTermination, ComponentModel):
         max_length=50,
         max_length=50,
         choices=InterfaceTypeChoices
         choices=InterfaceTypeChoices
     )
     )
-    enabled = models.BooleanField(
-        default=True
-    )
-    mac_address = MACAddressField(
-        null=True,
-        blank=True,
-        verbose_name='MAC Address'
-    )
-    mtu = models.PositiveIntegerField(
-        blank=True,
-        null=True,
-        validators=[MinValueValidator(1), MaxValueValidator(65536)],
-        verbose_name='MTU'
-    )
     mgmt_only = models.BooleanField(
     mgmt_only = models.BooleanField(
         default=False,
         default=False,
         verbose_name='OOB Management',
         verbose_name='OOB Management',
         help_text='This interface is used only for out-of-band management'
         help_text='This interface is used only for out-of-band management'
     )
     )
-    mode = models.CharField(
-        max_length=50,
-        choices=InterfaceModeChoices,
-        blank=True
-    )
     untagged_vlan = models.ForeignKey(
     untagged_vlan = models.ForeignKey(
         to='ipam.VLAN',
         to='ipam.VLAN',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,
@@ -694,15 +685,19 @@ class Interface(CableTermination, ComponentModel):
         blank=True,
         blank=True,
         verbose_name='Tagged VLANs'
         verbose_name='Tagged VLANs'
     )
     )
+    ip_addresses = GenericRelation(
+        to='ipam.IPAddress',
+        content_type_field='assigned_object_type',
+        object_id_field='assigned_object_id',
+        related_query_name='interface'
+    )
     tags = TaggableManager(through=TaggedItem)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     csv_headers = [
-        'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
-        'description', 'mode',
+        'device', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode',
     ]
     ]
 
 
     class Meta:
     class Meta:
-        # TODO: ordering and unique_together should include virtual_machine
         ordering = ('device', CollateAsChar('_name'))
         ordering = ('device', CollateAsChar('_name'))
         unique_together = ('device', 'name')
         unique_together = ('device', 'name')
 
 
@@ -712,7 +707,6 @@ class Interface(CableTermination, ComponentModel):
     def to_csv(self):
     def to_csv(self):
         return (
         return (
             self.device.identifier if self.device else None,
             self.device.identifier if self.device else None,
-            self.virtual_machine.name if self.virtual_machine else None,
             self.name,
             self.name,
             self.lag.name if self.lag else None,
             self.lag.name if self.lag else None,
             self.get_type_display(),
             self.get_type_display(),
@@ -726,18 +720,6 @@ class Interface(CableTermination, ComponentModel):
 
 
     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.type not in VMInterfaceTypeChoices.values():
-            raise ValidationError({
-                'type': "Invalid interface type for a virtual machine: {}".format(self.type)
-            })
-
         # Virtual interfaces cannot be connected
         # Virtual interfaces cannot be connected
         if self.type in NONCONNECTABLE_IFACE_TYPES and (
         if self.type in NONCONNECTABLE_IFACE_TYPES and (
                 self.cable or getattr(self, 'circuit_termination', False)
                 self.cable or getattr(self, 'circuit_termination', False)
@@ -773,7 +755,7 @@ class Interface(CableTermination, ComponentModel):
         if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
         if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
             raise ValidationError({
             raise ValidationError({
                 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
                 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
-                                 "device/VM, or it must be global".format(self.untagged_vlan)
+                                 "device, or it must be global".format(self.untagged_vlan)
             })
             })
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
@@ -788,21 +770,6 @@ class Interface(CableTermination, ComponentModel):
 
 
         return super().save(*args, **kwargs)
         return super().save(*args, **kwargs)
 
 
-    def to_objectchange(self, action):
-        # Annotate the parent Device/VM
-        try:
-            parent_obj = self.device or self.virtual_machine
-        except ObjectDoesNotExist:
-            parent_obj = None
-
-        return ObjectChange(
-            changed_object=self,
-            object_repr=str(self),
-            action=action,
-            related_object=parent_obj,
-            object_data=serialize_object(self)
-        )
-
     @property
     @property
     def connected_endpoint(self):
     def connected_endpoint(self):
         """
         """
@@ -841,7 +808,7 @@ class Interface(CableTermination, ComponentModel):
 
 
     @property
     @property
     def parent(self):
     def parent(self):
-        return self.device or self.virtual_machine
+        return self.device
 
 
     @property
     @property
     def is_connectable(self):
     def is_connectable(self):

+ 5 - 20
netbox/dcim/tables.py

@@ -598,17 +598,11 @@ class InterfaceImportTable(BaseTable):
         viewname='dcim:device',
         viewname='dcim:device',
         args=[Accessor('device.pk')]
         args=[Accessor('device.pk')]
     )
     )
-    virtual_machine = tables.LinkColumn(
-        viewname='virtualization:virtualmachine',
-        args=[Accessor('virtual_machine.pk')],
-        verbose_name='Virtual Machine'
-    )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Interface
         model = Interface
         fields = (
         fields = (
-            'device', 'virtual_machine', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu',
-            'mgmt_only', 'mode',
+            'device', 'name', 'description', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'mode',
         )
         )
         empty_text = False
         empty_text = False
 
 
@@ -863,6 +857,7 @@ class DeviceImportTable(BaseTable):
 
 
 class DeviceComponentDetailTable(BaseTable):
 class DeviceComponentDetailTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
+    device = tables.LinkColumn()
     name = tables.Column(order_by=('_name',))
     name = tables.Column(order_by=('_name',))
     cable = tables.LinkColumn()
     cable = tables.LinkColumn()
 
 
@@ -881,7 +876,6 @@ class ConsolePortTable(BaseTable):
 
 
 
 
 class ConsolePortDetailTable(DeviceComponentDetailTable):
 class ConsolePortDetailTable(DeviceComponentDetailTable):
-    device = tables.LinkColumn()
 
 
     class Meta(DeviceComponentDetailTable.Meta, ConsolePortTable.Meta):
     class Meta(DeviceComponentDetailTable.Meta, ConsolePortTable.Meta):
         pass
         pass
@@ -896,7 +890,6 @@ class ConsoleServerPortTable(BaseTable):
 
 
 
 
 class ConsoleServerPortDetailTable(DeviceComponentDetailTable):
 class ConsoleServerPortDetailTable(DeviceComponentDetailTable):
-    device = tables.LinkColumn()
 
 
     class Meta(DeviceComponentDetailTable.Meta, ConsoleServerPortTable.Meta):
     class Meta(DeviceComponentDetailTable.Meta, ConsoleServerPortTable.Meta):
         pass
         pass
@@ -911,7 +904,6 @@ class PowerPortTable(BaseTable):
 
 
 
 
 class PowerPortDetailTable(DeviceComponentDetailTable):
 class PowerPortDetailTable(DeviceComponentDetailTable):
-    device = tables.LinkColumn()
 
 
     class Meta(DeviceComponentDetailTable.Meta, PowerPortTable.Meta):
     class Meta(DeviceComponentDetailTable.Meta, PowerPortTable.Meta):
         pass
         pass
@@ -926,7 +918,6 @@ class PowerOutletTable(BaseTable):
 
 
 
 
 class PowerOutletDetailTable(DeviceComponentDetailTable):
 class PowerOutletDetailTable(DeviceComponentDetailTable):
-    device = tables.LinkColumn()
 
 
     class Meta(DeviceComponentDetailTable.Meta, PowerOutletTable.Meta):
     class Meta(DeviceComponentDetailTable.Meta, PowerOutletTable.Meta):
         pass
         pass
@@ -940,14 +931,11 @@ class InterfaceTable(BaseTable):
 
 
 
 
 class InterfaceDetailTable(DeviceComponentDetailTable):
 class InterfaceDetailTable(DeviceComponentDetailTable):
-    parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
-    name = tables.LinkColumn()
     enabled = BooleanColumn()
     enabled = BooleanColumn()
 
 
-    class Meta(InterfaceTable.Meta):
-        order_by = ('parent', 'name')
-        fields = ('pk', 'parent', 'name', 'label', 'enabled', 'type', 'description', 'cable')
-        sequence = ('pk', 'parent', 'name', 'label', 'enabled', 'type', 'description', 'cable')
+    class Meta(DeviceComponentDetailTable.Meta, InterfaceTable.Meta):
+        fields = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description', 'cable')
+        sequence = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description', 'cable')
 
 
 
 
 class FrontPortTable(BaseTable):
 class FrontPortTable(BaseTable):
@@ -960,7 +948,6 @@ class FrontPortTable(BaseTable):
 
 
 
 
 class FrontPortDetailTable(DeviceComponentDetailTable):
 class FrontPortDetailTable(DeviceComponentDetailTable):
-    device = tables.LinkColumn()
 
 
     class Meta(DeviceComponentDetailTable.Meta, FrontPortTable.Meta):
     class Meta(DeviceComponentDetailTable.Meta, FrontPortTable.Meta):
         pass
         pass
@@ -976,7 +963,6 @@ class RearPortTable(BaseTable):
 
 
 
 
 class RearPortDetailTable(DeviceComponentDetailTable):
 class RearPortDetailTable(DeviceComponentDetailTable):
-    device = tables.LinkColumn()
 
 
     class Meta(DeviceComponentDetailTable.Meta, RearPortTable.Meta):
     class Meta(DeviceComponentDetailTable.Meta, RearPortTable.Meta):
         pass
         pass
@@ -991,7 +977,6 @@ class DeviceBayTable(BaseTable):
 
 
 
 
 class DeviceBayDetailTable(DeviceComponentDetailTable):
 class DeviceBayDetailTable(DeviceComponentDetailTable):
-    device = tables.LinkColumn()
     installed_device = tables.LinkColumn()
     installed_device = tables.LinkColumn()
 
 
     class Meta(DeviceBayTable.Meta):
     class Meta(DeviceBayTable.Meta):

+ 2 - 2
netbox/dcim/tests/test_filters.py

@@ -1254,8 +1254,8 @@ class DeviceTestCase(TestCase):
 
 
         # Assign primary IPs for filtering
         # Assign primary IPs for filtering
         ipaddresses = (
         ipaddresses = (
-            IPAddress(address='192.0.2.1/24', interface=interfaces[0]),
-            IPAddress(address='192.0.2.2/24', interface=interfaces[1]),
+            IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
+            IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
         )
         )
         IPAddress.objects.bulk_create(ipaddresses)
         IPAddress.objects.bulk_create(ipaddresses)
         Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0])
         Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0])

+ 3 - 55
netbox/dcim/views.py

@@ -1,5 +1,4 @@
 from collections import OrderedDict
 from collections import OrderedDict
-import re
 
 
 from django.conf import settings
 from django.conf import settings
 from django.contrib import messages
 from django.contrib import messages
@@ -25,8 +24,9 @@ from utilities.paginator import EnhancedPaginator
 from utilities.permissions import get_permission_for_model
 from utilities.permissions import get_permission_for_model
 from utilities.utils import csv_format
 from utilities.utils import csv_format
 from utilities.views import (
 from utilities.views import (
-    BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
-    ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectPermissionRequiredMixin,
+    BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
+    GetReturnURLMixin, ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+    ObjectPermissionRequiredMixin,
 )
 )
 from virtualization.models import VirtualMachine
 from virtualization.models import VirtualMachine
 from . import filters, forms, tables
 from . import filters, forms, tables
@@ -41,58 +41,6 @@ from .models import (
 )
 )
 
 
 
 
-class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
-    """
-    An extendable view for renaming device components in bulk.
-    """
-    queryset = None
-    form = None
-    template_name = 'dcim/bulk_rename.html'
-
-    def get_required_permission(self):
-        return get_permission_for_model(self.queryset.model, 'change')
-
-    def post(self, request):
-
-        if '_preview' in request.POST or '_apply' in request.POST:
-            form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
-            selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
-
-            if form.is_valid():
-                for obj in selected_objects:
-                    find = form.cleaned_data['find']
-                    replace = form.cleaned_data['replace']
-                    if form.cleaned_data['use_regex']:
-                        try:
-                            obj.new_name = re.sub(find, replace, obj.name)
-                        # Catch regex group reference errors
-                        except re.error:
-                            obj.new_name = obj.name
-                    else:
-                        obj.new_name = obj.name.replace(find, replace)
-
-                if '_apply' in request.POST:
-                    for obj in selected_objects:
-                        obj.name = obj.new_name
-                        obj.save()
-                    messages.success(request, "Renamed {} {}".format(
-                        len(selected_objects),
-                        self.queryset.model._meta.verbose_name_plural
-                    ))
-                    return redirect(self.get_return_url(request))
-
-        else:
-            form = self.form(initial={'pk': request.POST.getlist('pk')})
-            selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
-
-        return render(request, self.template_name, {
-            'form': form,
-            'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
-            'selected_objects': selected_objects,
-            'return_url': self.get_return_url(request),
-        })
-
-
 class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
     """
     """
     An extendable view for disconnection console/power/interface components in bulk.
     An extendable view for disconnection console/power/interface components in bulk.

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

@@ -1,5 +1,7 @@
 from collections import OrderedDict
 from collections import OrderedDict
 
 
+from django.contrib.contenttypes.models import ContentType
+from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 from rest_framework import serializers
 from rest_framework.reverse import reverse
 from rest_framework.reverse import reverse
 from rest_framework.validators import UniqueTogetherValidator
 from rest_framework.validators import UniqueTogetherValidator
@@ -9,10 +11,12 @@ from dcim.models import Interface
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.serializers import TaggedObjectSerializer
 from extras.api.serializers import TaggedObjectSerializer
 from ipam.choices import *
 from ipam.choices import *
+from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import (
 from utilities.api import (
-    ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer,
+    ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer,
+    get_serializer_for_model,
 )
 )
 from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
 from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
 from .nested_serializers import *
 from .nested_serializers import *
@@ -228,18 +232,31 @@ class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=IPAddressStatusChoices, required=False)
     status = ChoiceField(choices=IPAddressStatusChoices, required=False)
     role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
     role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
-    interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
+    assigned_object_type = ContentTypeField(
+        queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
+        required=False
+    )
+    assigned_object = serializers.SerializerMethodField(read_only=True)
     nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
     nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
     nat_outside = NestedIPAddressSerializer(read_only=True)
     nat_outside = NestedIPAddressSerializer(read_only=True)
 
 
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
         fields = [
         fields = [
-            'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'nat_inside',
-            'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
+            'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id',
+            'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields',
+            'created', 'last_updated',
         ]
         ]
         read_only_fields = ['family']
         read_only_fields = ['family']
 
 
+    @swagger_serializer_method(serializer_or_field=serializers.DictField)
+    def get_assigned_object(self, obj):
+        if obj.assigned_object is None:
+            return None
+        serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested')
+        context = {'request': self.context['request']}
+        return serializer(obj.assigned_object, context=context).data
+
 
 
 class AvailableIPSerializer(serializers.Serializer):
 class AvailableIPSerializer(serializers.Serializer):
     """
     """

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

@@ -233,8 +233,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
 
 
 class IPAddressViewSet(CustomFieldModelViewSet):
 class IPAddressViewSet(CustomFieldModelViewSet):
     queryset = IPAddress.objects.prefetch_related(
     queryset = IPAddress.objects.prefetch_related(
-        'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine',
-        'nat_outside', 'tags',
+        'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags',
     )
     )
     serializer_class = serializers.IPAddressSerializer
     serializer_class = serializers.IPAddressSerializer
     filterset_class = filters.IPAddressFilterSet
     filterset_class = filters.IPAddressFilterSet

+ 7 - 0
netbox/ipam/constants.py

@@ -1,3 +1,5 @@
+from django.db.models import Q
+
 from .choices import IPAddressRoleChoices
 from .choices import IPAddressRoleChoices
 
 
 # BGP ASN bounds
 # BGP ASN bounds
@@ -29,6 +31,11 @@ PREFIX_LENGTH_MAX = 127  # IPv6
 # IPAddresses
 # IPAddresses
 #
 #
 
 
+IPADDRESS_ASSIGNMENT_MODELS = Q(
+    Q(app_label='dcim', model='interface') |
+    Q(app_label='virtualization', model='vminterface')
+)
+
 IPADDRESS_MASK_LENGTH_MIN = 1
 IPADDRESS_MASK_LENGTH_MIN = 1
 IPADDRESS_MASK_LENGTH_MAX = 128  # IPv6
 IPADDRESS_MASK_LENGTH_MAX = 128  # IPv6
 
 

+ 42 - 19
netbox/ipam/filters.py

@@ -11,7 +11,7 @@ from utilities.filters import (
     BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, TagFilter,
     BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, TagFilter,
     TreeNodeMultipleChoiceFilter,
     TreeNodeMultipleChoiceFilter,
 )
 )
-from virtualization.models import VirtualMachine
+from virtualization.models import VirtualMachine, VMInterface
 from .choices import *
 from .choices import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 
 
@@ -309,27 +309,38 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
         field_name='pk',
         field_name='pk',
         label='Device (ID)',
         label='Device (ID)',
     )
     )
-    virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='interface__virtual_machine',
-        queryset=VirtualMachine.objects.unrestricted(),
-        label='Virtual machine (ID)',
-    )
-    virtual_machine = django_filters.ModelMultipleChoiceFilter(
-        field_name='interface__virtual_machine__name',
-        queryset=VirtualMachine.objects.unrestricted(),
-        to_field_name='name',
+    virtual_machine = MultiValueCharFilter(
+        method='filter_virtual_machine',
+        field_name='name',
         label='Virtual machine (name)',
         label='Virtual machine (name)',
     )
     )
+    virtual_machine_id = MultiValueNumberFilter(
+        method='filter_virtual_machine',
+        field_name='pk',
+        label='Virtual machine (ID)',
+    )
     interface = django_filters.ModelMultipleChoiceFilter(
     interface = django_filters.ModelMultipleChoiceFilter(
         field_name='interface__name',
         field_name='interface__name',
         queryset=Interface.objects.unrestricted(),
         queryset=Interface.objects.unrestricted(),
         to_field_name='name',
         to_field_name='name',
-        label='Interface (ID)',
+        label='Interface (name)',
     )
     )
     interface_id = django_filters.ModelMultipleChoiceFilter(
     interface_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='interface',
         queryset=Interface.objects.unrestricted(),
         queryset=Interface.objects.unrestricted(),
         label='Interface (ID)',
         label='Interface (ID)',
     )
     )
+    vminterface = django_filters.ModelMultipleChoiceFilter(
+        field_name='vminterface__name',
+        queryset=VMInterface.objects.unrestricted(),
+        to_field_name='name',
+        label='VM interface (name)',
+    )
+    vminterface_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='vminterface',
+        queryset=VMInterface.objects.unrestricted(),
+        label='VM interface (ID)',
+    )
     assigned_to_interface = django_filters.BooleanFilter(
     assigned_to_interface = django_filters.BooleanFilter(
         method='_assigned_to_interface',
         method='_assigned_to_interface',
         label='Is assigned to an interface',
         label='Is assigned to an interface',
@@ -379,17 +390,29 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
         return queryset.filter(address__net_mask_length=value)
         return queryset.filter(address__net_mask_length=value)
 
 
     def filter_device(self, queryset, name, value):
     def filter_device(self, queryset, name, value):
-        try:
-            devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value})
-            vc_interface_ids = []
-            for device in devices:
-                vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')])
-            return queryset.filter(interface_id__in=vc_interface_ids)
-        except Device.DoesNotExist:
+        devices = Device.objects.filter(**{'{}__in'.format(name): value})
+        if not devices.exists():
             return queryset.none()
             return queryset.none()
+        interface_ids = []
+        for device in devices:
+            interface_ids.extend(device.vc_interfaces.values_list('id', flat=True))
+        return queryset.filter(
+            interface__in=interface_ids
+        )
+
+    def filter_virtual_machine(self, queryset, name, value):
+        virtual_machines = VirtualMachine.objects.filter(**{'{}__in'.format(name): value})
+        if not virtual_machines.exists():
+            return queryset.none()
+        interface_ids = []
+        for vm in virtual_machines:
+            interface_ids.extend(vm.interfaces.values_list('id', flat=True))
+        return queryset.filter(
+            vminterface__in=interface_ids
+        )
 
 
     def _assigned_to_interface(self, queryset, name, value):
     def _assigned_to_interface(self, queryset, name, value):
-        return queryset.exclude(interface__isnull=value)
+        return queryset.exclude(assigned_object_id__isnull=value)
 
 
 
 
 class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
 class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):

+ 77 - 54
netbox/ipam/forms.py

@@ -14,7 +14,7 @@ from utilities.forms import (
     ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
     ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
     BOOLEAN_WITH_BLANK_CHOICES,
     BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
-from virtualization.models import VirtualMachine
+from virtualization.models import VirtualMachine, VMInterface
 from .choices import *
 from .choices import *
 from .constants import *
 from .constants import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@@ -522,10 +522,33 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
 #
 #
 
 
 class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm):
 class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm):
-    interface = forms.ModelChoiceField(
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        widget=APISelect(
+            filter_for={
+                'interface': 'device_id'
+            }
+        )
+    )
+    interface = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         required=False
         required=False
     )
     )
+    virtual_machine = DynamicModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False,
+        widget=APISelect(
+            filter_for={
+                'vminterface': 'virtual_machine_id'
+            }
+        )
+    )
+    vminterface = DynamicModelChoiceField(
+        queryset=VMInterface.objects.all(),
+        required=False,
+        label='Interface'
+    )
     vrf = DynamicModelChoiceField(
     vrf = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         queryset=VRF.objects.all(),
         required=False,
         required=False,
@@ -597,8 +620,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
         fields = [
         fields = [
-            'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'interface', 'primary_for_parent',
-            'nat_site', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags',
+            'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack',
+            'nat_inside', 'tenant_group', 'tenant', 'tags',
         ]
         ]
         widgets = {
         widgets = {
             'status': StaticSelect2(),
             'status': StaticSelect2(),
@@ -610,32 +633,26 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
         # 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.nat_inside and instance.nat_inside.device is not None:
-            initial['nat_site'] = instance.nat_inside.device.site
-            initial['nat_rack'] = instance.nat_inside.device.rack
-            initial['nat_device'] = instance.nat_inside.device
+        if instance:
+            if type(instance.assigned_object) is Interface:
+                initial['device'] = instance.assigned_object.device
+                initial['interface'] = instance.assigned_object
+            elif type(instance.assigned_object) is VMInterface:
+                initial['virtual_machine'] = instance.assigned_object.virtual_machine
+                initial['vminterface'] = instance.assigned_object
+            if instance.nat_inside and instance.nat_inside.device is not None:
+                initial['nat_site'] = instance.nat_inside.device.site
+                initial['nat_rack'] = instance.nat_inside.device.rack
+                initial['nat_device'] = instance.nat_inside.device
         kwargs['initial'] = initial
         kwargs['initial'] = initial
 
 
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         self.fields['vrf'].empty_label = 'Global'
         self.fields['vrf'].empty_label = 'Global'
 
 
-        # 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
-            ).prefetch_related(
-                'device__primary_ip4',
-                'device__primary_ip6',
-                'virtual_machine__primary_ip4',
-                'virtual_machine__primary_ip6',
-            )  # We prefetch the primary address fields to ensure cache invalidation does not balk on the save()
-        else:
-            self.fields['interface'].choices = []
-
         # Initialize primary_for_parent if IP address is already assigned
         # 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 self.instance.pk and self.instance.assigned_object:
+            parent = self.instance.assigned_object.parent
             if (
             if (
                 self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or
                 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.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk
@@ -645,32 +662,39 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
+        # Cannot select both a device interface and a VM interface
+        if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
+            raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface")
+
         # 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_parent') and not self.cleaned_data.get('interface'):
+        interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
+        if self.cleaned_data.get('primary_for_parent') and not interface:
             self.add_error(
             self.add_error(
                 'primary_for_parent', "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):
 
 
+        # Set assigned object
+        interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
+        if interface:
+            self.instance.assigned_object = interface
+
         ipaddress = super().save(*args, **kwargs)
         ipaddress = super().save(*args, **kwargs)
 
 
         # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
         # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
-        if self.cleaned_data['primary_for_parent']:
-            parent = self.cleaned_data['interface'].parent
+        if interface and self.cleaned_data['primary_for_parent']:
             if ipaddress.address.version == 4:
             if ipaddress.address.version == 4:
-                parent.primary_ip4 = ipaddress
+                interface.parent.primary_ip4 = ipaddress
             else:
             else:
-                parent.primary_ip6 = ipaddress
-            parent.save()
-        elif self.cleaned_data['interface']:
-            parent = self.cleaned_data['interface'].parent
-            if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress:
-                parent.primary_ip4 = None
-                parent.save()
-            elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress:
-                parent.primary_ip6 = None
-                parent.save()
+                interface.primary_ip6 = ipaddress
+            interface.parent.save()
+        elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
+            interface.parent.primary_ip4 = None
+            interface.parent.save()
+        elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress:
+            interface.parent.primary_ip4 = None
+            interface.parent.save()
 
 
         return ipaddress
         return ipaddress
 
 
@@ -742,7 +766,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
         help_text='Parent VM of assigned interface (if any)'
         help_text='Parent VM of assigned interface (if any)'
     )
     )
     interface = CSVModelChoiceField(
     interface = CSVModelChoiceField(
-        queryset=Interface.objects.all(),
+        queryset=Interface.objects.none(),  # Can also refer to VMInterface
         required=False,
         required=False,
         to_field_name='name',
         to_field_name='name',
         help_text='Assigned interface'
         help_text='Assigned interface'
@@ -761,21 +785,17 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
 
 
         if data:
         if data:
 
 
-            # Limit interface queryset by assigned device or virtual machine
+            # Limit interface queryset by assigned device
             if data.get('device'):
             if data.get('device'):
-                params = {
-                    f"device__{self.fields['device'].to_field_name}": data.get('device')
-                }
+                self.fields['interface'].queryset = Interface.objects.filter(
+                    **{f"device__{self.fields['device'].to_field_name}": data['device']}
+                )
+
+            # Limit interface queryset by assigned device
             elif data.get('virtual_machine'):
             elif data.get('virtual_machine'):
-                params = {
-                    f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data.get('virtual_machine')
-                }
-            else:
-                params = {
-                    'device': None,
-                    'virtual_machine': None,
-                }
-            self.fields['interface'].queryset = self.fields['interface'].queryset.filter(**params)
+                self.fields['interface'].queryset = VMInterface.objects.filter(
+                    **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
+                )
 
 
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
@@ -790,6 +810,10 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
 
 
+        # Set interface assignment
+        if self.cleaned_data['interface']:
+            self.instance.assigned_object = self.cleaned_data['interface']
+
         ipaddress = super().save(*args, **kwargs)
         ipaddress = super().save(*args, **kwargs)
 
 
         # Set as primary for device/VM
         # Set as primary for device/VM
@@ -1194,13 +1218,12 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
 
 
         # Limit IP address choices to those assigned to interfaces of the parent device/VM
         # Limit IP address choices to those assigned to interfaces of the parent device/VM
         if self.instance.device:
         if self.instance.device:
-            vc_interface_ids = [i['id'] for i in self.instance.device.vc_interfaces.values('id')]
             self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
             self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
-                interface_id__in=vc_interface_ids
+                interface__in=self.instance.device.vc_interfaces.values_list('id', flat=True)
             )
             )
         elif self.instance.virtual_machine:
         elif self.instance.virtual_machine:
             self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
             self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
-                interface__virtual_machine=self.instance.virtual_machine
+                vminterface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True)
             )
             )
         else:
         else:
             self.fields['ipaddresses'].choices = []
             self.fields['ipaddresses'].choices = []

+ 40 - 0
netbox/ipam/migrations/0037_ipaddress_assignment.py

@@ -0,0 +1,40 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def set_assigned_object_type(apps, schema_editor):
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+    IPAddress = apps.get_model('ipam', 'IPAddress')
+
+    device_ct = ContentType.objects.get(app_label='dcim', model='interface').pk
+    IPAddress.objects.update(assigned_object_type=device_ct)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('ipam', '0036_standardize_description'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='ipaddress',
+            old_name='interface',
+            new_name='assigned_object_id',
+        ),
+        migrations.AlterField(
+            model_name='ipaddress',
+            name='assigned_object_id',
+            field=models.PositiveIntegerField(blank=True, null=True),
+        ),
+        migrations.AddField(
+            model_name='ipaddress',
+            name='assigned_object_type',
+            field=models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType'),
+            preserve_default=False,
+        ),
+        migrations.RunPython(
+            code=set_assigned_object_type
+        ),
+    ]

+ 43 - 49
netbox/ipam/models.py

@@ -1,10 +1,11 @@
 import netaddr
 import netaddr
 from django.conf import settings
 from django.conf import settings
-from django.contrib.contenttypes.fields import GenericRelation
-from django.core.exceptions import ValidationError, ObjectDoesNotExist
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.contenttypes.models import ContentType
+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 F, Q
+from django.db.models import F
 from django.urls import reverse
 from django.urls import reverse
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
@@ -14,7 +15,7 @@ from extras.utils import extras_features
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
 from utilities.utils import serialize_object
 from utilities.utils import serialize_object
-from virtualization.models import VirtualMachine
+from virtualization.models import VirtualMachine, VMInterface
 from .choices import *
 from .choices import *
 from .constants import *
 from .constants import *
 from .fields import IPNetworkField, IPAddressField
 from .fields import IPNetworkField, IPAddressField
@@ -606,13 +607,22 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
         blank=True,
         blank=True,
         help_text='The functional role of this IP'
         help_text='The functional role of this IP'
     )
     )
-    interface = models.ForeignKey(
-        to='dcim.Interface',
-        on_delete=models.CASCADE,
-        related_name='ip_addresses',
+    assigned_object_type = models.ForeignKey(
+        to=ContentType,
+        limit_choices_to=IPADDRESS_ASSIGNMENT_MODELS,
+        on_delete=models.PROTECT,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    assigned_object_id = models.PositiveIntegerField(
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    assigned_object = GenericForeignKey(
+        ct_field='assigned_object_type',
+        fk_field='assigned_object_id'
+    )
     nat_inside = models.OneToOneField(
     nat_inside = models.OneToOneField(
         to='self',
         to='self',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,
@@ -643,11 +653,11 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
     objects = IPAddressManager()
     objects = IPAddressManager()
 
 
     csv_headers = [
     csv_headers = [
-        'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
+        'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id', 'is_primary',
         'dns_name', 'description',
         'dns_name', 'description',
     ]
     ]
     clone_fields = [
     clone_fields = [
-        'vrf', 'tenant', 'status', 'role', 'description', 'interface',
+        'vrf', 'tenant', 'status', 'role', 'description',
     ]
     ]
 
 
     STATUS_CLASS_MAP = {
     STATUS_CLASS_MAP = {
@@ -707,32 +717,31 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
                         )
                         )
                     })
                     })
 
 
-        if self.pk:
-
-            # Check for primary IP assignment that doesn't match the assigned device/VM
+        # Check for primary IP assignment that doesn't match the assigned device/VM
+        if self.pk and type(self.assigned_object) is Interface:
             device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
             device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
             if device:
             if device:
-                if self.interface is None:
+                if self.assigned_object is None:
                     raise ValidationError({
                     raise ValidationError({
-                        'interface': "IP address is primary for device {} but not assigned".format(device)
+                        'interface': f"IP address is primary for device {device} but not assigned to an interface"
                     })
                     })
-                elif (device.primary_ip4 == self or device.primary_ip6 == self) and self.interface.device != device:
+                elif self.assigned_object.device != device:
                     raise ValidationError({
                     raise ValidationError({
-                        'interface': "IP address is primary for device {} but assigned to {} ({})".format(
-                            device, self.interface.device, self.interface
-                        )
+                        'interface': f"IP address is primary for device {device} but assigned to "
+                                     f"{self.assigned_object.device} ({self.assigned_object})"
                     })
                     })
+        elif self.pk and type(self.assigned_object) is VMInterface:
             vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
             vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
             if vm:
             if vm:
-                if self.interface is None:
+                if self.assigned_object is None:
                     raise ValidationError({
                     raise ValidationError({
-                        'interface': "IP address is primary for virtual machine {} but not assigned".format(vm)
+                        'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an "
+                                       f"interface"
                     })
                     })
-                elif (vm.primary_ip4 == self or vm.primary_ip6 == self) and self.interface.virtual_machine != vm:
+                elif self.interface.virtual_machine != vm:
                     raise ValidationError({
                     raise ValidationError({
-                        'interface': "IP address is primary for virtual machine {} but assigned to {} ({})".format(
-                            vm, self.interface.virtual_machine, self.interface
-                        )
+                        'vminterface': f"IP address is primary for virtual machine {vm} but assigned to "
+                                       f"{self.assigned_object.virtual_machine} ({self.assigned_object})"
                     })
                     })
 
 
     def save(self, *args, **kwargs):
     def save(self, *args, **kwargs):
@@ -743,29 +752,27 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
         super().save(*args, **kwargs)
         super().save(*args, **kwargs)
 
 
     def to_objectchange(self, action):
     def to_objectchange(self, action):
-        # Annotate the assigned Interface (if any)
-        try:
-            parent_obj = self.interface
-        except ObjectDoesNotExist:
-            parent_obj = None
-
+        # Annotate the assigned object, if any
         return ObjectChange(
         return ObjectChange(
             changed_object=self,
             changed_object=self,
             object_repr=str(self),
             object_repr=str(self),
             action=action,
             action=action,
-            related_object=parent_obj,
+            related_object=self.assigned_object,
             object_data=serialize_object(self)
             object_data=serialize_object(self)
         )
         )
 
 
     def to_csv(self):
     def to_csv(self):
 
 
         # Determine if this IP is primary for a Device
         # Determine if this IP is primary for a Device
+        is_primary = False
         if self.address.version == 4 and getattr(self, 'primary_ip4_for', False):
         if self.address.version == 4 and getattr(self, 'primary_ip4_for', False):
             is_primary = True
             is_primary = True
         elif self.address.version == 6 and getattr(self, 'primary_ip6_for', False):
         elif self.address.version == 6 and getattr(self, 'primary_ip6_for', False):
             is_primary = True
             is_primary = True
-        else:
-            is_primary = False
+
+        obj_type = None
+        if self.assigned_object_type:
+            obj_type = f'{self.assigned_object_type.app_label}.{self.assigned_object_type.model}'
 
 
         return (
         return (
             self.address,
             self.address,
@@ -773,9 +780,8 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
             self.tenant.name if self.tenant else None,
             self.tenant.name if self.tenant else None,
             self.get_status_display(),
             self.get_status_display(),
             self.get_role_display(),
             self.get_role_display(),
-            self.device.identifier if self.device else None,
-            self.virtual_machine.name if self.virtual_machine else None,
-            self.interface.name if self.interface else None,
+            obj_type,
+            self.assigned_object_id,
             is_primary,
             is_primary,
             self.dns_name,
             self.dns_name,
             self.description,
             self.description,
@@ -796,18 +802,6 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
             self.address.prefixlen = value
             self.address.prefixlen = value
     mask_length = property(fset=_set_mask_length)
     mask_length = property(fset=_set_mask_length)
 
 
-    @property
-    def device(self):
-        if self.interface:
-            return self.interface.device
-        return None
-
-    @property
-    def virtual_machine(self):
-        if self.interface:
-            return self.interface.virtual_machine
-        return None
-
     def get_status_class(self):
     def get_status_class(self):
         return self.STATUS_CLASS_MAP.get(self.status)
         return self.STATUS_CLASS_MAP.get(self.status)
 
 

+ 8 - 24
netbox/ipam/tables.py

@@ -92,14 +92,6 @@ IPADDRESS_ASSIGN_LINK = """
 {% endif %}
 {% endif %}
 """
 """
 
 
-IPADDRESS_PARENT = """
-{% if record.interface %}
-    <a href="{{ record.interface.parent.get_absolute_url }}">{{ record.interface.parent }}</a>
-{% else %}
-    &mdash;
-{% endif %}
-"""
-
 VRF_LINK = """
 VRF_LINK = """
 {% if record.vrf %}
 {% if record.vrf %}
     <a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
     <a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
@@ -168,7 +160,7 @@ VLAN_MEMBER_UNTAGGED = """
 
 
 VLAN_MEMBER_ACTIONS = """
 VLAN_MEMBER_ACTIONS = """
 {% if perms.dcim.change_interface %}
 {% if perms.dcim.change_interface %}
-    <a href="{% if record.device %}{% url 'dcim:interface_edit' pk=record.pk %}{% else %}{% url 'virtualization:interface_edit' pk=record.pk %}{% endif %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil"></i></a>
+    <a href="{% if record.device %}{% url 'dcim:interface_edit' pk=record.pk %}{% else %}{% url 'virtualization:vminterface_edit' pk=record.pk %}{% endif %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil"></i></a>
 {% endif %}
 {% endif %}
 """
 """
 
 
@@ -431,18 +423,14 @@ class IPAddressTable(BaseTable):
     tenant = tables.TemplateColumn(
     tenant = tables.TemplateColumn(
         template_code=TENANT_LINK
         template_code=TENANT_LINK
     )
     )
-    parent = tables.TemplateColumn(
-        template_code=IPADDRESS_PARENT,
-        orderable=False
-    )
-    interface = tables.Column(
-        orderable=False
+    assigned = tables.BooleanColumn(
+        accessor='assigned_object_id'
     )
     )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = IPAddress
         model = IPAddress
         fields = (
         fields = (
-            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description',
+            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', '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 '',
@@ -465,11 +453,11 @@ class IPAddressDetailTable(IPAddressTable):
 
 
     class Meta(IPAddressTable.Meta):
     class Meta(IPAddressTable.Meta):
         fields = (
         fields = (
-            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'dns_name',
+            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name',
             'description', 'tags',
             'description', 'tags',
         )
         )
         default_columns = (
         default_columns = (
-            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description',
+            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
         )
         )
 
 
 
 
@@ -481,17 +469,13 @@ class IPAddressAssignTable(BaseTable):
     status = tables.TemplateColumn(
     status = tables.TemplateColumn(
         template_code=STATUS_LABEL
         template_code=STATUS_LABEL
     )
     )
-    parent = tables.TemplateColumn(
-        template_code=IPADDRESS_PARENT,
-        orderable=False
-    )
-    interface = tables.Column(
+    assigned_object = tables.Column(
         orderable=False
         orderable=False
     )
     )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = IPAddress
         model = IPAddress
-        fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
+        fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'description')
         orderable = False
         orderable = False
 
 
 
 

+ 31 - 20
netbox/ipam/tests/test_filters.py

@@ -4,7 +4,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer,
 from ipam.choices import *
 from ipam.choices import *
 from ipam.filters import *
 from ipam.filters import *
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
 from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
-from virtualization.models import Cluster, ClusterType, VirtualMachine
+from virtualization.models import Cluster, ClusterType, VirtualMachine, VMInterface
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 
 
 
 
@@ -375,6 +375,13 @@ class IPAddressTestCase(TestCase):
         )
         )
         Device.objects.bulk_create(devices)
         Device.objects.bulk_create(devices)
 
 
+        interfaces = (
+            Interface(device=devices[0], name='Interface 1'),
+            Interface(device=devices[1], name='Interface 2'),
+            Interface(device=devices[2], name='Interface 3'),
+        )
+        Interface.objects.bulk_create(interfaces)
+
         clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
         clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
         cluster = Cluster.objects.create(type=clustertype, name='Cluster 1')
         cluster = Cluster.objects.create(type=clustertype, name='Cluster 1')
 
 
@@ -385,15 +392,12 @@ class IPAddressTestCase(TestCase):
         )
         )
         VirtualMachine.objects.bulk_create(virtual_machines)
         VirtualMachine.objects.bulk_create(virtual_machines)
 
 
-        interfaces = (
-            Interface(device=devices[0], name='Interface 1'),
-            Interface(device=devices[1], name='Interface 2'),
-            Interface(device=devices[2], name='Interface 3'),
-            Interface(virtual_machine=virtual_machines[0], name='Interface 1'),
-            Interface(virtual_machine=virtual_machines[1], name='Interface 2'),
-            Interface(virtual_machine=virtual_machines[2], name='Interface 3'),
+        vminterfaces = (
+            VMInterface(virtual_machine=virtual_machines[0], name='Interface 1'),
+            VMInterface(virtual_machine=virtual_machines[1], name='Interface 2'),
+            VMInterface(virtual_machine=virtual_machines[2], name='Interface 3'),
         )
         )
-        Interface.objects.bulk_create(interfaces)
+        VMInterface.objects.bulk_create(vminterfaces)
 
 
         tenant_groups = (
         tenant_groups = (
             TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
             TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
@@ -411,16 +415,16 @@ class IPAddressTestCase(TestCase):
         Tenant.objects.bulk_create(tenants)
         Tenant.objects.bulk_create(tenants)
 
 
         ipaddresses = (
         ipaddresses = (
-            IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
-            IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
-            IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
-            IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
-            IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
-            IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
-            IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], interface=interfaces[3], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
-            IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], interface=interfaces[4], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
-            IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], interface=interfaces[5], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
-            IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, interface=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
+            IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
+            IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], assigned_object=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
+            IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], assigned_object=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
+            IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], assigned_object=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
+            IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
+            IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
+            IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], assigned_object=vminterfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
+            IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], assigned_object=vminterfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
+            IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], assigned_object=vminterfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
+            IPAddress(address='2001:db8::1/65', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
         )
         )
         IPAddress.objects.bulk_create(ipaddresses)
         IPAddress.objects.bulk_create(ipaddresses)
 
 
@@ -487,7 +491,14 @@ class IPAddressTestCase(TestCase):
         params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
         params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'interface': ['Interface 1', 'Interface 2']}
         params = {'interface': ['Interface 1', 'Interface 2']}
-        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_vminterface(self):
+        vminterfaces = VMInterface.objects.all()[:2]
+        params = {'vminterface_id': [vminterfaces[0].pk, vminterfaces[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'vminterface': ['Interface 1', 'Interface 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
     def test_assigned_to_interface(self):
     def test_assigned_to_interface(self):
         params = {'assigned_to_interface': 'true'}
         params = {'assigned_to_interface': 'true'}

+ 0 - 1
netbox/ipam/tests/test_views.py

@@ -236,7 +236,6 @@ class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'tenant': None,
             'tenant': None,
             'status': IPAddressStatusChoices.STATUS_RESERVED,
             'status': IPAddressStatusChoices.STATUS_RESERVED,
             'role': IPAddressRoleChoices.ROLE_ANYCAST,
             'role': IPAddressRoleChoices.ROLE_ANYCAST,
-            'interface': None,
             'nat_inside': None,
             'nat_inside': None,
             'dns_name': 'example',
             'dns_name': 'example',
             'description': 'A new IP address',
             'description': 'A new IP address',

+ 17 - 16
netbox/ipam/views.py

@@ -1,6 +1,6 @@
 import netaddr
 import netaddr
 from django.conf import settings
 from django.conf import settings
-from django.db.models import Count, Q
+from django.db.models import Count
 from django.db.models.expressions import RawSQL
 from django.db.models.expressions import RawSQL
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django_tables2 import RequestConfig
 from django_tables2 import RequestConfig
@@ -11,7 +11,7 @@ from utilities.views import (
     BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
     BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
     ObjectListView,
     ObjectListView,
 )
 )
-from virtualization.models import VirtualMachine
+from virtualization.models import VirtualMachine, VMInterface
 from . import filters, forms, tables
 from . import filters, forms, tables
 from .choices import *
 from .choices import *
 from .constants import *
 from .constants import *
@@ -517,7 +517,7 @@ class PrefixIPAddressesView(ObjectView):
 
 
         # Find all IPAddresses belonging to this Prefix
         # Find all IPAddresses belonging to this Prefix
         ipaddresses = prefix.get_child_ips().restrict(request.user, 'view').prefetch_related(
         ipaddresses = prefix.get_child_ips().restrict(request.user, 'view').prefetch_related(
-            'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for'
+            'vrf', 'primary_ip4_for', 'primary_ip6_for'
         )
         )
 
 
         # Add available IP addresses to the table if requested
         # Add available IP addresses to the table if requested
@@ -593,7 +593,7 @@ class PrefixBulkDeleteView(BulkDeleteView):
 
 
 class IPAddressListView(ObjectListView):
 class IPAddressListView(ObjectListView):
     queryset = IPAddress.objects.prefetch_related(
     queryset = IPAddress.objects.prefetch_related(
-        'vrf__tenant', 'tenant', 'nat_inside', 'interface__device', 'interface__virtual_machine'
+        'vrf__tenant', 'tenant', 'nat_inside'
     )
     )
     filterset = filters.IPAddressFilterSet
     filterset = filters.IPAddressFilterSet
     filterset_form = forms.IPAddressFilterForm
     filterset_form = forms.IPAddressFilterForm
@@ -622,7 +622,7 @@ class IPAddressView(ObjectView):
         ).exclude(
         ).exclude(
             pk=ipaddress.pk
             pk=ipaddress.pk
         ).prefetch_related(
         ).prefetch_related(
-            'nat_inside', 'interface__device'
+            'nat_inside'
         )
         )
         # Exclude anycast IPs if this IP is anycast
         # Exclude anycast IPs if this IP is anycast
         if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST:
         if ipaddress.role == IPAddressRoleChoices.ROLE_ANYCAST:
@@ -630,9 +630,7 @@ class IPAddressView(ObjectView):
         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.restrict(request.user, 'view').prefetch_related(
-            'interface__device'
-        ).exclude(
+        related_ips = IPAddress.objects.restrict(request.user, 'view').exclude(
             address=str(ipaddress.address)
             address=str(ipaddress.address)
         ).filter(
         ).filter(
             vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
             vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
@@ -661,13 +659,18 @@ class IPAddressEditView(ObjectEditView):
 
 
     def alter_obj(self, obj, request, url_args, url_kwargs):
     def alter_obj(self, obj, request, url_args, url_kwargs):
 
 
-        interface_id = request.GET.get('interface')
-        if interface_id:
+        if 'interface' in request.GET:
             try:
             try:
-                obj.interface = Interface.objects.get(pk=interface_id)
+                obj.assigned_object = Interface.objects.get(pk=request.GET['interface'])
             except (ValueError, Interface.DoesNotExist):
             except (ValueError, Interface.DoesNotExist):
                 pass
                 pass
 
 
+        elif 'vminterface' in request.GET:
+            try:
+                obj.assigned_object = VMInterface.objects.get(pk=request.GET['vminterface'])
+            except (ValueError, VMInterface.DoesNotExist):
+                pass
+
         return obj
         return obj
 
 
 
 
@@ -699,9 +702,7 @@ class IPAddressAssignView(ObjectView):
 
 
         if form.is_valid():
         if form.is_valid():
 
 
-            addresses = self.queryset.prefetch_related(
-                'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
-            )
+            addresses = self.queryset.prefetch_related('vrf', 'tenant')
             # Limit to 100 results
             # Limit to 100 results
             addresses = filters.IPAddressFilterSet(request.POST, addresses).qs[:100]
             addresses = filters.IPAddressFilterSet(request.POST, addresses).qs[:100]
             table = tables.IPAddressAssignTable(addresses)
             table = tables.IPAddressAssignTable(addresses)
@@ -735,7 +736,7 @@ class IPAddressBulkImportView(BulkImportView):
 
 
 
 
 class IPAddressBulkEditView(BulkEditView):
 class IPAddressBulkEditView(BulkEditView):
-    queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
+    queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
     filterset = filters.IPAddressFilterSet
     filterset = filters.IPAddressFilterSet
     table = tables.IPAddressTable
     table = tables.IPAddressTable
     form = forms.IPAddressBulkEditForm
     form = forms.IPAddressBulkEditForm
@@ -743,7 +744,7 @@ class IPAddressBulkEditView(BulkEditView):
 
 
 
 
 class IPAddressBulkDeleteView(BulkDeleteView):
 class IPAddressBulkDeleteView(BulkDeleteView):
-    queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
+    queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
     filterset = filters.IPAddressFilterSet
     filterset = filters.IPAddressFilterSet
     table = tables.IPAddressTable
     table = tables.IPAddressTable
     default_return_url = 'ipam:ipaddress_list'
     default_return_url = 'ipam:ipaddress_list'

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

@@ -166,7 +166,7 @@
                     </ul>
                     </ul>
                 </span>
                 </span>
             {% endif %}
             {% endif %}
-            <a href="{% if iface.device_id %}{% url 'dcim:interface_edit' pk=iface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=iface.pk %}{% endif %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
+            <a href="{% url 'dcim:interface_edit' pk=iface.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
                 <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
             </a>
             </a>
         {% endif %}
         {% endif %}
@@ -176,7 +176,7 @@
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 </button>
                 </button>
             {% else %}
             {% else %}
-                <a href="{% if iface.device_id %}{% url 'dcim:interface_delete' pk=iface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=iface.pk %}{% endif %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
+                <a href="{% url 'dcim:interface_delete' pk=iface.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 </a>
                 </a>
             {% endif %}
             {% endif %}

+ 10 - 14
netbox/templates/dcim/interface.html

@@ -5,29 +5,25 @@
     <div class="row noprint">
     <div class="row noprint">
         <div class="col-md-12">
         <div class="col-md-12">
             <ol class="breadcrumb">
             <ol class="breadcrumb">
-                {% if interface.device %}
-                    <li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
-                {% else %}
-                    <li><a href="{% url 'virtualization:virtualmachine_list' %}">Virtual Machines</a></li>
-                {% endif %}
-                <li><a href="{{ interface.parent.get_absolute_url }}">{{ interface.parent }}</a></li>
+                <li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
+                <li><a href="{{ interface.device.get_absolute_url }}">{{ interface.device }}</a></li>
                 <li>{{ interface }}</li>
                 <li>{{ interface }}</li>
             </ol>
             </ol>
         </div>
         </div>
     </div>
     </div>
     <div class="pull-right noprint">
     <div class="pull-right noprint">
         {% if perms.dcim.change_interface %}
         {% if perms.dcim.change_interface %}
-            <a href="{% if interface.device %}{% url 'dcim:interface_edit' pk=interface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=interface.pk %}{% endif %}" class="btn btn-warning">
+            <a href="{% url 'dcim:interface_edit' pk=interface.pk %}" class="btn btn-warning">
                 <span class="fa fa-pencil" aria-hidden="true"></span> Edit
                 <span class="fa fa-pencil" aria-hidden="true"></span> Edit
             </a>
             </a>
         {% endif %}
         {% endif %}
         {% if perms.dcim.delete_interface %}
         {% if perms.dcim.delete_interface %}
-            <a href="{% if interface.device %}{% url 'dcim:interface_delete' pk=interface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=interface.pk %}{% endif %}" class="btn btn-danger">
+            <a href="{% url 'dcim:interface_delete' pk=interface.pk %}" class="btn btn-danger">
                 <span class="fa fa-trash" aria-hidden="true"></span> Delete
                 <span class="fa fa-trash" aria-hidden="true"></span> Delete
             </a>
             </a>
         {% endif %}
         {% endif %}
     </div>
     </div>
-    <h1>{% block title %}{{ interface.parent }} / {{ interface.name }}{% endblock %}</h1>
+    <h1>{% block title %}{{ interface.device }} / {{ interface.name }}{% endblock %}</h1>
     <ul class="nav nav-tabs">
     <ul class="nav nav-tabs">
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
         <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
             <a href="{{ interface.get_absolute_url }}">Interface</a>
             <a href="{{ interface.get_absolute_url }}">Interface</a>
@@ -49,9 +45,9 @@
             </div>
             </div>
             <table class="table table-hover panel-body attr-table">
             <table class="table table-hover panel-body attr-table">
                 <tr>
                 <tr>
-                    <td>{% if interface.device %}Device{% else %}Virtual Machine{% endif %}</td>
+                    <td>Device</td>
                     <td>
                     <td>
-                        <a href="{{ interface.parent.get_absolute_url }}">{{ interface.parent }}</a>
+                        <a href="{{ interface.device.get_absolute_url }}">{{ interface.device }}</a>
                     </td>
                     </td>
                 </tr>
                 </tr>
                 <tr>
                 <tr>
@@ -96,7 +92,7 @@
                 </tr>
                 </tr>
                 <tr>
                 <tr>
                     <td>MAC Address</td>
                     <td>MAC Address</td>
-                    <td>{{ interface.mac_address|placeholder }}</span></td>
+                    <td><span class="text-monospace">{{ interface.mac_address|placeholder }}</span></td>
                 </tr>
                 </tr>
                 <tr>
                 <tr>
                     <td>802.1Q Mode</td>
                     <td>802.1Q Mode</td>
@@ -118,7 +114,7 @@
                             <tr>
                             <tr>
                                 <td>Device</td>
                                 <td>Device</td>
                                 <td>
                                 <td>
-                                    <a href="{{ connected_interface.parent.get_absolute_url }}">{{ connected_interface.device }}</a>
+                                    <a href="{{ connected_interface.device.get_absolute_url }}">{{ connected_interface.device }}</a>
                                 </td>
                                 </td>
                             </tr>
                             </tr>
                             <tr>
                             <tr>
@@ -225,7 +221,7 @@
                         {% for member in interface.member_interfaces.all %}
                         {% for member in interface.member_interfaces.all %}
                             <tr>
                             <tr>
                                 <td>
                                 <td>
-                                    <a href="{{ member.parent.get_absolute_url }}">{{ member.parent }}</a>
+                                    <a href="{{ member.device.get_absolute_url }}">{{ member.device }}</a>
                                 </td>
                                 </td>
                                 <td>
                                 <td>
                                     <a href="{{ member.get_absolute_url }}">{{ member }}</a>
                                     <a href="{{ member.get_absolute_url }}">{{ member }}</a>

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

@@ -372,6 +372,14 @@
                             {% endif %}
                             {% endif %}
                             <a href="{% url 'virtualization:virtualmachine_list' %}">Virtual Machines</a>
                             <a href="{% url 'virtualization:virtualmachine_list' %}">Virtual Machines</a>
                         </li>
                         </li>
+                        <li{% if not perms.virtualization.view_vminterface%} class="disabled"{% endif %}>
+                            {% if perms.virtualization.add_vminterface %}
+                                <div class="buttons pull-right">
+                                    <a href="{% url 'virtualization:vminterface_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
+                                </div>
+                            {% endif %}
+                            <a href="{% url 'virtualization:vminterface_list' %}">Interfaces</a>
+                        </li>
                         <li class="divider"></li>
                         <li class="divider"></li>
                         <li class="dropdown-header">Clusters</li>
                         <li class="dropdown-header">Clusters</li>
                         <li{% if not perms.virtualization.view_cluster %} class="disabled"{% endif %}>
                         <li{% if not perms.virtualization.view_cluster %} class="disabled"{% endif %}>

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

@@ -120,8 +120,8 @@
                 <tr>
                 <tr>
                     <td>Assignment</td>
                     <td>Assignment</td>
                     <td>
                     <td>
-                        {% if ipaddress.interface %}
-                            <span><a href="{{ ipaddress.interface.parent.get_absolute_url }}">{{ ipaddress.interface.parent }}</a> ({{ ipaddress.interface }})</span>
+                        {% if ipaddress.assigned_object %}
+                            <span><a href="{{ ipaddress.assigned_object.parent.get_absolute_url }}">{{ ipaddress.assigned_object.parent }}</a> ({{ ipaddress.assigned_object }})</span>
                         {% else %}
                         {% else %}
                             <span class="text-muted">&mdash;</span>
                             <span class="text-muted">&mdash;</span>
                         {% endif %}
                         {% endif %}
@@ -132,8 +132,8 @@
                     <td>
                     <td>
                         {% 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 %}
-                                (<a href="{{ ipaddress.nat_inside.interface.parent.get_absolute_url }}">{{ ipaddress.nat_inside.interface.parent }}</a>)
+                            {% if ipaddress.nat_inside.assigned_object %}
+                                (<a href="{{ ipaddress.nat_inside.assigned_object.parent.get_absolute_url }}">{{ ipaddress.nat_inside.assigned_object.parent }}</a>)
                             {% endif %}
                             {% endif %}
                         {% else %}
                         {% else %}
                             <span class="text-muted">None</span>
                             <span class="text-muted">None</span>

+ 21 - 16
netbox/templates/ipam/ipaddress_edit.html

@@ -28,25 +28,30 @@
             {% render_field form.tenant %}
             {% render_field form.tenant %}
         </div>
         </div>
     </div>
     </div>
-    {% 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|meta:"verbose_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 class="panel panel-default">
+        <div class="panel-heading">
+            <strong>Interface Assignment</strong>
+        </div>
+        <div class="panel-body">
+            {% with vm_tab_active=obj.vminterface.exists %}
+                <ul class="nav nav-tabs" role="tablist">
+                    <li role="presentation"{% if not vm_tab_active %} class="active"{% endif %}><a href="#device" role="tab" data-toggle="tab">Device</a></li>
+                    <li role="presentation"{% if vm_tab_active %} class="active"{% endif %}><a href="#virtualmachine" role="tab" data-toggle="tab">Virtual Machine</a></li>
+                </ul>
+                <div class="tab-content">
+                    <div class="tab-pane{% if not vm_tab_active %} active{% endif %}" id="device">
+                        {% render_field form.device %}
+                        {% render_field form.interface %}
+                    </div>
+                    <div class="tab-pane{% if vm_tab_active %} active{% endif %}" id="virtualmachine">
+                        {% render_field form.virtual_machine %}
+                        {% render_field form.vminterface %}
                     </div>
                     </div>
                 </div>
                 </div>
-                {% render_field form.interface %}
-                {% render_field form.primary_for_parent %}
-            </div>
+            {% endwith %}
+            {% render_field form.primary_for_parent %}
         </div>
         </div>
-    {% endif %}
+    </div>
     <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">

+ 0 - 0
netbox/templates/dcim/bulk_rename.html → netbox/templates/utilities/obj_bulk_rename.html


+ 141 - 0
netbox/templates/virtualization/inc/vminterface.html

@@ -0,0 +1,141 @@
+{% load helpers %}
+<tr class="interface{% if not iface.enabled %} danger{% endif %}" id="interface_{{ iface.name }}">
+
+    {# Checkbox #}
+    {% if perms.virtualization.change_interface or perms.virtualization.delete_interface %}
+        <td class="pk">
+            <input name="pk" type="checkbox" value="{{ iface.pk }}" />
+        </td>
+    {% endif %}
+
+    {# Name #}
+    <td>
+        <a href="{{ iface.get_absolute_url }}">{{ iface }}</a>
+    </td>
+
+    {# MAC address #}
+    <td class="text-monospace">
+        {{ iface.mac_address|default:"&mdash;" }}
+    </td>
+
+    {# MTU #}
+    <td>{{ iface.mtu|default:"&mdash;" }}</td>
+
+    {# 802.1Q mode #}
+    <td>{{ iface.get_mode_display|default:"&mdash;" }}</td>
+
+    {# Description/tags #}
+    <td>
+        {% if iface.description %}
+            {{ iface.description }}<br/>
+        {% endif %}
+        {% for tag in iface.tags.all %}
+            {% tag tag %}
+        {% empty %}
+            {% if not iface.description %}&mdash;{% endif %}
+        {% endfor %}
+    </td>
+
+    {# Buttons #}
+    <td class="text-right text-nowrap noprint">
+        {% if show_interface_graphs %}
+            <button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ virtualmachine.name }} - {{ iface.name }}" data-url="{% url 'virtualization-api:vminterface-graphs' pk=iface.pk %}" title="Show graphs">
+                <i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
+            </button>
+        {% endif %}
+        {% if perms.ipam.add_ipaddress %}
+            <a href="{% url 'ipam:ipaddress_add' %}?vminterface={{ iface.pk }}&return_url={{ virtualmachine.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.virtualization.change_interface %}
+            <a href="{% url 'virtualization:vminterface_edit' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
+                <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
+            </a>
+        {% endif %}
+        {% if perms.virtualization.delete_interface %}
+            <a href="{% url 'virtualization:vminterface_delete' pk=iface.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete interface">
+                <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
+            </a>
+        {% endif %}
+    </td>
+</tr>
+
+{% with ipaddresses=iface.ip_addresses.all %}
+    {% if ipaddresses %}
+        <tr class="ipaddresses">
+            {# Placeholder #}
+            {% if perms.virtualization.change_interface or perms.virtualization.delete_interface %}
+                <td></td>
+            {% endif %}
+
+            {# IP addresses table #}
+            <td colspan="9" style="padding: 0">
+                <table class="table table-condensed interface-ips">
+                    <thead>
+                        <tr class="text-muted">
+                            <th class="col-md-3">IP Address</th>
+                            <th class="col-md-2">Status/Role</th>
+                            <th class="col-md-3">VRF</th>
+                            <th class="col-md-3">Description</th>
+                            <th class="col-md-1"></th>
+                        </tr>
+                    </thead>
+                    {% for ip in iface.ip_addresses.all %}
+                        <tr>
+
+                            {# IP address #}
+                            <td>
+                                <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a>
+                            </td>
+
+                            {# Primary/status/role #}
+                            <td>
+                                {% if virtualmachine.primary_ip4 == ip or virtualmachine.primary_ip6 == ip %}
+                                    <span class="label label-success">Primary</span>
+                                {% endif %}
+                                <span class="label label-{{ ip.get_status_class }}">{{ ip.get_status_display }}</span>
+                                {% if ip.role %}
+                                    <span class="label label-{{ ip.get_role_class }}">{{ ip.get_role_display }}</span>
+                                {% endif %}
+                            </td>
+
+                            {# VRF #}
+                            <td>
+                                {% if ip.vrf %}
+                                    <a href="{% url 'ipam:vrf' pk=ip.vrf.pk %}" title="{{ ip.vrf.rd }}">{{ ip.vrf.name }}</a>
+                                {% else %}
+                                    <span class="text-muted">Global</span>
+                                {% endif %}
+                            </td>
+
+                            {# Description #}
+                            <td>
+                                {% if ip.description %}
+                                    {{ ip.description }}
+                                {% else %}
+                                    <span class="text-muted">&mdash;</span>
+                                {% endif %}
+                            </td>
+
+                            {# Buttons #}
+                            <td class="text-right text-nowrap noprint">
+                                {% if perms.ipam.change_ipaddress %}
+                                    <a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ virtualmachine.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={{ virtualmachine.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 %}
+                </table>
+            </td>
+        </tr>
+    {% endif %}
+{% endwith %}

+ 14 - 16
netbox/templates/virtualization/virtualmachine.html

@@ -248,7 +248,7 @@
 </div>
 </div>
 <div class="row">
 <div class="row">
     <div class="col-md-12">
     <div class="col-md-12">
-        {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
+        {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
             <form method="post">
             <form method="post">
             {% csrf_token %}
             {% csrf_token %}
             <input type="hidden" name="virtual_machine" value="{{ virtualmachine.pk }}" />
             <input type="hidden" name="virtual_machine" value="{{ virtualmachine.pk }}" />
@@ -268,22 +268,20 @@
             <table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
             <table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
                 <thead>
                 <thead>
                     <tr>
                     <tr>
-                        {% if perms.dcim.change_interface or perms.dcim.delete_interface %}
+                        {% if perms.virtualization.change_vminterface or perms.virtualization.delete_vminterface %}
                             <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
                             <th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
                         {% endif %}
                         {% endif %}
                         <th>Name</th>
                         <th>Name</th>
-                        <th>LAG</th>
-                        <th>Description</th>
+                        <th>MAC Address</th>
                         <th>MTU</th>
                         <th>MTU</th>
                         <th>Mode</th>
                         <th>Mode</th>
-                        <th>Cable</th>
-                        <th colspan="2">Connection</th>
+                        <th>Description</th>
                         <th></th>
                         <th></th>
                     </tr>
                     </tr>
                 </thead>
                 </thead>
                 <tbody>
                 <tbody>
                     {% for iface in interfaces %}
                     {% for iface in interfaces %}
-                        {% include 'dcim/inc/interface.html' with device=virtualmachine %}
+                        {% include 'virtualization/inc/vminterface.html' %}
                     {% empty %}
                     {% empty %}
                         <tr>
                         <tr>
                             <td colspan="8" class="text-center text-muted">&mdash; No interfaces defined &mdash;</td>
                             <td colspan="8" class="text-center text-muted">&mdash; No interfaces defined &mdash;</td>
@@ -291,24 +289,24 @@
                     {% endfor %}
                     {% endfor %}
                 </tbody>
                 </tbody>
             </table>
             </table>
-            {% if perms.dcim.add_interface or perms.dcim.delete_interface %}
+            {% if perms.virtualization.add_vminterface or perms.virtualization.delete_vminterface %}
                 <div class="panel-footer noprint">
                 <div class="panel-footer noprint">
-                    {% if interfaces and perms.dcim.change_interface %}
-                        <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
+                    {% if interfaces and perms.virtualization.change_vminterface %}
+                        <button type="submit" name="_rename" formaction="{% url 'virtualization:vminterface_bulk_rename' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
                         </button>
                         </button>
-                        <button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
+                        <button type="submit" name="_edit" formaction="{% url 'virtualization:vminterface_bulk_edit' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                             <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                         </button>
                         </button>
                     {% endif %}
                     {% endif %}
-                    {% if interfaces and perms.dcim.delete_interface %}
-                        <button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs">
+                    {% if interfaces and perms.virtualization.delete_vminterface %}
+                        <button type="submit" name="_delete" formaction="{% url 'virtualization:vminterface_bulk_delete' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs">
                             <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                             <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
                         </button>
                         </button>
                     {% endif %}
                     {% endif %}
-                    {% if perms.dcim.add_interface %}
+                    {% if perms.virtualization.add_vminterface %}
                         <div class="pull-right">
                         <div class="pull-right">
-                            <a href="{% url 'virtualization:interface_add' %}?virtual_machine={{ virtualmachine.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-primary btn-xs">
+                            <a href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ virtualmachine.pk }}&return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-primary btn-xs">
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
                             </a>
                             </a>
                         </div>
                         </div>
@@ -317,7 +315,7 @@
                  </div>
                  </div>
             {% endif %}
             {% endif %}
         </div>
         </div>
-        {% if perms.dcim.delete_interface %}
+        {% if perms.virtualization.delete_vminterface %}
             </form>
             </form>
         {% endif %}
         {% endif %}
 	</div>
 	</div>

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

@@ -22,12 +22,6 @@
                     <strong>{{ component_type|bettertitle }}</strong>
                     <strong>{{ component_type|bettertitle }}</strong>
                 </div>
                 </div>
                 <div class="panel-body">
                 <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 %}
                     {% render_form form %}
                 </div>
                 </div>
             </div>
             </div>

+ 1 - 1
netbox/templates/virtualization/virtualmachine_list.html

@@ -7,7 +7,7 @@
                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Components <span class="caret"></span>
             </button>
             </button>
             <ul class="dropdown-menu">
             <ul class="dropdown-menu">
-                {% if perms.dcim.add_interface %}<li><a href="{% url 'virtualization:virtualmachine_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
+                {% if perms.dcim.add_interface %}<li><a href="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="formaction">Interfaces</a></li>{% endif %}
             </ul>
             </ul>
         </div>
         </div>
     {% endif %}
     {% endif %}

+ 100 - 0
netbox/templates/virtualization/vminterface.html

@@ -0,0 +1,100 @@
+{% extends 'base.html' %}
+{% load helpers %}
+
+{% block header %}
+    <div class="row noprint">
+        <div class="col-md-12">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'virtualization:virtualmachine_list' %}">Virtual Machines</a></li>
+                <li><a href="{{ vminterface.virtual_machine.get_absolute_url }}">{{ vminterface.virtual_machine }}</a></li>
+                <li>{{ vminterface }}</li>
+            </ol>
+        </div>
+    </div>
+    <div class="pull-right noprint">
+        {% if perms.virtualization.change_vminterface %}
+            <a href="{% url 'virtualization:vminterface_edit' pk=vminterface.pk %}" class="btn btn-warning">
+                <span class="fa fa-pencil" aria-hidden="true"></span> Edit
+            </a>
+        {% endif %}
+        {% if perms.virtualization.delete_vminterface %}
+            <a href="{% url 'virtualization:vminterface_delete' pk=vminterface.pk %}" class="btn btn-danger">
+                <span class="fa fa-trash" aria-hidden="true"></span> Delete
+            </a>
+        {% endif %}
+    </div>
+    <h1>{% block title %}{{ vminterface.virtual_machine }} / {{ vminterface.name }}{% endblock %}</h1>
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ vminterface.get_absolute_url }}">Interface</a>
+        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'virtualization:vminterface_changelog' pk=vminterface.pk %}">Change Log</a>
+            </li>
+        {% endif %}
+    </ul>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Interface</strong>
+            </div>
+            <table class="table table-hover panel-body attr-table">
+                <tr>
+                    <td>Virtual Machine</td>
+                    <td>
+                        <a href="{{ vminterface.virtual_machine.get_absolute_url }}">{{ vminterface.virtual_machine }}</a>
+                    </td>
+                </tr>
+                <tr>
+                    <td>Name</td>
+                    <td>{{ vminterface.name }}</td>
+                </tr>
+                <tr>
+                    <td>Enabled</td>
+                    <td>
+                        {% if vminterface.enabled %}
+                            <span class="text-success"><i class="fa fa-check"></i></span>
+                        {% else %}
+                            <span class="text-danger"><i class="fa fa-close"></i></span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>Description</td>
+                    <td>{{ vminterface.description|placeholder }} </td>
+                </tr>
+                <tr>
+                    <td>MTU</td>
+                    <td>{{ vminterface.mtu|placeholder }}</td>
+                </tr>
+                <tr>
+                    <td>MAC Address</td>
+                    <td><span class="text-monospace">{{ vminterface.mac_address|placeholder }}</span></td>
+                </tr>
+                <tr>
+                    <td>802.1Q Mode</td>
+                    <td>{{ vminterface.get_mode_display }}</td>
+                </tr>
+            </table>
+        </div>
+    </div>
+    <div class="col-md-6">
+        {% include 'extras/inc/tags_panel.html' with tags=vminterface.tags.all %}
+    </div>
+</div>
+<div class="row">
+    <div class="col-md-12">
+        {% include 'panel_table.html' with table=ipaddress_table heading="IP Addresses" %}
+    </div>
+</div>
+<div class="row">
+    <div class="col-md-12">
+        {% include 'panel_table.html' with table=vlan_table heading="VLANs" %}
+    </div>
+</div>
+{% endblock %}

+ 1 - 1
netbox/templates/virtualization/interface_edit.html → netbox/templates/virtualization/vminterface_edit.html

@@ -21,7 +21,7 @@
 {% block buttons %}
 {% block buttons %}
     {% if obj.pk %}
     {% if obj.pk %}
         <button type="submit" name="_update" class="btn btn-primary">Update</button>
         <button type="submit" name="_update" class="btn btn-primary">Update</button>
-        <button type="submit" formaction="?return_url={% url 'virtualization:interface_edit' pk=obj.pk %}" class="btn btn-primary">Update and Continue Editing</button>
+        <button type="submit" formaction="?return_url={% url 'virtualization:vminterface_edit' pk=obj.pk %}" class="btn btn-primary">Update and Continue Editing</button>
     {% else %}
     {% else %}
         <button type="submit" name="_create" class="btn btn-primary">Create</button>
         <button type="submit" name="_create" class="btn btn-primary">Create</button>
         <button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>
         <button type="submit" name="_addanother" class="btn btn-primary">Create and Add Another</button>

+ 0 - 6
netbox/utilities/custom_inspectors.py

@@ -5,14 +5,8 @@ from drf_yasg.utils import get_serializer_ref_name
 from rest_framework.fields import ChoiceField
 from rest_framework.fields import ChoiceField
 from rest_framework.relations import ManyRelatedField
 from rest_framework.relations import ManyRelatedField
 
 
-from dcim.api.serializers import InterfaceSerializer as DeviceInterfaceSerializer
 from extras.api.customfields import CustomFieldsSerializer
 from extras.api.customfields import CustomFieldsSerializer
 from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer
 from utilities.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer
-from virtualization.api.serializers import InterfaceSerializer as VirtualMachineInterfaceSerializer
-
-# this might be ugly, but it limits drf_yasg-specific code to this file
-DeviceInterfaceSerializer.Meta.ref_name = 'DeviceInterface'
-VirtualMachineInterfaceSerializer.Meta.ref_name = 'VirtualMachineInterface'
 
 
 
 
 class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):
 class NetBoxSwaggerAutoSchema(SwaggerAutoSchema):

+ 24 - 0
netbox/utilities/forms.py

@@ -733,6 +733,30 @@ class BulkEditForm(forms.Form):
             self.nullable_fields = self.Meta.nullable_fields
             self.nullable_fields = self.Meta.nullable_fields
 
 
 
 
+class BulkRenameForm(forms.Form):
+    """
+    An extendable form to be used for renaming objects in bulk.
+    """
+    find = forms.CharField()
+    replace = forms.CharField()
+    use_regex = forms.BooleanField(
+        required=False,
+        initial=True,
+        label='Use regular expressions'
+    )
+
+    def clean(self):
+
+        # Validate regular expression in "find" field
+        if self.cleaned_data['use_regex']:
+            try:
+                re.compile(self.cleaned_data['find'])
+            except re.error:
+                raise forms.ValidationError({
+                    'find': "Invalid regular expression"
+                })
+
+
 class CSVModelForm(forms.ModelForm):
 class CSVModelForm(forms.ModelForm):
     """
     """
     ModelForm used for the import of objects in CSV format.
     ModelForm used for the import of objects in CSV format.

+ 53 - 0
netbox/utilities/views.py

@@ -1,4 +1,5 @@
 import logging
 import logging
+import re
 import sys
 import sys
 from copy import deepcopy
 from copy import deepcopy
 
 
@@ -963,6 +964,58 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
         })
         })
 
 
 
 
+class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
+    """
+    An extendable view for renaming objects in bulk.
+    """
+    queryset = None
+    form = None
+    template_name = 'utilities/obj_bulk_rename.html'
+
+    def get_required_permission(self):
+        return get_permission_for_model(self.queryset.model, 'change')
+
+    def post(self, request):
+
+        if '_preview' in request.POST or '_apply' in request.POST:
+            form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
+            selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
+
+            if form.is_valid():
+                for obj in selected_objects:
+                    find = form.cleaned_data['find']
+                    replace = form.cleaned_data['replace']
+                    if form.cleaned_data['use_regex']:
+                        try:
+                            obj.new_name = re.sub(find, replace, obj.name)
+                        # Catch regex group reference errors
+                        except re.error:
+                            obj.new_name = obj.name
+                    else:
+                        obj.new_name = obj.name.replace(find, replace)
+
+                if '_apply' in request.POST:
+                    for obj in selected_objects:
+                        obj.name = obj.new_name
+                        obj.save()
+                    messages.success(request, "Renamed {} {}".format(
+                        len(selected_objects),
+                        self.queryset.model._meta.verbose_name_plural
+                    ))
+                    return redirect(self.get_return_url(request))
+
+        else:
+            form = self.form(initial={'pk': request.POST.getlist('pk')})
+            selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
+
+        return render(request, self.template_name, {
+            'form': form,
+            'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
+            'selected_objects': selected_objects,
+            'return_url': self.get_return_url(request),
+        })
+
+
 class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
     """
     """
     Delete objects in bulk.
     Delete objects in bulk.

+ 3 - 3
netbox/virtualization/api/nested_serializers.py

@@ -8,7 +8,7 @@ __all__ = [
     'NestedClusterGroupSerializer',
     'NestedClusterGroupSerializer',
     'NestedClusterSerializer',
     'NestedClusterSerializer',
     'NestedClusterTypeSerializer',
     'NestedClusterTypeSerializer',
-    'NestedInterfaceSerializer',
+    'NestedVMInterfaceSerializer',
     'NestedVirtualMachineSerializer',
     'NestedVirtualMachineSerializer',
 ]
 ]
 
 
@@ -56,8 +56,8 @@ class NestedVirtualMachineSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'name']
         fields = ['id', 'url', 'name']
 
 
 
 
-class NestedInterfaceSerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:interface-detail')
+class NestedVMInterfaceSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail')
     virtual_machine = NestedVirtualMachineSerializer(read_only=True)
     virtual_machine = NestedVirtualMachineSerializer(read_only=True)
 
 
     class Meta:
     class Meta:

+ 5 - 7
netbox/virtualization/api/serializers.py

@@ -3,7 +3,6 @@ from rest_framework import serializers
 
 
 from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
 from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
-from dcim.models import Interface
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.serializers import TaggedObjectSerializer
 from extras.api.serializers import TaggedObjectSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
 from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
@@ -11,7 +10,7 @@ from ipam.models import VLAN
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from tenancy.api.nested_serializers import NestedTenantSerializer
 from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer
 from utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer
 from virtualization.choices import *
 from virtualization.choices import *
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from .nested_serializers import *
 from .nested_serializers import *
 
 
 
 
@@ -95,9 +94,8 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
 # VM interfaces
 # VM interfaces
 #
 #
 
 
-class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
+class VMInterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
     virtual_machine = NestedVirtualMachineSerializer()
     virtual_machine = NestedVirtualMachineSerializer()
-    type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False)
     mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
     mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
     tagged_vlans = SerializedPKRelatedField(
@@ -108,8 +106,8 @@ class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
     )
     )
 
 
     class Meta:
     class Meta:
-        model = Interface
+        model = VMInterface
         fields = [
         fields = [
-            'id', 'virtual_machine', 'name', 'type', 'enabled', 'mtu', 'mac_address', 'description', 'mode',
-            'untagged_vlan', 'tagged_vlans', 'tags',
+            'id', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan',
+            'tagged_vlans', 'tags',
         ]
         ]

+ 1 - 1
netbox/virtualization/api/urls.py

@@ -21,7 +21,7 @@ router.register('clusters', views.ClusterViewSet)
 
 
 # VirtualMachines
 # VirtualMachines
 router.register('virtual-machines', views.VirtualMachineViewSet)
 router.register('virtual-machines', views.VirtualMachineViewSet)
-router.register('interfaces', views.InterfaceViewSet)
+router.register('interfaces', views.VMInterfaceViewSet)
 
 
 app_name = 'virtualization-api'
 app_name = 'virtualization-api'
 urlpatterns = router.urls
 urlpatterns = router.urls

+ 6 - 13
netbox/virtualization/api/views.py

@@ -1,11 +1,11 @@
 from django.db.models import Count
 from django.db.models import Count
 
 
-from dcim.models import Device, Interface
+from dcim.models import Device
 from extras.api.views import CustomFieldModelViewSet
 from extras.api.views import CustomFieldModelViewSet
 from utilities.api import ModelViewSet
 from utilities.api import ModelViewSet
 from utilities.utils import get_subquery
 from utilities.utils import get_subquery
 from virtualization import filters
 from virtualization import filters
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from . import serializers
 from . import serializers
 
 
 
 
@@ -71,18 +71,11 @@ class VirtualMachineViewSet(CustomFieldModelViewSet):
         return serializers.VirtualMachineWithConfigContextSerializer
         return serializers.VirtualMachineWithConfigContextSerializer
 
 
 
 
-class InterfaceViewSet(ModelViewSet):
-    queryset = Interface.objects.filter(
+class VMInterfaceViewSet(ModelViewSet):
+    queryset = VMInterface.objects.filter(
         virtual_machine__isnull=False
         virtual_machine__isnull=False
     ).prefetch_related(
     ).prefetch_related(
         'virtual_machine', 'tags'
         'virtual_machine', 'tags'
     )
     )
-    serializer_class = serializers.InterfaceSerializer
-    filterset_class = filters.InterfaceFilterSet
-
-    def get_serializer_class(self):
-        request = self.get_serializer_context()['request']
-        if request.query_params.get('brief', False):
-            # Override get_serializer_for_model(), which will return the DCIM NestedInterfaceSerializer
-            return serializers.NestedInterfaceSerializer
-        return serializers.InterfaceSerializer
+    serializer_class = serializers.VMInterfaceSerializer
+    filterset_class = filters.VMInterfaceFilterSet

+ 0 - 14
netbox/virtualization/choices.py

@@ -1,4 +1,3 @@
-from dcim.choices import InterfaceTypeChoices
 from utilities.choices import ChoiceSet
 from utilities.choices import ChoiceSet
 
 
 
 
@@ -29,16 +28,3 @@ class VirtualMachineStatusChoices(ChoiceSet):
         STATUS_ACTIVE: 1,
         STATUS_ACTIVE: 1,
         STATUS_STAGED: 3,
         STATUS_STAGED: 3,
     }
     }
-
-
-#
-# Interface types (for VirtualMachines)
-#
-
-class VMInterfaceTypeChoices(ChoiceSet):
-
-    TYPE_VIRTUAL = InterfaceTypeChoices.TYPE_VIRTUAL
-
-    CHOICES = (
-        (TYPE_VIRTUAL, 'Virtual'),
-    )

+ 5 - 5
netbox/virtualization/filters.py

@@ -1,7 +1,7 @@
 import django_filters
 import django_filters
 from django.db.models import Q
 from django.db.models import Q
 
 
-from dcim.models import DeviceRole, Interface, Platform, Region, Site
+from dcim.models import DeviceRole, Platform, Region, Site
 from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet
 from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet
 from tenancy.filters import TenancyFilterSet
 from tenancy.filters import TenancyFilterSet
 from utilities.filters import (
 from utilities.filters import (
@@ -9,14 +9,14 @@ from utilities.filters import (
     TreeNodeMultipleChoiceFilter,
     TreeNodeMultipleChoiceFilter,
 )
 )
 from .choices import *
 from .choices import *
-from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
 __all__ = (
 __all__ = (
     'ClusterFilterSet',
     'ClusterFilterSet',
     'ClusterGroupFilterSet',
     'ClusterGroupFilterSet',
     'ClusterTypeFilterSet',
     'ClusterTypeFilterSet',
-    'InterfaceFilterSet',
     'VirtualMachineFilterSet',
     'VirtualMachineFilterSet',
+    'VMInterfaceFilterSet',
 )
 )
 
 
 
 
@@ -201,7 +201,7 @@ class VirtualMachineFilterSet(
         )
         )
 
 
 
 
-class InterfaceFilterSet(BaseFilterSet):
+class VMInterfaceFilterSet(BaseFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
         label='Search',
         label='Search',
@@ -222,7 +222,7 @@ class InterfaceFilterSet(BaseFilterSet):
     )
     )
 
 
     class Meta:
     class Meta:
-        model = Interface
+        model = VMInterface
         fields = ['id', 'name', 'enabled', 'mtu']
         fields = ['id', 'name', 'enabled', 'mtu']
 
 
     def search(self, queryset, name, value):
     def search(self, queryset, name, value):

+ 71 - 36
netbox/virtualization/forms.py

@@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
 from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
 from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
 from dcim.forms import INTERFACE_MODE_HELP_TEXT
 from dcim.forms import INTERFACE_MODE_HELP_TEXT
-from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
+from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
 from extras.forms import (
 from extras.forms import (
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
 )
 )
@@ -14,12 +14,12 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
     add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
-    CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
-    DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea,
-    StaticSelect2, StaticSelect2Multiple, TagFilterField,
+    BulkRenameForm, CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm,
+    DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
+    SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
 )
 )
 from .choices import *
 from .choices import *
-from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
 
 
 #
 #
@@ -356,7 +356,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
                 ip_choices = [(None, '---------')]
                 ip_choices = [(None, '---------')]
                 # Collect interface IPs
                 # Collect interface IPs
                 interface_ips = IPAddress.objects.prefetch_related('interface').filter(
                 interface_ips = IPAddress.objects.prefetch_related('interface').filter(
-                    address__family=family, interface__virtual_machine=self.instance
+                    address__family=family,
+                    vminterface__in=self.instance.interfaces.values_list('id', flat=True)
                 )
                 )
                 if interface_ips:
                 if interface_ips:
                     ip_choices.append(
                     ip_choices.append(
@@ -366,7 +367,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
                     )
                     )
                 # Collect NAT IPs
                 # Collect NAT IPs
                 nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
                 nat_ips = IPAddress.objects.prefetch_related('nat_inside').filter(
-                    address__family=family, nat_inside__interface__virtual_machine=self.instance
+                    address__family=family,
+                    nat_inside__vminterface__in=self.instance.interfaces.values_list('id', flat=True)
                 )
                 )
                 if nat_ips:
                 if nat_ips:
                     ip_choices.append(
                     ip_choices.append(
@@ -569,7 +571,7 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
 # VM interfaces
 # VM interfaces
 #
 #
 
 
-class InterfaceForm(BootstrapMixin, forms.ModelForm):
+class VMInterfaceForm(BootstrapMixin, forms.ModelForm):
     untagged_vlan = DynamicModelChoiceField(
     untagged_vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),
         queryset=VLAN.objects.all(),
         required=False,
         required=False,
@@ -598,14 +600,13 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
     )
     )
 
 
     class Meta:
     class Meta:
-        model = Interface
+        model = VMInterface
         fields = [
         fields = [
-            'virtual_machine', 'name', 'type', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags',
-            'untagged_vlan', 'tagged_vlans',
+            'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', 'untagged_vlan',
+            'tagged_vlans',
         ]
         ]
         widgets = {
         widgets = {
             'virtual_machine': forms.HiddenInput(),
             'virtual_machine': forms.HiddenInput(),
-            'type': forms.HiddenInput(),
             'mode': StaticSelect2()
             'mode': StaticSelect2()
         }
         }
         labels = {
         labels = {
@@ -618,10 +619,13 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
+        virtual_machine = VirtualMachine.objects.get(
+            pk=self.initial.get('virtual_machine') or self.data.get('virtual_machine')
+        )
+
         # Add current site to VLANs query params
         # Add current site to VLANs query params
-        site = getattr(self.instance.parent, 'site', None)
-        if site is not None:
-            # Add current site to VLANs query params
+        site = virtual_machine.site
+        if site:
             self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk)
             self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk)
             self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk)
             self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk)
 
 
@@ -642,19 +646,13 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
             self.cleaned_data['tagged_vlans'] = []
             self.cleaned_data['tagged_vlans'] = []
 
 
 
 
-class InterfaceCreateForm(BootstrapMixin, forms.Form):
-    virtual_machine = forms.ModelChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        widget=forms.HiddenInput()
+class VMInterfaceCreateForm(BootstrapMixin, forms.Form):
+    virtual_machine = DynamicModelChoiceField(
+        queryset=VirtualMachine.objects.all()
     )
     )
     name_pattern = ExpandableNameField(
     name_pattern = ExpandableNameField(
         label='Name'
         label='Name'
     )
     )
-    type = forms.ChoiceField(
-        choices=VMInterfaceTypeChoices,
-        initial=VMInterfaceTypeChoices.TYPE_VIRTUAL,
-        widget=forms.HiddenInput()
-    )
     enabled = forms.BooleanField(
     enabled = forms.BooleanField(
         required=False,
         required=False,
         initial=True
         initial=True
@@ -712,16 +710,39 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form):
             pk=self.initial.get('virtual_machine') or self.data.get('virtual_machine')
             pk=self.initial.get('virtual_machine') or self.data.get('virtual_machine')
         )
         )
 
 
-        site = getattr(virtual_machine.cluster, 'site', None)
-        if site is not None:
-            # Add current site to VLANs query params
+        # Add current site to VLANs query params
+        site = virtual_machine.site
+        if site:
             self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk)
             self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk)
             self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk)
             self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk)
 
 
 
 
-class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
+class VMInterfaceCSVForm(CSVModelForm):
+    virtual_machine = CSVModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        to_field_name='name'
+    )
+    mode = CSVChoiceField(
+        choices=InterfaceModeChoices,
+        required=False,
+        help_text='IEEE 802.1Q operational mode (for L2 interfaces)'
+    )
+
+    class Meta:
+        model = VMInterface
+        fields = VMInterface.csv_headers
+
+    def clean_enabled(self):
+        # Make sure enabled is True when it's not included in the uploaded data
+        if 'enabled' not in self.data:
+            return True
+        else:
+            return self.cleaned_data['enabled']
+
+
+class VMInterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
-        queryset=Interface.objects.all(),
+        queryset=VMInterface.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
     virtual_machine = forms.ModelChoiceField(
     virtual_machine = forms.ModelChoiceField(
@@ -789,6 +810,24 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
                 self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk)
                 self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk)
 
 
 
 
+class VMInterfaceBulkRenameForm(BulkRenameForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=VMInterface.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+
+
+class VMInterfaceFilterForm(forms.Form):
+    model = VMInterface
+    enabled = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect2(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    tag = TagFilterField(model)
+
+
 #
 #
 # Bulk VirtualMachine component creation
 # Bulk VirtualMachine component creation
 #
 #
@@ -808,12 +847,8 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form):
         return ','.join(self.cleaned_data.get('tags'))
         return ','.join(self.cleaned_data.get('tags'))
 
 
 
 
-class InterfaceBulkCreateForm(
-    form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']),
+class VMInterfaceBulkCreateForm(
+    form_from_model(VMInterface, ['enabled', 'mtu', 'description', 'tags']),
     VirtualMachineBulkAddComponentForm
     VirtualMachineBulkAddComponentForm
 ):
 ):
-    type = forms.ChoiceField(
-        choices=VMInterfaceTypeChoices,
-        initial=VMInterfaceTypeChoices.TYPE_VIRTUAL,
-        widget=forms.HiddenInput()
-    )
+    pass

+ 44 - 0
netbox/virtualization/migrations/0015_vminterface.py

@@ -0,0 +1,44 @@
+# Generated by Django 3.0.6 on 2020-06-18 20:21
+
+import dcim.fields
+import django.core.validators
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+import utilities.fields
+import utilities.ordering
+import utilities.query_functions
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0036_standardize_description'),
+        ('extras', '0042_customfield_manager'),
+        ('virtualization', '0014_standardize_description'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='VMInterface',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=64)),
+                ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface)),
+                ('enabled', models.BooleanField(default=True)),
+                ('mac_address', dcim.fields.MACAddressField(blank=True, null=True)),
+                ('mtu', models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65536)])),
+                ('mode', models.CharField(blank=True, max_length=50)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('tagged_vlans', models.ManyToManyField(blank=True, related_name='vminterfaces_as_tagged', to='ipam.VLAN')),
+                ('tags', taggit.managers.TaggableManager(related_name='vminterface', through='extras.TaggedItem', to='extras.Tag')),
+                ('untagged_vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vminterfaces_as_untagged', to='ipam.VLAN')),
+                ('virtual_machine', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='virtualization.VirtualMachine')),
+            ],
+            options={
+                'ordering': ('virtual_machine', utilities.query_functions.CollateAsChar('_name')),
+                'unique_together': {('virtual_machine', 'name')},
+                'verbose_name': 'interface',
+            },
+        ),
+    ]

+ 69 - 0
netbox/virtualization/migrations/0016_replicate_interfaces.py

@@ -0,0 +1,69 @@
+import sys
+
+from django.db import migrations
+
+
+def replicate_interfaces(apps, schema_editor):
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+    TaggedItem = apps.get_model('extras', 'TaggedItem')
+    Interface = apps.get_model('dcim', 'Interface')
+    IPAddress = apps.get_model('ipam', 'IPAddress')
+    VMInterface = apps.get_model('virtualization', 'VMInterface')
+
+    interface_ct = ContentType.objects.get_for_model(Interface)
+    vminterface_ct = ContentType.objects.get_for_model(VMInterface)
+
+    # Replicate dcim.Interface instances assigned to VirtualMachines
+    original_interfaces = Interface.objects.filter(virtual_machine__isnull=False)
+    for interface in original_interfaces:
+        vminterface = VMInterface(
+            virtual_machine=interface.virtual_machine,
+            name=interface.name,
+            enabled=interface.enabled,
+            mac_address=interface.mac_address,
+            mtu=interface.mtu,
+            mode=interface.mode,
+            description=interface.description,
+            untagged_vlan=interface.untagged_vlan,
+        )
+        vminterface.save()
+
+        # Copy tagged VLANs
+        vminterface.tagged_vlans.set(interface.tagged_vlans.all())
+
+        # Reassign tags to the new instance
+        TaggedItem.objects.filter(
+            content_type=interface_ct, object_id=interface.pk
+        ).update(
+            content_type=vminterface_ct, object_id=vminterface.pk
+        )
+
+        # Update any assigned IPAddresses
+        IPAddress.objects.filter(assigned_object_id=interface.pk).update(
+            assigned_object_type=vminterface_ct,
+            assigned_object_id=vminterface.pk
+        )
+
+    replicated_count = VMInterface.objects.count()
+    if 'test' not in sys.argv:
+        print(f"\n    Replicated {replicated_count} interfaces ", end='', flush=True)
+
+    # Verify that all interfaces have been replicated
+    assert replicated_count == original_interfaces.count(), "Replicated interfaces count does not match original count!"
+
+    # Delete original VM interfaces
+    original_interfaces.delete()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ipam', '0037_ipaddress_assignment'),
+        ('virtualization', '0015_vminterface'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=replicate_interfaces
+        ),
+    ]

+ 114 - 2
netbox/virtualization/models.py

@@ -5,11 +5,14 @@ from django.db import models
 from django.urls import reverse
 from django.urls import reverse
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 
 
-from dcim.models import Device
-from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
+from dcim.choices import InterfaceModeChoices
+from dcim.models import BaseInterface, Device
+from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
 from extras.utils import extras_features
 from extras.utils import extras_features
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
+from utilities.query_functions import CollateAsChar
 from utilities.querysets import RestrictedQuerySet
 from utilities.querysets import RestrictedQuerySet
+from utilities.utils import serialize_object
 from .choices import *
 from .choices import *
 
 
 
 
@@ -18,6 +21,7 @@ __all__ = (
     'ClusterGroup',
     'ClusterGroup',
     'ClusterType',
     'ClusterType',
     'VirtualMachine',
     'VirtualMachine',
+    'VMInterface',
 )
 )
 
 
 
 
@@ -370,3 +374,111 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     @property
     @property
     def site(self):
     def site(self):
         return self.cluster.site
         return self.cluster.site
+
+
+#
+# Interfaces
+#
+
+@extras_features('graphs', 'export_templates', 'webhooks')
+class VMInterface(BaseInterface):
+    virtual_machine = models.ForeignKey(
+        to='virtualization.VirtualMachine',
+        on_delete=models.CASCADE,
+        related_name='interfaces'
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True
+    )
+    untagged_vlan = models.ForeignKey(
+        to='ipam.VLAN',
+        on_delete=models.SET_NULL,
+        related_name='vminterfaces_as_untagged',
+        null=True,
+        blank=True,
+        verbose_name='Untagged VLAN'
+    )
+    tagged_vlans = models.ManyToManyField(
+        to='ipam.VLAN',
+        related_name='vminterfaces_as_tagged',
+        blank=True,
+        verbose_name='Tagged VLANs'
+    )
+    ip_addresses = GenericRelation(
+        to='ipam.IPAddress',
+        content_type_field='assigned_object_type',
+        object_id_field='assigned_object_id',
+        related_query_name='vminterface'
+    )
+    tags = TaggableManager(
+        through=TaggedItem,
+        related_name='vminterface'
+    )
+
+    objects = RestrictedQuerySet.as_manager()
+
+    csv_headers = [
+        'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
+    ]
+
+    class Meta:
+        verbose_name = 'interface'
+        ordering = ('virtual_machine', CollateAsChar('_name'))
+        unique_together = ('virtual_machine', 'name')
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('virtualization:vminterface', kwargs={'pk': self.pk})
+
+    def to_csv(self):
+        return (
+            self.virtual_machine.name,
+            self.name,
+            self.enabled,
+            self.mac_address,
+            self.mtu,
+            self.description,
+            self.get_mode_display(),
+        )
+
+    def clean(self):
+
+        # Validate untagged VLAN
+        if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
+            raise ValidationError({
+                'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
+                                 "virtual machine, or it must be global".format(self.untagged_vlan)
+            })
+
+    def save(self, *args, **kwargs):
+
+        # Remove untagged VLAN assignment for non-802.1Q interfaces
+        if self.mode is None:
+            self.untagged_vlan = None
+
+        # Only "tagged" interfaces may have tagged VLANs assigned. ("tagged all" implies all VLANs are assigned.)
+        if self.pk and self.mode != InterfaceModeChoices.MODE_TAGGED:
+            self.tagged_vlans.clear()
+
+        return super().save(*args, **kwargs)
+
+    def to_objectchange(self, action):
+        # Annotate the parent VirtualMachine
+        return ObjectChange(
+            changed_object=self,
+            object_repr=str(self),
+            action=action,
+            related_object=self.virtual_machine,
+            object_data=serialize_object(self)
+        )
+
+    @property
+    def parent(self):
+        return self.virtual_machine
+
+    @property
+    def count_ipaddresses(self):
+        return self.ip_addresses.count()

+ 8 - 5
netbox/virtualization/tables.py

@@ -1,10 +1,9 @@
 import django_tables2 as tables
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
-from dcim.models import Interface
 from tenancy.tables import COL_TENANT
 from tenancy.tables import COL_TENANT
 from utilities.tables import BaseTable, ColoredLabelColumn, TagColumn, ToggleColumn
 from utilities.tables import BaseTable, ColoredLabelColumn, TagColumn, ToggleColumn
-from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
 CLUSTERTYPE_ACTIONS = """
 CLUSTERTYPE_ACTIONS = """
 <a href="{% url 'virtualization:clustertype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
 <a href="{% url 'virtualization:clustertype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
@@ -173,8 +172,12 @@ class VirtualMachineDetailTable(VirtualMachineTable):
 # VM components
 # VM components
 #
 #
 
 
-class InterfaceTable(BaseTable):
+class VMInterfaceTable(BaseTable):
+    virtual_machine = tables.LinkColumn()
+    name = tables.Column(
+        linkify=True
+    )
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
-        model = Interface
-        fields = ('name', 'enabled', 'description')
+        model = VMInterface
+        fields = ('virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description')

+ 34 - 39
netbox/virtualization/tests/test_api.py

@@ -2,11 +2,9 @@ from django.urls import reverse
 from rest_framework import status
 from rest_framework import status
 
 
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
-from dcim.models import Interface
 from ipam.models import VLAN
 from ipam.models import VLAN
 from utilities.testing import APITestCase, APIViewTestCases
 from utilities.testing import APITestCase, APIViewTestCases
-from virtualization.choices import *
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
 
 
 class AppTest(APITestCase):
 class AppTest(APITestCase):
@@ -196,7 +194,7 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
 
 
 
 
 # TODO: Standardize InterfaceTest (pending #4721)
 # TODO: Standardize InterfaceTest (pending #4721)
-class InterfaceTest(APITestCase):
+class VMInterfaceTest(APITestCase):
 
 
     def setUp(self):
     def setUp(self):
 
 
@@ -205,20 +203,17 @@ class InterfaceTest(APITestCase):
         clustertype = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
         clustertype = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
         cluster = Cluster.objects.create(name='Test Cluster 1', type=clustertype)
         cluster = Cluster.objects.create(name='Test Cluster 1', type=clustertype)
         self.virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1')
         self.virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1')
-        self.interface1 = Interface.objects.create(
+        self.interface1 = VMInterface.objects.create(
             virtual_machine=self.virtualmachine,
             virtual_machine=self.virtualmachine,
-            name='Test Interface 1',
-            type=InterfaceTypeChoices.TYPE_VIRTUAL
+            name='Test Interface 1'
         )
         )
-        self.interface2 = Interface.objects.create(
+        self.interface2 = VMInterface.objects.create(
             virtual_machine=self.virtualmachine,
             virtual_machine=self.virtualmachine,
-            name='Test Interface 2',
-            type=InterfaceTypeChoices.TYPE_VIRTUAL
+            name='Test Interface 2'
         )
         )
-        self.interface3 = Interface.objects.create(
+        self.interface3 = VMInterface.objects.create(
             virtual_machine=self.virtualmachine,
             virtual_machine=self.virtualmachine,
-            name='Test Interface 3',
-            type=InterfaceTypeChoices.TYPE_VIRTUAL
+            name='Test Interface 3'
         )
         )
 
 
         self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1)
         self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1)
@@ -226,22 +221,22 @@ class InterfaceTest(APITestCase):
         self.vlan3 = VLAN.objects.create(name="Test VLAN 3", vid=3)
         self.vlan3 = VLAN.objects.create(name="Test VLAN 3", vid=3)
 
 
     def test_get_interface(self):
     def test_get_interface(self):
-        url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
-        self.add_permissions('dcim.view_interface')
+        url = reverse('virtualization-api:vminterface-detail', kwargs={'pk': self.interface1.pk})
+        self.add_permissions('virtualization.view_vminterface')
 
 
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
         self.assertEqual(response.data['name'], self.interface1.name)
         self.assertEqual(response.data['name'], self.interface1.name)
 
 
     def test_list_interfaces(self):
     def test_list_interfaces(self):
-        url = reverse('virtualization-api:interface-list')
-        self.add_permissions('dcim.view_interface')
+        url = reverse('virtualization-api:vminterface-list')
+        self.add_permissions('virtualization.view_vminterface')
 
 
         response = self.client.get(url, **self.header)
         response = self.client.get(url, **self.header)
         self.assertEqual(response.data['count'], 3)
         self.assertEqual(response.data['count'], 3)
 
 
     def test_list_interfaces_brief(self):
     def test_list_interfaces_brief(self):
-        url = reverse('virtualization-api:interface-list')
-        self.add_permissions('dcim.view_interface')
+        url = reverse('virtualization-api:vminterface-list')
+        self.add_permissions('virtualization.view_vminterface')
 
 
         response = self.client.get('{}?brief=1'.format(url), **self.header)
         response = self.client.get('{}?brief=1'.format(url), **self.header)
         self.assertEqual(
         self.assertEqual(
@@ -254,13 +249,13 @@ class InterfaceTest(APITestCase):
             'virtual_machine': self.virtualmachine.pk,
             'virtual_machine': self.virtualmachine.pk,
             'name': 'Test Interface 4',
             'name': 'Test Interface 4',
         }
         }
-        url = reverse('virtualization-api:interface-list')
-        self.add_permissions('dcim.add_interface')
+        url = reverse('virtualization-api:vminterface-list')
+        self.add_permissions('virtualization.add_vminterface')
 
 
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(Interface.objects.count(), 4)
-        interface4 = Interface.objects.get(pk=response.data['id'])
+        self.assertEqual(VMInterface.objects.count(), 4)
+        interface4 = VMInterface.objects.get(pk=response.data['id'])
         self.assertEqual(interface4.virtual_machine_id, data['virtual_machine'])
         self.assertEqual(interface4.virtual_machine_id, data['virtual_machine'])
         self.assertEqual(interface4.name, data['name'])
         self.assertEqual(interface4.name, data['name'])
 
 
@@ -272,12 +267,12 @@ class InterfaceTest(APITestCase):
             'untagged_vlan': self.vlan3.id,
             'untagged_vlan': self.vlan3.id,
             'tagged_vlans': [self.vlan1.id, self.vlan2.id],
             'tagged_vlans': [self.vlan1.id, self.vlan2.id],
         }
         }
-        url = reverse('virtualization-api:interface-list')
-        self.add_permissions('dcim.add_interface')
+        url = reverse('virtualization-api:vminterface-list')
+        self.add_permissions('virtualization.add_vminterface')
 
 
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(Interface.objects.count(), 4)
+        self.assertEqual(VMInterface.objects.count(), 4)
         self.assertEqual(response.data['virtual_machine']['id'], data['virtual_machine'])
         self.assertEqual(response.data['virtual_machine']['id'], data['virtual_machine'])
         self.assertEqual(response.data['name'], data['name'])
         self.assertEqual(response.data['name'], data['name'])
         self.assertEqual(response.data['untagged_vlan']['id'], data['untagged_vlan'])
         self.assertEqual(response.data['untagged_vlan']['id'], data['untagged_vlan'])
@@ -298,12 +293,12 @@ class InterfaceTest(APITestCase):
                 'name': 'Test Interface 6',
                 'name': 'Test Interface 6',
             },
             },
         ]
         ]
-        url = reverse('virtualization-api:interface-list')
-        self.add_permissions('dcim.add_interface')
+        url = reverse('virtualization-api:vminterface-list')
+        self.add_permissions('virtualization.add_vminterface')
 
 
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(Interface.objects.count(), 6)
+        self.assertEqual(VMInterface.objects.count(), 6)
         self.assertEqual(response.data[0]['name'], data[0]['name'])
         self.assertEqual(response.data[0]['name'], data[0]['name'])
         self.assertEqual(response.data[1]['name'], data[1]['name'])
         self.assertEqual(response.data[1]['name'], data[1]['name'])
         self.assertEqual(response.data[2]['name'], data[2]['name'])
         self.assertEqual(response.data[2]['name'], data[2]['name'])
@@ -332,12 +327,12 @@ class InterfaceTest(APITestCase):
                 'tagged_vlans': [self.vlan1.id],
                 'tagged_vlans': [self.vlan1.id],
             },
             },
         ]
         ]
-        url = reverse('virtualization-api:interface-list')
-        self.add_permissions('dcim.add_interface')
+        url = reverse('virtualization-api:vminterface-list')
+        self.add_permissions('virtualization.add_vminterface')
 
 
         response = self.client.post(url, data, format='json', **self.header)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(Interface.objects.count(), 6)
+        self.assertEqual(VMInterface.objects.count(), 6)
         for i in range(0, 3):
         for i in range(0, 3):
             self.assertEqual(response.data[i]['name'], data[i]['name'])
             self.assertEqual(response.data[i]['name'], data[i]['name'])
             self.assertEqual([v['id'] for v in response.data[i]['tagged_vlans']], data[i]['tagged_vlans'])
             self.assertEqual([v['id'] for v in response.data[i]['tagged_vlans']], data[i]['tagged_vlans'])
@@ -348,19 +343,19 @@ class InterfaceTest(APITestCase):
             'virtual_machine': self.virtualmachine.pk,
             'virtual_machine': self.virtualmachine.pk,
             'name': 'Test Interface X',
             'name': 'Test Interface X',
         }
         }
-        url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
-        self.add_permissions('dcim.change_interface')
+        url = reverse('virtualization-api:vminterface-detail', kwargs={'pk': self.interface1.pk})
+        self.add_permissions('virtualization.change_vminterface')
 
 
         response = self.client.put(url, data, format='json', **self.header)
         response = self.client.put(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
-        self.assertEqual(Interface.objects.count(), 3)
-        interface1 = Interface.objects.get(pk=response.data['id'])
+        self.assertEqual(VMInterface.objects.count(), 3)
+        interface1 = VMInterface.objects.get(pk=response.data['id'])
         self.assertEqual(interface1.name, data['name'])
         self.assertEqual(interface1.name, data['name'])
 
 
     def test_delete_interface(self):
     def test_delete_interface(self):
-        url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
-        self.add_permissions('dcim.delete_interface')
+        url = reverse('virtualization-api:vminterface-detail', kwargs={'pk': self.interface1.pk})
+        self.add_permissions('virtualization.delete_vminterface')
 
 
         response = self.client.delete(url, **self.header)
         response = self.client.delete(url, **self.header)
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
-        self.assertEqual(Interface.objects.count(), 2)
+        self.assertEqual(VMInterface.objects.count(), 2)

+ 13 - 13
netbox/virtualization/tests/test_filters.py

@@ -1,10 +1,10 @@
 from django.test import TestCase
 from django.test import TestCase
 
 
-from dcim.models import DeviceRole, Interface, Platform, Region, Site
+from dcim.models import DeviceRole, Platform, Region, Site
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from virtualization.choices import *
 from virtualization.choices import *
 from virtualization.filters import *
 from virtualization.filters import *
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
 
 
 class ClusterTypeTestCase(TestCase):
 class ClusterTypeTestCase(TestCase):
@@ -260,11 +260,11 @@ class VirtualMachineTestCase(TestCase):
         VirtualMachine.objects.bulk_create(vms)
         VirtualMachine.objects.bulk_create(vms)
 
 
         interfaces = (
         interfaces = (
-            Interface(virtual_machine=vms[0], name='Interface 1', mac_address='00-00-00-00-00-01'),
-            Interface(virtual_machine=vms[1], name='Interface 2', mac_address='00-00-00-00-00-02'),
-            Interface(virtual_machine=vms[2], name='Interface 3', mac_address='00-00-00-00-00-03'),
+            VMInterface(virtual_machine=vms[0], name='Interface 1', mac_address='00-00-00-00-00-01'),
+            VMInterface(virtual_machine=vms[1], name='Interface 2', mac_address='00-00-00-00-00-02'),
+            VMInterface(virtual_machine=vms[2], name='Interface 3', mac_address='00-00-00-00-00-03'),
         )
         )
-        Interface.objects.bulk_create(interfaces)
+        VMInterface.objects.bulk_create(interfaces)
 
 
     def test_id(self):
     def test_id(self):
         params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
         params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
@@ -365,9 +365,9 @@ class VirtualMachineTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
-class InterfaceTestCase(TestCase):
-    queryset = Interface.objects.all()
-    filterset = InterfaceFilterSet
+class VMInterfaceTestCase(TestCase):
+    queryset = VMInterface.objects.all()
+    filterset = VMInterfaceFilterSet
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -394,11 +394,11 @@ class InterfaceTestCase(TestCase):
         VirtualMachine.objects.bulk_create(vms)
         VirtualMachine.objects.bulk_create(vms)
 
 
         interfaces = (
         interfaces = (
-            Interface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01'),
-            Interface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02'),
-            Interface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03'),
+            VMInterface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01'),
+            VMInterface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02'),
+            VMInterface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03'),
         )
         )
-        Interface.objects.bulk_create(interfaces)
+        VMInterface.objects.bulk_create(interfaces)
 
 
     def test_id(self):
     def test_id(self):
         id_list = self.queryset.values_list('id', flat=True)[:2]
         id_list = self.queryset.values_list('id', flat=True)[:2]

+ 18 - 32
netbox/virtualization/tests/test_views.py

@@ -1,11 +1,11 @@
 from netaddr import EUI
 from netaddr import EUI
 
 
 from dcim.choices import InterfaceModeChoices
 from dcim.choices import InterfaceModeChoices
-from dcim.models import DeviceRole, Interface, Platform, Site
+from dcim.models import DeviceRole, Platform, Site
 from ipam.models import VLAN
 from ipam.models import VLAN
 from utilities.testing import ViewTestCases
 from utilities.testing import ViewTestCases
 from virtualization.choices import *
 from virtualization.choices import *
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
 
 
 class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@@ -189,21 +189,11 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
 
 
-# TODO: Update base class to DeviceComponentViewTestCase
-# Blocked by #4721
-class InterfaceTestCase(
+class VMInterfaceTestCase(
     ViewTestCases.GetObjectViewTestCase,
     ViewTestCases.GetObjectViewTestCase,
-    ViewTestCases.EditObjectViewTestCase,
-    ViewTestCases.DeleteObjectViewTestCase,
-    ViewTestCases.BulkCreateObjectsViewTestCase,
-    ViewTestCases.BulkEditObjectsViewTestCase,
-    ViewTestCases.BulkDeleteObjectsViewTestCase,
+    ViewTestCases.DeviceComponentViewTestCase,
 ):
 ):
-    model = Interface
-
-    def _get_base_url(self):
-        # Interface belongs to the DCIM app, so we have to override the base URL
-        return 'virtualization:interface_{}'
+    model = VMInterface
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
@@ -218,10 +208,10 @@ class InterfaceTestCase(
         )
         )
         VirtualMachine.objects.bulk_create(virtualmachines)
         VirtualMachine.objects.bulk_create(virtualmachines)
 
 
-        Interface.objects.bulk_create([
-            Interface(virtual_machine=virtualmachines[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL),
-            Interface(virtual_machine=virtualmachines[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL),
-            Interface(virtual_machine=virtualmachines[0], name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+        VMInterface.objects.bulk_create([
+            VMInterface(virtual_machine=virtualmachines[0], name='Interface 1'),
+            VMInterface(virtual_machine=virtualmachines[0], name='Interface 2'),
+            VMInterface(virtual_machine=virtualmachines[0], name='Interface 3'),
         ])
         ])
 
 
         vlans = (
         vlans = (
@@ -237,9 +227,7 @@ class InterfaceTestCase(
         cls.form_data = {
         cls.form_data = {
             'virtual_machine': virtualmachines[1].pk,
             'virtual_machine': virtualmachines[1].pk,
             'name': 'Interface X',
             'name': 'Interface X',
-            'type': InterfaceTypeChoices.TYPE_VIRTUAL,
             'enabled': False,
             'enabled': False,
-            'mgmt_only': False,
             'mac_address': EUI('01-02-03-04-05-06'),
             'mac_address': EUI('01-02-03-04-05-06'),
             'mtu': 2000,
             'mtu': 2000,
             'description': 'New description',
             'description': 'New description',
@@ -252,9 +240,7 @@ class InterfaceTestCase(
         cls.bulk_create_data = {
         cls.bulk_create_data = {
             'virtual_machine': virtualmachines[1].pk,
             'virtual_machine': virtualmachines[1].pk,
             'name_pattern': 'Interface [4-6]',
             'name_pattern': 'Interface [4-6]',
-            'type': InterfaceTypeChoices.TYPE_VIRTUAL,
             'enabled': False,
             'enabled': False,
-            'mgmt_only': False,
             'mac_address': EUI('01-02-03-04-05-06'),
             'mac_address': EUI('01-02-03-04-05-06'),
             'mtu': 2000,
             'mtu': 2000,
             'description': 'New description',
             'description': 'New description',
@@ -264,19 +250,19 @@ class InterfaceTestCase(
             'tags': [t.pk for t in tags],
             'tags': [t.pk for t in tags],
         }
         }
 
 
+        cls.csv_data = (
+            "virtual_machine,name",
+            "Virtual Machine 2,Interface 4",
+            "Virtual Machine 2,Interface 5",
+            "Virtual Machine 2,Interface 6",
+        )
+
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
             'virtual_machine': virtualmachines[1].pk,
             'virtual_machine': virtualmachines[1].pk,
             'enabled': False,
             'enabled': False,
             'mtu': 2000,
             'mtu': 2000,
             'description': 'New description',
             'description': 'New description',
             'mode': InterfaceModeChoices.MODE_TAGGED,
             'mode': InterfaceModeChoices.MODE_TAGGED,
-            # 'untagged_vlan': vlans[0].pk,
-            # 'tagged_vlans': [v.pk for v in vlans[1:4]],
+            'untagged_vlan': vlans[0].pk,
+            'tagged_vlans': [v.pk for v in vlans[1:4]],
         }
         }
-
-        cls.csv_data = (
-            "device,name,type",
-            "Device 1,Interface 4,1000BASE-T (1GE)",
-            "Device 1,Interface 5,1000BASE-T (1GE)",
-            "Device 1,Interface 6,1000BASE-T (1GE)",
-        )

+ 12 - 7
netbox/virtualization/urls.py

@@ -3,7 +3,7 @@ from django.urls import path
 from extras.views import ObjectChangeLogView
 from extras.views import ObjectChangeLogView
 from ipam.views import ServiceEditView
 from ipam.views import ServiceEditView
 from . import views
 from . import views
-from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
 app_name = 'virtualization'
 app_name = 'virtualization'
 urlpatterns = [
 urlpatterns = [
@@ -51,11 +51,16 @@ urlpatterns = [
     path('virtual-machines/<int:virtualmachine>/services/assign/', ServiceEditView.as_view(), name='virtualmachine_service_assign'),
     path('virtual-machines/<int:virtualmachine>/services/assign/', ServiceEditView.as_view(), name='virtualmachine_service_assign'),
 
 
     # VM interfaces
     # VM interfaces
-    path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
-    path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
-    path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
-    path('interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
-    path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
-    path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'),
+    path('interfaces/', views.VMInterfaceListView.as_view(), name='vminterface_list'),
+    path('interfaces/add/', views.VMInterfaceCreateView.as_view(), name='vminterface_add'),
+    path('interfaces/import/', views.VMInterfaceBulkImportView.as_view(), name='vminterface_import'),
+    path('interfaces/edit/', views.VMInterfaceBulkEditView.as_view(), name='vminterface_bulk_edit'),
+    path('interfaces/rename/', views.VMInterfaceBulkRenameView.as_view(), name='vminterface_bulk_rename'),
+    path('interfaces/delete/', views.VMInterfaceBulkDeleteView.as_view(), name='vminterface_bulk_delete'),
+    path('interfaces/<int:pk>/', views.VMInterfaceView.as_view(), name='vminterface'),
+    path('interfaces/<int:pk>/edit/', views.VMInterfaceEditView.as_view(), name='vminterface_edit'),
+    path('interfaces/<int:pk>/delete/', views.VMInterfaceDeleteView.as_view(), name='vminterface_delete'),
+    path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vminterface_changelog', kwargs={'model': VMInterface}),
+    path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'),
 
 
 ]
 ]

+ 81 - 25
netbox/virtualization/views.py

@@ -4,16 +4,17 @@ from django.db.models import Count
 from django.shortcuts import get_object_or_404, redirect, render
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 
 
-from dcim.models import Device, Interface
+from dcim.models import Device
 from dcim.tables import DeviceTable
 from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
 from extras.views import ObjectConfigContextView
 from ipam.models import Service
 from ipam.models import Service
+from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
 from utilities.views import (
 from utilities.views import (
-    BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectView,
-    ObjectDeleteView, ObjectEditView, ObjectListView,
+    BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, BulkRenameView, ComponentCreateView,
+    ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 )
 from . import filters, forms, tables
 from . import filters, forms, tables
-from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 
 
 
 
 #
 #
@@ -235,7 +236,7 @@ class VirtualMachineView(ObjectView):
     def get(self, request, pk):
     def get(self, request, pk):
 
 
         virtualmachine = get_object_or_404(self.queryset, pk=pk)
         virtualmachine = get_object_or_404(self.queryset, pk=pk)
-        interfaces = Interface.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine)
+        interfaces = VMInterface.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine)
         services = Service.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine)
         services = Service.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine)
 
 
         return render(request, 'virtualization/virtualmachine.html', {
         return render(request, 'virtualization/virtualmachine.html', {
@@ -288,32 +289,87 @@ class VirtualMachineBulkDeleteView(BulkDeleteView):
 # VM interfaces
 # VM interfaces
 #
 #
 
 
-class InterfaceCreateView(ComponentCreateView):
-    queryset = Interface.objects.all()
-    form = forms.InterfaceCreateForm
-    model_form = forms.InterfaceForm
+class VMInterfaceListView(ObjectListView):
+    queryset = VMInterface.objects.prefetch_related('virtual_machine')
+    filterset = filters.VMInterfaceFilterSet
+    filterset_form = forms.VMInterfaceFilterForm
+    table = tables.VMInterfaceTable
+    action_buttons = ('export',)
+
+
+class VMInterfaceView(ObjectView):
+    queryset = VMInterface.objects.all()
+
+    def get(self, request, pk):
+
+        vminterface = get_object_or_404(self.queryset, pk=pk)
+
+        # Get assigned IP addresses
+        ipaddress_table = InterfaceIPAddressTable(
+            data=vminterface.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
+            orderable=False
+        )
+
+        # Get assigned VLANs and annotate whether each is tagged or untagged
+        vlans = []
+        if vminterface.untagged_vlan is not None:
+            vlans.append(vminterface.untagged_vlan)
+            vlans[0].tagged = False
+        for vlan in vminterface.tagged_vlans.prefetch_related('site', 'group', 'tenant', 'role'):
+            vlan.tagged = True
+            vlans.append(vlan)
+        vlan_table = InterfaceVLANTable(
+            interface=vminterface,
+            data=vlans,
+            orderable=False
+        )
+
+        return render(request, 'virtualization/vminterface.html', {
+            'vminterface': vminterface,
+            'ipaddress_table': ipaddress_table,
+            'vlan_table': vlan_table,
+        })
+
+
+# TODO: This should not use ComponentCreateView
+class VMInterfaceCreateView(ComponentCreateView):
+    queryset = VMInterface.objects.all()
+    form = forms.VMInterfaceCreateForm
+    model_form = forms.VMInterfaceForm
     template_name = 'virtualization/virtualmachine_component_add.html'
     template_name = 'virtualization/virtualmachine_component_add.html'
 
 
 
 
-class InterfaceEditView(ObjectEditView):
-    queryset = Interface.objects.all()
-    model_form = forms.InterfaceForm
-    template_name = 'virtualization/interface_edit.html'
+class VMInterfaceEditView(ObjectEditView):
+    queryset = VMInterface.objects.all()
+    model_form = forms.VMInterfaceForm
+    template_name = 'virtualization/vminterface_edit.html'
+
+
+class VMInterfaceDeleteView(ObjectDeleteView):
+    queryset = VMInterface.objects.all()
+
+
+class VMInterfaceBulkImportView(BulkImportView):
+    queryset = VMInterface.objects.all()
+    model_form = forms.VMInterfaceCSVForm
+    table = tables.VMInterfaceTable
+    default_return_url = 'virtualization:vminterface_list'
 
 
 
 
-class InterfaceDeleteView(ObjectDeleteView):
-    queryset = Interface.objects.all()
+class VMInterfaceBulkEditView(BulkEditView):
+    queryset = VMInterface.objects.all()
+    table = tables.VMInterfaceTable
+    form = forms.VMInterfaceBulkEditForm
 
 
 
 
-class InterfaceBulkEditView(BulkEditView):
-    queryset = Interface.objects.all()
-    table = tables.InterfaceTable
-    form = forms.InterfaceBulkEditForm
+class VMInterfaceBulkRenameView(BulkRenameView):
+    queryset = VMInterface.objects.all()
+    form = forms.VMInterfaceBulkRenameForm
 
 
 
 
-class InterfaceBulkDeleteView(BulkDeleteView):
-    queryset = Interface.objects.all()
-    table = tables.InterfaceTable
+class VMInterfaceBulkDeleteView(BulkDeleteView):
+    queryset = VMInterface.objects.all()
+    table = tables.VMInterfaceTable
 
 
 
 
 #
 #
@@ -323,9 +379,9 @@ class InterfaceBulkDeleteView(BulkDeleteView):
 class VirtualMachineBulkAddInterfaceView(BulkComponentCreateView):
 class VirtualMachineBulkAddInterfaceView(BulkComponentCreateView):
     parent_model = VirtualMachine
     parent_model = VirtualMachine
     parent_field = 'virtual_machine'
     parent_field = 'virtual_machine'
-    form = forms.InterfaceBulkCreateForm
-    queryset = Interface.objects.all()
-    model_form = forms.InterfaceForm
+    form = forms.VMInterfaceBulkCreateForm
+    queryset = VMInterface.objects.all()
+    model_form = forms.VMInterfaceForm
     filterset = filters.VirtualMachineFilterSet
     filterset = filters.VirtualMachineFilterSet
     table = tables.VirtualMachineTable
     table = tables.VirtualMachineTable
     default_return_url = 'virtualization:virtualmachine_list'
     default_return_url = 'virtualization:virtualmachine_list'