Răsfoiți Sursa

Extended Cables to connect CircuitTerminations

Jeremy Stretch 7 ani în urmă
părinte
comite
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.constants import CIRCUIT_STATUS_CHOICES
 from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 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 extras.api.customfields import CustomFieldModelSerializer
 from tenancy.api.serializers import NestedTenantSerializer
 from tenancy.api.serializers import NestedTenantSerializer
 from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
 from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
@@ -85,10 +85,18 @@ class NestedCircuitSerializer(WritableNestedSerializer):
 class CircuitTerminationSerializer(ValidatedModelSerializer):
 class CircuitTerminationSerializer(ValidatedModelSerializer):
     circuit = NestedCircuitSerializer()
     circuit = NestedCircuitSerializer()
     site = NestedSiteSerializer()
     site = NestedSiteSerializer()
-    interface = InterfaceSerializer(required=False, allow_null=True)
 
 
     class Meta:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
         fields = [
         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):
 class CircuitTerminationViewSet(ModelViewSet):
-    queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device')
+    queryset = CircuitTermination.objects.select_related('circuit', 'site')
     serializer_class = serializers.CircuitTerminationSerializer
     serializer_class = serializers.CircuitTerminationSerializer
     filter_class = filters.CircuitTerminationFilter
     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 django.db.models import Count
 from taggit.forms import TagField
 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 extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from tenancy.forms import TenancyForm
 from tenancy.forms import TenancyForm
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -203,57 +203,12 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
 # Circuit terminations
 # 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:
     class Meta:
         model = CircuitTermination
         model = CircuitTermination
         fields = [
         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 = {
         help_texts = {
             'port_speed': "Physical circuit speed",
             'port_speed': "Physical circuit speed",
@@ -263,25 +218,3 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm
         widgets = {
         widgets = {
             'term_side': forms.HiddenInput(),
             '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 django.urls import reverse
 from taggit.managers import TaggableManager
 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 dcim.fields import ASNField
 from extras.models import CustomFieldModel, ObjectChange
 from extras.models import CustomFieldModel, ObjectChange
 from utilities.models import ChangeLoggedModel
 from utilities.models import ChangeLoggedModel
@@ -114,8 +114,8 @@ class CircuitType(ChangeLoggedModel):
 class Circuit(ChangeLoggedModel, CustomFieldModel):
 class Circuit(ChangeLoggedModel, CustomFieldModel):
     """
     """
     A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
     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(
     cid = models.CharField(
         max_length=50,
         max_length=50,
@@ -227,13 +227,17 @@ class CircuitTermination(models.Model):
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
         related_name='circuit_terminations'
         related_name='circuit_terminations'
     )
     )
-    interface = models.OneToOneField(
+    connected_endpoint = models.OneToOneField(
         to='dcim.Interface',
         to='dcim.Interface',
-        on_delete=models.PROTECT,
-        related_name='circuit_termination',
+        on_delete=models.SET_NULL,
+        related_name='+',
         blank=True,
         blank=True,
         null=True
         null=True
     )
     )
+    connection_status = models.NullBooleanField(
+        choices=CONNECTION_STATUS_CHOICES,
+        default=CONNECTION_STATUS_CONNECTED
+    )
     port_speed = models.PositiveIntegerField(
     port_speed = models.PositiveIntegerField(
         verbose_name='Port speed (Kbps)'
         verbose_name='Port speed (Kbps)'
     )
     )

+ 0 - 6
netbox/circuits/tables.py

@@ -23,12 +23,6 @@ STATUS_LABEL = """
 class CircuitTerminationColumn(tables.Column):
 class CircuitTerminationColumn(tables.Column):
 
 
     def render(self, value):
     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(
         return mark_safe('<a href="{}">{}</a>'.format(
             value.site.get_absolute_url(),
             value.site.get_absolute_url(),
             value.site
             value.site

+ 3 - 1
netbox/circuits/urls.py

@@ -1,8 +1,9 @@
 from django.conf.urls import url
 from django.conf.urls import url
 
 
+from dcim.views import CableCreateView
 from extras.views import ObjectChangeLogView
 from extras.views import ObjectChangeLogView
 from . import views
 from . import views
-from .models import Circuit, CircuitType, Provider
+from .models import Circuit, CircuitTermination, CircuitType, Provider
 
 
 app_name = 'circuits'
 app_name = 'circuits'
 urlpatterns = [
 urlpatterns = [
@@ -42,5 +43,6 @@ urlpatterns = [
     url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
     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+)/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<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(
     queryset = Circuit.objects.select_related(
         'provider', 'type', 'tenant'
         'provider', 'type', 'tenant'
     ).prefetch_related(
     ).prefetch_related(
-        'terminations__site', 'terminations__interface__device'
+        'terminations__site'
     )
     )
     filter = filters.CircuitFilter
     filter = filters.CircuitFilter
     filter_form = forms.CircuitFilterForm
     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)
         circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk)
         termination_a = CircuitTermination.objects.select_related(
         termination_a = CircuitTermination.objects.select_related(
-            'site__region', 'interface__device'
+            'site__region', 'connected_endpoint__device'
         ).filter(
         ).filter(
             circuit=circuit, term_side=TERM_SIDE_A
             circuit=circuit, term_side=TERM_SIDE_A
         ).first()
         ).first()
         termination_z = CircuitTermination.objects.select_related(
         termination_z = CircuitTermination.objects.select_related(
-            'site__region', 'interface__device'
+            'site__region', 'connected_endpoint__device'
         ).filter(
         ).filter(
             circuit=circuit, term_side=TERM_SIDE_Z
             circuit=circuit, term_side=TERM_SIDE_Z
         ).first()
         ).first()

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

@@ -696,8 +696,7 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
     form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
     lag = NestedInterfaceSerializer(required=False, allow_null=True)
     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)
     mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
     untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True)
     untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True)
     tagged_vlans = SerializedPKRelatedField(
     tagged_vlans = SerializedPKRelatedField(
@@ -713,7 +712,7 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
         model = Interface
         model = Interface
         fields = [
         fields = [
             'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
             '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):
     def validate(self, data):
@@ -735,6 +734,19 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
 
 
         return super(InterfaceSerializer, self).validate(data)
         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
 # Rear ports

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

@@ -1,7 +1,7 @@
 from collections import OrderedDict
 from collections import OrderedDict
 
 
 from django.conf import settings
 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.http import HttpResponseForbidden
 from django.shortcuts import get_object_or_404
 from django.shortcuts import get_object_or_404
 from drf_yasg import openapi
 from drf_yasg import openapi
@@ -407,7 +407,7 @@ class PowerOutletViewSet(CableTraceMixin, ModelViewSet):
 
 
 class InterfaceViewSet(CableTraceMixin, ModelViewSet):
 class InterfaceViewSet(CableTraceMixin, ModelViewSet):
     queryset = Interface.objects.select_related(
     queryset = Interface.objects.select_related(
-        'device', 'connected_endpoint__device', 'cable'
+        'device', '_connected_interface', '_connected_circuittermination', 'cable'
     ).prefetch_related(
     ).prefetch_related(
         'tags'
         'tags'
     )
     )
@@ -483,10 +483,11 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
 
 
 class InterfaceConnectionViewSet(ModelViewSet):
 class InterfaceConnectionViewSet(ModelViewSet):
     queryset = Interface.objects.select_related(
     queryset = Interface.objects.select_related(
-        'device', 'connected_endpoint__device'
+        'device', '_connected_interface', '_connected_circuittermination'
     ).filter(
     ).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
     serializer_class = serializers.InterfaceConnectionSerializer
     filter_class = filters.InterfaceConnectionFilter
     filter_class = filters.InterfaceConnectionFilter

+ 2 - 1
netbox/dcim/constants.py

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

+ 3 - 3
netbox/dcim/filters.py

@@ -1,6 +1,6 @@
 import django_filters
 import django_filters
 from django.contrib.auth.models import User
 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 import EUI
 from netaddr.core import AddrFormatError
 from netaddr.core import AddrFormatError
 
 
@@ -876,7 +876,7 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
             Q(device__site__slug=value) |
             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):
     def filter_device(self, queryset, name, value):
@@ -884,5 +884,5 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
             return queryset
             return queryset
         return queryset.filter(
         return queryset.filter(
             Q(device__name__icontains=value) |
             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():
     for conn in InterfaceConnection.objects.all():
         c = Cable()
         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_type = interface_type
         c.termination_a_id = conn.interface_a_id
         c.termination_a_id = conn.interface_a_id
-        c.termination_a = conn.interface_a
         c.termination_b_type = interface_type
         c.termination_b_type = interface_type
         c.termination_b_id = conn.interface_b_id
         c.termination_b_id = conn.interface_b_id
-        c.termination_b = conn.interface_b
         c.connection_status = conn.connection_status
         c.connection_status = conn.connection_status
         c.save()
         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()
     cable_count = Cable.objects.filter(termination_a_type=interface_type).count()
     print("{} cables created".format(cable_count))
     print("{} cables created".format(cable_count))
@@ -120,6 +118,7 @@ class Migration(migrations.Migration):
 
 
     dependencies = [
     dependencies = [
         ('contenttypes', '0002_remove_content_type_name'),
         ('contenttypes', '0002_remove_content_type_name'),
+        ('circuits', '0006_terminations'),
         ('dcim', '0065_front_rear_ports'),
         ('dcim', '0065_front_rear_ports'),
     ]
     ]
 
 
@@ -217,7 +216,12 @@ class Migration(migrations.Migration):
         # Alter the Interface model
         # Alter the Interface model
         migrations.AddField(
         migrations.AddField(
             model_name='interface',
             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'),
             field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Interface'),
         ),
         ),
         migrations.AddField(
         migrations.AddField(

+ 31 - 2
netbox/dcim/models.py

@@ -15,7 +15,7 @@ from mptt.models import MPTTModel, TreeForeignKey
 from taggit.managers import TaggableManager
 from taggit.managers import TaggableManager
 from timezone_field import TimeZoneField
 from timezone_field import TimeZoneField
 
 
-from circuits.models import Circuit
+from circuits.models import Circuit, CircuitTermination
 from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange
 from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange
 from utilities.fields import ColorField, NullableCharField
 from utilities.fields import ColorField, NullableCharField
 from utilities.managers import NaturalOrderByManager
 from utilities.managers import NaturalOrderByManager
@@ -1843,13 +1843,20 @@ class Interface(CableTermination, ComponentModel):
     name = models.CharField(
     name = models.CharField(
         max_length=64
         max_length=64
     )
     )
-    connected_endpoint = models.OneToOneField(
+    _connected_interface = models.OneToOneField(
         to='self',
         to='self',
         on_delete=models.SET_NULL,
         on_delete=models.SET_NULL,
         related_name='+',
         related_name='+',
         blank=True,
         blank=True,
         null=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(
     connection_status = models.NullBooleanField(
         choices=CONNECTION_STATUS_CHOICES,
         choices=CONNECTION_STATUS_CHOICES,
         default=CONNECTION_STATUS_CONNECTED
         default=CONNECTION_STATUS_CONNECTED
@@ -2008,6 +2015,28 @@ class Interface(CableTermination, ComponentModel):
             object_data=serialize_object(self)
             object_data=serialize_object(self)
         ).save()
         ).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
     @property
     def parent(self):
     def parent(self):
         return self.device or self.virtual_machine
         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(
         interfaces = device.vc_interfaces.order_naturally(
             device.device_type.interface_ordering
             device.device_type.interface_ordering
         ).select_related(
         ).select_related(
-            'lag', 'connected_endpoint__device', 'circuit_termination__circuit', 'cable'
+            'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable'
         ).prefetch_related(
         ).prefetch_related(
             'cable__termination_a', 'cable__termination_b', 'ip_addresses'
             'cable__termination_a', 'cable__termination_b', 'ip_addresses'
         )
         )
@@ -999,7 +999,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
         interfaces = device.vc_interfaces.order_naturally(
         interfaces = device.vc_interfaces.order_naturally(
             device.device_type.interface_ordering
             device.device_type.interface_ordering
         ).connectable().select_related(
         ).connectable().select_related(
-            'connected_endpoint__device'
+            '_connected_interface__device'
         )
         )
 
 
         return render(request, 'dcim/device_lldp_neighbors.html', {
         return render(request, 'dcim/device_lldp_neighbors.html', {
@@ -1667,13 +1667,6 @@ class InterfaceView(View):
 
 
         interface = get_object_or_404(Interface, pk=pk)
         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
         # Get assigned IP addresses
         ipaddress_table = InterfaceIPAddressTable(
         ipaddress_table = InterfaceIPAddressTable(
             data=interface.ip_addresses.select_related('vrf', 'tenant'),
             data=interface.ip_addresses.select_related('vrf', 'tenant'),
@@ -1696,7 +1689,8 @@ class InterfaceView(View):
 
 
         return render(request, 'dcim/interface.html', {
         return render(request, 'dcim/interface.html', {
             'interface': interface,
             'interface': interface,
-            'connected_interface': connected_interface,
+            # TODO: Also handle connected CircuitTerminations
+            'connected_interface': interface._connected_interface,
             'ipaddress_table': ipaddress_table,
             'ipaddress_table': ipaddress_table,
             'vlan_table': vlan_table,
             'vlan_table': vlan_table,
         })
         })
@@ -1736,8 +1730,8 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
     form = forms.InterfaceBulkDisconnectForm
     form = forms.InterfaceBulkDisconnectForm
 
 
     def disconnect_objects(self, interfaces):
     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):
 class InterfaceConnectionsListView(ObjectListView):
     queryset = Interface.objects.select_related(
     queryset = Interface.objects.select_related(
-        'connected_endpoint__device',
+        '_connected_interface', '_connected_circuittermination'
     ).filter(
     ).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 = filters.InterfaceConnectionFilter
     filter_form = forms.InterfaceConnectionFilterForm
     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
         # Add all interface connections to the graph
         connected_interfaces = Interface.objects.select_related(
         connected_interfaces = Interface.objects.select_related(
-            'connected_endpoint__device'
+            '_connected_interface__device'
         ).filter(
         ).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:
         for interface in connected_interfaces:
             style = 'solid' if interface.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
             style = 'solid' if interface.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
             self.graph.edge(interface.device.name, interface.connected_endpoint.device.name, style=style)
             self.graph.edge(interface.device.name, interface.connected_endpoint.device.name, style=style)
 
 
         # Add all circuits to the graph
         # 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()
             peer_termination = termination.get_peer_termination()
             if (peer_termination is not None and peer_termination.interface is not None and
             if (peer_termination is not None and peer_termination.interface is not None and
                     peer_termination.interface.device in devices):
                     peer_termination.interface.device in devices):

+ 2 - 2
netbox/netbox/views.py

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

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

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

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

@@ -39,10 +39,17 @@
             <tr>
             <tr>
                 <td>Termination</td>
                 <td>Termination</td>
                 <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 %}
                     {% 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>
                         <span class="text-muted">Not defined</span>
                     {% endif %}
                     {% endif %}
                 </td>
                 </td>
@@ -61,8 +68,8 @@
             <tr>
             <tr>
                 <td>IP Addressing</td>
                 <td>IP Addressing</td>
                 <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 %}
                             {% if not forloop.first %}<br />{% endif %}
                             <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a> ({{ ip.vrf|default:"Global" }})
                             <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a> ({{ ip.vrf|default:"Global" }})
                         {% empty %}
                         {% empty %}

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

@@ -29,30 +29,59 @@
                         <strong>A Side</strong>
                         <strong>A Side</strong>
                     </div>
                     </div>
                     <div class="panel-body">
                     <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>
-                        <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>
-                        <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>
-                        <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>
-                        </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>
                 </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">
 <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>
 </table>

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

@@ -50,17 +50,17 @@
         <td colspan="2" class="text-muted">Virtual interface</td>
         <td colspan="2" class="text-muted">Virtual interface</td>
     {% elif iface.is_wireless %}
     {% elif iface.is_wireless %}
         <td colspan="2" class="text-muted">Wireless interface</td>
         <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">
             <td colspan="2">
                 <i class="fa fa-fw fa-globe" title="Circuit"></i>
                 <i class="fa fa-fw fa-globe" title="Circuit"></i>
                 {% if peer_termination %}
                 {% if peer_termination %}
@@ -72,7 +72,7 @@
                     {% endif %}
                     {% endif %}
                     via
                     via
                 {% endif %}
                 {% 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>
             </td>
         {% endwith %}
         {% endwith %}
     {% else %}
     {% else %}
@@ -84,7 +84,7 @@
     {# Buttons #}
     {# Buttons #}
     <td class="text-right text-nowrap">
     <td class="text-right text-nowrap">
         {% if show_graphs %}
         {% 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">
                 <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>
                     <i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
                 </button>
                 </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">
                     <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>
                         <i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
                     </a>
                     </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 %}
                 {% 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">
                     <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>
                         <i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>