Browse Source

Closes #49: Introduction of circuit terminations

Jeremy Stretch 8 years ago
parent
commit
bf817eb69e

+ 2 - 4
netbox/circuits/admin.py

@@ -21,11 +21,9 @@ class CircuitTypeAdmin(admin.ModelAdmin):
 
 
 @admin.register(Circuit)
 @admin.register(Circuit)
 class CircuitAdmin(admin.ModelAdmin):
 class CircuitAdmin(admin.ModelAdmin):
-    list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed_human',
-                    'upstream_speed_human', 'commit_rate_human', 'xconnect_id']
+    list_display = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate_human']
     list_filter = ['provider', 'type', 'tenant']
     list_filter = ['provider', 'type', 'tenant']
-    exclude = ['interface']
 
 
     def get_queryset(self, request):
     def get_queryset(self, request):
         qs = super(CircuitAdmin, self).get_queryset(request)
         qs = super(CircuitAdmin, self).get_queryset(request)
-        return qs.select_related('provider', 'type', 'tenant', 'site')
+        return qs.select_related('provider', 'type', 'tenant')

+ 12 - 5
netbox/circuits/api/serializers.py

@@ -1,6 +1,6 @@
 from rest_framework import serializers
 from rest_framework import serializers
 
 
-from circuits.models import Provider, CircuitType, Circuit
+from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
 from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
 from extras.api.serializers import CustomFieldSerializer
 from extras.api.serializers import CustomFieldSerializer
 from tenancy.api.serializers import TenantNestedSerializer
 from tenancy.api.serializers import TenantNestedSerializer
@@ -45,17 +45,24 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer):
 # Circuits
 # Circuits
 #
 #
 
 
+class CircuitTerminationSerializer(serializers.ModelSerializer):
+    site = SiteNestedSerializer()
+    interface = InterfaceNestedSerializer()
+
+    class Meta:
+        model = CircuitTermination
+        fields = ['id', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info']
+
+
 class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
 class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer):
     provider = ProviderNestedSerializer()
     provider = ProviderNestedSerializer()
     type = CircuitTypeNestedSerializer()
     type = CircuitTypeNestedSerializer()
     tenant = TenantNestedSerializer()
     tenant = TenantNestedSerializer()
-    site = SiteNestedSerializer()
-    interface = InterfaceNestedSerializer()
+    terminations = CircuitTerminationSerializer(many=True)
 
 
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
-        fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed',
-                  'upstream_speed', 'commit_rate', 'xconnect_id', 'comments', 'custom_fields']
+        fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'comments', 'terminations', 'custom_fields']
 
 
 
 
 class CircuitNestedSerializer(CircuitSerializer):
 class CircuitNestedSerializer(CircuitSerializer):

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

@@ -43,7 +43,7 @@ class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView):
     """
     """
     List circuits (filterable)
     List circuits (filterable)
     """
     """
-    queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\
+    queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\
         .prefetch_related('custom_field_values__field')
         .prefetch_related('custom_field_values__field')
     serializer_class = serializers.CircuitSerializer
     serializer_class = serializers.CircuitSerializer
     filter_class = CircuitFilter
     filter_class = CircuitFilter
@@ -53,6 +53,6 @@ class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
     """
     """
     Retrieve a single circuit
     Retrieve a single circuit
     """
     """
-    queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\
+    queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\
         .prefetch_related('custom_field_values__field')
         .prefetch_related('custom_field_values__field')
     serializer_class = serializers.CircuitSerializer
     serializer_class = serializers.CircuitSerializer

+ 5 - 6
netbox/circuits/filters.py

@@ -16,12 +16,12 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Search',
         label='Search',
     )
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
-        name='circuits__site',
+        name='circuits__terminations__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site',
         label='Site',
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
-        name='circuits__site',
+        name='circuits__terminations__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
@@ -78,12 +78,12 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Tenant (slug)',
         label='Tenant (slug)',
     )
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
     site_id = django_filters.ModelMultipleChoiceFilter(
-        name='site',
+        name='terminations__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         label='Site (ID)',
         label='Site (ID)',
     )
     )
     site = django_filters.ModelMultipleChoiceFilter(
     site = django_filters.ModelMultipleChoiceFilter(
-        name='site',
+        name='terminations__site',
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         to_field_name='slug',
         to_field_name='slug',
         label='Site (slug)',
         label='Site (slug)',
@@ -91,12 +91,11 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
 
     class Meta:
     class Meta:
         model = Circuit
         model = Circuit
-        fields = ['q', 'provider_id', 'provider', 'type_id', 'type', 'site_id', 'site', 'interface', 'install_date']
+        fields = ['q', 'provider_id', 'provider', 'type_id', 'type', 'install_date']
 
 
     def search(self, queryset, value):
     def search(self, queryset, value):
         return queryset.filter(
         return queryset.filter(
             Q(cid__icontains=value) |
             Q(cid__icontains=value) |
             Q(xconnect_id__icontains=value) |
             Q(xconnect_id__icontains=value) |
-            Q(pp_info__icontains=value) |
             Q(comments__icontains=value)
             Q(comments__icontains=value)
         )
         )

+ 71 - 58
netbox/circuits/forms.py

@@ -9,7 +9,7 @@ from utilities.forms import (
     SlugField,
     SlugField,
 )
 )
 
 
-from .models import Circuit, CircuitType, Provider
+from .models import Circuit, CircuitTermination, CircuitType, Provider
 
 
 
 
 #
 #
@@ -82,6 +82,64 @@ class CircuitTypeForm(forms.ModelForm, BootstrapMixin):
 #
 #
 
 
 class CircuitForm(BootstrapMixin, CustomFieldForm):
 class CircuitForm(BootstrapMixin, CustomFieldForm):
+    comments = CommentField()
+
+    class Meta:
+        model = Circuit
+        fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'comments']
+        help_texts = {
+            'cid': "Unique circuit ID",
+            'install_date': "Format: YYYY-MM-DD",
+            'commit_rate': "Commited rate",
+        }
+
+
+class CircuitFromCSVForm(forms.ModelForm):
+    provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name',
+                                      error_messages={'invalid_choice': 'Provider not found.'})
+    type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
+                                  error_messages={'invalid_choice': 'Invalid circuit type.'})
+    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
+                                    error_messages={'invalid_choice': 'Tenant not found.'})
+
+    class Meta:
+        model = Circuit
+        fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate']
+
+
+class CircuitImportForm(BulkImportForm, BootstrapMixin):
+    csv = CSVDataField(csv_form=CircuitFromCSVForm)
+
+
+class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
+    type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
+    provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
+    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
+    commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
+    comments = CommentField(widget=SmallTextarea)
+
+    class Meta:
+        nullable_fields = ['tenant', 'commit_rate', 'comments']
+
+
+class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
+    model = Circuit
+    type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
+                             to_field_name='slug')
+    provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),
+                                 to_field_name='slug')
+    tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug',
+                               null_option=(0, 'None'))
+    site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')),
+                             to_field_name='slug')
+
+
+#
+# Circuit terminations
+#
+
+class CircuitTerminationForm(forms.ModelForm, BootstrapMixin):
     site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
     site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'}))
     rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
     rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
                                   widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
                                   widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}',
@@ -95,28 +153,25 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
     interface = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Interface',
     interface = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Interface',
                                        widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
                                        widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
                                                         disabled_indicator='is_connected'))
                                                         disabled_indicator='is_connected'))
-    comments = CommentField()
 
 
     class Meta:
     class Meta:
-        model = Circuit
-        fields = [
-            'cid', 'type', 'provider', 'tenant', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date',
-            'port_speed', 'upstream_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments'
-        ]
+        model = CircuitTermination
+        fields = ['term_side', 'site', 'rack', 'device', 'livesearch', 'interface', 'port_speed', 'upstream_speed',
+                  'xconnect_id', 'pp_info']
         help_texts = {
         help_texts = {
-            'cid': "Unique circuit ID",
-            'install_date': "Format: YYYY-MM-DD",
             'port_speed': "Physical circuit speed",
             'port_speed': "Physical circuit speed",
-            'commit_rate': "Commited rate",
             'xconnect_id': "ID of the local cross-connect",
             'xconnect_id': "ID of the local cross-connect",
             'pp_info': "Patch panel ID and port number(s)"
             'pp_info': "Patch panel ID and port number(s)"
         }
         }
+        widgets = {
+            'term_side': forms.HiddenInput(),
+        }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
 
 
-        super(CircuitForm, self).__init__(*args, **kwargs)
+        super(CircuitTerminationForm, self).__init__(*args, **kwargs)
 
 
-        # If this circuit has been assigned to an interface, initialize rack and device
+        # If an interface has been assigned, initialize rack and device
         if self.instance.interface:
         if self.instance.interface:
             self.initial['rack'] = self.instance.interface.device.rack
             self.initial['rack'] = self.instance.interface.device.rack
             self.initial['device'] = self.instance.interface.device
             self.initial['device'] = self.instance.interface.device
@@ -140,11 +195,13 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
         # Limit interface choices
         # Limit interface choices
         if self.is_bound and self.data.get('device'):
         if self.is_bound and self.data.get('device'):
             interfaces = Interface.objects.filter(device=self.data['device'])\
             interfaces = Interface.objects.filter(device=self.data['device'])\
-                .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
+                .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a',
+                                                                      'connected_as_b')
             self.fields['interface'].widget.attrs['initial'] = self.data.get('interface')
             self.fields['interface'].widget.attrs['initial'] = self.data.get('interface')
         elif self.initial.get('device'):
         elif self.initial.get('device'):
             interfaces = Interface.objects.filter(device=self.initial['device'])\
             interfaces = Interface.objects.filter(device=self.initial['device'])\
-                .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b')
+                .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a',
+                                                                      'connected_as_b')
             self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
             self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
         else:
         else:
             interfaces = []
             interfaces = []
@@ -154,47 +211,3 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
                 'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'),
                 'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'),
             }) for iface in interfaces
             }) for iface in interfaces
         ]
         ]
-
-
-class CircuitFromCSVForm(forms.ModelForm):
-    provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name',
-                                      error_messages={'invalid_choice': 'Provider not found.'})
-    type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name',
-                                  error_messages={'invalid_choice': 'Invalid circuit type.'})
-    tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False,
-                                    error_messages={'invalid_choice': 'Tenant not found.'})
-    site = forms.ModelChoiceField(Site.objects.all(), to_field_name='name',
-                                  error_messages={'invalid_choice': 'Site not found.'})
-
-    class Meta:
-        model = Circuit
-        fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'upstream_speed',
-                  'commit_rate', 'xconnect_id', 'pp_info']
-
-
-class CircuitImportForm(BulkImportForm, BootstrapMixin):
-    csv = CSVDataField(csv_form=CircuitFromCSVForm)
-
-
-class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
-    pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
-    type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
-    provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
-    tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
-    port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)')
-    commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
-    comments = CommentField(widget=SmallTextarea)
-
-    class Meta:
-        nullable_fields = ['tenant', 'port_speed', 'commit_rate', 'comments']
-
-
-class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
-    model = Circuit
-    type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')),
-                             to_field_name='slug')
-    provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')),
-                                 to_field_name='slug')
-    tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug',
-                               null_option=(0, 'None'))
-    site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuits')), to_field_name='slug')

+ 99 - 0
netbox/circuits/migrations/0006_terminations.py

@@ -0,0 +1,99 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.10 on 2016-12-13 16:30
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+def circuits_to_terms(apps, schema_editor):
+    Circuit = apps.get_model('circuits', 'Circuit')
+    CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
+    for c in Circuit.objects.all():
+        CircuitTermination(
+            circuit=c,
+            term_side=b'A',
+            site=c.site,
+            interface=c.interface,
+            port_speed=c.port_speed,
+            upstream_speed=c.upstream_speed,
+            xconnect_id=c.xconnect_id,
+            pp_info=c.pp_info,
+        ).save()
+
+
+def terms_to_circuits(apps, schema_editor):
+    CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
+    for ct in CircuitTermination.objects.filter(term_side='A'):
+        c = ct.circuit
+        c.site = ct.site
+        c.interface = ct.interface
+        c.port_speed = ct.port_speed
+        c.upstream_speed = ct.upstream_speed
+        c.xconnect_id = ct.xconnect_id
+        c.pp_info = ct.pp_info
+        c.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0022_color_names_to_rgb'),
+        ('circuits', '0005_circuit_add_upstream_speed'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='CircuitTermination',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('term_side', models.CharField(choices=[(b'A', b'A'), (b'Z', b'Z')], max_length=1,
+                                               verbose_name='Termination')),
+                ('port_speed', models.PositiveIntegerField(verbose_name=b'Port speed (Kbps)')),
+                ('upstream_speed',
+                 models.PositiveIntegerField(blank=True, help_text=b'Upstream speed, if different from port speed',
+                                             null=True, verbose_name=b'Upstream speed (Kbps)')),
+                ('xconnect_id', models.CharField(blank=True, max_length=50, verbose_name=b'Cross-connect ID')),
+                ('pp_info', models.CharField(blank=True, max_length=100, verbose_name=b'Patch panel/port(s)')),
+                ('circuit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations',
+                                              to='circuits.Circuit')),
+                ('interface', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
+                                                   related_name='circuit_termination', to='dcim.Interface')),
+                ('site',
+                 models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations',
+                                   to='dcim.Site')),
+            ],
+            options={
+                'ordering': ['circuit', 'term_side'],
+            },
+        ),
+        migrations.AlterUniqueTogether(
+            name='circuittermination',
+            unique_together=set([('circuit', 'term_side')]),
+        ),
+        migrations.RunPython(circuits_to_terms, terms_to_circuits),
+        migrations.RemoveField(
+            model_name='circuit',
+            name='interface',
+        ),
+        migrations.RemoveField(
+            model_name='circuit',
+            name='port_speed',
+        ),
+        migrations.RemoveField(
+            model_name='circuit',
+            name='pp_info',
+        ),
+        migrations.RemoveField(
+            model_name='circuit',
+            name='site',
+        ),
+        migrations.RemoveField(
+            model_name='circuit',
+            name='upstream_speed',
+        ),
+        migrations.RemoveField(
+            model_name='circuit',
+            name='xconnect_id',
+        ),
+    ]

+ 72 - 37
netbox/circuits/models.py

@@ -3,12 +3,35 @@ from django.core.urlresolvers import reverse
 from django.db import models
 from django.db import models
 
 
 from dcim.fields import ASNField
 from dcim.fields import ASNField
-from dcim.models import Site, Interface
 from extras.models import CustomFieldModel, CustomFieldValue
 from extras.models import CustomFieldModel, CustomFieldValue
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.models import CreatedUpdatedModel
 from utilities.models import CreatedUpdatedModel
 
 
 
 
+TERM_SIDE_A = 'A'
+TERM_SIDE_Z = 'Z'
+TERM_SIDE_CHOICES = (
+    (TERM_SIDE_A, 'A'),
+    (TERM_SIDE_Z, 'Z'),
+)
+
+
+def humanize_speed(speed):
+    """
+    Humanize speeds given in Kbps (e.g. 10000000 becomes '10 Gbps')
+    """
+    if speed >= 1000000000 and speed % 1000000000 == 0:
+        return '{} Tbps'.format(speed / 1000000000)
+    elif speed >= 1000000 and speed % 1000000 == 0:
+        return '{} Gbps'.format(speed / 1000000)
+    elif speed >= 1000 and speed % 1000 == 0:
+        return '{} Mbps'.format(speed / 1000)
+    elif speed >= 1000:
+        return '{} Mbps'.format(float(speed) / 1000)
+    else:
+        return '{} Kbps'.format(speed)
+
+
 class Provider(CreatedUpdatedModel, CustomFieldModel):
 class Provider(CreatedUpdatedModel, CustomFieldModel):
     """
     """
     Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
     Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
@@ -71,15 +94,8 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
     provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
     provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
     type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
     type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
     tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
     tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
-    site = models.ForeignKey(Site, related_name='circuits', on_delete=models.PROTECT)
-    interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True)
     install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
     install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
-    port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
-    upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)',
-                                                 help_text='Upstream speed, if different from port speed')
     commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
     commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
-    xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
-    pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
     comments = models.TextField(blank=True)
     comments = models.TextField(blank=True)
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
     custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
 
 
@@ -99,42 +115,61 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
             self.provider.name,
             self.provider.name,
             self.type.name,
             self.type.name,
             self.tenant.name if self.tenant else '',
             self.tenant.name if self.tenant else '',
-            self.site.name,
             self.install_date.isoformat() if self.install_date else '',
             self.install_date.isoformat() if self.install_date else '',
-            str(self.port_speed),
-            str(self.upstream_speed),
             str(self.commit_rate) if self.commit_rate else '',
             str(self.commit_rate) if self.commit_rate else '',
-            self.xconnect_id,
-            self.pp_info,
         ])
         ])
 
 
-    def _humanize_speed(self, speed):
-        """
-        Humanize speeds given in Kbps (e.g. 10000000 becomes '10 Gbps')
-        """
-        if speed >= 1000000000 and speed % 1000000000 == 0:
-            return '{} Tbps'.format(speed / 1000000000)
-        elif speed >= 1000000 and speed % 1000000 == 0:
-            return '{} Gbps'.format(speed / 1000000)
-        elif speed >= 1000 and speed % 1000 == 0:
-            return '{} Mbps'.format(speed / 1000)
-        elif speed >= 1000:
-            return '{} Mbps'.format(float(speed) / 1000)
-        else:
-            return '{} Kbps'.format(speed)
+    def _get_termination(self, side):
+        for ct in self.terminations.all():
+            if ct.term_side == side:
+                return ct
+        return None
+
+    @property
+    def termination_a(self):
+        return self._get_termination('A')
+
+    @property
+    def termination_z(self):
+        return self._get_termination('Z')
+
+    def commit_rate_human(self):
+        return '' if not self.commit_rate else humanize_speed(self.commit_rate)
+    commit_rate_human.admin_order_field = 'commit_rate'
+
+
+class CircuitTermination(models.Model):
+    circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE)
+    term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination')
+    site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT)
+    interface = models.OneToOneField('dcim.Interface', related_name='circuit_termination', blank=True, null=True)
+    port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)')
+    upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)',
+                                                 help_text='Upstream speed, if different from port speed')
+    xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID')
+    pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)')
+
+    class Meta:
+        ordering = ['circuit', 'term_side']
+        unique_together = ['circuit', 'term_side']
+
+    def __unicode__(self):
+        return u'{} (Side {})'.format(self.circuit, self.get_term_side_display())
+
+    def get_parent_url(self):
+        return self.circuit.get_absolute_url()
+
+    def get_peer_termination(self):
+        peer_side = 'Z' if self.term_side == 'A' else 'A'
+        try:
+            return CircuitTermination.objects.select_related('site').get(circuit=self.circuit, term_side=peer_side)
+        except CircuitTermination.DoesNotExist:
+            return None
 
 
     def port_speed_human(self):
     def port_speed_human(self):
-        return self._humanize_speed(self.port_speed)
+        return humanize_speed(self.port_speed)
     port_speed_human.admin_order_field = 'port_speed'
     port_speed_human.admin_order_field = 'port_speed'
 
 
     def upstream_speed_human(self):
     def upstream_speed_human(self):
-        if not self.upstream_speed:
-            return ''
-        return self._humanize_speed(self.upstream_speed)
+        return '' if not self.upstream_speed else humanize_speed(self.upstream_speed)
     upstream_speed_human.admin_order_field = 'upstream_speed'
     upstream_speed_human.admin_order_field = 'upstream_speed'
-
-    def commit_rate_human(self):
-        if not self.commit_rate:
-            return ''
-        return self._humanize_speed(self.commit_rate)
-    commit_rate_human.admin_order_field = 'commit_rate'

+ 5 - 4
netbox/circuits/tables.py

@@ -56,12 +56,13 @@ class CircuitTable(BaseTable):
     type = tables.Column(verbose_name='Type')
     type = tables.Column(verbose_name='Type')
     provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
     provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
     tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
-    site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
-    port_speed = tables.Column(accessor=Accessor('port_speed_human'), order_by=Accessor('port_speed'),
-                               verbose_name='Port Speed')
+    a_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_a.site'), orderable=False,
+                               args=[Accessor('termination_a.site.slug')])
+    z_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_z.site'), orderable=False,
+                               args=[Accessor('termination_z.site.slug')])
     commit_rate = tables.Column(accessor=Accessor('commit_rate_human'), order_by=Accessor('commit_rate'),
     commit_rate = tables.Column(accessor=Accessor('commit_rate_human'), order_by=Accessor('commit_rate'),
                                 verbose_name='Commit Rate')
                                 verbose_name='Commit Rate')
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = Circuit
         model = Circuit
-        fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'site', 'port_speed', 'commit_rate')
+        fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'commit_rate')

+ 6 - 0
netbox/circuits/urls.py

@@ -30,5 +30,11 @@ urlpatterns = [
     url(r'^circuits/(?P<pk>\d+)/$', views.circuit, name='circuit'),
     url(r'^circuits/(?P<pk>\d+)/$', views.circuit, name='circuit'),
     url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'),
     url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'),
     url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'),
     url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'),
+    url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),
+
+    # Circuit terminations
+    url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationEditView.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'),
 
 
 ]
 ]

+ 81 - 5
netbox/circuits/views.py

@@ -1,14 +1,18 @@
+from django.contrib import messages
+from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import PermissionRequiredMixin
 from django.contrib.auth.mixins import PermissionRequiredMixin
+from django.db import transaction
 from django.db.models import Count
 from django.db.models import Count
-from django.shortcuts import get_object_or_404, render
+from django.shortcuts import get_object_or_404, redirect, render
 
 
 from extras.models import Graph, GRAPH_TYPE_PROVIDER
 from extras.models import Graph, GRAPH_TYPE_PROVIDER
+from utilities.forms import ConfirmationForm
 from utilities.views import (
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 )
 
 
 from . import filters, forms, tables
 from . import filters, forms, tables
-from .models import Circuit, CircuitType, Provider
+from .models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z
 
 
 
 
 #
 #
@@ -27,7 +31,7 @@ class ProviderListView(ObjectListView):
 def provider(request, slug):
 def provider(request, slug):
 
 
     provider = get_object_or_404(Provider, slug=slug)
     provider = get_object_or_404(Provider, slug=slug)
-    circuits = Circuit.objects.filter(provider=provider).select_related('site', 'interface__device')
+    circuits = Circuit.objects.filter(provider=provider)
     show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
     show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
 
 
     return render(request, 'circuits/provider.html', {
     return render(request, 'circuits/provider.html', {
@@ -103,7 +107,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 #
 
 
 class CircuitListView(ObjectListView):
 class CircuitListView(ObjectListView):
-    queryset = Circuit.objects.select_related('provider', 'type', 'tenant', 'site')
+    queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
     filter = filters.CircuitFilter
     filter = filters.CircuitFilter
     filter_form = forms.CircuitFilterForm
     filter_form = forms.CircuitFilterForm
     table = tables.CircuitTable
     table = tables.CircuitTable
@@ -114,9 +118,13 @@ class CircuitListView(ObjectListView):
 def circuit(request, pk):
 def circuit(request, pk):
 
 
     circuit = get_object_or_404(Circuit, pk=pk)
     circuit = get_object_or_404(Circuit, pk=pk)
+    termination_a = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_A).first()
+    termination_z = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_Z).first()
 
 
     return render(request, 'circuits/circuit.html', {
     return render(request, 'circuits/circuit.html', {
         'circuit': circuit,
         'circuit': circuit,
+        'termination_a': termination_a,
+        'termination_z': termination_z,
     })
     })
 
 
 
 
@@ -124,7 +132,7 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'circuits.change_circuit'
     permission_required = 'circuits.change_circuit'
     model = Circuit
     model = Circuit
     form_class = forms.CircuitForm
     form_class = forms.CircuitForm
-    fields_initial = ['site']
+    fields_initial = ['provider']
     template_name = 'circuits/circuit_edit.html'
     template_name = 'circuits/circuit_edit.html'
     obj_list_url = 'circuits:circuit_list'
     obj_list_url = 'circuits:circuit_list'
 
 
@@ -155,3 +163,71 @@ class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_circuit'
     permission_required = 'circuits.delete_circuit'
     cls = Circuit
     cls = Circuit
     default_redirect_url = 'circuits:circuit_list'
     default_redirect_url = 'circuits:circuit_list'
+
+
+@permission_required('circuits.change_circuittermination')
+def circuit_terminations_swap(request, pk):
+
+    circuit = get_object_or_404(Circuit, pk=pk)
+    termination_a = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_A).first()
+    termination_z = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_Z).first()
+    if not termination_a and not termination_z:
+        messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
+        return redirect('circuits:circuit', pk=circuit.pk)
+
+    if request.method == 'POST':
+        form = ConfirmationForm(request.POST)
+        if form.is_valid():
+            if termination_a and termination_z:
+                # Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
+                with transaction.atomic():
+                    termination_a.term_side = '_'
+                    termination_a.save()
+                    termination_z.term_side = 'A'
+                    termination_z.save()
+                    termination_a.term_side = 'Z'
+                    termination_a.save()
+            elif termination_a:
+                termination_a.term_side = 'Z'
+                termination_a.save()
+            else:
+                termination_z.term_side = 'A'
+                termination_z.save()
+            messages.success(request, "Swapped terminations for circuit {}.".format(circuit))
+            return redirect('circuits:circuit', pk=circuit.pk)
+
+    else:
+        form = ConfirmationForm()
+
+    return render(request, 'circuits/circuit_terminations_swap.html', {
+        'circuit': circuit,
+        'termination_a': termination_a,
+        'termination_z': termination_z,
+        'form': form,
+        'panel_class': 'default',
+        'button_class': 'primary',
+        'cancel_url': circuit.get_absolute_url(),
+    })
+
+
+#
+# Circuit terminations
+#
+
+class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView):
+    permission_required = 'circuits.change_circuittermination'
+    model = CircuitTermination
+    form_class = forms.CircuitTerminationForm
+    fields_initial = ['term_side']
+    template_name = 'circuits/circuittermination_edit.html'
+
+    def alter_obj(self, obj, args, kwargs):
+        if 'circuit' in kwargs:
+            circuit = get_object_or_404(Circuit, pk=kwargs['circuit'])
+            obj.circuit = circuit
+        return obj
+
+
+class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+    permission_required = 'circuits.delete_circuittermination'
+    model = CircuitTermination

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

@@ -484,7 +484,7 @@ class RelatedConnectionsView(APIView):
 
 
         # Interface connections
         # Interface connections
         interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b',
         interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b',
-                                                                            'circuit')
+                                                                            'circuit_termination')
         for iface in interfaces:
         for iface in interfaces:
             data = serializers.InterfaceDetailSerializer(instance=iface).data
             data = serializers.InterfaceDetailSerializer(instance=iface).data
             del(data['device'])
             del(data['device'])

+ 3 - 2
netbox/dcim/models.py

@@ -9,6 +9,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
 from django.db.models import Count, Q, ObjectDoesNotExist
 from django.db.models import Count, Q, ObjectDoesNotExist
 
 
+from circuits.models import Circuit
 from extras.models import CustomFieldModel, CustomField, CustomFieldValue
 from extras.models import CustomFieldModel, CustomField, CustomFieldValue
 from extras.rpc import RPC_CLIENTS
 from extras.rpc import RPC_CLIENTS
 from tenancy.models import Tenant
 from tenancy.models import Tenant
@@ -285,7 +286,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
 
 
     @property
     @property
     def count_circuits(self):
     def count_circuits(self):
-        return self.circuits.count()
+        return Circuit.objects.filter(terminations__site=self).count()
 
 
 
 
 #
 #
@@ -1136,7 +1137,7 @@ class Interface(models.Model):
     @property
     @property
     def is_connected(self):
     def is_connected(self):
         try:
         try:
-            return bool(self.circuit)
+            return bool(self.circuit_termination)
         except ObjectDoesNotExist:
         except ObjectDoesNotExist:
             pass
             pass
         return bool(self.connection)
         return bool(self.connection)

+ 3 - 3
netbox/dcim/views.py

@@ -78,7 +78,7 @@ def site(request, slug):
         'device_count': Device.objects.filter(rack__site=site).count(),
         'device_count': Device.objects.filter(rack__site=site).count(),
         'prefix_count': Prefix.objects.filter(site=site).count(),
         'prefix_count': Prefix.objects.filter(site=site).count(),
         'vlan_count': VLAN.objects.filter(site=site).count(),
         'vlan_count': VLAN.objects.filter(site=site).count(),
-        'circuit_count': Circuit.objects.filter(site=site).count(),
+        'circuit_count': Circuit.objects.filter(terminations__site=site).count(),
     }
     }
     rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks'))
     rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks'))
     topology_maps = TopologyMap.objects.filter(site=site)
     topology_maps = TopologyMap.objects.filter(site=site)
@@ -561,9 +561,9 @@ def device(request, pk):
         PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name')
         PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name')
     )
     )
     interfaces = Interface.objects.filter(device=device, mgmt_only=False)\
     interfaces = Interface.objects.filter(device=device, mgmt_only=False)\
-        .select_related('connected_as_a', 'connected_as_b', 'circuit')
+        .select_related('connected_as_a', 'connected_as_b', 'circuit_termination__circuit')
     mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True)\
     mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True)\
-        .select_related('connected_as_a', 'connected_as_b', 'circuit')
+        .select_related('connected_as_a', 'connected_as_b', 'circuit_termination__circuit')
     device_bays = natsorted(
     device_bays = natsorted(
         DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
         DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
         key=attrgetter('name')
         key=attrgetter('name')

+ 4 - 71
netbox/templates/circuits/circuit.html

@@ -83,17 +83,6 @@
                     </td>
                     </td>
                 </tr>
                 </tr>
                 <tr>
                 <tr>
-                    <td>Speed</td>
-                    <td>
-                        {% if circuit.upstream_speed %}
-                            <i class="fa fa-arrow-down" title="Downstream"></i> {{ circuit.port_speed_human }} &nbsp;
-                            <i class="fa fa-arrow-up" title="Upstream"></i> {{ circuit.upstream_speed_human }}
-                        {% else %}
-                            {{ circuit.port_speed_human }}
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
                     <td>Commit Rate</td>
                     <td>Commit Rate</td>
                     <td>
                     <td>
                         {% if circuit.commit_rate %}
                         {% if circuit.commit_rate %}
@@ -108,66 +97,6 @@
         {% with circuit.get_custom_fields as custom_fields %}
         {% with circuit.get_custom_fields as custom_fields %}
             {% include 'inc/custom_fields_panel.html' %}
             {% include 'inc/custom_fields_panel.html' %}
         {% endwith %}
         {% endwith %}
-	</div>
-	<div class="col-md-6">
-        <div class="panel panel-default">
-            <div class="panel-heading">
-                <strong>Termination</strong>
-            </div>
-            <table class="table table-hover panel-body">
-                <tr>
-                    <td>Site</td>
-                    <td>
-                        <a href="{% url 'dcim:site' slug=circuit.site.slug %}">{{ circuit.site }}</a>
-                    </td>
-                </tr>
-                <tr>
-                    <td>Termination</td>
-                    <td>
-                        {% if circuit.interface %}
-                            <span><a href="{% url 'dcim:device' pk=circuit.interface.device.pk %}">{{ circuit.interface.device }}</a> {{ circuit.interface }}</span>
-                        {% else %}
-                            <span class="text-muted">Not defined</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>IP Addressing</td>
-                    <td>
-                        {% if circuit.interface %}
-                            {% for ip in circuit.interface.ip_addresses.all %}
-                                {% if not forloop.first %}<br />{% endif %}
-                                <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a> ({{ ip.vrf|default:"Global" }})
-                            {% empty %}
-                                <span class="text-muted">None</span>
-                            {% endfor %}
-                        {% else %}
-                            <span class="text-muted">N/A</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Cross-Connect</td>
-                    <td>
-                        {% if circuit.xconnect_id %}
-                            {{ circuit.xconnect_id }}
-                        {% else %}
-                            <span class="text-muted">N/A</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <td>Patch Panel/Port</td>
-                    <td>
-                        {% if circuit.pp_info %}
-                            {{ circuit.pp_info }}
-                        {% else %}
-                            <span class="text-muted">N/A</span>
-                        {% endif %}
-                    </td>
-                </tr>
-            </table>
-        </div>
         <div class="panel panel-default">
         <div class="panel panel-default">
             <div class="panel-heading">
             <div class="panel-heading">
                 <strong>Comments</strong>
                 <strong>Comments</strong>
@@ -180,6 +109,10 @@
                 {% endif %}
                 {% endif %}
             </div>
             </div>
         </div>
         </div>
+	</div>
+	<div class="col-md-6">
+        {% include 'circuits/inc/circuit_termination.html' with termination=termination_a side='A' %}
+        {% include 'circuits/inc/circuit_termination.html' with termination=termination_z side='Z' %}
     </div>
     </div>
 </div>
 </div>
 {% endblock %}
 {% endblock %}

+ 0 - 34
netbox/templates/circuits/circuit_edit.html

@@ -1,5 +1,4 @@
 {% extends 'utilities/obj_edit.html' %}
 {% extends 'utilities/obj_edit.html' %}
-{% load static from staticfiles %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block form %}
 {% block form %}
@@ -11,15 +10,6 @@
             {% render_field form.type %}
             {% render_field form.type %}
             {% render_field form.tenant %}
             {% render_field form.tenant %}
             {% render_field form.install_date %}
             {% render_field form.install_date %}
-            {% render_field form.xconnect_id %}
-            {% render_field form.pp_info %}
-        </div>
-    </div>
-    <div class="panel panel-default">
-        <div class="panel-heading"><strong>Bandwidth</strong></div>
-        <div class="panel-body">
-            {% render_field form.port_speed %}
-            {% render_field form.upstream_speed %}
             {% render_field form.commit_rate %}
             {% render_field form.commit_rate %}
         </div>
         </div>
     </div>
     </div>
@@ -32,33 +22,9 @@
         </div>
         </div>
     {% endif %}
     {% endif %}
     <div class="panel panel-default">
     <div class="panel panel-default">
-        <div class="panel-heading"><strong>Termination</strong></div>
-        <div class="panel-body">
-            {% render_field form.site %}
-            <ul class="nav nav-tabs" role="tablist">
-                <li role="presentation" class="active"><a href="#select" aria-controls="home" role="tab" data-toggle="tab">Select</a></li>
-                <li role="presentation"><a href="#search" aria-controls="search" role="tab" data-toggle="tab">Search</a></li>
-            </ul>
-            <div class="tab-content">
-                <div class="tab-pane active" id="select">
-                    {% render_field form.rack %}
-                    {% render_field form.device %}
-                </div>
-                <div class="tab-pane" id="search">
-                    {% render_field form.livesearch %}
-                </div>
-            </div>
-            {% render_field form.interface %}
-        </div>
-    </div>
-    <div class="panel panel-default">
         <div class="panel-heading"><strong>Comments</strong></div>
         <div class="panel-heading"><strong>Comments</strong></div>
         <div class="panel-body">
         <div class="panel-body">
             {% render_field form.comments %}
             {% render_field form.comments %}
         </div>
         </div>
     </div>
     </div>
 {% endblock %}
 {% endblock %}
-
-{% block javascript %}
-<script src="{% static 'js/livesearch.js' %}"></script>
-{% endblock %}

+ 25 - 0
netbox/templates/circuits/circuit_terminations_swap.html

@@ -0,0 +1,25 @@
+{% extends 'utilities/confirmation_form.html' %}
+
+{% block title %}Swap Circuit Terminations{% endblock %}
+
+{% block message %}
+    <p>Swap these terminations for circuit {{ circuit }}?</p>
+    <ul>
+        <li>
+            <strong>A side:</strong>
+            {% if termination_a %}
+                {{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
+            {% else %}
+                <span class="text-muted">None</span>
+            {% endif %}
+        </li>
+        <li>
+            <strong>Z side:</strong>
+            {% if termination_z %}
+                {{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %}
+            {% else %}
+                <span class="text-muted">None</span>
+            {% endif %}
+        </li>
+    </ul>
+{% endblock %}

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

@@ -0,0 +1,94 @@
+{% extends '_base.html' %}
+{% load staticfiles %}
+{% load form_helpers %}
+
+{% block title %}
+    Circuit {{ obj.circuit }} - Side {{ form.term_side.value }}
+{% endblock %}
+
+{% block content %}
+    <form action="." method="post" class="form form-horizontal">
+        {% csrf_token %}
+        {% for field in form.hidden_fields %}
+            {{ field }}
+        {% endfor %}
+        <div class="row">
+            <div class="col-md-6 col-md-offset-3">
+                <h3>Circuit {{ obj.circuit }} - Side {{ form.term_side.value }}</h3>
+                {% if form.non_field_errors %}
+                    <div class="panel panel-danger">
+                        <div class="panel-heading"><strong>Errors</strong></div>
+                        <div class="panel-body">
+                            {{ form.non_field_errors }}
+                        </div>
+                    </div>
+                {% endif %}
+                <div class="panel panel-default">
+                    <div class="panel-heading"><strong>Location</strong></div>
+                    <div class="panel-body">
+                        <div class="form-group">
+                            <label class="col-md-3 control-label">Provider</label>
+                            <div class="col-md-9">
+                                <p class="form-control-static">{{ obj.circuit.provider }}</p>
+                            </div>
+                        </div>
+                        <div class="form-group">
+                            <label class="col-md-3 control-label">Circuit</label>
+                            <div class="col-md-9">
+                                <p class="form-control-static">{{ obj.circuit.cid }}</p>
+                            </div>
+                        </div>
+                        <div class="form-group">
+                            <label class="col-md-3 control-label">Termination</label>
+                            <div class="col-md-9">
+                                <p class="form-control-static">{{ form.term_side.value }}</p>
+                            </div>
+                        </div>
+                        {% render_field form.site %}
+                        <div class="row">
+                            <div class="col-md-9 col-md-offset-3">
+                                <ul class="nav nav-tabs" role="tablist">
+                                    <li role="presentation" class="active"><a href="#select" aria-controls="home" role="tab" data-toggle="tab">Select</a></li>
+                                    <li role="presentation"><a href="#search" aria-controls="search" role="tab" data-toggle="tab">Search</a></li>
+                                </ul>
+                            </div>
+                        </div>
+                        <div class="tab-content">
+                            <div class="tab-pane active" id="select">
+                                {% render_field form.rack %}
+                                {% render_field form.device %}
+                            </div>
+                            <div class="tab-pane" id="search">
+                                {% render_field form.livesearch %}
+                            </div>
+                        </div>
+                        {% render_field form.interface %}
+                    </div>
+                </div>
+                <div class="panel panel-default">
+                    <div class="panel-heading"><strong>Termination Details</strong></div>
+                    <div class="panel-body">
+                        {% render_field form.port_speed %}
+                        {% render_field form.upstream_speed %}
+                        {% render_field form.xconnect_id %}
+                        {% render_field form.pp_info %}
+                    </div>
+                </div>
+            </div>
+        </div>
+        <div class="row">
+            <div class="col-md-6 col-md-offset-3 text-right">
+                {% if obj.pk %}
+                    <button type="submit" name="_update" class="btn btn-primary">Update</button>
+                {% else %}
+                    <button type="submit" name="_create" class="btn btn-primary">Create</button>
+                {% endif %}
+                <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
+            </div>
+        </div>
+    </form>
+{% endblock %}
+
+{% block javascript %}
+<script src="{% static 'js/livesearch.js' %}"></script>
+{% endblock %}

+ 95 - 0
netbox/templates/circuits/inc/circuit_termination.html

@@ -0,0 +1,95 @@
+<div class="panel panel-default">
+    <div class="panel-heading">
+        <div class="pull-right">
+            {% if not termination and perms.circuits.add_circuittermination %}
+                <a href="{% url 'circuits:circuittermination_add' circuit=circuit.pk %}?term_side={{ side }}" class="btn btn-xs btn-success">
+                    <span class="fa fa-plus" aria-hidden="true"></span> Add
+                </a>
+            {% endif %}
+            {% if termination and perms.circuits.change_circuittermination %}
+                <a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}" class="btn btn-xs btn-warning">
+                    <span class="fa fa-pencil" aria-hidden="true"></span> Edit
+                </a>
+                <a href="{% url 'circuits:circuit_terminations_swap' pk=circuit.pk %}" class="btn btn-xs btn-primary">
+                    <span class="fa fa-refresh" aria-hidden="true"></span> Swap
+                </a>
+            {% endif %}
+            {% if termination and perms.circuits.delete_circuittermination %}
+                <a href="{% url 'circuits:circuittermination_delete' pk=termination.pk %}" class="btn btn-xs btn-danger">
+                    <span class="fa fa-trash" aria-hidden="true"></span> Delete
+                </a>
+            {% endif %}
+        </div>
+        <strong>Termination - {{ side }} Side</strong>
+    </div>
+    {% if termination %}
+        <table class="table table-hover panel-body">
+            <tr>
+                <td>Site</td>
+                <td>
+                    <a href="{% url 'dcim:site' slug=termination.site.slug %}">{{ termination.site }}</a>
+                </td>
+            </tr>
+            <tr>
+                <td>Termination</td>
+                <td>
+                    {% if termination.interface %}
+                        <span><a href="{% url 'dcim:device' pk=termination.interface.device.pk %}">{{ termination.interface.device }}</a> {{ termination.interface }}</span>
+                    {% else %}
+                        <span class="text-muted">Not defined</span>
+                    {% endif %}
+                </td>
+            </tr>
+            <tr>
+                <td>Speed</td>
+                <td>
+                    {% if termination.upstream_speed %}
+                        <i class="fa fa-arrow-down" title="Downstream"></i> {{ termination.port_speed_human }} &nbsp;
+                        <i class="fa fa-arrow-up" title="Upstream"></i> {{ termination.upstream_speed_human }}
+                    {% else %}
+                        {{ termination.port_speed_human }}
+                    {% endif %}
+                </td>
+            </tr>
+            <tr>
+                <td>IP Addressing</td>
+                <td>
+                    {% if termination.interface %}
+                        {% for ip in termination.interface.ip_addresses.all %}
+                            {% if not forloop.first %}<br />{% endif %}
+                            <a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a> ({{ ip.vrf|default:"Global" }})
+                        {% empty %}
+                            <span class="text-muted">None</span>
+                        {% endfor %}
+                    {% else %}
+                        <span class="text-muted">N/A</span>
+                    {% endif %}
+                </td>
+            </tr>
+            <tr>
+                <td>Cross-Connect</td>
+                <td>
+                    {% if termination.xconnect_id %}
+                        {{ termination.xconnect_id }}
+                    {% else %}
+                        <span class="text-muted">N/A</span>
+                    {% endif %}
+                </td>
+            </tr>
+            <tr>
+                <td>Patch Panel/Port</td>
+                <td>
+                    {% if termination.pp_info %}
+                        {{ termination.pp_info }}
+                    {% else %}
+                        <span class="text-muted">N/A</span>
+                    {% endif %}
+                </td>
+            </tr>
+        </table>
+    {% else %}
+        <div class="panel-body">
+            <span class="text-muted">None</span>
+        </div>
+    {% endif %}
+</div>

+ 8 - 7
netbox/templates/circuits/provider.html

@@ -134,14 +134,8 @@
                             <a href="{% url 'circuits:circuit' pk=c.pk %}">{{ c.cid }}</a>
                             <a href="{% url 'circuits:circuit' pk=c.pk %}">{{ c.cid }}</a>
                         </td>
                         </td>
                         <td>
                         <td>
-                            <a href="{% url 'dcim:site' slug=c.site.slug %}">{{ c.site }}</a>
+                            <a href="{% url 'circuits:circuit_list' %}?type={{ c.type.slug }}">{{ c.type }}</a>
                         </td>
                         </td>
-                        <td>
-                            {% if c.interface %}
-                                <a href="{% url 'dcim:device' pk=c.interface.device.pk %}">{{ c.interface.device }}</a>
-                            {% endif %}
-                        </td>
-                        <td>{{ c.port_speed_human }}</td>
                     </tr>
                     </tr>
                 {% empty %}
                 {% empty %}
                     <tr>
                     <tr>
@@ -149,6 +143,13 @@
                     </tr>
                     </tr>
                 {% endfor %}
                 {% endfor %}
             </table>
             </table>
+            {% if perms.circuits.add_circuit %}
+                <div class="panel-footer text-right">
+                    <a href="{% url 'circuits:circuit_add' %}?provider={{ provider.pk }}" class="btn btn-xs btn-primary">
+                        <span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add circuit
+                    </a>
+                </div>
+            {% endif %}
         </div>
         </div>
     </div>
     </div>
 </div>
 </div>

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

@@ -57,7 +57,7 @@
         <div class="panel-body">
         <div class="panel-body">
             {% render_field form.platform %}
             {% render_field form.platform %}
             {% render_field form.status %}
             {% render_field form.status %}
-            {% if obj %}
+            {% if obj.pk %}
                 {% render_field form.primary_ip4 %}
                 {% render_field form.primary_ip4 %}
                 {% render_field form.primary_ip6 %}
                 {% render_field form.primary_ip6 %}
             {% endif %}
             {% endif %}

+ 18 - 9
netbox/templates/dcim/inc/_interface.html

@@ -14,7 +14,7 @@
         <small>{{ iface.mac_address|default:'' }}</small>
         <small>{{ iface.mac_address|default:'' }}</small>
     </td>
     </td>
     {% if not iface.is_physical %}
     {% if not iface.is_physical %}
-        <td colspan="2">Virtual</td>
+        <td colspan="2" class="text-muted">Virtual interface</td>
     {% elif iface.connection %}
     {% elif iface.connection %}
         {% with iface.get_connected_interface as connected_iface %}
         {% with iface.get_connected_interface as connected_iface %}
             <td>
             <td>
@@ -24,10 +24,16 @@
                 <span title="{{ connected_iface.get_form_factor_display }}">{{ connected_iface }}</span>
                 <span title="{{ connected_iface.get_form_factor_display }}">{{ connected_iface }}</span>
             </td>
             </td>
         {% endwith %}
         {% endwith %}
-    {% elif iface.circuit %}
-        <td colspan="2">
-            <a href="{% url 'circuits:circuit' pk=iface.circuit.pk %}">{{ iface.circuit }}</a>
-        </td>
+    {% elif iface.circuit_termination %}
+        {% with iface.circuit_termination.get_peer_termination as peer_termination %}
+            <td colspan="2">
+                <i class="fa fa-fw fa-globe"></i>
+                {% if peer_termination %}
+                    <a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">{{ peer_termination.site }}</a> via
+                {% endif %}
+                <a href="{% url 'circuits:circuit' pk=iface.circuit_termination.circuit_id %}">{{ iface.circuit_termination.circuit }}</a>
+            </td>
+        {% endwith %}
     {% else %}
     {% else %}
         <td colspan="2">
         <td colspan="2">
             <span class="text-muted">Not connected</span>
             <span class="text-muted">Not connected</span>
@@ -35,7 +41,7 @@
     {% endif %}
     {% endif %}
     <td class="text-right">
     <td class="text-right">
         {% if show_graphs %}
         {% if show_graphs %}
-            {% if iface.circuit or iface.connection %}
+            {% if iface.circuit_termination or iface.connection %}
                 <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>
@@ -56,8 +62,11 @@
                     <a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Delete connection">
                     <a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Delete connection">
                         <i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
                         <i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
                     </a>
                     </a>
-                {% elif iface.circuit and perms.circuits.change_circuit %}
-                    <a href="{% url 'circuits:circuit_edit' pk=iface.circuit.pk %}" class="btn btn-danger btn-xs" title="Edit circuit">
+                {% 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 %}" class="btn btn-danger btn-xs" title="Edit circuit termination">
                         <i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
                         <i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
                     </a>
                     </a>
                 {% else %}
                 {% else %}
@@ -71,7 +80,7 @@
             </a>
             </a>
         {% endif %}
         {% endif %}
         {% if perms.dcim.delete_interface %}
         {% if perms.dcim.delete_interface %}
-            {% if iface.connection or iface.circuit %}
+            {% if iface.connection or iface.circuit_termination %}
                 <button class="btn btn-danger btn-xs" disabled="disabled">
                 <button class="btn btn-danger btn-xs" disabled="disabled">
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 </button>
                 </button>

+ 1 - 1
netbox/templates/ipam/ipaddress_edit.html

@@ -10,7 +10,7 @@
             {% render_field form.vrf %}
             {% render_field form.vrf %}
             {% render_field form.tenant %}
             {% render_field form.tenant %}
             {% render_field form.status %}
             {% render_field form.status %}
-            {% if obj %}
+            {% if obj.pk %}
                 <div class="form-group">
                 <div class="form-group">
                     <label class="col-md-3 control-label">Device</label>
                     <label class="col-md-3 control-label">Device</label>
                     <div class="col-md-9">
                     <div class="col-md-9">

+ 2 - 2
netbox/templates/utilities/confirmation_form.html

@@ -6,7 +6,7 @@
 	<div class="col-md-6 col-md-offset-3">
 	<div class="col-md-6 col-md-offset-3">
         <form action="." method="post" class="form">
         <form action="." method="post" class="form">
         {% csrf_token %}
         {% csrf_token %}
-            <div class="panel panel-danger">
+            <div class="panel panel-{{ panel_class|default:"danger" }}">
                 <div class="panel-heading">{% block title %}{% endblock %}</div>
                 <div class="panel-heading">{% block title %}{% endblock %}</div>
                 <div class="panel-body">
                 <div class="panel-body">
                     {% block message %}<p>Are you sure?</p>{% endblock %}
                     {% block message %}<p>Are you sure?</p>{% endblock %}
@@ -22,7 +22,7 @@
                         </div>
                         </div>
                     </div>
                     </div>
                     <div class="text-right">
                     <div class="text-right">
-                        <button type="submit" name="_confirm" class="btn btn-danger">Confirm</button>
+                        <button type="submit" name="_confirm" class="btn btn-{{ button_class|default:"danger" }}">Confirm</button>
                         <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
                         <a href="{{ cancel_url }}" class="btn btn-default">Cancel</a>
                     </div>
                     </div>
                 </div>
                 </div>

+ 6 - 3
netbox/templates/utilities/obj_edit.html

@@ -2,15 +2,18 @@
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block title %}
 {% block title %}
-    {% if obj %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}
+    {% if obj.pk %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}
 {% endblock %}
 {% endblock %}
 
 
 {% block content %}
 {% block content %}
     <form action="." method="post" class="form form-horizontal">
     <form action="." method="post" class="form form-horizontal">
         {% csrf_token %}
         {% csrf_token %}
+        {% for field in form.hidden_fields %}
+            {{ field }}
+        {% endfor %}
         <div class="row">
         <div class="row">
             <div class="col-md-6 col-md-offset-3">
             <div class="col-md-6 col-md-offset-3">
-                <h3>{% if obj %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}</h3>
+                <h3>{% if obj.pk %}Editing {{ obj_type }} {{ obj }}{% else %}Add a new {{ obj_type }}{% endif %}</h3>
                 {% if form.non_field_errors %}
                 {% if form.non_field_errors %}
                     <div class="panel panel-danger">
                     <div class="panel panel-danger">
                         <div class="panel-heading"><strong>Errors</strong></div>
                         <div class="panel-heading"><strong>Errors</strong></div>
@@ -31,7 +34,7 @@
         </div>
         </div>
         <div class="row">
         <div class="row">
             <div class="col-md-6 col-md-offset-3 text-right">
             <div class="col-md-6 col-md-offset-3 text-right">
-                {% if obj %}
+                {% if obj.pk %}
                     <button type="submit" name="_update" class="btn btn-primary">Update</button>
                     <button type="submit" name="_update" class="btn btn-primary">Update</button>
                 {% else %}
                 {% else %}
                     <button type="submit" name="_create" class="btn btn-primary">Create</button>
                     <button type="submit" name="_create" class="btn btn-primary">Create</button>

+ 20 - 16
netbox/utilities/views.py

@@ -123,28 +123,32 @@ class ObjectEditView(View):
     use_obj_view = True
     use_obj_view = True
 
 
     def get_object(self, kwargs):
     def get_object(self, kwargs):
-        # Look up object by slug if one has been provided. Otherwise, use PK.
+        # Look up object by slug or PK. Return None if neither was provided.
         if 'slug' in kwargs:
         if 'slug' in kwargs:
             return get_object_or_404(self.model, slug=kwargs['slug'])
             return get_object_or_404(self.model, slug=kwargs['slug'])
-        else:
+        elif 'pk' in kwargs:
             return get_object_or_404(self.model, pk=kwargs['pk'])
             return get_object_or_404(self.model, pk=kwargs['pk'])
+        return self.model()
+
+    def alter_obj(self, obj, args, kwargs):
+        # Allow views to add extra info to an object before it is processed. For example, a parent object can be defined
+        # given some parameter from the request URI.
+        return obj
 
 
     def get_redirect_url(self, obj):
     def get_redirect_url(self, obj):
-        if obj and self.use_obj_view:
-            if hasattr(obj, 'get_absolute_url'):
-                return obj.get_absolute_url()
-            if hasattr(obj, 'get_parent_url'):
-                return obj.get_parent_url()
+        # Determine where to redirect the user after updating an object (or aborting an update).
+        if obj.pk and self.use_obj_view and hasattr(obj, 'get_absolute_url'):
+            return obj.get_absolute_url()
+        if obj and self.use_obj_view and hasattr(obj, 'get_parent_url'):
+            return obj.get_parent_url()
         return reverse(self.obj_list_url)
         return reverse(self.obj_list_url)
 
 
     def get(self, request, *args, **kwargs):
     def get(self, request, *args, **kwargs):
 
 
-        if kwargs:
-            obj = self.get_object(kwargs)
-            form = self.form_class(instance=obj)
-        else:
-            obj = None
-            form = self.form_class(initial={k: request.GET.get(k) for k in self.fields_initial})
+        obj = self.get_object(kwargs)
+        obj = self.alter_obj(obj, args, kwargs)
+        initial_data = {k: request.GET[k] for k in self.fields_initial if k in request.GET}
+        form = self.form_class(instance=obj, initial=initial_data)
 
 
         return render(request, self.template_name, {
         return render(request, self.template_name, {
             'obj': obj,
             'obj': obj,
@@ -155,10 +159,10 @@ class ObjectEditView(View):
 
 
     def post(self, request, *args, **kwargs):
     def post(self, request, *args, **kwargs):
 
 
-        # Validate object if editing an existing object
-        obj = self.get_object(kwargs) if kwargs else None
-
+        obj = self.get_object(kwargs)
+        obj = self.alter_obj(obj, args, kwargs)
         form = self.form_class(request.POST, instance=obj)
         form = self.form_class(request.POST, instance=obj)
+
         if form.is_valid():
         if form.is_valid():
             obj = form.save(commit=False)
             obj = form.save(commit=False)
             obj_created = not obj.pk
             obj_created = not obj.pk