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

Extended Cables to connect CircuitTerminations

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

+ 11 - 3
netbox/circuits/api/serializers.py

@@ -3,7 +3,7 @@ from taggit_serializer.serializers import TaggitSerializer, TagListSerializerFie
 
 from circuits.constants import CIRCUIT_STATUS_CHOICES
 from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
-from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
+from dcim.api.serializers import NestedSiteSerializer
 from extras.api.customfields import CustomFieldModelSerializer
 from tenancy.api.serializers import NestedTenantSerializer
 from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
@@ -85,10 +85,18 @@ class NestedCircuitSerializer(WritableNestedSerializer):
 class CircuitTerminationSerializer(ValidatedModelSerializer):
     circuit = NestedCircuitSerializer()
     site = NestedSiteSerializer()
-    interface = InterfaceSerializer(required=False, allow_null=True)
 
     class Meta:
         model = CircuitTermination
         fields = [
-            'id', 'circuit', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
+            'id', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
         ]
+
+
+class NestedCircuitTerminationSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
+    circuit = NestedCircuitSerializer()
+
+    class Meta:
+        model = CircuitTermination
+        fields = ['id', 'url', 'circuit', 'term_side']

+ 1 - 1
netbox/circuits/api/views.py

@@ -67,6 +67,6 @@ class CircuitViewSet(CustomFieldModelViewSet):
 #
 
 class CircuitTerminationViewSet(ModelViewSet):
-    queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device')
+    queryset = CircuitTermination.objects.select_related('circuit', 'site')
     serializer_class = serializers.CircuitTerminationSerializer
     filter_class = filters.CircuitTerminationFilter

+ 3 - 70
netbox/circuits/forms.py

@@ -2,7 +2,7 @@ from django import forms
 from django.db.models import Count
 from taggit.forms import TagField
 
-from dcim.models import Site, Device, Interface, Rack
+from dcim.models import Site, Device, Rack
 from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
@@ -203,57 +203,12 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
 # Circuit terminations
 #
 
-class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm):
-    site = forms.ModelChoiceField(
-        queryset=Site.objects.all(),
-        widget=forms.Select(
-            attrs={'filter-for': 'rack'}
-        )
-    )
-    rack = ChainedModelChoiceField(
-        queryset=Rack.objects.all(),
-        chains=(
-            ('site', 'site'),
-        ),
-        required=False,
-        label='Rack',
-        widget=APISelect(
-            api_url='/api/dcim/racks/?site_id={{site}}',
-            attrs={'filter-for': 'device', 'nullable': 'true'}
-        )
-    )
-    device = ChainedModelChoiceField(
-        queryset=Device.objects.all(),
-        chains=(
-            ('site', 'site'),
-            ('rack', 'rack'),
-        ),
-        required=False,
-        label='Device',
-        widget=APISelect(
-            api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
-            display_field='display_name',
-            attrs={'filter-for': 'interface'}
-        )
-    )
-    interface = ChainedModelChoiceField(
-        queryset=Interface.objects.connectable().select_related('circuit_termination'),
-        chains=(
-            ('device', 'device'),
-        ),
-        required=False,
-        label='Interface',
-        widget=APISelect(
-            api_url='/api/dcim/interfaces/?device_id={{device}}&type=physical',
-            disabled_indicator='cable'
-        )
-    )
+class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
 
     class Meta:
         model = CircuitTermination
         fields = [
-            'term_side', 'site', 'rack', 'device', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id',
-            'pp_info',
+            'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
         ]
         help_texts = {
             'port_speed': "Physical circuit speed",
@@ -263,25 +218,3 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
         widgets = {
             'term_side': forms.HiddenInput(),
         }
-
-    def __init__(self, *args, **kwargs):
-
-        # Initialize helper selectors
-        instance = kwargs.get('instance')
-        if instance and instance.interface is not None:
-            initial = kwargs.get('initial', {}).copy()
-            initial['rack'] = instance.interface.device.rack
-            initial['device'] = instance.interface.device
-            kwargs['initial'] = initial
-
-        super(CircuitTerminationForm, self).__init__(*args, **kwargs)
-
-        # Mark occupied interfaces as disabled
-        self.fields['interface'].choices = []
-        for iface in self.fields['interface'].queryset:
-            self.fields['interface'].choices.append(
-                (iface.id, {
-                    'label': iface.name,
-                    'disabled': bool(iface.cable) and iface.pk != self.initial.get('interface'),
-                })
-            )

+ 80 - 0
netbox/circuits/migrations/0013_cables.py

@@ -0,0 +1,80 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+from dcim.constants import CONNECTION_STATUS_CONNECTED
+
+
+def circuit_terminations_to_cables(apps, schema_editor):
+    """
+    Copy all existing CircuitTermination Interface associations as Cables
+    """
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+    CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
+    Interface = apps.get_model('dcim', 'Interface')
+    Cable = apps.get_model('dcim', 'Cable')
+
+    # Load content types
+    circuittermination_type = ContentType.objects.get_for_model(CircuitTermination)
+    interface_type = ContentType.objects.get_for_model(Interface)
+
+    # Create a new Cable instance from each console connection
+    print("\n    Adding circuit terminations... ", end='', flush=True)
+    for circuittermination in CircuitTermination.objects.filter(interface__isnull=False):
+        c = Cable()
+
+        # We have to assign all fields manually because we're inside a migration.
+        c.termination_a_type = circuittermination_type
+        c.termination_a_id = circuittermination.id
+        c.termination_b_type = interface_type
+        c.termination_b_id = circuittermination.interface_id
+        c.connection_status = CONNECTION_STATUS_CONNECTED
+        c.save()
+
+        # Cache the connected Cable on the CircuitTermination
+        circuittermination.cable = c
+        circuittermination.connected_endpoint = circuittermination.interface
+        circuittermination.connection_status = CONNECTION_STATUS_CONNECTED
+        circuittermination.save()
+
+        # Cache the connected Cable on the Interface
+        interface = circuittermination.interface
+        interface.cable = c
+        interface._connected_circuittermination = circuittermination
+        interface.connection_status = CONNECTION_STATUS_CONNECTED
+        interface.save()
+
+    cable_count = Cable.objects.filter(termination_a_type=circuittermination_type).count()
+    print("{} cables created".format(cable_count))
+
+
+class Migration(migrations.Migration):
+    atomic = False
+
+    dependencies = [
+        ('circuits', '0012_change_logging'),
+        ('dcim', '0066_cables'),
+    ]
+
+    operations = [
+
+        # Add CircuitTermination.connected_endpoint
+        migrations.AddField(
+            model_name='circuittermination',
+            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='circuittermination',
+            name='connection_status',
+            field=models.NullBooleanField(default=True),
+        ),
+
+        # Copy CircuitTermination connections to Interfaces as Cables
+        migrations.RunPython(circuit_terminations_to_cables),
+
+        # Model changes
+        migrations.RemoveField(
+            model_name='circuittermination',
+            name='interface',
+        ),
+    ]

+ 10 - 6
netbox/circuits/models.py

@@ -3,7 +3,7 @@ from django.db import models
 from django.urls import reverse
 from taggit.managers import TaggableManager
 
-from dcim.constants import STATUS_CLASSES
+from dcim.constants import CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, STATUS_CLASSES
 from dcim.fields import ASNField
 from extras.models import CustomFieldModel, ObjectChange
 from utilities.models import ChangeLoggedModel
@@ -114,8 +114,8 @@ class CircuitType(ChangeLoggedModel):
 class Circuit(ChangeLoggedModel, CustomFieldModel):
     """
     A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
-    circuits. Each circuit is also assigned a CircuitType and a Site. A Circuit may be terminated to a specific device
-    interface, but this is not required. Circuit port speed and commit rate are measured in Kbps.
+    circuits. Each circuit is also assigned a CircuitType and a Site.  Circuit port speed and commit rate are measured
+    in Kbps.
     """
     cid = models.CharField(
         max_length=50,
@@ -227,13 +227,17 @@ class CircuitTermination(models.Model):
         on_delete=models.PROTECT,
         related_name='circuit_terminations'
     )
-    interface = models.OneToOneField(
+    connected_endpoint = models.OneToOneField(
         to='dcim.Interface',
-        on_delete=models.PROTECT,
-        related_name='circuit_termination',
+        on_delete=models.SET_NULL,
+        related_name='+',
         blank=True,
         null=True
     )
+    connection_status = models.NullBooleanField(
+        choices=CONNECTION_STATUS_CHOICES,
+        default=CONNECTION_STATUS_CONNECTED
+    )
     port_speed = models.PositiveIntegerField(
         verbose_name='Port speed (Kbps)'
     )

+ 0 - 6
netbox/circuits/tables.py

@@ -23,12 +23,6 @@ STATUS_LABEL = """
 class CircuitTerminationColumn(tables.Column):
 
     def render(self, value):
-        if value.interface:
-            return mark_safe('<a href="{}" title="{}">{}</a>'.format(
-                value.interface.device.get_absolute_url(),
-                value.site,
-                value.interface.device
-            ))
         return mark_safe('<a href="{}">{}</a>'.format(
             value.site.get_absolute_url(),
             value.site

+ 3 - 1
netbox/circuits/urls.py

@@ -1,8 +1,9 @@
 from django.conf.urls import url
 
+from dcim.views import CableCreateView
 from extras.views import ObjectChangeLogView
 from . import views
-from .models import Circuit, CircuitType, Provider
+from .models import Circuit, CircuitTermination, CircuitType, Provider
 
 app_name = 'circuits'
 urlpatterns = [
@@ -42,5 +43,6 @@ urlpatterns = [
     url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
     url(r'^circuit-terminations/(?P<pk>\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
     url(r'^circuit-terminations/(?P<pk>\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
+    url(r'^circuit-terminations/(?P<termination_a_id>\d+)/connect/$', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
 
 ]

+ 3 - 3
netbox/circuits/views.py

@@ -132,7 +132,7 @@ class CircuitListView(ObjectListView):
     queryset = Circuit.objects.select_related(
         'provider', 'type', 'tenant'
     ).prefetch_related(
-        'terminations__site', 'terminations__interface__device'
+        'terminations__site'
     )
     filter = filters.CircuitFilter
     filter_form = forms.CircuitFilterForm
@@ -146,12 +146,12 @@ class CircuitView(View):
 
         circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk)
         termination_a = CircuitTermination.objects.select_related(
-            'site__region', 'interface__device'
+            'site__region', 'connected_endpoint__device'
         ).filter(
             circuit=circuit, term_side=TERM_SIDE_A
         ).first()
         termination_z = CircuitTermination.objects.select_related(
-            'site__region', 'interface__device'
+            'site__region', 'connected_endpoint__device'
         ).filter(
             circuit=circuit, term_side=TERM_SIDE_Z
         ).first()

+ 15 - 3
netbox/dcim/api/serializers.py

@@ -696,8 +696,7 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
     device = NestedDeviceSerializer()
     form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
-    connected_endpoint = NestedInterfaceSerializer(read_only=True)
-    circuit_termination = InterfaceCircuitTerminationSerializer(read_only=True)
+    connected_endpoint = serializers.SerializerMethodField(read_only=True)
     mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
     untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
@@ -713,7 +712,7 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
         model = Interface
         fields = [
             'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
-            'connected_endpoint', 'circuit_termination', 'cable', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
+            'connected_endpoint', 'cable', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
         ]
 
     def validate(self, data):
@@ -735,6 +734,19 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
 
         return super(InterfaceSerializer, self).validate(data)
 
+    def get_connected_endpoint(self, obj):
+        """
+        Return the appropriate serializer for the type of connected object.
+        """
+        if obj.connected_endpoint is None:
+            return None
+
+        serializer = get_serializer_for_model(obj.connected_endpoint, prefix='Nested')
+        context = {'request': self.context['request']}
+        data = serializer(obj.connected_endpoint, context=context).data
+
+        return data
+
 
 #
 # Rear ports

+ 6 - 5
netbox/dcim/api/views.py

@@ -1,7 +1,7 @@
 from collections import OrderedDict
 
 from django.conf import settings
-from django.db.models import F
+from django.db.models import F, Q
 from django.http import HttpResponseForbidden
 from django.shortcuts import get_object_or_404
 from drf_yasg import openapi
@@ -407,7 +407,7 @@ class PowerOutletViewSet(CableTraceMixin, ModelViewSet):
 
 class InterfaceViewSet(CableTraceMixin, ModelViewSet):
     queryset = Interface.objects.select_related(
-        'device', 'connected_endpoint__device', 'cable'
+        'device', '_connected_interface', '_connected_circuittermination', 'cable'
     ).prefetch_related(
         'tags'
     )
@@ -483,10 +483,11 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
 
 class InterfaceConnectionViewSet(ModelViewSet):
     queryset = Interface.objects.select_related(
-        'device', 'connected_endpoint__device'
+        'device', '_connected_interface', '_connected_circuittermination'
     ).filter(
-        connected_endpoint__isnull=False,
-        pk__lt=F('connected_endpoint')
+        # Avoid duplicate connections by only selecting the lower PK in a connected pair
+        Q(_connected_interface__isnull=False, pk__lt=F('_connected_interface')) |
+        Q(_connected_circuittermination__isnull=False)
     )
     serializer_class = serializers.InterfaceConnectionSerializer
     filter_class = filters.InterfaceConnectionFilter

+ 2 - 1
netbox/dcim/constants.py

@@ -340,9 +340,10 @@ COMPATIBLE_TERMINATION_TYPES = {
     'consoleserverport': ['consoleport', 'frontport', 'rearport'],
     'powerport': ['poweroutlet'],
     'poweroutlet': ['powerport'],
-    'interface': ['interface', 'frontport', 'rearport'],
+    'interface': ['interface', 'circuittermination', 'frontport', 'rearport'],
     'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport'],
     'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport'],
+    'circuittermination': ['interface', 'frontport', 'rearport'],
 }
 
 LENGTH_UNIT_METER = 'm'

+ 3 - 3
netbox/dcim/filters.py

@@ -1,6 +1,6 @@
 import django_filters
 from django.contrib.auth.models import User
-from django.db.models import Count, Q
+from django.db.models import Q
 from netaddr import EUI
 from netaddr.core import AddrFormatError
 
@@ -876,7 +876,7 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
             return queryset
         return queryset.filter(
             Q(device__site__slug=value) |
-            Q(connected_endpoint__device__site__slug=value)
+            Q(_connected_interface__device__site__slug=value)
         )
 
     def filter_device(self, queryset, name, value):
@@ -884,5 +884,5 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
             return queryset
         return queryset.filter(
             Q(device__name__icontains=value) |
-            Q(connected_endpoint__device__name__icontains=value)
+            Q(_connected_interface__device__name__icontains=value)
         )

+ 20 - 16
netbox/dcim/migrations/0066_cables.py

@@ -88,28 +88,26 @@ def interface_connections_to_cables(apps, schema_editor):
     for conn in InterfaceConnection.objects.all():
         c = Cable()
 
-        # We have to assign GFK fields manually because we're inside a migration.
+        # We have to assign all fields manually because we're inside a migration.
         c.termination_a_type = interface_type
         c.termination_a_id = conn.interface_a_id
-        c.termination_a = conn.interface_a
         c.termination_b_type = interface_type
         c.termination_b_id = conn.interface_b_id
-        c.termination_b = conn.interface_b
         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,
-            cable=c
-        )
-        Interface.objects.filter(pk=conn.interface_b_id).update(
-            connected_endpoint=conn.interface_a_id,
-            connection_status=conn.connection_status,
-            cable=c
-        )
+        # Cache the connected Cable on each Interface
+        interface_a = conn.interface_a
+        interface_a._connected_interface = conn.interface_b
+        interface_a.connection_status = conn.connection_status
+        interface_a.cable = c
+        interface_a.save()
+
+        interface_b = conn.interface_b
+        interface_b._connected_interface = conn.interface_a
+        interface_b.connection_status = conn.connection_status
+        interface_b.cable = c
+        interface_b.save()
 
     cable_count = Cable.objects.filter(termination_a_type=interface_type).count()
     print("{} cables created".format(cable_count))
@@ -120,6 +118,7 @@ class Migration(migrations.Migration):
 
     dependencies = [
         ('contenttypes', '0002_remove_content_type_name'),
+        ('circuits', '0006_terminations'),
         ('dcim', '0065_front_rear_ports'),
     ]
 
@@ -217,7 +216,12 @@ class Migration(migrations.Migration):
         # Alter the Interface model
         migrations.AddField(
             model_name='interface',
-            name='connected_endpoint',
+            name='_connected_circuittermination',
+            field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.CircuitTermination'),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='_connected_interface',
             field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Interface'),
         ),
         migrations.AddField(

+ 31 - 2
netbox/dcim/models.py

@@ -15,7 +15,7 @@ from mptt.models import MPTTModel, TreeForeignKey
 from taggit.managers import TaggableManager
 from timezone_field import TimeZoneField
 
-from circuits.models import Circuit
+from circuits.models import Circuit, CircuitTermination
 from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange
 from utilities.fields import ColorField, NullableCharField
 from utilities.managers import NaturalOrderByManager
@@ -1843,13 +1843,20 @@ class Interface(CableTermination, ComponentModel):
     name = models.CharField(
         max_length=64
     )
-    connected_endpoint = models.OneToOneField(
+    _connected_interface = models.OneToOneField(
         to='self',
         on_delete=models.SET_NULL,
         related_name='+',
         blank=True,
         null=True
     )
+    _connected_circuittermination = models.OneToOneField(
+        to='circuits.CircuitTermination',
+        on_delete=models.SET_NULL,
+        related_name='+',
+        blank=True,
+        null=True
+    )
     connection_status = models.NullBooleanField(
         choices=CONNECTION_STATUS_CHOICES,
         default=CONNECTION_STATUS_CONNECTED
@@ -2008,6 +2015,28 @@ class Interface(CableTermination, ComponentModel):
             object_data=serialize_object(self)
         ).save()
 
+    @property
+    def connected_endpoint(self):
+        if self._connected_interface:
+            return self._connected_interface
+        return self._connected_circuittermination
+
+    @connected_endpoint.setter
+    def connected_endpoint(self, value):
+        if value is None:
+            self._connected_interface = None
+            self._connected_circuittermination = None
+        elif isinstance(value, Interface):
+            self._connected_interface = value
+            self._connected_circuittermination = None
+        elif isinstance(value, CircuitTermination):
+            self._connected_interface = None
+            self._connected_circuittermination = value
+        else:
+            raise ValueError(
+                "Connected endpoint must be an Interface or CircuitTermination, not {}.".format(type(value))
+            )
+
     @property
     def parent(self):
         return self.device or self.virtual_machine

+ 10 - 15
netbox/dcim/views.py

@@ -904,7 +904,7 @@ class DeviceView(View):
         interfaces = device.vc_interfaces.order_naturally(
             device.device_type.interface_ordering
         ).select_related(
-            'lag', 'connected_endpoint__device', 'circuit_termination__circuit', 'cable'
+            'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable'
         ).prefetch_related(
             'cable__termination_a', 'cable__termination_b', 'ip_addresses'
         )
@@ -999,7 +999,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
         interfaces = device.vc_interfaces.order_naturally(
             device.device_type.interface_ordering
         ).connectable().select_related(
-            'connected_endpoint__device'
+            '_connected_interface__device'
         )
 
         return render(request, 'dcim/device_lldp_neighbors.html', {
@@ -1667,13 +1667,6 @@ class InterfaceView(View):
 
         interface = get_object_or_404(Interface, pk=pk)
 
-        # Get connected interface
-        connected_interface = interface.connected_endpoint
-        if connected_interface is None and hasattr(interface, 'circuit_termination'):
-            peer_termination = interface.circuit_termination.get_peer_termination()
-            if peer_termination is not None:
-                connected_interface = peer_termination.interface
-
         # Get assigned IP addresses
         ipaddress_table = InterfaceIPAddressTable(
             data=interface.ip_addresses.select_related('vrf', 'tenant'),
@@ -1696,7 +1689,8 @@ class InterfaceView(View):
 
         return render(request, 'dcim/interface.html', {
             'interface': interface,
-            'connected_interface': connected_interface,
+            # TODO: Also handle connected CircuitTerminations
+            'connected_interface': interface._connected_interface,
             'ipaddress_table': ipaddress_table,
             'vlan_table': vlan_table,
         })
@@ -1736,8 +1730,8 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
     form = forms.InterfaceBulkDisconnectForm
 
     def disconnect_objects(self, interfaces):
-        return Interface.objects.filter(connected_endpoint__in=interfaces).update(
-            connected_endpoint=None, connection_status=None
+        return Interface.objects.filter(_connected_interface__in=interfaces).update(
+            _connected_interface=None, connection_status=None
         )
 
 
@@ -2103,10 +2097,11 @@ class PowerConnectionsListView(ObjectListView):
 
 class InterfaceConnectionsListView(ObjectListView):
     queryset = Interface.objects.select_related(
-        'connected_endpoint__device',
+        '_connected_interface', '_connected_circuittermination'
     ).filter(
-        connected_endpoint__isnull=False,
-        pk__lt=F('connected_endpoint'),
+        # Avoid duplicate connections by only selecting the lower PK in a connected pair
+        _connected_interface__isnull=False,
+        pk__lt=F('_connected_interface')
     )
     filter = filters.InterfaceConnectionFilter
     filter_form = forms.InterfaceConnectionFilterForm

+ 4 - 4
netbox/extras/models.py

@@ -508,17 +508,17 @@ class TopologyMap(models.Model):
 
         # Add all interface connections to the graph
         connected_interfaces = Interface.objects.select_related(
-            'connected_endpoint__device'
+            '_connected_interface__device'
         ).filter(
-            Q(device__in=devices) | Q(connected_endpoint__device__in=devices),
-            connected_endpoint__isnull=False,
+            Q(device__in=devices) | Q(_connected_interface__device__in=devices),
+            _connected_interface__isnull=False,
         )
         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):
+        for termination in CircuitTermination.objects.filter(term_side='A', connected_endpoint__device__in=devices):
             peer_termination = termination.get_peer_termination()
             if (peer_termination is not None and peer_termination.interface is not None and
                     peer_termination.interface.device in devices):

+ 2 - 2
netbox/netbox/views.py

@@ -163,8 +163,8 @@ class HomeView(View):
             connected_endpoint__isnull=False
         )
         connected_interfaces = Interface.objects.filter(
-            connected_endpoint__isnull=False,
-            pk__lt=F('connected_endpoint')
+            _connected_interface__isnull=False,
+            pk__lt=F('_connected_interface')
         )
 
         stats = {

+ 0 - 3
netbox/templates/circuits/circuittermination_edit.html

@@ -41,9 +41,6 @@
                             </div>
                         </div>
                         {% render_field form.site %}
-                        {% render_field form.rack %}
-                        {% render_field form.device %}
-                        {% render_field form.interface %}
                     </div>
                 </div>
                 <div class="panel panel-default">

+ 12 - 5
netbox/templates/circuits/inc/circuit_termination.html

@@ -39,10 +39,17 @@
             <tr>
                 <td>Termination</td>
                 <td>
-                    {% if termination.interface %}
-                        <a href="{% url 'dcim:device' pk=termination.interface.device.pk %}">{{ termination.interface.device }}</a>
-                        <i class="fa fa-angle-right"></i> {{ termination.interface }}
+                    {% if termination.connected_endpoint %}
+                        <a href="{% url 'dcim:device' pk=termination.connected_endpoint.device.pk %}">{{ termination.connected_endpoint.device }}</a>
+                        <i class="fa fa-angle-right"></i> {{ termination.connected_endpoint }}
                     {% else %}
+                        {% if perms.circuits.change_circuittermination %}
+                            <div class="pull-right">
+                                <a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk %}?return_url={{ circuit.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
+                                    <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i> Connect
+                                </a>
+                            </div>
+                        {% endif %}
                         <span class="text-muted">Not defined</span>
                     {% endif %}
                 </td>
@@ -61,8 +68,8 @@
             <tr>
                 <td>IP Addressing</td>
                 <td>
-                    {% if termination.interface %}
-                        {% for ip in termination.interface.ip_addresses.all %}
+                    {% if termination.connected_endpoint %}
+                        {% for ip in termination.connected_endpoint.ip_addresses.all %}
                             {% if not forloop.first %}<br />{% endif %}
                             <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a> ({{ ip.vrf|default:"Global" }})
                         {% empty %}

+ 49 - 20
netbox/templates/dcim/cable_connect.html

@@ -29,30 +29,59 @@
                         <strong>A Side</strong>
                     </div>
                     <div class="panel-body">
-                        <div class="form-group">
-                            <label class="col-md-3 control-label required">Site</label>
-                            <div class="col-md-9">
-                                <p class="form-control-static">{{ termination_a.device.site }}</p>
+                        {% if termination_a.device %}
+                            {# Device component #}
+                            <div class="form-group">
+                                <label class="col-md-3 control-label required">Site</label>
+                                <div class="col-md-9">
+                                    <p class="form-control-static">{{ termination_a.device.site }}</p>
+                                </div>
                             </div>
-                        </div>
-                        <div class="form-group">
-                            <label class="col-md-3 control-label required">Rack</label>
-                            <div class="col-md-9">
-                                <p class="form-control-static">{{ termination_a.device.rack|default:"None" }}</p>
+                            <div class="form-group">
+                                <label class="col-md-3 control-label required">Rack</label>
+                                <div class="col-md-9">
+                                    <p class="form-control-static">{{ termination_a.device.rack|default:"None" }}</p>
+                                </div>
                             </div>
-                        </div>
-                        <div class="form-group">
-                            <label class="col-md-3 control-label required">Device</label>
-                            <div class="col-md-9">
-                                <p class="form-control-static">{{ termination_a.device }}</p>
+                            <div class="form-group">
+                                <label class="col-md-3 control-label required">Device</label>
+                                <div class="col-md-9">
+                                    <p class="form-control-static">{{ termination_a.device }}</p>
+                                </div>
                             </div>
-                        </div>
-                        <div class="form-group">
-                            <label class="col-md-3 control-label required">Name</label>
-                            <div class="col-md-9">
-                                <p class="form-control-static">{{ termination_a }}</p>
+                            <div class="form-group">
+                                <label class="col-md-3 control-label required">Name</label>
+                                <div class="col-md-9">
+                                    <p class="form-control-static">{{ termination_a }}</p>
+                                </div>
                             </div>
-                        </div>
+                        {% else %}
+                            {# Circuit termination #}
+                            <div class="form-group">
+                                <label class="col-md-3 control-label required">Site</label>
+                                <div class="col-md-9">
+                                    <p class="form-control-static">{{ termination_a.site }}</p>
+                                </div>
+                            </div>
+                            <div class="form-group">
+                                <label class="col-md-3 control-label required">Provider</label>
+                                <div class="col-md-9">
+                                    <p class="form-control-static">{{ termination_a.circuit.provider }}</p>
+                                </div>
+                            </div>
+                            <div class="form-group">
+                                <label class="col-md-3 control-label required">Circuit</label>
+                                <div class="col-md-9">
+                                    <p class="form-control-static">{{ termination_a.circuit.cid }}</p>
+                                </div>
+                            </div>
+                            <div class="form-group">
+                                <label class="col-md-3 control-label required">Side</label>
+                                <div class="col-md-9">
+                                    <p class="form-control-static">{{ termination_a.term_side }}</p>
+                                </div>
+                            </div>
+                        {% endif %}
                     </div>
                 </div>
             </div>

+ 27 - 10
netbox/templates/dcim/inc/cable_termination.html

@@ -1,12 +1,29 @@
 <table class="table table-hover panel-body attr-table">
-    <tr>
-        <td>Device</td>
-        <td>
-            <a href="{{ termination.device.get_absolute_url }}">{{ termination.device }}</a>
-        </td>
-    </tr>
-    <tr>
-        <td>Component</td>
-        <td>{{ termination }}</td>
-    </tr>
+    {% if termination.device %}
+        {# Device component #}
+        <tr>
+            <td>Device</td>
+            <td>
+                <a href="{{ termination.device.get_absolute_url }}">{{ termination.device }}</a>
+            </td>
+        </tr>
+        <tr>
+            <td>Component</td>
+            <td>{{ termination }}</td>
+        </tr>
+    {% else %}
+        {# Circuit termination #}
+        <tr>
+            <td>Provider</td>
+            <td>
+                <a href="{{ termination.circuit.provider.get_absolute_url }}">{{ termination.circuit.provider }}</a>
+            </td>
+        </tr>
+        <tr>
+            <td>Circuit</td>
+            <td>
+                <a href="{{ termination.circuit.get_absolute_url }}">{{ termination.circuit }}</a> (Side {{ termination.term_side }})
+            </td>
+        </tr>
+    {% endif %}
 </table>

+ 13 - 20
netbox/templates/dcim/inc/interface.html

@@ -50,17 +50,17 @@
         <td colspan="2" class="text-muted">Virtual interface</td>
     {% elif iface.is_wireless %}
         <td colspan="2" class="text-muted">Wireless interface</td>
-    {% elif iface.connected_endpoint %}
-        {% with connected_iface=iface.connected_endpoint %}
-            <td>
-                <a href="{% url 'dcim:device' pk=connected_iface.device.pk %}">{{ connected_iface.device }}</a>
-            </td>
-            <td>
-                <a href="{% url 'dcim:interface' pk=connected_iface.pk %}"><span title="{{ connected_iface.get_form_factor_display }}">{{ connected_iface }}</span></a>
-            </td>
-        {% endwith %}
-    {% elif iface.circuit_termination %}
-        {% with iface.circuit_termination.get_peer_termination as peer_termination %}
+    {% elif iface.connected_endpoint.name %}
+        {# Connected to an Interface #}
+        <td>
+            <a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">{{ iface.connected_endpoint.device }}</a>
+        </td>
+        <td>
+            <a href="{% url 'dcim:interface' pk=iface.connected_endpoint.pk %}"><span title="{{ iface.connected_endpoint.get_form_factor_display }}">{{ iface.connected_endpoint }}</span></a>
+        </td>
+    {% elif iface.connected_endpoint.term_side %}
+        {# Connected to a CircuitTermination #}
+        {% with iface.connected_endpoint.get_peer_termination as peer_termination %}
             <td colspan="2">
                 <i class="fa fa-fw fa-globe" title="Circuit"></i>
                 {% if peer_termination %}
@@ -72,7 +72,7 @@
                     {% endif %}
                     via
                 {% endif %}
-                <a href="{% url 'circuits:circuit' pk=iface.circuit_termination.circuit_id %}">{{ iface.circuit_termination.circuit }}</a>
+                <a href="{% url 'circuits:circuit' pk=iface.connected_endpoint.circuit_id %}">{{ iface.connected_endpoint.circuit }}</a>
             </td>
         {% endwith %}
     {% else %}
@@ -84,7 +84,7 @@
     {# Buttons #}
     <td class="text-right text-nowrap">
         {% if show_graphs %}
-            {% if iface.circuit_termination or iface.connected_endpoint %}
+            {% if iface.connected_endpoint %}
                 <button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }} - {{ iface.name }}" data-url="{% url 'dcim-api:interface-graphs' pk=iface.pk %}" title="Show graphs">
                     <i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
                 </button>
@@ -110,13 +110,6 @@
                     <a href="{% url 'dcim:cable_delete' pk=iface.cable.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Remove cable">
                         <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                     </a>
-                {% elif iface.circuit_termination and perms.circuits.change_circuittermination %}
-                    <button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected">
-                        <i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
-                    </button>
-                    <a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Edit circuit termination">
-                        <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
-                    </a>
                 {% else %}
                     <a href="{% url 'dcim:interface_connect' termination_a_id=iface.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>