diff --git a/netbox/circuits/admin.py b/netbox/circuits/admin.py index 97711b7a8..281ed2104 100644 --- a/netbox/circuits/admin.py +++ b/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') diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index d7f32f958..1b894afe0 100644 --- a/netbox/circuits/api/serializers.py +++ b/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 CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer): - provider = ProviderNestedSerializer() - type = CircuitTypeNestedSerializer() - tenant = TenantNestedSerializer() +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() + 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): diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 866f9283b..d89286036 100644 --- a/netbox/circuits/api/views.py +++ b/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 diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 152588c5a..0b51ae206 100644 --- a/netbox/circuits/filters.py +++ b/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) ) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index c66a8bc40..02230e7c2 100644 --- a/netbox/circuits/forms.py +++ b/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') diff --git a/netbox/circuits/migrations/0006_terminations.py b/netbox/circuits/migrations/0006_terminations.py new file mode 100644 index 000000000..e5451498a --- /dev/null +++ b/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', + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index fd4fdf634..c4bf86a28 100644 --- a/netbox/circuits/models.py +++ b/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' diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index f82459890..34236d843 100644 --- a/netbox/circuits/tables.py +++ b/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') diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 9ecb1d5ae..7dd00b268 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -30,5 +30,11 @@ urlpatterns = [ url(r'^circuits/(?P\d+)/$', views.circuit, name='circuit'), url(r'^circuits/(?P\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'), url(r'^circuits/(?P\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'), + url(r'^circuits/(?P\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'), + + # Circuit terminations + url(r'^circuits/(?P\d+)/terminations/add/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), + url(r'^circuit-terminations/(?P\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), + url(r'^circuit-terminations/(?P\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), ] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 292328d61..9feb19ef6 100644 --- a/netbox/circuits/views.py +++ b/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 diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 01a8c6f61..0322208ee 100644 --- a/netbox/dcim/api/views.py +++ b/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']) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index a87b778d7..784f4ef02 100644 --- a/netbox/dcim/models.py +++ b/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) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 21a44c6b4..de4fe4228 100644 --- a/netbox/dcim/views.py +++ b/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') diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 1c2df3680..92aa4e09e 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -82,17 +82,6 @@ {% endif %} - - Speed - - {% if circuit.upstream_speed %} - {{ circuit.port_speed_human }}   - {{ circuit.upstream_speed_human }} - {% else %} - {{ circuit.port_speed_human }} - {% endif %} - - Commit Rate @@ -108,66 +97,6 @@ {% with circuit.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} - -
-
-
- Termination -
- - - - - - - - - - - - - - - - - - - - - -
Site - {{ circuit.site }} -
Termination - {% if circuit.interface %} - {{ circuit.interface.device }} {{ circuit.interface }} - {% else %} - Not defined - {% endif %} -
IP Addressing - {% if circuit.interface %} - {% for ip in circuit.interface.ip_addresses.all %} - {% if not forloop.first %}
{% endif %} - {{ ip }} ({{ ip.vrf|default:"Global" }}) - {% empty %} - None - {% endfor %} - {% else %} - N/A - {% endif %} -
Cross-Connect - {% if circuit.xconnect_id %} - {{ circuit.xconnect_id }} - {% else %} - N/A - {% endif %} -
Patch Panel/Port - {% if circuit.pp_info %} - {{ circuit.pp_info }} - {% else %} - N/A - {% endif %} -
-
Comments @@ -180,6 +109,10 @@ {% endif %}
+
+
+ {% include 'circuits/inc/circuit_termination.html' with termination=termination_a side='A' %} + {% include 'circuits/inc/circuit_termination.html' with termination=termination_z side='Z' %}
{% endblock %} diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html index 863b0a0a2..67d18d1ae 100644 --- a/netbox/templates/circuits/circuit_edit.html +++ b/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 %} - - -
-
Bandwidth
-
- {% render_field form.port_speed %} - {% render_field form.upstream_speed %} {% render_field form.commit_rate %}
@@ -31,26 +21,6 @@ {% endif %} -
-
Termination
-
- {% render_field form.site %} - -
-
- {% render_field form.rack %} - {% render_field form.device %} -
- -
- {% render_field form.interface %} -
-
Comments
@@ -58,7 +28,3 @@
{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/circuits/circuit_terminations_swap.html b/netbox/templates/circuits/circuit_terminations_swap.html new file mode 100644 index 000000000..9fda09481 --- /dev/null +++ b/netbox/templates/circuits/circuit_terminations_swap.html @@ -0,0 +1,25 @@ +{% extends 'utilities/confirmation_form.html' %} + +{% block title %}Swap Circuit Terminations{% endblock %} + +{% block message %} +

Swap these terminations for circuit {{ circuit }}?

+
    +
  • + A side: + {% if termination_a %} + {{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %} + {% else %} + None + {% endif %} +
  • +
  • + Z side: + {% if termination_z %} + {{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %} + {% else %} + None + {% endif %} +
  • +
+{% endblock %} diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html new file mode 100644 index 000000000..186b0e56c --- /dev/null +++ b/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 %} +
+ {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
+
+

Circuit {{ obj.circuit }} - Side {{ form.term_side.value }}

+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
Location
+
+
+ +
+

{{ obj.circuit.provider }}

+
+
+
+ +
+

{{ obj.circuit.cid }}

+
+
+
+ +
+

{{ form.term_side.value }}

+
+
+ {% render_field form.site %} +
+
+ +
+
+
+
+ {% render_field form.rack %} + {% render_field form.device %} +
+ +
+ {% render_field form.interface %} +
+
+
+
Termination Details
+
+ {% render_field form.port_speed %} + {% render_field form.upstream_speed %} + {% render_field form.xconnect_id %} + {% render_field form.pp_info %} +
+
+
+
+
+
+ {% if obj.pk %} + + {% else %} + + {% endif %} + Cancel +
+
+
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html new file mode 100644 index 000000000..7be5e4df4 --- /dev/null +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -0,0 +1,95 @@ +
+
+
+ {% if not termination and perms.circuits.add_circuittermination %} + + Add + + {% endif %} + {% if termination and perms.circuits.change_circuittermination %} + + Edit + + + Swap + + {% endif %} + {% if termination and perms.circuits.delete_circuittermination %} + + Delete + + {% endif %} +
+ Termination - {{ side }} Side +
+ {% if termination %} + + + + + + + + + + + + + + + + + + + + + + + + + +
Site + {{ termination.site }} +
Termination + {% if termination.interface %} + {{ termination.interface.device }} {{ termination.interface }} + {% else %} + Not defined + {% endif %} +
Speed + {% if termination.upstream_speed %} + {{ termination.port_speed_human }}   + {{ termination.upstream_speed_human }} + {% else %} + {{ termination.port_speed_human }} + {% endif %} +
IP Addressing + {% if termination.interface %} + {% for ip in termination.interface.ip_addresses.all %} + {% if not forloop.first %}
{% endif %} + {{ ip }} ({{ ip.vrf|default:"Global" }}) + {% empty %} + None + {% endfor %} + {% else %} + N/A + {% endif %} +
Cross-Connect + {% if termination.xconnect_id %} + {{ termination.xconnect_id }} + {% else %} + N/A + {% endif %} +
Patch Panel/Port + {% if termination.pp_info %} + {{ termination.pp_info }} + {% else %} + N/A + {% endif %} +
+ {% else %} +
+ None +
+ {% endif %} +
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index d7f945ab2..aff57abed 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -134,14 +134,8 @@ {{ c.cid }} - {{ c.site }} + {{ c.type }} - - {% if c.interface %} - {{ c.interface.device }} - {% endif %} - - {{ c.port_speed_human }} {% empty %} @@ -149,6 +143,13 @@ {% endfor %} + {% if perms.circuits.add_circuit %} + + {% endif %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index dbba77b31..fabb6dae2 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -57,7 +57,7 @@
{% render_field form.platform %} {% render_field form.status %} - {% if obj %} + {% if obj.pk %} {% render_field form.primary_ip4 %} {% render_field form.primary_ip6 %} {% endif %} diff --git a/netbox/templates/dcim/inc/_interface.html b/netbox/templates/dcim/inc/_interface.html index 4ecede5b0..e4403cd5c 100644 --- a/netbox/templates/dcim/inc/_interface.html +++ b/netbox/templates/dcim/inc/_interface.html @@ -14,7 +14,7 @@ {{ iface.mac_address|default:'' }} {% if not iface.is_physical %} - Virtual + Virtual interface {% elif iface.connection %} {% with iface.get_connected_interface as connected_iface %} @@ -24,10 +24,16 @@ {{ connected_iface }} {% endwith %} - {% elif iface.circuit %} - - {{ iface.circuit }} - + {% elif iface.circuit_termination %} + {% with iface.circuit_termination.get_peer_termination as peer_termination %} + + + {% if peer_termination %} + {{ peer_termination.site }} via + {% endif %} + {{ iface.circuit_termination.circuit }} + + {% endwith %} {% else %} Not connected @@ -35,7 +41,7 @@ {% endif %} {% if show_graphs %} - {% if iface.circuit or iface.connection %} + {% if iface.circuit_termination or iface.connection %} @@ -56,8 +62,11 @@ - {% elif iface.circuit and perms.circuits.change_circuit %} - + {% elif iface.circuit_termination and perms.circuits.change_circuittermination %} + + {% else %} @@ -71,7 +80,7 @@ {% endif %} {% if perms.dcim.delete_interface %} - {% if iface.connection or iface.circuit %} + {% if iface.connection or iface.circuit_termination %} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index 65d0678b7..78d9efa95 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/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 %}
diff --git a/netbox/templates/utilities/confirmation_form.html b/netbox/templates/utilities/confirmation_form.html index 05f7effbb..04e674e8a 100644 --- a/netbox/templates/utilities/confirmation_form.html +++ b/netbox/templates/utilities/confirmation_form.html @@ -6,7 +6,7 @@
{% csrf_token %} -
+
{% block title %}{% endblock %}
{% block message %}

Are you sure?

{% endblock %} @@ -22,7 +22,7 @@
- + Cancel
diff --git a/netbox/templates/utilities/obj_edit.html b/netbox/templates/utilities/obj_edit.html index 6266bd3fc..18da9378d 100644 --- a/netbox/templates/utilities/obj_edit.html +++ b/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 %} {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
-

{% 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 %}

{% if form.non_field_errors %}
Errors
@@ -31,7 +34,7 @@
- {% if obj %} + {% if obj.pk %} {% else %} diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 1b4493281..2784c12bc 100644 --- a/netbox/utilities/views.py +++ b/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