Răsfoiți Sursa

Initial work on #4721 (WIP)

Jeremy Stretch 5 ani în urmă
părinte
comite
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,
     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',

+ 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.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,10 +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
+    A network interface within a Device. A physical Interface can connect to exactly one other
     Interface.
     Interface.
     """
     """
     device = models.ForeignKey(
     device = models.ForeignKey(
@@ -605,22 +632,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 +667,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 +686,19 @@ class Interface(CableTermination, ComponentModel):
         blank=True,
         blank=True,
         verbose_name='Tagged VLANs'
         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)
     tags = TaggableManager(through=TaggedItem)
 
 
     csv_headers = [
     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',
         '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 +708,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 +721,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 +756,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 +771,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 +809,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):

+ 4 - 13
netbox/dcim/tables.py

@@ -863,6 +863,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 +882,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 +896,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 +910,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 +924,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 +937,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 +954,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 +969,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 +983,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):

+ 1 - 1
netbox/dcim/views.py

@@ -1442,7 +1442,7 @@ class InterfaceView(ObjectView):
 
 
         # Get assigned IP addresses
         # Get assigned IP addresses
         ipaddress_table = InterfaceIPAddressTable(
         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
             orderable=False
         )
         )
 
 

+ 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='interface')
+)
+
 IPADDRESS_MASK_LENGTH_MIN = 1
 IPADDRESS_MASK_LENGTH_MIN = 1
 IPADDRESS_MASK_LENGTH_MAX = 128  # IPv6
 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',
         to_field_name='rd',
         label='VRF (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(
     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',

+ 6 - 4
netbox/ipam/forms.py

@@ -1,4 +1,5 @@
 from django import forms
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 
 
 from dcim.models import Device, Interface, Rack, Region, Site
 from dcim.models import Device, Interface, Rack, Region, Site
@@ -14,7 +15,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 Interface as VMInterface, VirtualMachine
 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
@@ -1194,13 +1195,14 @@ 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
+                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:
         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
+                assigned_object_type=ContentType.objects.get_for_model(VMInterface),
+                assigned_object_id__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True)
             )
             )
         else:
         else:
             self.fields['ipaddresses'].choices = []
             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
 import netaddr
 from django.conf import settings
 from django.conf import settings
-from django.contrib.contenttypes.fields import GenericRelation
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError, ObjectDoesNotExist
 from django.core.exceptions import ValidationError, ObjectDoesNotExist
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
@@ -606,13 +607,25 @@ 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(
+    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',
         to='dcim.Interface',
         on_delete=models.CASCADE,
         on_delete=models.CASCADE,
         related_name='ip_addresses',
         related_name='ip_addresses',
         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,

+ 5 - 9
netbox/ipam/tables.py

@@ -431,18 +431,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 +461,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',
         )
         )
 
 
 
 

+ 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.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, Interfaces as VMInterface, VirtualMachine
 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'),
+        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 = (
         tenant_groups = (
             TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
             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:
                 except django_filters.exceptions.FieldLookupError:
                     # The filter could not be created because the lookup expression is not supported on the field
                     # The filter could not be created because the lookup expression is not supported on the field
                     continue
                     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'):
                 if lookup_name.startswith('n'):
                     # This is a negation filter which requires a queryset.exclude() clause
                     # 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.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, Interface, VirtualMachine
 from .nested_serializers import *
 from .nested_serializers import *
 
 
 
 
@@ -97,7 +96,6 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
 
 
 class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
 class InterfaceSerializer(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(
@@ -110,6 +108,6 @@ class InterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         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',
         ]
         ]

+ 2 - 2
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, Interface, VirtualMachine
 from . import serializers
 from . import serializers
 
 
 
 

+ 2 - 2
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,7 +9,7 @@ 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, Interface, VirtualMachine
 
 
 __all__ = (
 __all__ = (
     'ClusterFilterSet',
     'ClusterFilterSet',

+ 23 - 18
netbox/virtualization/forms.py

@@ -1,10 +1,11 @@
 from django import forms
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
 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,
 )
 )
@@ -16,10 +17,10 @@ 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,
     CommentField, ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
     DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea,
     DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea,
-    StaticSelect2, StaticSelect2Multiple, TagFilterField,
+    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, Interface, VirtualMachine
 
 
 
 
 #
 #
@@ -355,8 +356,11 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             for family in [4, 6]:
             for family in [4, 6]:
                 ip_choices = [(None, '---------')]
                 ip_choices = [(None, '---------')]
                 # Collect interface IPs
                 # Collect interface IPs
+                interface_pks = self.instance.interfaces.values_list('id', flat=True)
                 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,
+                    assigned_object_type=ContentType.objects.get_for_model(Interface),
+                    assigned_object_id__in=interface_pks
                 )
                 )
                 if interface_ips:
                 if interface_ips:
                     ip_choices.append(
                     ip_choices.append(
@@ -600,12 +604,11 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = Interface
         model = Interface
         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 = {
@@ -619,7 +622,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         # Add current site to VLANs query params
         # 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:
         if site is not None:
             # Add current site to VLANs query params
             # Add current site to VLANs query params
             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)
@@ -650,11 +653,6 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form):
     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
@@ -789,6 +787,17 @@ 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 InterfaceFilterForm(forms.Form):
+    model = Interface
+    enabled = forms.NullBooleanField(
+        required=False,
+        widget=StaticSelect2(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    tag = TagFilterField(model)
+
+
 #
 #
 # Bulk VirtualMachine component creation
 # Bulk VirtualMachine component creation
 #
 #
@@ -812,8 +821,4 @@ class InterfaceBulkCreateForm(
     form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']),
     form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']),
     VirtualMachineBulkAddComponentForm
     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 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 *
 
 
 
 
@@ -17,6 +20,7 @@ __all__ = (
     'Cluster',
     'Cluster',
     'ClusterGroup',
     'ClusterGroup',
     'ClusterType',
     'ClusterType',
+    'Interface',
     'VirtualMachine',
     'VirtualMachine',
 )
 )
 
 
@@ -370,3 +374,109 @@ 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 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
 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, Interface, VirtualMachine
 
 
 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">

+ 13 - 18
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, Interface, VirtualMachine
 
 
 
 
 class AppTest(APITestCase):
 class AppTest(APITestCase):
@@ -207,18 +205,15 @@ class InterfaceTest(APITestCase):
         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 = Interface.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 = Interface.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 = Interface.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)
@@ -227,21 +222,21 @@ class InterfaceTest(APITestCase):
 
 
     def test_get_interface(self):
     def test_get_interface(self):
         url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
         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)
         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')
         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)
         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')
         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)
         response = self.client.get('{}?brief=1'.format(url), **self.header)
         self.assertEqual(
         self.assertEqual(
@@ -255,7 +250,7 @@ class InterfaceTest(APITestCase):
             'name': 'Test Interface 4',
             'name': 'Test Interface 4',
         }
         }
         url = reverse('virtualization-api:interface-list')
         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)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -273,7 +268,7 @@ class InterfaceTest(APITestCase):
             'tagged_vlans': [self.vlan1.id, self.vlan2.id],
             'tagged_vlans': [self.vlan1.id, self.vlan2.id],
         }
         }
         url = reverse('virtualization-api:interface-list')
         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)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -299,7 +294,7 @@ class InterfaceTest(APITestCase):
             },
             },
         ]
         ]
         url = reverse('virtualization-api:interface-list')
         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)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -333,7 +328,7 @@ class InterfaceTest(APITestCase):
             },
             },
         ]
         ]
         url = reverse('virtualization-api:interface-list')
         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)
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
         self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -349,7 +344,7 @@ class InterfaceTest(APITestCase):
             'name': 'Test Interface X',
             'name': 'Test Interface X',
         }
         }
         url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
         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)
         response = self.client.put(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertHttpStatus(response, status.HTTP_200_OK)
@@ -359,7 +354,7 @@ class InterfaceTest(APITestCase):
 
 
     def test_delete_interface(self):
     def test_delete_interface(self):
         url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
         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)
         response = self.client.delete(url, **self.header)
         self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
         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 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, Interface, VirtualMachine
 
 
 
 
 class ClusterTypeTestCase(TestCase):
 class ClusterTypeTestCase(TestCase):

+ 5 - 11
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, Interface, VirtualMachine
 
 
 
 
 class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
@@ -201,10 +201,6 @@ class InterfaceTestCase(
 ):
 ):
     model = Interface
     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
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
 
 
@@ -219,9 +215,9 @@ class InterfaceTestCase(
         VirtualMachine.objects.bulk_create(virtualmachines)
         VirtualMachine.objects.bulk_create(virtualmachines)
 
 
         Interface.objects.bulk_create([
         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 = (
         vlans = (
@@ -237,7 +233,6 @@ 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,
             'mgmt_only': False,
             'mac_address': EUI('01-02-03-04-05-06'),
             'mac_address': EUI('01-02-03-04-05-06'),
@@ -252,7 +247,6 @@ 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,
             'mgmt_only': False,
             'mac_address': EUI('01-02-03-04-05-06'),
             '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/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
     path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
     path('interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
     path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
     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>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
     path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
     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('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.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.views import InterfaceView as DeviceInterfaceView
 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
@@ -13,7 +14,7 @@ from utilities.views import (
     ObjectDeleteView, ObjectEditView, ObjectListView,
     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, Interface, VirtualMachine
 
 
 
 
 #
 #
@@ -288,6 +289,18 @@ class VirtualMachineBulkDeleteView(BulkDeleteView):
 # VM interfaces
 # 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):
 class InterfaceCreateView(ComponentCreateView):
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
     form = forms.InterfaceCreateForm
     form = forms.InterfaceCreateForm