Parcourir la source

Closes #49: Introduction of circuit terminations

Jeremy Stretch il y a 8 ans
Parent
commit
bf817eb69e

+ 2 - 4
netbox/circuits/admin.py

@@ -21,11 +21,9 @@ class CircuitTypeAdmin(admin.ModelAdmin):
 
 @admin.register(Circuit)
 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']
-    exclude = ['interface']
 
     def get_queryset(self, 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 circuits.models import Provider, CircuitType, Circuit
+from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
 from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer
 from extras.api.serializers import CustomFieldSerializer
 from tenancy.api.serializers import TenantNestedSerializer
@@ -45,17 +45,24 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer):
 # 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):
     provider = ProviderNestedSerializer()
     type = CircuitTypeNestedSerializer()
     tenant = TenantNestedSerializer()
-    site = SiteNestedSerializer()
-    interface = InterfaceNestedSerializer()
+    terminations = CircuitTerminationSerializer(many=True)
 
     class Meta:
         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):

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

@@ -43,7 +43,7 @@ class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView):
     """
     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')
     serializer_class = serializers.CircuitSerializer
     filter_class = CircuitFilter
@@ -53,6 +53,6 @@ class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView):
     """
     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')
     serializer_class = serializers.CircuitSerializer

+ 5 - 6
netbox/circuits/filters.py

@@ -16,12 +16,12 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Search',
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
-        name='circuits__site',
+        name='circuits__terminations__site',
         queryset=Site.objects.all(),
         label='Site',
     )
     site = django_filters.ModelMultipleChoiceFilter(
-        name='circuits__site',
+        name='circuits__terminations__site',
         queryset=Site.objects.all(),
         to_field_name='slug',
         label='Site (slug)',
@@ -78,12 +78,12 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
         label='Tenant (slug)',
     )
     site_id = django_filters.ModelMultipleChoiceFilter(
-        name='site',
+        name='terminations__site',
         queryset=Site.objects.all(),
         label='Site (ID)',
     )
     site = django_filters.ModelMultipleChoiceFilter(
-        name='site',
+        name='terminations__site',
         queryset=Site.objects.all(),
         to_field_name='slug',
         label='Site (slug)',
@@ -91,12 +91,11 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
 
     class Meta:
         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):
         return queryset.filter(
             Q(cid__icontains=value) |
             Q(xconnect_id__icontains=value) |
-            Q(pp_info__icontains=value) |
             Q(comments__icontains=value)
         )

+ 71 - 58
netbox/circuits/forms.py

@@ -9,7 +9,7 @@ from utilities.forms import (
     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):
+    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'}))
     rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack',
                                   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',
                                        widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical',
                                                         disabled_indicator='is_connected'))
-    comments = CommentField()
 
     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 = {
-            'cid': "Unique circuit ID",
-            'install_date': "Format: YYYY-MM-DD",
             'port_speed': "Physical circuit speed",
-            'commit_rate': "Commited rate",
             'xconnect_id': "ID of the local cross-connect",
             'pp_info': "Patch panel ID and port number(s)"
         }
+        widgets = {
+            'term_side': forms.HiddenInput(),
+        }
 
     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:
             self.initial['rack'] = self.instance.interface.device.rack
             self.initial['device'] = self.instance.interface.device
@@ -140,11 +195,13 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
         # Limit interface choices
         if self.is_bound and self.data.get('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')
         elif self.initial.get('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')
         else:
             interfaces = []
@@ -154,47 +211,3 @@ class CircuitForm(BootstrapMixin, CustomFieldForm):
                 'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'),
             }) 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 dcim.fields import ASNField
-from dcim.models import Site, Interface
 from extras.models import CustomFieldModel, CustomFieldValue
 from tenancy.models import Tenant
 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):
     """
     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)
     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)
-    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')
-    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)')
-    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)
     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.type.name,
             self.tenant.name if self.tenant else '',
-            self.site.name,
             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 '',
-            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):
-        return self._humanize_speed(self.port_speed)
+        return humanize_speed(self.port_speed)
     port_speed_human.admin_order_field = 'port_speed'
 
     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'
-
-    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')
     provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider')
     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'),
                                 verbose_name='Commit Rate')
 
     class Meta(BaseTable.Meta):
         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+)/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+)/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.db import transaction
 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 utilities.forms import ConfirmationForm
 from utilities.views import (
     BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 
 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):
 
     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()
 
     return render(request, 'circuits/provider.html', {
@@ -103,7 +107,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
 #
 
 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_form = forms.CircuitFilterForm
     table = tables.CircuitTable
@@ -114,9 +118,13 @@ class CircuitListView(ObjectListView):
 def circuit(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()
 
     return render(request, 'circuits/circuit.html', {
         'circuit': circuit,
+        'termination_a': termination_a,
+        'termination_z': termination_z,
     })
 
 
@@ -124,7 +132,7 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'circuits.change_circuit'
     model = Circuit
     form_class = forms.CircuitForm
-    fields_initial = ['site']
+    fields_initial = ['provider']
     template_name = 'circuits/circuit_edit.html'
     obj_list_url = 'circuits:circuit_list'
 
@@ -155,3 +163,71 @@ class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
     permission_required = 'circuits.delete_circuit'
     cls = Circuit
     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
         interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b',
-                                                                            'circuit')
+                                                                            'circuit_termination')
         for iface in interfaces:
             data = serializers.InterfaceDetailSerializer(instance=iface).data
             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.models import Count, Q, ObjectDoesNotExist
 
+from circuits.models import Circuit
 from extras.models import CustomFieldModel, CustomField, CustomFieldValue
 from extras.rpc import RPC_CLIENTS
 from tenancy.models import Tenant
@@ -285,7 +286,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
 
     @property
     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
     def is_connected(self):
         try:
-            return bool(self.circuit)
+            return bool(self.circuit_termination)
         except ObjectDoesNotExist:
             pass
         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(),
         'prefix_count': Prefix.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'))
     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')
     )
     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)\
-        .select_related('connected_as_a', 'connected_as_b', 'circuit')
+        .select_related('connected_as_a', 'connected_as_b', 'circuit_termination__circuit')
     device_bays = natsorted(
         DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'),
         key=attrgetter('name')

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

@@ -83,17 +83,6 @@
                     </td>
                 </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>
                         {% if circuit.commit_rate %}
@@ -108,66 +97,6 @@
         {% with circuit.get_custom_fields as custom_fields %}
             {% include 'inc/custom_fields_panel.html' %}
         {% 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-heading">
                 <strong>Comments</strong>
@@ -180,6 +109,10 @@
                 {% endif %}
             </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>
 {% endblock %}

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

@@ -1,5 +1,4 @@
 {% extends 'utilities/obj_edit.html' %}
-{% load static from staticfiles %}
 {% load form_helpers %}
 
 {% block form %}
@@ -11,15 +10,6 @@
             {% render_field form.type %}
             {% render_field form.tenant %}
             {% 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 %}
         </div>
     </div>
@@ -32,33 +22,9 @@
         </div>
     {% endif %}
     <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-body">
             {% render_field form.comments %}
         </div>
     </div>
 {% 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>
                         </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>
-                            {% 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>
                 {% empty %}
                     <tr>
@@ -149,6 +143,13 @@
                     </tr>
                 {% endfor %}
             </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>

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

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

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

@@ -14,7 +14,7 @@
         <small>{{ iface.mac_address|default:'' }}</small>
     </td>
     {% if not iface.is_physical %}
-        <td colspan="2">Virtual</td>
+        <td colspan="2" class="text-muted">Virtual interface</td>
     {% elif iface.connection %}
         {% with iface.get_connected_interface as connected_iface %}
             <td>
@@ -24,10 +24,16 @@
                 <span title="{{ connected_iface.get_form_factor_display }}">{{ connected_iface }}</span>
             </td>
         {% 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 %}
         <td colspan="2">
             <span class="text-muted">Not connected</span>
@@ -35,7 +41,7 @@
     {% endif %}
     <td class="text-right">
         {% 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">
                     <i class="glyphicon glyphicon-signal" aria-hidden="true"></i>
                 </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">
                         <i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
                     </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>
                     </a>
                 {% else %}
@@ -71,7 +80,7 @@
             </a>
         {% endif %}
         {% 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">
                     <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                 </button>

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

@@ -10,7 +10,7 @@
             {% render_field form.vrf %}
             {% render_field form.tenant %}
             {% render_field form.status %}
-            {% if obj %}
+            {% if obj.pk %}
                 <div class="form-group">
                     <label class="col-md-3 control-label">Device</label>
                     <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">
         <form action="." method="post" class="form">
         {% 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-body">
                     {% block message %}<p>Are you sure?</p>{% endblock %}
@@ -22,7 +22,7 @@
                         </div>
                     </div>
                     <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>
                     </div>
                 </div>

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

@@ -2,15 +2,18 @@
 {% load form_helpers %}
 
 {% 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 %}
 
 {% 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>{% 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 %}
                     <div class="panel panel-danger">
                         <div class="panel-heading"><strong>Errors</strong></div>
@@ -31,7 +34,7 @@
         </div>
         <div class="row">
             <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>
                 {% else %}
                     <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
 
     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:
             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 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):
-        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)
 
     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, {
             'obj': obj,
@@ -155,10 +159,10 @@ class ObjectEditView(View):
 
     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)
+
         if form.is_valid():
             obj = form.save(commit=False)
             obj_created = not obj.pk