Просмотр исходного кода

Deprecated the InterfaceConnection model

Jeremy Stretch 7 лет назад
Родитель
Сommit
f30367e094

+ 1 - 3
netbox/circuits/forms.py

@@ -237,9 +237,7 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
         )
     )
     interface = ChainedModelChoiceField(
-        queryset=Interface.objects.connectable().select_related(
-            'circuit_termination', 'connected_as_a', 'connected_as_b'
-        ),
+        queryset=Interface.objects.connectable().select_related('circuit_termination'),
         chains=(
             ('device', 'device'),
         ),

+ 13 - 42
netbox/dcim/api/serializers.py

@@ -6,10 +6,9 @@ from circuits.models import Circuit, CircuitTermination
 from dcim.constants import *
 from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceType, DeviceRole, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
-    InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
-    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site,
-    VirtualChassis,
+    DeviceBayTemplate, DeviceType, DeviceRole, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceTemplate,
+    Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
+    RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site, VirtualChassis,
 )
 from extras.api.customfields import CustomFieldModelSerializer
 from ipam.models import IPAddress, VLAN
@@ -614,7 +613,7 @@ class IsConnectedMixin(object):
         """
         Return True if the interface has a connected interface or circuit.
         """
-        if obj.connection:
+        if obj.connected_endpoint:
             return True
         if hasattr(obj, 'circuit_termination') and obj.circuit_termination is not None:
             return True
@@ -662,8 +661,8 @@ class InterfaceSerializer(TaggitSerializer, IsConnectedMixin, ValidatedModelSeri
     device = NestedDeviceSerializer()
     form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
+    connected_endpoint = NestedInterfaceSerializer(required=False, allow_null=True)
     is_connected = serializers.SerializerMethodField(read_only=True)
-    interface_connection = serializers.SerializerMethodField(read_only=True)
     circuit_termination = InterfaceCircuitTerminationSerializer(read_only=True)
     mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
     untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True)
@@ -679,7 +678,7 @@ class InterfaceSerializer(TaggitSerializer, IsConnectedMixin, ValidatedModelSeri
         model = Interface
         fields = [
             'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
-            'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans',
+            'is_connected', 'connected_endpoint', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans',
             'tags',
         ]
 
@@ -702,15 +701,6 @@ class InterfaceSerializer(TaggitSerializer, IsConnectedMixin, ValidatedModelSeri
 
         return super(InterfaceSerializer, self).validate(data)
 
-    def get_interface_connection(self, obj):
-        if obj.connection:
-            context = {
-                'request': self.context['request'],
-                'interface': obj.connected_interface,
-            }
-            return ContextualInterfaceConnectionSerializer(obj.connection, context=context).data
-        return None
-
 
 #
 # Rear panel ports
@@ -804,36 +794,17 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
 #
 
 class InterfaceConnectionSerializer(ValidatedModelSerializer):
-    interface_a = NestedInterfaceSerializer()
-    interface_b = NestedInterfaceSerializer()
+    interface_a = serializers.SerializerMethodField()
+    interface_b = NestedInterfaceSerializer(source='connected_endpoint')
     connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False)
 
     class Meta:
-        model = InterfaceConnection
-        fields = ['id', 'interface_a', 'interface_b', 'connection_status']
-
-
-class NestedInterfaceConnectionSerializer(WritableNestedSerializer):
-    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfaceconnection-detail')
-
-    class Meta:
-        model = InterfaceConnection
-        fields = ['id', 'url', 'connection_status']
-
-
-class ContextualInterfaceConnectionSerializer(serializers.ModelSerializer):
-    """
-    A read-only representation of an InterfaceConnection from the perspective of either of its two connected Interfaces.
-    """
-    interface = serializers.SerializerMethodField(read_only=True)
-    connection_status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, read_only=True)
-
-    class Meta:
-        model = InterfaceConnection
-        fields = ['id', 'interface', 'connection_status']
+        model = Interface
+        fields = ['interface_a', 'interface_b', 'connection_status']
 
-    def get_interface(self, obj):
-        return NestedInterfaceSerializer(self.context['interface'], context=self.context).data
+    def get_interface_a(self, obj):
+        context = {'request': self.context['request']}
+        return NestedInterfaceSerializer(instance=obj, context=context).data
 
 
 #

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

@@ -60,7 +60,7 @@ router.register(r'inventory-items', views.InventoryItemViewSet)
 # Connections
 router.register(r'console-connections', views.ConsoleConnectionViewSet, base_name='consoleconnections')
 router.register(r'power-connections', views.PowerConnectionViewSet, base_name='powerconnections')
-router.register(r'interface-connections', views.InterfaceConnectionViewSet)
+router.register(r'interface-connections', views.InterfaceConnectionViewSet, base_name='interfaceconnections')
 
 # Virtual chassis
 router.register(r'virtual-chassis', views.VirtualChassisViewSet)

+ 11 - 7
netbox/dcim/api/views.py

@@ -1,6 +1,7 @@
 from collections import OrderedDict
 
 from django.conf import settings
+from django.db.models import F
 from django.http import HttpResponseForbidden
 from django.shortcuts import get_object_or_404
 from drf_yasg import openapi
@@ -14,10 +15,9 @@ from rest_framework.viewsets import GenericViewSet, ViewSet
 from dcim import filters
 from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
-    InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
-    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site,
-    VirtualChassis,
+    DeviceBayTemplate, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceTemplate,
+    Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
+    RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site, VirtualChassis,
 )
 from extras.api.serializers import RenderedGraphSerializer
 from extras.api.views import CustomFieldModelViewSet
@@ -35,8 +35,7 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
     fields = (
         (Device, ['face', 'status']),
         (ConsolePort, ['connection_status']),
-        (Interface, ['form_factor', 'mode']),
-        (InterfaceConnection, ['connection_status']),
+        (Interface, ['connection_status', 'form_factor', 'mode']),
         (InterfaceTemplate, ['form_factor']),
         (PowerPort, ['connection_status']),
         (Rack, ['type', 'width']),
@@ -419,7 +418,12 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
 
 
 class InterfaceConnectionViewSet(ModelViewSet):
-    queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
+    queryset = Interface.objects.select_related(
+        'device', 'connected_endpoint__device'
+    ).filter(
+        connected_endpoint__isnull=False,
+        pk__lt=F('connected_endpoint')
+    )
     serializer_class = serializers.InterfaceConnectionSerializer
     filter_class = filters.InterfaceConnectionFilter
 

+ 8 - 9
netbox/dcim/filters.py

@@ -14,10 +14,9 @@ from .constants import (
 )
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
-    InterfaceTemplate, InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
-    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site,
-    VirtualChassis,
+    DeviceBayTemplate, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceTemplate,
+    InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
+    RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site, VirtualChassis,
 )
 
 
@@ -853,21 +852,21 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
     )
 
     class Meta:
-        model = InterfaceConnection
+        model = Interface
         fields = ['connection_status']
 
     def filter_site(self, queryset, name, value):
         if not value.strip():
             return queryset
         return queryset.filter(
-            Q(interface_a__device__site__slug=value) |
-            Q(interface_b__device__site__slug=value)
+            Q(device__site__slug=value) |
+            Q(connected_endpoint__device__site__slug=value)
         )
 
     def filter_device(self, queryset, name, value):
         if not value.strip():
             return queryset
         return queryset.filter(
-            Q(interface_a__device__name__icontains=value) |
-            Q(interface_b__device__name__icontains=value)
+            Q(device__name__icontains=value) |
+            Q(connected_endpoint__device__name__icontains=value)
         )

+ 0 - 153
netbox/dcim/fixtures/dcim.json

@@ -5746,158 +5746,5 @@
         "mgmt_only": true,
         "description": ""
     }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 3,
-    "fields": {
-        "interface_a": 99,
-        "interface_b": 15,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 4,
-    "fields": {
-        "interface_a": 100,
-        "interface_b": 153,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 5,
-    "fields": {
-        "interface_a": 46,
-        "interface_b": 14,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 6,
-    "fields": {
-        "interface_a": 47,
-        "interface_b": 152,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 7,
-    "fields": {
-        "interface_a": 91,
-        "interface_b": 144,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 8,
-    "fields": {
-        "interface_a": 92,
-        "interface_b": 145,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 16,
-    "fields": {
-        "interface_a": 189,
-        "interface_b": 37,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 17,
-    "fields": {
-        "interface_a": 192,
-        "interface_b": 175,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 18,
-    "fields": {
-        "interface_a": 195,
-        "interface_b": 41,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 19,
-    "fields": {
-        "interface_a": 198,
-        "interface_b": 179,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 20,
-    "fields": {
-        "interface_a": 191,
-        "interface_b": 197,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 21,
-    "fields": {
-        "interface_a": 194,
-        "interface_b": 200,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 22,
-    "fields": {
-        "interface_a": 9,
-        "interface_b": 218,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 23,
-    "fields": {
-        "interface_a": 8,
-        "interface_b": 206,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 24,
-    "fields": {
-        "interface_a": 7,
-        "interface_b": 212,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 25,
-    "fields": {
-        "interface_a": 217,
-        "interface_b": 205,
-        "connection_status": true
-    }
-},
-{
-    "model": "dcim.interfaceconnection",
-    "pk": 26,
-    "fields": {
-        "interface_a": 216,
-        "interface_b": 211,
-        "connection_status": true
-    }
 }
 ]

+ 3 - 171
netbox/dcim/forms.py

@@ -26,10 +26,9 @@ from virtualization.models import Cluster
 from .constants import *
 from .models import (
     Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
-    Device, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
-    InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
-    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site,
-    VirtualChassis,
+    Device, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceTemplate, Manufacturer,
+    InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
+    RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site, VirtualChassis,
 )
 
 DEVICE_BY_PK_RE = r'{\d+\}'
@@ -2017,173 +2016,6 @@ class InterfaceBulkDisconnectForm(ConfirmationForm):
     pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
 
 
-#
-# Interface connections
-#
-
-class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
-    interface_a = forms.ChoiceField(
-        choices=[],
-        widget=SelectWithDisabled,
-        label='Interface'
-    )
-    site_b = forms.ModelChoiceField(
-        queryset=Site.objects.all(),
-        label='Site',
-        required=False,
-        widget=forms.Select(
-            attrs={'filter-for': 'rack_b'}
-        )
-    )
-    rack_b = ChainedModelChoiceField(
-        queryset=Rack.objects.all(),
-        chains=(
-            ('site', 'site_b'),
-        ),
-        label='Rack',
-        required=False,
-        widget=APISelect(
-            api_url='/api/dcim/racks/?site_id={{site_b}}',
-            attrs={'filter-for': 'device_b', 'nullable': 'true'}
-        )
-    )
-    device_b = ChainedModelChoiceField(
-        queryset=Device.objects.all(),
-        chains=(
-            ('site', 'site_b'),
-            ('rack', 'rack_b'),
-        ),
-        label='Device',
-        required=False,
-        widget=APISelect(
-            api_url='/api/dcim/devices/?site_id={{site_b}}&rack_id={{rack_b}}',
-            display_field='display_name',
-            attrs={'filter-for': 'interface_b'}
-        )
-    )
-    livesearch = forms.CharField(
-        required=False,
-        label='Device',
-        widget=Livesearch(
-            query_key='q',
-            query_url='dcim-api:device-list',
-            field_to_update='device_b'
-        )
-    )
-    interface_b = ChainedModelChoiceField(
-        queryset=Interface.objects.connectable().select_related(
-            'circuit_termination', 'connected_as_a', 'connected_as_b'
-        ),
-        chains=(
-            ('device', 'device_b'),
-        ),
-        label='Interface',
-        widget=APISelect(
-            api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical',
-            disabled_indicator='is_connected'
-        )
-    )
-
-    class Meta:
-        model = InterfaceConnection
-        fields = ['interface_a', 'site_b', 'rack_b', 'device_b', 'interface_b', 'livesearch', 'connection_status']
-
-    def __init__(self, device_a, *args, **kwargs):
-
-        super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
-
-        # Initialize interface A choices
-        device_a_interfaces = device_a.vc_interfaces.connectable().order_naturally().select_related(
-            'circuit_termination', 'connected_as_a', 'connected_as_b'
-        )
-        self.fields['interface_a'].choices = [
-            (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces
-        ]
-
-        # Mark connected interfaces as disabled
-        if self.data.get('device_b'):
-            self.fields['interface_b'].choices = []
-            for iface in self.fields['interface_b'].queryset:
-                self.fields['interface_b'].choices.append(
-                    (iface.id, {'label': iface.name, 'disabled': iface.is_connected})
-                )
-
-
-class InterfaceConnectionCSVForm(forms.ModelForm):
-    device_a = FlexibleModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        help_text='Name or ID of device A',
-        error_messages={'invalid_choice': 'Device A not found.'}
-    )
-    interface_a = forms.CharField(
-        help_text='Name of interface A'
-    )
-    device_b = FlexibleModelChoiceField(
-        queryset=Device.objects.all(),
-        to_field_name='name',
-        help_text='Name or ID of device B',
-        error_messages={'invalid_choice': 'Device B not found.'}
-    )
-    interface_b = forms.CharField(
-        help_text='Name of interface B'
-    )
-    connection_status = CSVChoiceField(
-        choices=CONNECTION_STATUS_CHOICES,
-        help_text='Connection status'
-    )
-
-    class Meta:
-        model = InterfaceConnection
-        fields = InterfaceConnection.csv_headers
-
-    def clean_interface_a(self):
-
-        interface_name = self.cleaned_data.get('interface_a')
-        if not interface_name:
-            return None
-
-        try:
-            # Retrieve interface by name
-            interface = Interface.objects.get(
-                device=self.cleaned_data['device_a'], name=interface_name
-            )
-            # Check for an existing connection to this interface
-            if InterfaceConnection.objects.filter(Q(interface_a=interface) | Q(interface_b=interface)).count():
-                raise forms.ValidationError("{} {} is already connected".format(
-                    self.cleaned_data['device_a'], interface_name
-                ))
-        except Interface.DoesNotExist:
-            raise forms.ValidationError("Invalid interface ({} {})".format(
-                self.cleaned_data['device_a'], interface_name
-            ))
-
-        return interface
-
-    def clean_interface_b(self):
-
-        interface_name = self.cleaned_data.get('interface_b')
-        if not interface_name:
-            return None
-
-        try:
-            # Retrieve interface by name
-            interface = Interface.objects.get(
-                device=self.cleaned_data['device_b'], name=interface_name
-            )
-            # Check for an existing connection to this interface
-            if InterfaceConnection.objects.filter(Q(interface_a=interface) | Q(interface_b=interface)).count():
-                raise forms.ValidationError("{} {} is already connected".format(
-                    self.cleaned_data['device_b'], interface_name
-                ))
-        except Interface.DoesNotExist:
-            raise forms.ValidationError("Invalid interface ({} {})".format(
-                self.cleaned_data['device_b'], interface_name
-            ))
-
-        return interface
-
-
 #
 # Front panel ports
 #

+ 49 - 0
netbox/dcim/migrations/0066_cables.py

@@ -17,6 +17,7 @@ def console_connections_to_cables(apps, schema_editor):
     consoleserverport_type = ContentType.objects.get_for_model(ConsoleServerPort)
 
     # Create a new Cable instance from each console connection
+    print("\n    Adding console connections... ", end='', flush=True)
     for consoleport in ConsolePort.objects.filter(connected_endpoint__isnull=False):
         c = Cable()
         # We have to assign GFK fields manually because we're inside a migration.
@@ -27,6 +28,9 @@ def console_connections_to_cables(apps, schema_editor):
         c.connection_status = consoleport.connection_status
         c.save()
 
+    cable_count = Cable.objects.filter(endpoint_a_type=consoleport_type).count()
+    print("{} cables created".format(cable_count))
+
 
 def power_connections_to_cables(apps, schema_editor):
     """
@@ -42,6 +46,7 @@ def power_connections_to_cables(apps, schema_editor):
     poweroutlet_type = ContentType.objects.get_for_model(PowerOutlet)
 
     # Create a new Cable instance from each power connection
+    print("    Adding power connections... ", end='', flush=True)
     for powerport in PowerPort.objects.filter(connected_endpoint__isnull=False):
         c = Cable()
         # We have to assign GFK fields manually because we're inside a migration.
@@ -52,6 +57,9 @@ def power_connections_to_cables(apps, schema_editor):
         c.connection_status = powerport.connection_status
         c.save()
 
+    cable_count = Cable.objects.filter(endpoint_a_type=powerport_type).count()
+    print("{} cables created".format(cable_count))
+
 
 def interface_connections_to_cables(apps, schema_editor):
     """
@@ -66,6 +74,7 @@ def interface_connections_to_cables(apps, schema_editor):
     interface_type = ContentType.objects.get_for_model(Interface)
 
     # Create a new Cable instance from each InterfaceConnection
+    print("    Adding interface connections... ", end='', flush=True)
     for conn in InterfaceConnection.objects.all():
         c = Cable()
         # We have to assign GFK fields manually because we're inside a migration.
@@ -76,8 +85,23 @@ def interface_connections_to_cables(apps, schema_editor):
         c.connection_status = conn.connection_status
         c.save()
 
+        # connected_endpoint and connection_status must be manually assigned
+        # since these are new fields on Interface
+        Interface.objects.filter(pk=conn.interface_a_id).update(
+            connected_endpoint=conn.interface_b_id,
+            connection_status=conn.connection_status
+        )
+        Interface.objects.filter(pk=conn.interface_b_id).update(
+            connected_endpoint=conn.interface_a_id,
+            connection_status=conn.connection_status
+        )
+
+    cable_count = Cable.objects.filter(endpoint_a_type=interface_type).count()
+    print("{} cables created".format(cable_count))
+
 
 class Migration(migrations.Migration):
+    atomic = False
 
     dependencies = [
         ('contenttypes', '0002_remove_content_type_name'),
@@ -142,9 +166,34 @@ class Migration(migrations.Migration):
             field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlets', to='dcim.Device'),
         ),
 
+        # Alter the Interface model
+        migrations.AddField(
+            model_name='interface',
+            name='connected_endpoint',
+            field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Interface'),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='connection_status',
+            field=models.NullBooleanField(default=True),
+        ),
+
         # Copy console/power/interface connections as Cables
         migrations.RunPython(console_connections_to_cables),
         migrations.RunPython(power_connections_to_cables),
         migrations.RunPython(interface_connections_to_cables),
 
+        # Delete the InterfaceConnection model
+        migrations.RemoveField(
+            model_name='interfaceconnection',
+            name='interface_a',
+        ),
+        migrations.RemoveField(
+            model_name='interfaceconnection',
+            name='interface_b',
+        ),
+        migrations.DeleteModel(
+            name='InterfaceConnection',
+        ),
+
     ]

+ 17 - 142
netbox/dcim/models.py

@@ -1826,7 +1826,7 @@ class PowerOutlet(ComponentModel):
 class Interface(ComponentModel):
     """
     A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
-    Interface via the creation of an InterfaceConnection.
+    Interface.
     """
     device = models.ForeignKey(
         to='Device',
@@ -1842,6 +1842,20 @@ class Interface(ComponentModel):
         null=True,
         blank=True
     )
+    name = models.CharField(
+        max_length=64
+    )
+    connected_endpoint = models.OneToOneField(
+        to='self',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    connection_status = models.NullBooleanField(
+        choices=CONNECTION_STATUS_CHOICES,
+        default=CONNECTION_STATUS_CONNECTED
+    )
     lag = models.ForeignKey(
         to='self',
         on_delete=models.SET_NULL,
@@ -1850,9 +1864,6 @@ class Interface(ComponentModel):
         blank=True,
         verbose_name='Parent LAG'
     )
-    name = models.CharField(
-        max_length=64
-    )
     form_factor = models.PositiveSmallIntegerField(
         choices=IFACE_FF_CHOICES,
         default=IFACE_FF_10GE_SFP_PLUS
@@ -2002,10 +2013,7 @@ class Interface(ComponentModel):
             changed_object=self,
             related_object=parent_obj,
             action=action,
-            object_data=serialize_object(self, extra={
-                'connected_interface': self.connected_interface.pk if self.connection else None,
-                'connection_status': self.connection.connection_status if self.connection else None,
-            })
+            object_data=serialize_object(self)
         ).save()
 
     @property
@@ -2034,140 +2042,7 @@ class Interface(ComponentModel):
             return bool(self.circuit_termination)
         except ObjectDoesNotExist:
             pass
-        return bool(self.connection)
-
-    @property
-    def connection(self):
-        try:
-            return self.connected_as_a
-        except ObjectDoesNotExist:
-            pass
-        try:
-            return self.connected_as_b
-        except ObjectDoesNotExist:
-            pass
-        return None
-
-    @property
-    def connected_interface(self):
-        try:
-            if self.connected_as_a:
-                return self.connected_as_a.interface_b
-        except ObjectDoesNotExist:
-            pass
-        try:
-            if self.connected_as_b:
-                return self.connected_as_b.interface_a
-        except ObjectDoesNotExist:
-            pass
-        return None
-
-
-class InterfaceConnection(models.Model):
-    """
-    An InterfaceConnection represents a symmetrical, one-to-one connection between two Interfaces. There is no
-    significant difference between the interface_a and interface_b fields.
-    """
-    interface_a = models.OneToOneField(
-        to='dcim.Interface',
-        on_delete=models.CASCADE,
-        related_name='connected_as_a'
-    )
-    interface_b = models.OneToOneField(
-        to='dcim.Interface',
-        on_delete=models.CASCADE,
-        related_name='connected_as_b'
-    )
-    connection_status = models.BooleanField(
-        choices=CONNECTION_STATUS_CHOICES,
-        default=CONNECTION_STATUS_CONNECTED,
-        verbose_name='Status'
-    )
-
-    csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']
-
-    def clean(self):
-
-        # An interface cannot be connected to itself
-        if self.interface_a == self.interface_b:
-            raise ValidationError({
-                'interface_b': "Cannot connect an interface to itself."
-            })
-
-        # Only connectable interface types are permitted
-        if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES:
-            raise ValidationError({
-                'interface_a': '{} is not a connectable interface type.'.format(
-                    self.interface_a.get_form_factor_display()
-                )
-            })
-        if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES:
-            raise ValidationError({
-                'interface_b': '{} is not a connectable interface type.'.format(
-                    self.interface_b.get_form_factor_display()
-                )
-            })
-
-        # Prevent the A side of one connection from being the B side of another
-        interface_a_connections = InterfaceConnection.objects.filter(
-            Q(interface_a=self.interface_a) |
-            Q(interface_b=self.interface_a)
-        ).exclude(pk=self.pk)
-        if interface_a_connections.exists():
-            raise ValidationError({
-                'interface_a': "This interface is already connected."
-            })
-        interface_b_connections = InterfaceConnection.objects.filter(
-            Q(interface_a=self.interface_b) |
-            Q(interface_b=self.interface_b)
-        ).exclude(pk=self.pk)
-        if interface_b_connections.exists():
-            raise ValidationError({
-                'interface_b': "This interface is already connected."
-            })
-
-    def to_csv(self):
-        return (
-            self.interface_a.device.identifier,
-            self.interface_a.name,
-            self.interface_b.device.identifier,
-            self.interface_b.name,
-            self.get_connection_status_display(),
-        )
-
-    def log_change(self, user, request_id, action):
-        """
-        Create a new ObjectChange for each of the two affected Interfaces.
-        """
-        interfaces = (
-            (self.interface_a, self.interface_b),
-            (self.interface_b, self.interface_a),
-        )
-
-        for interface, peer_interface in interfaces:
-            if action == OBJECTCHANGE_ACTION_DELETE:
-                connection_data = {
-                    'connected_interface': None,
-                }
-            else:
-                connection_data = {
-                    'connected_interface': peer_interface.pk,
-                    'connection_status': self.connection_status
-                }
-
-            try:
-                parent_obj = interface.parent
-            except ObjectDoesNotExist:
-                parent_obj = None
-
-            ObjectChange(
-                user=user,
-                request_id=request_id,
-                changed_object=interface,
-                related_object=parent_obj,
-                action=OBJECTCHANGE_ACTION_UPDATE,
-                object_data=serialize_object(interface, extra=connection_data)
-            ).save()
+        return bool(self.connected_endpoint)
 
 
 #

+ 28 - 13
netbox/dcim/tables.py

@@ -5,10 +5,9 @@ from tenancy.tables import COL_TENANT
 from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
 from .models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
-    InterfaceTemplate, InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
-    PowerPortTemplate, Rack, RackGroup, RackReservation, RearPanelPort, RearPanelPortTemplate, Region, Site,
-    VirtualChassis,
+    DeviceBayTemplate, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceTemplate,
+    InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
+    RackGroup, RackReservation, RearPanelPort, RearPanelPortTemplate, Region, Site, VirtualChassis,
 )
 
 REGION_LINK = """
@@ -654,17 +653,33 @@ class PowerConnectionTable(BaseTable):
 
 
 class InterfaceConnectionTable(BaseTable):
-    device_a = tables.LinkColumn('dcim:device', accessor=Accessor('interface_a.device'),
-                                 args=[Accessor('interface_a.device.pk')], verbose_name='Device A')
-    interface_a = tables.LinkColumn('dcim:interface', accessor=Accessor('interface_a'),
-                                    args=[Accessor('interface_a.pk')], verbose_name='Interface A')
-    device_b = tables.LinkColumn('dcim:device', accessor=Accessor('interface_b.device'),
-                                 args=[Accessor('interface_b.device.pk')], verbose_name='Device B')
-    interface_b = tables.LinkColumn('dcim:interface', accessor=Accessor('interface_b'),
-                                    args=[Accessor('interface_b.pk')], verbose_name='Interface B')
+    device_a = tables.LinkColumn(
+        viewname='dcim:device',
+        accessor=Accessor('device'),
+        args=[Accessor('device.pk')],
+        verbose_name='Device A'
+    )
+    interface_a = tables.LinkColumn(
+        viewname='dcim:interface',
+        accessor=Accessor('name'),
+        args=[Accessor('pk')],
+        verbose_name='Interface A'
+    )
+    device_b = tables.LinkColumn(
+        viewname='dcim:device',
+        accessor=Accessor('connected_endpoint.device'),
+        args=[Accessor('connected_endpoint.device.pk')],
+        verbose_name='Device B'
+    )
+    interface_b = tables.LinkColumn(
+        viewname='dcim:interface',
+        accessor=Accessor('connected_endpoint.name'),
+        args=[Accessor('connected_endpoint.pk')],
+        verbose_name='Interface B'
+    )
 
     class Meta(BaseTable.Meta):
-        model = InterfaceConnection
+        model = Interface
         fields = ('device_a', 'interface_a', 'device_b', 'interface_b')
 
 

+ 40 - 174
netbox/dcim/tests/test_api.py

@@ -8,7 +8,7 @@ from dcim.constants import (
 )
 from dcim.models import (
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
+    DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer,
     InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup,
     RackReservation, RackRole, Region, Site, VirtualChassis,
 )
@@ -2393,6 +2393,7 @@ class InterfaceTest(APITestCase):
         url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk})
         response = self.client.get(url, **self.header)
 
+        self.assertHttpStatus(response, status.HTTP_200_OK)
         self.assertEqual(response.data['name'], self.interface1.name)
 
     def test_get_interface_graphs(self):
@@ -2882,179 +2883,44 @@ class PowerConnectionTest(APITestCase):
         self.assertEqual(response.data['count'], 3)
 
 
-class InterfaceConnectionTest(APITestCase):
-
-    def setUp(self):
-
-        super(InterfaceConnectionTest, self).setUp()
-
-        site = Site.objects.create(name='Test Site 1', slug='test-site-1')
-        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
-        devicetype = DeviceType.objects.create(
-            manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
-        )
-        devicerole = DeviceRole.objects.create(
-            name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
-        )
-        self.device = Device.objects.create(
-            device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
-        )
-        self.interface1 = Interface.objects.create(device=self.device, name='Test Interface 1')
-        self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2')
-        self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3')
-        self.interface4 = Interface.objects.create(device=self.device, name='Test Interface 4')
-        self.interface5 = Interface.objects.create(device=self.device, name='Test Interface 5')
-        self.interface6 = Interface.objects.create(device=self.device, name='Test Interface 6')
-        self.interface7 = Interface.objects.create(device=self.device, name='Test Interface 7')
-        self.interface8 = Interface.objects.create(device=self.device, name='Test Interface 8')
-        self.interface9 = Interface.objects.create(device=self.device, name='Test Interface 9')
-        self.interface10 = Interface.objects.create(device=self.device, name='Test Interface 10')
-        self.interface11 = Interface.objects.create(device=self.device, name='Test Interface 11')
-        self.interface12 = Interface.objects.create(device=self.device, name='Test Interface 12')
-        self.interfaceconnection1 = InterfaceConnection.objects.create(
-            interface_a=self.interface1, interface_b=self.interface2
-        )
-        self.interfaceconnection2 = InterfaceConnection.objects.create(
-            interface_a=self.interface3, interface_b=self.interface4
-        )
-        self.interfaceconnection3 = InterfaceConnection.objects.create(
-            interface_a=self.interface5, interface_b=self.interface6
-        )
-
-    def test_get_interfaceconnection(self):
-
-        url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk})
-        response = self.client.get(url, **self.header)
-
-        self.assertEqual(response.data['interface_a']['id'], self.interfaceconnection1.interface_a_id)
-        self.assertEqual(response.data['interface_b']['id'], self.interfaceconnection1.interface_b_id)
-
-    def test_list_interfaceconnections(self):
-
-        url = reverse('dcim-api:interfaceconnection-list')
-        response = self.client.get(url, **self.header)
-
-        self.assertEqual(response.data['count'], 3)
-
-    def test_list_interfaceconnections_brief(self):
-
-        url = reverse('dcim-api:interfaceconnection-list')
-        response = self.client.get('{}?brief=1'.format(url), **self.header)
-
-        self.assertEqual(
-            sorted(response.data['results'][0]),
-            ['connection_status', 'id', 'url']
-        )
-
-    def test_create_interfaceconnection(self):
-
-        data = {
-            'interface_a': self.interface7.pk,
-            'interface_b': self.interface8.pk,
-        }
-
-        url = reverse('dcim-api:interfaceconnection-list')
-        response = self.client.post(url, data, format='json', **self.header)
-
-        self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(InterfaceConnection.objects.count(), 4)
-        interfaceconnection4 = InterfaceConnection.objects.get(pk=response.data['id'])
-        self.assertEqual(interfaceconnection4.interface_a_id, data['interface_a'])
-        self.assertEqual(interfaceconnection4.interface_b_id, data['interface_b'])
-
-    def test_create_interfaceconnection_bulk(self):
-
-        data = [
-            {
-                'interface_a': self.interface7.pk,
-                'interface_b': self.interface8.pk,
-            },
-            {
-                'interface_a': self.interface9.pk,
-                'interface_b': self.interface10.pk,
-            },
-            {
-                'interface_a': self.interface11.pk,
-                'interface_b': self.interface12.pk,
-            },
-        ]
-
-        url = reverse('dcim-api:interfaceconnection-list')
-        response = self.client.post(url, data, format='json', **self.header)
-
-        self.assertHttpStatus(response, status.HTTP_201_CREATED)
-        self.assertEqual(InterfaceConnection.objects.count(), 6)
-        for i in range(0, 3):
-            self.assertEqual(response.data[i]['interface_a']['id'], data[i]['interface_a'])
-            self.assertEqual(response.data[i]['interface_b']['id'], data[i]['interface_b'])
-
-    def test_update_interfaceconnection(self):
-
-        new_connection_status = not self.interfaceconnection1.connection_status
-
-        data = {
-            'interface_a': self.interface7.pk,
-            'interface_b': self.interface8.pk,
-            'connection_status': new_connection_status,
-        }
-
-        url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk})
-        response = self.client.put(url, data, format='json', **self.header)
-
-        self.assertHttpStatus(response, status.HTTP_200_OK)
-        self.assertEqual(InterfaceConnection.objects.count(), 3)
-        interfaceconnection1 = InterfaceConnection.objects.get(pk=response.data['id'])
-        self.assertEqual(interfaceconnection1.interface_a_id, data['interface_a'])
-        self.assertEqual(interfaceconnection1.interface_b_id, data['interface_b'])
-        self.assertEqual(interfaceconnection1.connection_status, data['connection_status'])
-
-    def test_delete_interfaceconnection(self):
-
-        url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.pk})
-        response = self.client.delete(url, **self.header)
-
-        self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
-        self.assertEqual(InterfaceConnection.objects.count(), 2)
-
-
-class ConnectedDeviceTest(APITestCase):
-
-    def setUp(self):
-
-        super(ConnectedDeviceTest, self).setUp()
-
-        self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
-        self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
-        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
-        self.devicetype1 = DeviceType.objects.create(
-            manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
-        )
-        self.devicetype2 = DeviceType.objects.create(
-            manufacturer=manufacturer, model='Test Device Type 2', slug='test-device-type-2'
-        )
-        self.devicerole1 = DeviceRole.objects.create(
-            name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
-        )
-        self.devicerole2 = DeviceRole.objects.create(
-            name='Test Device Role 2', slug='test-device-role-2', color='00ff00'
-        )
-        self.device1 = Device.objects.create(
-            device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice1', site=self.site1
-        )
-        self.device2 = Device.objects.create(
-            device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice2', site=self.site1
-        )
-        self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
-        self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
-        InterfaceConnection.objects.create(interface_a=self.interface1, interface_b=self.interface2)
-
-    def test_get_connected_device(self):
-
-        url = reverse('dcim-api:connected-device-list')
-        response = self.client.get(url + '?peer-device=TestDevice2&peer-interface=eth0', **self.header)
-
-        self.assertHttpStatus(response, status.HTTP_200_OK)
-        self.assertEqual(response.data['name'], self.device1.name)
+# class ConnectedDeviceTest(APITestCase):
+#
+#     def setUp(self):
+#
+#         super(ConnectedDeviceTest, self).setUp()
+#
+#         self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
+#         self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
+#         manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+#         self.devicetype1 = DeviceType.objects.create(
+#             manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+#         )
+#         self.devicetype2 = DeviceType.objects.create(
+#             manufacturer=manufacturer, model='Test Device Type 2', slug='test-device-type-2'
+#         )
+#         self.devicerole1 = DeviceRole.objects.create(
+#             name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
+#         )
+#         self.devicerole2 = DeviceRole.objects.create(
+#             name='Test Device Role 2', slug='test-device-role-2', color='00ff00'
+#         )
+#         self.device1 = Device.objects.create(
+#             device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice1', site=self.site1
+#         )
+#         self.device2 = Device.objects.create(
+#             device_type=self.devicetype1, device_role=self.devicerole1, name='TestDevice2', site=self.site1
+#         )
+#         self.interface1 = Interface.objects.create(device=self.device1, name='eth0')
+#         self.interface2 = Interface.objects.create(device=self.device2, name='eth0')
+#         InterfaceConnection.objects.create(interface_a=self.interface1, interface_b=self.interface2)
+#
+#     def test_get_connected_device(self):
+#
+#         url = reverse('dcim-api:connected-device-list')
+#         response = self.client.get(url + '?peer-device=TestDevice2&peer-interface=eth0', **self.header)
+#
+#         self.assertHttpStatus(response, status.HTTP_200_OK)
+#         self.assertEqual(response.data['name'], self.device1.name)
 
 
 class VirtualChassisTest(APITestCase):

+ 6 - 5
netbox/dcim/urls.py

@@ -207,8 +207,9 @@ urlpatterns = [
     url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
     url(r'^devices/(?P<pk>\d+)/interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
     url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
-    url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'),
-    url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'),
+    # url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'),
+    url(r'^interfaces/(?P<endpoint_a_id>\d+)/connect/$', views.CableConnectView.as_view(), name='interface_connect', kwargs={'endpoint_a_type': Interface}),
+    # url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'),
     url(r'^interfaces/(?P<pk>\d+)/$', views.InterfaceView.as_view(), name='interface'),
     url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
     url(r'^interfaces/(?P<pk>\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
@@ -253,11 +254,11 @@ urlpatterns = [
 
     # Console/power/interface connections
     url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
-    url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'),
+    # url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'),
     url(r'^power-connections/$', views.PowerConnectionsListView.as_view(), name='power_connections_list'),
-    url(r'^power-connections/import/$', views.PowerConnectionsBulkImportView.as_view(), name='power_connections_import'),
+    # url(r'^power-connections/import/$', views.PowerConnectionsBulkImportView.as_view(), name='power_connections_import'),
     url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
-    url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
+    # url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
 
     # Virtual chassis
     url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),

+ 14 - 125
netbox/dcim/views.py

@@ -6,7 +6,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.core.exceptions import ObjectDoesNotExist
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.db import transaction
-from django.db.models import Count, Q
+from django.db.models import Count, F, Q
 from django.forms import modelformset_factory
 from django.http import Http404, HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect, render
@@ -33,10 +33,9 @@ from . import filters, forms, tables
 from .constants import CONNECTION_STATUS_CONNECTED
 from .models import (
     Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceConnection,
-    InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort,
-    PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site,
-    VirtualChassis,
+    DeviceBayTemplate, DeviceRole, DeviceType, FrontPanelPort, FrontPanelPortTemplate, Interface, InterfaceTemplate,
+    Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
+    RackGroup, RackReservation, RackRole, RearPanelPort, RearPanelPortTemplate, Region, Site, VirtualChassis,
 )
 
 
@@ -905,8 +904,7 @@ class DeviceView(View):
         interfaces = device.vc_interfaces.order_naturally(
             device.device_type.interface_ordering
         ).select_related(
-            'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
-            'circuit_termination__circuit'
+            'connected_endpoint__device', 'circuit_termination__circuit'
         ).prefetch_related('ip_addresses')
 
         # Front panel ports
@@ -999,7 +997,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
         interfaces = device.vc_interfaces.order_naturally(
             device.device_type.interface_ordering
         ).connectable().select_related(
-            'connected_as_a', 'connected_as_b'
+            'connected_endpoint__device'
         )
 
         return render(request, 'dcim/device_lldp_neighbors.html', {
@@ -1736,10 +1734,9 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
     form = forms.InterfaceBulkDisconnectForm
 
     def disconnect_objects(self, interfaces):
-        count, _ = InterfaceConnection.objects.filter(
-            Q(interface_a__in=interfaces) | Q(interface_b__in=interfaces)
-        ).delete()
-        return count
+        return Interface.objects.filter(connected_endpoint__in=interfaces).update(
+            connected_endpoint=None, connection_status=None
+        )
 
 
 class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
@@ -2016,115 +2013,6 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie
     default_return_url = 'dcim:device_list'
 
 
-#
-# Interface connections
-#
-
-class InterfaceConnectionAddView(PermissionRequiredMixin, GetReturnURLMixin, View):
-    permission_required = 'dcim.add_interfaceconnection'
-    default_return_url = 'dcim:device_list'
-
-    def get(self, request, pk):
-
-        device = get_object_or_404(Device, pk=pk)
-        form = forms.InterfaceConnectionForm(device, initial={
-            'interface_a': request.GET.get('interface_a'),
-            'site_b': request.GET.get('site_b'),
-            'rack_b': request.GET.get('rack_b'),
-            'device_b': request.GET.get('device_b'),
-            'interface_b': request.GET.get('interface_b'),
-        })
-
-        return render(request, 'dcim/interfaceconnection_edit.html', {
-            'device': device,
-            'form': form,
-            'return_url': device.get_absolute_url(),
-        })
-
-    def post(self, request, pk):
-
-        device = get_object_or_404(Device, pk=pk)
-        form = forms.InterfaceConnectionForm(device, request.POST)
-
-        if form.is_valid():
-
-            interfaceconnection = form.save()
-            msg = 'Connected <a href="{}">{}</a> {} to <a href="{}">{}</a> {}'.format(
-                interfaceconnection.interface_a.device.get_absolute_url(),
-                escape(interfaceconnection.interface_a.device),
-                escape(interfaceconnection.interface_a.name),
-                interfaceconnection.interface_b.device.get_absolute_url(),
-                escape(interfaceconnection.interface_b.device),
-                escape(interfaceconnection.interface_b.name),
-            )
-            messages.success(request, mark_safe(msg))
-
-            if '_addanother' in request.POST:
-                base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk})
-                device_b = interfaceconnection.interface_b.device
-                params = urlencode({
-                    'rack_b': device_b.rack.pk if device_b.rack else '',
-                    'device_b': device_b.pk,
-                })
-                return HttpResponseRedirect('{}?{}'.format(base_url, params))
-            else:
-                return redirect('dcim:device', pk=device.pk)
-
-        return render(request, 'dcim/interfaceconnection_edit.html', {
-            'device': device,
-            'form': form,
-            'return_url': device.get_absolute_url(),
-        })
-
-
-class InterfaceConnectionDeleteView(PermissionRequiredMixin, GetReturnURLMixin, View):
-    permission_required = 'dcim.delete_interfaceconnection'
-    default_return_url = 'dcim:device_list'
-
-    def get(self, request, pk):
-
-        interfaceconnection = get_object_or_404(InterfaceConnection, pk=pk)
-        form = forms.ConfirmationForm()
-
-        return render(request, 'dcim/interfaceconnection_delete.html', {
-            'interfaceconnection': interfaceconnection,
-            'form': form,
-            'return_url': self.get_return_url(request, interfaceconnection),
-        })
-
-    def post(self, request, pk):
-
-        interfaceconnection = get_object_or_404(InterfaceConnection, pk=pk)
-        form = forms.ConfirmationForm(request.POST)
-
-        if form.is_valid():
-            interfaceconnection.delete()
-            msg = 'Disconnected <a href="{}">{}</a> {} from <a href="{}">{}</a> {}'.format(
-                interfaceconnection.interface_a.device.get_absolute_url(),
-                escape(interfaceconnection.interface_a.device),
-                escape(interfaceconnection.interface_a.name),
-                interfaceconnection.interface_b.device.get_absolute_url(),
-                escape(interfaceconnection.interface_b.device),
-                escape(interfaceconnection.interface_b.name),
-            )
-            messages.success(request, mark_safe(msg))
-
-            return redirect(self.get_return_url(request, interfaceconnection))
-
-        return render(request, 'dcim/interfaceconnection_delete.html', {
-            'interfaceconnection': interfaceconnection,
-            'form': form,
-            'return_url': self.get_return_url(request, interfaceconnection),
-        })
-
-
-class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView):
-    permission_required = 'dcim.change_interface'
-    model_form = forms.InterfaceConnectionCSVForm
-    table = tables.InterfaceConnectionTable
-    default_return_url = 'dcim:interface_connections_list'
-
-
 #
 # Connections
 #
@@ -2158,10 +2046,11 @@ class PowerConnectionsListView(ObjectListView):
 
 
 class InterfaceConnectionsListView(ObjectListView):
-    queryset = InterfaceConnection.objects.select_related(
-        'interface_a__device', 'interface_b__device'
-    ).order_by(
-        'interface_a__device__name', 'interface_a__name'
+    queryset = Interface.objects.select_related(
+        'connected_endpoint__device',
+    ).filter(
+        connected_endpoint__isnull=False,
+        pk__lt=F('connected_endpoint'),
     )
     filter = filters.InterfaceConnectionFilter
     filter_form = forms.InterfaceConnectionFilterForm

+ 1 - 1
netbox/extras/constants.py

@@ -49,7 +49,7 @@ GRAPH_TYPE_CHOICES = (
 EXPORTTEMPLATE_MODELS = [
     'provider', 'circuit',                                                          # Circuits
     'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device',  # DCIM
-    'consoleport', 'powerport', 'interfaceconnection', 'virtualchassis',            # DCIM
+    'consoleport', 'powerport', 'interface', 'virtualchassis',                      # DCIM
     'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service',                   # IPAM
     'secret',                                                                       # Secrets
     'tenant',                                                                       # Tenancy

+ 9 - 6
netbox/extras/models.py

@@ -504,15 +504,18 @@ class TopologyMap(models.Model):
     def add_network_connections(self, devices):
 
         from circuits.models import CircuitTermination
-        from dcim.models import InterfaceConnection
+        from dcim.models import Interface
 
         # Add all interface connections to the graph
-        connections = InterfaceConnection.objects.filter(
-            interface_a__device__in=devices, interface_b__device__in=devices
+        connected_interfaces = Interface.objects.select_related(
+            'connected_endpoint__device'
+        ).filter(
+            Q(device__in=devices) | Q(connected_endpoint__device__in=devices),
+            connected_endpoint__isnull=False,
         )
-        for c in connections:
-            style = 'solid' if c.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
-            self.graph.edge(c.interface_a.device.name, c.interface_b.device.name, style=style)
+        for interface in connected_interfaces:
+            style = 'solid' if interface.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
+            self.graph.edge(interface.device.name, interface.connected_endpoint.device.name, style=style)
 
         # Add all circuits to the graph
         for termination in CircuitTermination.objects.filter(term_side='A', interface__device__in=devices):

+ 16 - 6
netbox/netbox/views.py

@@ -1,6 +1,6 @@
 from collections import OrderedDict
 
-from django.db.models import Count
+from django.db.models import Count, F
 from django.shortcuts import render
 from django.views.generic import View
 from rest_framework.response import Response
@@ -14,8 +14,7 @@ from dcim.filters import (
     DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter
 )
 from dcim.models import (
-    ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, RackGroup, Site,
-    VirtualChassis
+    ConsolePort, Device, DeviceType, Interface, PowerPort, Rack, RackGroup, Site, VirtualChassis
 )
 from dcim.tables import (
     DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable
@@ -157,6 +156,17 @@ class HomeView(View):
 
     def get(self, request):
 
+        connected_consoleports = ConsolePort.objects.filter(
+            connected_endpoint__isnull=False
+        )
+        connected_powerports = PowerPort.objects.filter(
+            connected_endpoint__isnull=False
+        )
+        connected_interfaces = Interface.objects.filter(
+            connected_endpoint__isnull=False,
+            pk__lt=F('connected_endpoint')
+        )
+
         stats = {
 
             # Organization
@@ -166,9 +176,9 @@ class HomeView(View):
             # DCIM
             'rack_count': Rack.objects.count(),
             'device_count': Device.objects.count(),
-            'interface_connections_count': InterfaceConnection.objects.count(),
-            'console_connections_count': ConsolePort.objects.filter(connected_endpoint__isnull=False).count(),
-            'power_connections_count': PowerPort.objects.filter(connected_endpoint__isnull=False).count(),
+            'interface_connections_count': connected_interfaces.count(),
+            'console_connections_count': connected_consoleports.count(),
+            'power_connections_count': connected_powerports.count(),
 
             # IPAM
             'vrf_count': VRF.objects.count(),

+ 0 - 3
netbox/templates/dcim/console_connections_list.html

@@ -3,9 +3,6 @@
 
 {% block content %}
 <div class="pull-right">
-    {% if perms.dcim.change_consoleport %}
-        {% import_button 'dcim:console_connections_import' %}
-    {% endif %}
     {% export_button content_type %}
 </div>
 <h1>{% block title %}Console Connections{% endblock %}</h1>

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

@@ -549,7 +549,7 @@
                                 <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
                             </button>
                         {% endif %}
-                        {% if interfaces and perms.dcim.delete_interfaceconnection %}
+                        {% if interfaces and perms.dcim.change_interface %}
                             <button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
                                 <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
                             </button>

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

@@ -106,7 +106,7 @@
                         <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                     </a>
                 {% else %}
-                    <a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
+                    <a href="{% url 'dcim:interface_connect' endpoint_a_id=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
                         <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
                     </a>
                 {% endif %}

+ 0 - 3
netbox/templates/dcim/interface_connections_list.html

@@ -3,9 +3,6 @@
 
 {% block content %}
 <div class="pull-right">
-    {% if perms.dcim.add_interfaceconnection %}
-        {% import_button 'dcim:interface_connections_import' %}
-    {% endif %}
     {% export_button content_type %}
 </div>
 <h1>{% block title %}Interface Connections{% endblock %}</h1>

+ 0 - 3
netbox/templates/dcim/power_connections_list.html

@@ -3,9 +3,6 @@
 
 {% block content %}
 <div class="pull-right">
-    {% if perms.dcim.change_powerport %}
-        {% import_button 'dcim:power_connections_import' %}
-    {% endif %}
     {% export_button content_type %}
 </div>
 <h1>{% block title %}Power Connections{% endblock %}</h1>

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

@@ -180,27 +180,12 @@
                         <li class="divider"></li>
                         <li class="dropdown-header">Connections</li>
                         <li>
-                            {% if perms.dcim.change_consoleport %}
-                                <div class="buttons pull-right">
-                                    <a href="{% url 'dcim:console_connections_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
-                                </div>
-                            {% endif %}
                             <a href="{% url 'dcim:console_connections_list' %}">Console Connections</a>
                         </li>
                         <li>
-                            {% if perms.dcim.change_powerport %}
-                                <div class="buttons pull-right">
-                                    <a href="{% url 'dcim:power_connections_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
-                                </div>
-                            {% endif %}
                             <a href="{% url 'dcim:power_connections_list' %}">Power Connections</a>
                         </li>
                         <li>
-                            {% if perms.dcim.add_interfaceconnection %}
-                                <div class="buttons pull-right">
-                                    <a href="{% url 'dcim:interface_connections_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
-                                </div>
-                            {% endif %}
                             <a href="{% url 'dcim:interface_connections_list' %}">Interface Connections</a>
                         </li>
                     </ul>