Procházet zdrojové kódy

Initial work on #4721 (WIP)

Jeremy Stretch před 5 roky
rodič
revize
6cb31a274f

+ 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,
 )
 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__ = (
+    'BaseInterface',
     'Cable',
     'CableTermination',
     'ConsolePort',

+ 46 - 78
netbox/dcim/models/device_components.py

@@ -19,7 +19,6 @@ from utilities.ordering import naturalize_interface
 from utilities.querysets import RestrictedQuerySet
 from utilities.query_functions import CollateAsChar
 from utilities.utils import serialize_object
-from virtualization.choices import VMInterfaceTypeChoices
 
 
 __all__ = (
@@ -53,18 +52,12 @@ class ComponentModel(models.Model):
         return self.name
 
     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(
             changed_object=self,
             object_repr=str(self),
             action=action,
-            related_object=parent,
+            related_object=self.device,
             object_data=serialize_object(self)
         )
 
@@ -592,10 +585,44 @@ class PowerOutlet(CableTermination, ComponentModel):
 # 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')
-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
+    A network interface within a Device. A physical Interface can connect to exactly one other
     Interface.
     """
     device = models.ForeignKey(
@@ -605,22 +632,6 @@ class Interface(CableTermination, ComponentModel):
         null=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(
         max_length=64,
         blank=True,
@@ -656,30 +667,11 @@ class Interface(CableTermination, ComponentModel):
         max_length=50,
         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(
         default=False,
         verbose_name='OOB 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(
         to='ipam.VLAN',
         on_delete=models.SET_NULL,
@@ -694,15 +686,19 @@ class Interface(CableTermination, ComponentModel):
         blank=True,
         verbose_name='Tagged VLANs'
     )
+    ipaddresses = GenericRelation(
+        to='ipam.IPAddress',
+        content_type_field='assigned_object_type',
+        object_id_field='assigned_object_id'
+    )
     tags = TaggableManager(through=TaggedItem)
 
     csv_headers = [
-        'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
+        'device', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
         'description', 'mode',
     ]
 
     class Meta:
-        # TODO: ordering and unique_together should include virtual_machine
         ordering = ('device', CollateAsChar('_name'))
         unique_together = ('device', 'name')
 
@@ -712,7 +708,6 @@ class Interface(CableTermination, ComponentModel):
     def to_csv(self):
         return (
             self.device.identifier if self.device else None,
-            self.virtual_machine.name if self.virtual_machine else None,
             self.name,
             self.lag.name if self.lag else None,
             self.get_type_display(),
@@ -726,18 +721,6 @@ class Interface(CableTermination, ComponentModel):
 
     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
         if self.type in NONCONNECTABLE_IFACE_TYPES and (
                 self.cable or getattr(self, 'circuit_termination', False)
@@ -773,7 +756,7 @@ class Interface(CableTermination, ComponentModel):
         if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
             raise ValidationError({
                 '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):
@@ -788,21 +771,6 @@ class Interface(CableTermination, ComponentModel):
 
         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
     def connected_endpoint(self):
         """
@@ -841,7 +809,7 @@ class Interface(CableTermination, ComponentModel):
 
     @property
     def parent(self):
-        return self.device or self.virtual_machine
+        return self.device
 
     @property
     def is_connectable(self):

+ 4 - 13
netbox/dcim/tables.py

@@ -863,6 +863,7 @@ class DeviceImportTable(BaseTable):
 
 class DeviceComponentDetailTable(BaseTable):
     pk = ToggleColumn()
+    device = tables.LinkColumn()
     name = tables.Column(order_by=('_name',))
     cable = tables.LinkColumn()
 
@@ -881,7 +882,6 @@ class ConsolePortTable(BaseTable):
 
 
 class ConsolePortDetailTable(DeviceComponentDetailTable):
-    device = tables.LinkColumn()
 
     class Meta(DeviceComponentDetailTable.Meta, ConsolePortTable.Meta):
         pass
@@ -896,7 +896,6 @@ class ConsoleServerPortTable(BaseTable):
 
 
 class ConsoleServerPortDetailTable(DeviceComponentDetailTable):
-    device = tables.LinkColumn()
 
     class Meta(DeviceComponentDetailTable.Meta, ConsoleServerPortTable.Meta):
         pass
@@ -911,7 +910,6 @@ class PowerPortTable(BaseTable):
 
 
 class PowerPortDetailTable(DeviceComponentDetailTable):
-    device = tables.LinkColumn()
 
     class Meta(DeviceComponentDetailTable.Meta, PowerPortTable.Meta):
         pass
@@ -926,7 +924,6 @@ class PowerOutletTable(BaseTable):
 
 
 class PowerOutletDetailTable(DeviceComponentDetailTable):
-    device = tables.LinkColumn()
 
     class Meta(DeviceComponentDetailTable.Meta, PowerOutletTable.Meta):
         pass
@@ -940,14 +937,11 @@ class InterfaceTable(BaseTable):
 
 
 class InterfaceDetailTable(DeviceComponentDetailTable):
-    parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
-    name = tables.LinkColumn()
     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):
@@ -960,7 +954,6 @@ class FrontPortTable(BaseTable):
 
 
 class FrontPortDetailTable(DeviceComponentDetailTable):
-    device = tables.LinkColumn()
 
     class Meta(DeviceComponentDetailTable.Meta, FrontPortTable.Meta):
         pass
@@ -976,7 +969,6 @@ class RearPortTable(BaseTable):
 
 
 class RearPortDetailTable(DeviceComponentDetailTable):
-    device = tables.LinkColumn()
 
     class Meta(DeviceComponentDetailTable.Meta, RearPortTable.Meta):
         pass
@@ -991,7 +983,6 @@ class DeviceBayTable(BaseTable):
 
 
 class DeviceBayDetailTable(DeviceComponentDetailTable):
-    device = tables.LinkColumn()
     installed_device = tables.LinkColumn()
 
     class Meta(DeviceBayTable.Meta):

+ 1 - 1
netbox/dcim/views.py

@@ -1442,7 +1442,7 @@ class InterfaceView(ObjectView):
 
         # Get assigned IP addresses
         ipaddress_table = InterfaceIPAddressTable(
-            data=interface.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
+            data=interface.ipaddresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
             orderable=False
         )
 

+ 7 - 0
netbox/ipam/constants.py

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

+ 31 - 31
netbox/ipam/filters.py

@@ -299,37 +299,37 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
         to_field_name='rd',
         label='VRF (RD)',
     )
-    device = MultiValueCharFilter(
-        method='filter_device',
-        field_name='name',
-        label='Device (name)',
-    )
-    device_id = MultiValueNumberFilter(
-        method='filter_device',
-        field_name='pk',
-        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',
-        label='Virtual machine (name)',
-    )
-    interface = django_filters.ModelMultipleChoiceFilter(
-        field_name='interface__name',
-        queryset=Interface.objects.unrestricted(),
-        to_field_name='name',
-        label='Interface (ID)',
-    )
-    interface_id = django_filters.ModelMultipleChoiceFilter(
-        queryset=Interface.objects.unrestricted(),
-        label='Interface (ID)',
-    )
+    # device = MultiValueCharFilter(
+    #     method='filter_device',
+    #     field_name='name',
+    #     label='Device (name)',
+    # )
+    # device_id = MultiValueNumberFilter(
+    #     method='filter_device',
+    #     field_name='pk',
+    #     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',
+    #     label='Virtual machine (name)',
+    # )
+    # interface = django_filters.ModelMultipleChoiceFilter(
+    #     field_name='interface__name',
+    #     queryset=Interface.objects.unrestricted(),
+    #     to_field_name='name',
+    #     label='Interface (ID)',
+    # )
+    # interface_id = django_filters.ModelMultipleChoiceFilter(
+    #     queryset=Interface.objects.unrestricted(),
+    #     label='Interface (ID)',
+    # )
     assigned_to_interface = django_filters.BooleanFilter(
         method='_assigned_to_interface',
         label='Is assigned to an interface',

+ 6 - 4
netbox/ipam/forms.py

@@ -1,4 +1,5 @@
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 from django.core.validators import MaxValueValidator, MinValueValidator
 
 from dcim.models import Device, Interface, Rack, Region, Site
@@ -14,7 +15,7 @@ from utilities.forms import (
     ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
     BOOLEAN_WITH_BLANK_CHOICES,
 )
-from virtualization.models import VirtualMachine
+from virtualization.models import Interface as VMInterface, VirtualMachine
 from .choices import *
 from .constants import *
 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@@ -1194,13 +1195,14 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
 
         # Limit IP address choices to those assigned to interfaces of the parent device/VM
         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(
-                interface_id__in=vc_interface_ids
+                assigned_object_type=ContentType.objects.get_for_model(Interface),
+                assigned_object_id__in=self.instance.device.vc_interfaces.values('id', flat=True)
             )
         elif self.instance.virtual_machine:
             self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
-                interface__virtual_machine=self.instance.virtual_machine
+                assigned_object_type=ContentType.objects.get_for_model(VMInterface),
+                assigned_object_id__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True)
             )
         else:
             self.fields['ipaddresses'].choices = []

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

@@ -0,0 +1,35 @@
+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.AddField(
+            model_name='ipaddress',
+            name='assigned_object_type',
+            field=models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'virtualization'), ('model', 'interface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType', blank=True, null=True),
+            preserve_default=False,
+        ),
+        migrations.RunPython(
+            code=set_assigned_object_type
+        ),
+    ]

+ 15 - 2
netbox/ipam/models.py

@@ -1,6 +1,7 @@
 import netaddr
 from django.conf import settings
-from django.contrib.contenttypes.fields import GenericRelation
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError, ObjectDoesNotExist
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
@@ -606,13 +607,25 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
         blank=True,
         help_text='The functional role of this IP'
     )
-    interface = models.ForeignKey(
+    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.ForeignKey(
         to='dcim.Interface',
         on_delete=models.CASCADE,
         related_name='ip_addresses',
         blank=True,
         null=True
     )
+    assigned_object = GenericForeignKey(
+        ct_field='assigned_object_type',
+        fk_field='assigned_object_id'
+    )
     nat_inside = models.OneToOneField(
         to='self',
         on_delete=models.SET_NULL,

+ 5 - 9
netbox/ipam/tables.py

@@ -431,18 +431,14 @@ class IPAddressTable(BaseTable):
     tenant = tables.TemplateColumn(
         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):
         model = IPAddress
         fields = (
-            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description',
+            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
         )
         row_attrs = {
             'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
@@ -465,11 +461,11 @@ class IPAddressDetailTable(IPAddressTable):
 
     class Meta(IPAddressTable.Meta):
         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',
         )
         default_columns = (
-            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description',
+            'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
         )
 
 

+ 13 - 9
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.filters import *
 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, Interfaces as VMInterface, VirtualMachine
 from tenancy.models import Tenant, TenantGroup
 
 
@@ -375,6 +375,13 @@ class IPAddressTestCase(TestCase):
         )
         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')
         cluster = Cluster.objects.create(type=clustertype, name='Cluster 1')
 
@@ -385,15 +392,12 @@ class IPAddressTestCase(TestCase):
         )
         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'),
+        vm_interfaces = (
+            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(vm_interfaces)
 
         tenant_groups = (
             TenantGroup(name='Tenant group 1', slug='tenant-group-1'),

+ 4 - 0
netbox/utilities/filters.py

@@ -256,6 +256,10 @@ class BaseFilterSet(django_filters.FilterSet):
                 except django_filters.exceptions.FieldLookupError:
                     # The filter could not be created because the lookup expression is not supported on the field
                     continue
+                except Exception as e:
+                    print(existing_filter_name, existing_filter)
+                    print(f'field: {field}, lookup_expr: {lookup_expr}')
+                    raise e
 
                 if lookup_name.startswith('n'):
                     # This is a negation filter which requires a queryset.exclude() clause

+ 3 - 5
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.choices import InterfaceModeChoices
-from dcim.models import Interface
 from extras.api.customfields import CustomFieldModelSerializer
 from extras.api.serializers import TaggedObjectSerializer
 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 utilities.api import ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer
 from virtualization.choices import *
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine
 from .nested_serializers import *
 
 
@@ -97,7 +96,6 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
 
 class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
     virtual_machine = NestedVirtualMachineSerializer()
-    type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False)
     mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
     untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
@@ -110,6 +108,6 @@ class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
     class Meta:
         model = Interface
         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',
         ]

+ 2 - 2
netbox/virtualization/api/views.py

@@ -1,11 +1,11 @@
 from django.db.models import Count
 
-from dcim.models import Device, Interface
+from dcim.models import Device
 from extras.api.views import CustomFieldModelViewSet
 from utilities.api import ModelViewSet
 from utilities.utils import get_subquery
 from virtualization import filters
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine
 from . import serializers
 
 

+ 2 - 2
netbox/virtualization/filters.py

@@ -1,7 +1,7 @@
 import django_filters
 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 tenancy.filters import TenancyFilterSet
 from utilities.filters import (
@@ -9,7 +9,7 @@ from utilities.filters import (
     TreeNodeMultipleChoiceFilter,
 )
 from .choices import *
-from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine
 
 __all__ = (
     'ClusterFilterSet',

+ 23 - 18
netbox/virtualization/forms.py

@@ -1,10 +1,11 @@
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 
 from dcim.choices import InterfaceModeChoices
 from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
 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 (
     AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
 )
@@ -16,10 +17,10 @@ from utilities.forms import (
     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,
+    StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
 )
 from .choices import *
-from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine
 
 
 #
@@ -355,8 +356,11 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             for family in [4, 6]:
                 ip_choices = [(None, '---------')]
                 # Collect interface IPs
+                interface_pks = self.instance.interfaces.values_list('id', flat=True)
                 interface_ips = IPAddress.objects.prefetch_related('interface').filter(
-                    address__family=family, interface__virtual_machine=self.instance
+                    address__family=family,
+                    assigned_object_type=ContentType.objects.get_for_model(Interface),
+                    assigned_object_id__in=interface_pks
                 )
                 if interface_ips:
                     ip_choices.append(
@@ -600,12 +604,11 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = Interface
         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 = {
             'virtual_machine': forms.HiddenInput(),
-            'type': forms.HiddenInput(),
             'mode': StaticSelect2()
         }
         labels = {
@@ -619,7 +622,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
         super().__init__(*args, **kwargs)
 
         # Add current site to VLANs query params
-        site = getattr(self.instance.parent, 'site', None)
+        site = getattr(self.instance.virtual_machine, 'site', None)
         if site is not None:
             # Add current site to VLANs query params
             self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', site.pk)
@@ -650,11 +653,6 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form):
     name_pattern = ExpandableNameField(
         label='Name'
     )
-    type = forms.ChoiceField(
-        choices=VMInterfaceTypeChoices,
-        initial=VMInterfaceTypeChoices.TYPE_VIRTUAL,
-        widget=forms.HiddenInput()
-    )
     enabled = forms.BooleanField(
         required=False,
         initial=True
@@ -789,6 +787,17 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
                 self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', site.pk)
 
 
+class InterfaceFilterForm(forms.Form):
+    model = Interface
+    enabled = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect2(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    tag = TagFilterField(model)
+
+
 #
 # Bulk VirtualMachine component creation
 #
@@ -812,8 +821,4 @@ class InterfaceBulkCreateForm(
     form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']),
     VirtualMachineBulkAddComponentForm
 ):
-    type = forms.ChoiceField(
-        choices=VMInterfaceTypeChoices,
-        initial=VMInterfaceTypeChoices.TYPE_VIRTUAL,
-        widget=forms.HiddenInput()
-    )
+    pass

+ 43 - 0
netbox/virtualization/migrations/0015_interface.py

@@ -0,0 +1,43 @@
+# 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='Interface',
+            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='vm_interfaces_as_tagged', to='ipam.VLAN')),
+                ('tags', taggit.managers.TaggableManager(related_name='vm_interface', through='extras.TaggedItem', to='extras.Tag')),
+                ('untagged_vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vm_interfaces_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')},
+            },
+        ),
+    ]

+ 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('taggit', 'TaggedItem')
+    Interface = apps.get_model('dcim', 'Interface')
+    IPAddress = apps.get_model('ipam', 'IPAddress')
+    VMInterface = apps.get_model('virtualization', 'Interface')
+
+    interface_ct = ContentType.objects.get_for_model(Interface)
+    vm_interface_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:
+        vm_interface = 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,
+        )
+        vm_interface.save()
+
+        # Copy tagged VLANs
+        vm_interface.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=vm_interface_ct, object_id=vm_interface.pk
+        )
+
+        # Update any assigned IPAddresses
+        IPAddress.objects.filter(assigned_object_id=interface.pk).update(
+            assigned_object_type=vm_interface_ct,
+            assigned_object_id=vm_interface.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_interface'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=replicate_interfaces
+        ),
+    ]

+ 112 - 2
netbox/virtualization/models.py

@@ -5,11 +5,14 @@ from django.db import models
 from django.urls import reverse
 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 utilities.models import ChangeLoggedModel
+from utilities.query_functions import CollateAsChar
 from utilities.querysets import RestrictedQuerySet
+from utilities.utils import serialize_object
 from .choices import *
 
 
@@ -17,6 +20,7 @@ __all__ = (
     'Cluster',
     'ClusterGroup',
     'ClusterType',
+    'Interface',
     'VirtualMachine',
 )
 
@@ -370,3 +374,109 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     @property
     def site(self):
         return self.cluster.site
+
+
+#
+# Interfaces
+#
+
+@extras_features('graphs', 'export_templates', 'webhooks')
+class Interface(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='vm_interfaces_as_untagged',
+        null=True,
+        blank=True,
+        verbose_name='Untagged VLAN'
+    )
+    tagged_vlans = models.ManyToManyField(
+        to='ipam.VLAN',
+        related_name='vm_interfaces_as_tagged',
+        blank=True,
+        verbose_name='Tagged VLANs'
+    )
+    ipaddresses = GenericRelation(
+        to='ipam.IPAddress',
+        content_type_field='assigned_object_type',
+        object_id_field='assigned_object_id'
+    )
+    tags = TaggableManager(
+        through=TaggedItem,
+        related_name='vm_interface'
+    )
+
+    objects = RestrictedQuerySet.as_manager()
+
+    csv_headers = [
+        'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
+    ]
+
+    class Meta:
+        ordering = ('virtual_machine', CollateAsChar('_name'))
+        unique_together = ('virtual_machine', 'name')
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('virtualization:interface', 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.parent.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()

+ 1 - 2
netbox/virtualization/tables.py

@@ -1,10 +1,9 @@
 import django_tables2 as tables
 from django_tables2.utils import Accessor
 
-from dcim.models import Interface
 from tenancy.tables import COL_TENANT
 from utilities.tables import BaseTable, ColoredLabelColumn, TagColumn, ToggleColumn
-from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine
 
 CLUSTERTYPE_ACTIONS = """
 <a href="{% url 'virtualization:clustertype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">

+ 13 - 18
netbox/virtualization/tests/test_api.py

@@ -2,11 +2,9 @@ from django.urls import reverse
 from rest_framework import status
 
 from dcim.choices import InterfaceModeChoices
-from dcim.models import Interface
 from ipam.models import VLAN
 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, Interface, VirtualMachine
 
 
 class AppTest(APITestCase):
@@ -207,18 +205,15 @@ class InterfaceTest(APITestCase):
         self.virtualmachine = VirtualMachine.objects.create(cluster=cluster, name='Test VM 1')
         self.interface1 = Interface.objects.create(
             virtual_machine=self.virtualmachine,
-            name='Test Interface 1',
-            type=InterfaceTypeChoices.TYPE_VIRTUAL
+            name='Test Interface 1'
         )
         self.interface2 = Interface.objects.create(
             virtual_machine=self.virtualmachine,
-            name='Test Interface 2',
-            type=InterfaceTypeChoices.TYPE_VIRTUAL
+            name='Test Interface 2'
         )
         self.interface3 = Interface.objects.create(
             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)
@@ -227,21 +222,21 @@ class InterfaceTest(APITestCase):
 
     def test_get_interface(self):
         url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
-        self.add_permissions('dcim.view_interface')
+        self.add_permissions('virtualization.view_interface')
 
         response = self.client.get(url, **self.header)
         self.assertEqual(response.data['name'], self.interface1.name)
 
     def test_list_interfaces(self):
         url = reverse('virtualization-api:interface-list')
-        self.add_permissions('dcim.view_interface')
+        self.add_permissions('virtualization.view_interface')
 
         response = self.client.get(url, **self.header)
         self.assertEqual(response.data['count'], 3)
 
     def test_list_interfaces_brief(self):
         url = reverse('virtualization-api:interface-list')
-        self.add_permissions('dcim.view_interface')
+        self.add_permissions('virtualization.view_interface')
 
         response = self.client.get('{}?brief=1'.format(url), **self.header)
         self.assertEqual(
@@ -255,7 +250,7 @@ class InterfaceTest(APITestCase):
             'name': 'Test Interface 4',
         }
         url = reverse('virtualization-api:interface-list')
-        self.add_permissions('dcim.add_interface')
+        self.add_permissions('virtualization.add_interface')
 
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -273,7 +268,7 @@ class InterfaceTest(APITestCase):
             'tagged_vlans': [self.vlan1.id, self.vlan2.id],
         }
         url = reverse('virtualization-api:interface-list')
-        self.add_permissions('dcim.add_interface')
+        self.add_permissions('virtualization.add_interface')
 
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -299,7 +294,7 @@ class InterfaceTest(APITestCase):
             },
         ]
         url = reverse('virtualization-api:interface-list')
-        self.add_permissions('dcim.add_interface')
+        self.add_permissions('virtualization.add_interface')
 
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -333,7 +328,7 @@ class InterfaceTest(APITestCase):
             },
         ]
         url = reverse('virtualization-api:interface-list')
-        self.add_permissions('dcim.add_interface')
+        self.add_permissions('virtualization.add_interface')
 
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -349,7 +344,7 @@ class InterfaceTest(APITestCase):
             'name': 'Test Interface X',
         }
         url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
-        self.add_permissions('dcim.change_interface')
+        self.add_permissions('virtualization.change_interface')
 
         response = self.client.put(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
@@ -359,7 +354,7 @@ class InterfaceTest(APITestCase):
 
     def test_delete_interface(self):
         url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
-        self.add_permissions('dcim.delete_interface')
+        self.add_permissions('virtualization.delete_interface')
 
         response = self.client.delete(url, **self.header)
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)

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

@@ -1,10 +1,10 @@
 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 virtualization.choices import *
 from virtualization.filters import *
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine
 
 
 class ClusterTypeTestCase(TestCase):

+ 5 - 11
netbox/virtualization/tests/test_views.py

@@ -1,11 +1,11 @@
 from netaddr import EUI
 
 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 utilities.testing import ViewTestCases
 from virtualization.choices import *
-from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from virtualization.models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine
 
 
 class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@@ -201,10 +201,6 @@ class InterfaceTestCase(
 ):
     model = Interface
 
-    def _get_base_url(self):
-        # Interface belongs to the DCIM app, so we have to override the base URL
-        return 'virtualization:interface_{}'
-
     @classmethod
     def setUpTestData(cls):
 
@@ -219,9 +215,9 @@ class InterfaceTestCase(
         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),
+            Interface(virtual_machine=virtualmachines[0], name='Interface 1'),
+            Interface(virtual_machine=virtualmachines[0], name='Interface 2'),
+            Interface(virtual_machine=virtualmachines[0], name='Interface 3'),
         ])
 
         vlans = (
@@ -237,7 +233,6 @@ class InterfaceTestCase(
         cls.form_data = {
             'virtual_machine': virtualmachines[1].pk,
             'name': 'Interface X',
-            'type': InterfaceTypeChoices.TYPE_VIRTUAL,
             'enabled': False,
             'mgmt_only': False,
             'mac_address': EUI('01-02-03-04-05-06'),
@@ -252,7 +247,6 @@ class InterfaceTestCase(
         cls.bulk_create_data = {
             'virtual_machine': virtualmachines[1].pk,
             'name_pattern': 'Interface [4-6]',
-            'type': InterfaceTypeChoices.TYPE_VIRTUAL,
             'enabled': False,
             'mgmt_only': False,
             'mac_address': EUI('01-02-03-04-05-06'),

+ 1 - 0
netbox/virtualization/urls.py

@@ -54,6 +54,7 @@ urlpatterns = [
     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>/', views.InterfaceView.as_view(), name='interface'),
     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'),

+ 15 - 2
netbox/virtualization/views.py

@@ -4,7 +4,8 @@ from django.db.models import Count
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 
-from dcim.models import Device, Interface
+from dcim.models import Device
+from dcim.views import InterfaceView as DeviceInterfaceView
 from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
 from ipam.models import Service
@@ -13,7 +14,7 @@ from utilities.views import (
     ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 from . import filters, forms, tables
-from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
+from .models import Cluster, ClusterGroup, ClusterType, Interface, VirtualMachine
 
 
 #
@@ -288,6 +289,18 @@ class VirtualMachineBulkDeleteView(BulkDeleteView):
 # VM interfaces
 #
 
+class InterfaceListView(ObjectListView):
+    queryset = Interface.objects.prefetch_related('virtual_machine', 'virtual_machine__tenant', 'cable')
+    filterset = filters.InterfaceFilterSet
+    filterset_form = forms.InterfaceFilterForm
+    table = tables.InterfaceTable
+    action_buttons = ('import', 'export')
+
+
+class InterfaceView(DeviceInterfaceView):
+    queryset = Interface.objects.all()
+
+
 class InterfaceCreateView(ComponentCreateView):
     queryset = Interface.objects.all()
     form = forms.InterfaceCreateForm