From 6ff8a267e9305d67a2d08a9383f7c0fdd672424d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Mar 2021 11:10:48 -0400 Subject: [PATCH 1/8] Introduce the Cloud model --- netbox/circuits/api/nested_serializers.py | 15 ++++- netbox/circuits/api/serializers.py | 18 +++++- netbox/circuits/api/urls.py | 3 + netbox/circuits/api/views.py | 12 +++- netbox/circuits/filters.py | 33 +++++++++- netbox/circuits/forms.py | 79 ++++++++++++++++++++++- netbox/circuits/migrations/0027_cloud.py | 40 ++++++++++++ netbox/circuits/models.py | 54 ++++++++++++++++ netbox/circuits/tables.py | 24 ++++++- netbox/circuits/tests/test_api.py | 42 +++++++++++- netbox/circuits/tests/test_filters.py | 39 ++++++++++- netbox/circuits/tests/test_views.py | 44 ++++++++++++- netbox/circuits/urls.py | 14 +++- netbox/circuits/views.py | 45 ++++++++++++- netbox/netbox/constants.py | 14 ++-- netbox/templates/circuits/cloud.html | 55 ++++++++++++++++ netbox/templates/inc/nav_menu.html | 8 +++ 17 files changed, 523 insertions(+), 16 deletions(-) create mode 100644 netbox/circuits/migrations/0027_cloud.py create mode 100644 netbox/templates/circuits/cloud.html diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 7c7d371addb..0fd07d31b73 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -1,16 +1,29 @@ from rest_framework import serializers -from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from circuits.models import * from netbox.api import WritableNestedSerializer __all__ = [ 'NestedCircuitSerializer', 'NestedCircuitTerminationSerializer', 'NestedCircuitTypeSerializer', + 'NestedCloudSerializer', 'NestedProviderSerializer', ] +# +# Clouds +# + +class NestedCloudSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:cloud-detail') + + class Meta: + model = Provider + fields = ['id', 'url', 'display', 'name'] + + # # Providers # diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index bae45e2b356..556721c94c1 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from circuits.choices import CircuitStatusChoices -from circuits.models import Provider, Circuit, CircuitTermination, CircuitType +from circuits.models import * from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer from netbox.api import ChoiceField @@ -28,6 +28,22 @@ class Meta: ] +# +# Clouds +# + +class CloudSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:cloud-detail') + provider = NestedProviderSerializer() + + class Meta: + model = Cloud + fields = [ + 'id', 'url', 'display', 'provider', 'name', 'description', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', + ] + + # # Circuits # diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index b496796fe3f..4f31806bd9d 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -13,5 +13,8 @@ router.register('circuits', views.CircuitViewSet) router.register('circuit-terminations', views.CircuitTerminationViewSet) +# Clouds +router.register('clouds', views.CloudViewSet) + app_name = 'circuits-api' urlpatterns = router.urls diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index c2fe3d089f4..373b3e18dde 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -2,7 +2,7 @@ from rest_framework.routers import APIRootView from circuits import filters -from circuits.models import Provider, CircuitTermination, CircuitType, Circuit +from circuits.models import * from dcim.api.views import PathEndpointMixin from extras.api.views import CustomFieldModelViewSet from netbox.api.views import ModelViewSet @@ -66,3 +66,13 @@ class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet): serializer_class = serializers.CircuitTerminationSerializer filterset_class = filters.CircuitTerminationFilterSet brief_prefetch_fields = ['circuit'] + + +# +# Clouds +# + +class CloudViewSet(CustomFieldModelViewSet): + queryset = Cloud.objects.prefetch_related('tags') + serializer_class = serializers.CloudSerializer + filterset_class = filters.CloudFilterSet diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 03da662e7ad..376cc2af7b9 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -9,12 +9,13 @@ BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter ) from .choices import * -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * __all__ = ( 'CircuitFilterSet', 'CircuitTerminationFilterSet', 'CircuitTypeFilterSet', + 'CloudFilterSet', 'ProviderFilterSet', ) @@ -79,6 +80,36 @@ def search(self, queryset, name, value): ) +class CloudFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + provider_id = django_filters.ModelMultipleChoiceFilter( + queryset=Provider.objects.all(), + label='Provider (ID)', + ) + provider = django_filters.ModelMultipleChoiceFilter( + field_name='provider__slug', + queryset=Provider.objects.all(), + to_field_name='slug', + label='Provider (slug)', + ) + tag = TagFilter() + + class Meta: + model = Cloud + fields = ['id', 'name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(description__icontains=value) | + Q(comments__icontains=value) + ).distinct() + + class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 002c73b9adc..295a3ea6313 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -14,7 +14,7 @@ StaticSelect2, StaticSelect2Multiple, TagFilterField, ) from .choices import CircuitStatusChoices -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * # @@ -128,6 +128,83 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): tag = TagFilterField(model) +# +# Clouds +# + +class CloudForm(BootstrapMixin, CustomFieldModelForm): + provider = DynamicModelChoiceField( + queryset=Provider.objects.all() + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Cloud + fields = [ + 'provider', 'name', 'description', 'comments', 'tags', + ] + fieldsets = ( + ('Cloud', ('provider', 'name', 'description', 'tags')), + ) + + +class CloudCSVForm(CustomFieldModelCSVForm): + provider = CSVModelChoiceField( + queryset=Provider.objects.all(), + to_field_name='name', + help_text='Assigned provider' + ) + + class Meta: + model = Cloud + fields = [ + 'provider', 'name', 'description', 'comments', + ] + + +class CloudBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Cloud.objects.all(), + widget=forms.MultipleHiddenInput + ) + provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) + + class Meta: + nullable_fields = [ + 'description', 'comments', + ] + + +class CloudFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Cloud + field_order = ['q', 'provider_id'] + q = forms.CharField( + required=False, + label=_('Search') + ) + provider_id = DynamicModelMultipleChoiceField( + queryset=Provider.objects.all(), + required=False, + label=_('Provider') + ) + tag = TagFilterField(model) + + # # Circuit types # diff --git a/netbox/circuits/migrations/0027_cloud.py b/netbox/circuits/migrations/0027_cloud.py new file mode 100644 index 00000000000..36cceb7cad2 --- /dev/null +++ b/netbox/circuits/migrations/0027_cloud.py @@ -0,0 +1,40 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0058_journalentry'), + ('circuits', '0026_mark_connected'), + ] + + operations = [ + migrations.CreateModel( + name='Cloud', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='clouds', to='circuits.provider')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('provider', 'name'), + }, + ), + migrations.AddConstraint( + model_name='cloud', + constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_cloud_provider_name'), + ), + migrations.AlterUniqueTogether( + name='cloud', + unique_together={('provider', 'name')}, + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index d19841e4fb0..d2f8a5b1d50 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -15,6 +15,7 @@ 'Circuit', 'CircuitTermination', 'CircuitType', + 'Cloud', 'Provider', ) @@ -91,6 +92,59 @@ def to_csv(self): ) +# +# Clouds +# + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class Cloud(PrimaryModel): + name = models.CharField( + max_length=100 + ) + provider = models.ForeignKey( + to='circuits.Provider', + on_delete=models.PROTECT, + related_name='clouds' + ) + description = models.CharField( + max_length=200, + blank=True + ) + comments = models.TextField( + blank=True + ) + + csv_headers = [ + 'provider', 'name', 'description', 'comments', + ] + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('provider', 'name') + constraints = ( + models.UniqueConstraint( + fields=('provider', 'name'), + name='circuits_cloud_provider_name' + ), + ) + unique_together = ('provider', 'name') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('circuits:cloud', args=[self.pk]) + + def to_csv(self): + return ( + self.provider.name, + self.name, + self.description, + self.comments, + ) + + @extras_features('custom_fields', 'export_templates', 'webhooks') class CircuitType(OrganizationalModel): """ diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index efa7e4c49bb..94894368ea3 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -3,7 +3,7 @@ from tenancy.tables import TenantColumn from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn -from .models import Circuit, CircuitType, Provider +from .models import * # @@ -29,6 +29,28 @@ class Meta(BaseTable.Meta): default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') +# +# Clouds +# + +class CloudTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + provider = tables.Column( + linkify=True + ) + tags = TagColumn( + url_name='circuits:cloud_list' + ) + + class Meta(BaseTable.Meta): + model = Cloud + fields = ('pk', 'name', 'provider', 'description', 'tags') + default_columns = ('pk', 'name', 'provider', 'description') + + # # Circuit types # diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 3341c72c336..01e228f761f 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -1,7 +1,7 @@ from django.urls import reverse from circuits.choices import * -from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from circuits.models import * from dcim.models import Site from utilities.testing import APITestCase, APIViewTestCases @@ -178,3 +178,43 @@ def setUpTestData(cls): cls.bulk_update_data = { 'port_speed': 123456 } + + +class CloudTest(APIViewTestCases.APIViewTestCase): + model = Cloud + brief_fields = ['display', 'id', 'name', 'url'] + + @classmethod + def setUpTestData(cls): + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + ) + Provider.objects.bulk_create(providers) + + clouds = ( + Cloud(name='Cloud 1', provider=providers[0]), + Cloud(name='Cloud 2', provider=providers[0]), + Cloud(name='Cloud 3', provider=providers[0]), + ) + Cloud.objects.bulk_create(clouds) + + cls.create_data = [ + { + 'name': 'Cloud 4', + 'provider': providers[0].pk, + }, + { + 'name': 'Cloud 5', + 'provider': providers[0].pk, + }, + { + 'name': 'Cloud 6', + 'provider': providers[0].pk, + }, + ] + + cls.bulk_update_data = { + 'provider': providers[1].pk, + 'description': 'New description', + } diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py index b9e1eac458c..af465c42765 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -2,7 +2,7 @@ from circuits.choices import * from circuits.filters import * -from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from circuits.models import * from dcim.models import Cable, Region, Site, SiteGroup from tenancy.models import Tenant, TenantGroup @@ -353,3 +353,40 @@ def test_connected(self): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + +class CloudTestCase(TestCase): + queryset = Cloud.objects.all() + filterset = CloudFilterSet + + @classmethod + def setUpTestData(cls): + + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + Provider(name='Provider 3', slug='provider-3'), + ) + Provider.objects.bulk_create(providers) + + clouds = ( + Cloud(name='Cloud 1', provider=providers[0]), + Cloud(name='Cloud 2', provider=providers[1]), + Cloud(name='Cloud 3', provider=providers[2]), + ) + Cloud.objects.bulk_create(clouds) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['Cloud 1', 'Cloud 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_provider(self): + providers = Provider.objects.all()[:2] + params = {'provider_id': [providers[0].pk, providers[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'provider': [providers[0].slug, providers[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index de0d2c970d6..ba2d4fc220d 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -1,7 +1,7 @@ import datetime from circuits.choices import * -from circuits.models import Circuit, CircuitType, Provider +from circuits.models import * from utilities.testing import ViewTestCases @@ -133,3 +133,45 @@ def setUpTestData(cls): 'description': 'New description', 'comments': 'New comments', } + + +class CloudTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Cloud + + @classmethod + def setUpTestData(cls): + + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + ) + Provider.objects.bulk_create(providers) + + Cloud.objects.bulk_create([ + Cloud(name='Cloud 1', provider=providers[0]), + Cloud(name='Cloud 2', provider=providers[0]), + Cloud(name='Cloud 3', provider=providers[0]), + ]) + + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Cloud X', + 'provider': providers[1].pk, + 'description': 'A new cloud', + 'comments': 'Longer description goes here', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,provider,description", + "Cloud 4,Provider 1,Foo", + "Cloud 5,Provider 1,Bar", + "Cloud 6,Provider 1,Baz", + ) + + cls.bulk_edit_data = { + 'provider': providers[1].pk, + 'description': 'New description', + 'comments': 'New comments', + } diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 0b47b4b2c3d..acc3baac581 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -3,7 +3,7 @@ from dcim.views import CableCreateView, PathTraceView from extras.views import ObjectChangeLogView, ObjectJournalView from . import views -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * app_name = 'circuits' urlpatterns = [ @@ -20,6 +20,18 @@ path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), path('providers//journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}), + # Clouds + path('clouds/', views.CloudListView.as_view(), name='cloud_list'), + path('clouds/add/', views.CloudEditView.as_view(), name='cloud_add'), + path('clouds/import/', views.CloudBulkImportView.as_view(), name='cloud_import'), + path('clouds/edit/', views.CloudBulkEditView.as_view(), name='cloud_bulk_edit'), + path('clouds/delete/', views.CloudBulkDeleteView.as_view(), name='cloud_bulk_delete'), + path('clouds//', views.CloudView.as_view(), name='cloud'), + path('clouds//edit/', views.CloudEditView.as_view(), name='cloud_edit'), + path('clouds//delete/', views.CloudDeleteView.as_view(), name='cloud_delete'), + path('clouds//changelog/', ObjectChangeLogView.as_view(), name='cloud_changelog', kwargs={'model': Cloud}), + path('clouds//journal/', ObjectJournalView.as_view(), name='cloud_journal', kwargs={'model': Cloud}), + # Circuit types path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index b3215c029c5..2484a84e421 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -9,7 +9,7 @@ from utilities.utils import count_related from . import filters, forms, tables from .choices import CircuitTerminationSideChoices -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * # @@ -81,6 +81,49 @@ class ProviderBulkDeleteView(generic.BulkDeleteView): table = tables.ProviderTable +# +# Clouds +# + +class CloudListView(generic.ObjectListView): + queryset = Cloud.objects.all() + filterset = filters.CloudFilterSet + filterset_form = forms.CloudFilterForm + table = tables.CloudTable + + +class CloudView(generic.ObjectView): + queryset = Cloud.objects.all() + + +class CloudEditView(generic.ObjectEditView): + queryset = Cloud.objects.all() + model_form = forms.CloudForm + + +class CloudDeleteView(generic.ObjectDeleteView): + queryset = Cloud.objects.all() + + +class CloudBulkImportView(generic.BulkImportView): + queryset = Cloud.objects.all() + model_form = forms.CloudCSVForm + table = tables.CloudTable + + +class CloudBulkEditView(generic.BulkEditView): + queryset = Cloud.objects.all() + filterset = filters.CloudFilterSet + table = tables.CloudTable + form = forms.CloudBulkEditForm + + +class CloudBulkDeleteView(generic.BulkDeleteView): + queryset = Cloud.objects.all() + filterset = filters.CloudFilterSet + table = tables.CloudTable + + # # Circuit Types # diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index e5b3f763cdc..2a466b4cd54 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -1,10 +1,8 @@ from collections import OrderedDict -from django.db.models import Count - -from circuits.filters import CircuitFilterSet, ProviderFilterSet -from circuits.models import Circuit, Provider -from circuits.tables import CircuitTable, ProviderTable +from circuits.filters import CircuitFilterSet, CloudFilterSet, ProviderFilterSet +from circuits.models import Circuit, Cloud, Provider +from circuits.tables import CircuitTable, CloudTable, ProviderTable from dcim.filters import ( CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, LocationFilterSet, SiteFilterSet, VirtualChassisFilterSet, @@ -47,6 +45,12 @@ 'table': CircuitTable, 'url': 'circuits:circuit_list', }), + ('cloud', { + 'queryset': Cloud.objects.prefetch_related('provider'), + 'filterset': CloudFilterSet, + 'table': CloudTable, + 'url': 'circuits:cloud_list', + }), # DCIM ('site', { 'queryset': Site.objects.prefetch_related('region', 'tenant'), diff --git a/netbox/templates/circuits/cloud.html b/netbox/templates/circuits/cloud.html new file mode 100644 index 00000000000..61dec5ead21 --- /dev/null +++ b/netbox/templates/circuits/cloud.html @@ -0,0 +1,55 @@ +{% extends 'generic/object.html' %} +{% load static %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Clouds
  • +
  • {{ object.provider }}
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Cloud +
    + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description }}
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:cloud_list' %} + {% plugin_left_page object %} +
    +
    +
    +
    + Comments +
    +
    + {% if object.comments %} + {{ object.comments|render_markdown }} + {% else %} + None + {% endif %} +
    +
    + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 4fff1614174..fb77dc2b69f 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -465,6 +465,14 @@ {% endif %} Providers + + {% if perms.circuits.add_cloud %} +
    + + +
    + {% endif %} + Clouds From 574a43fff77a0ef104e1ac4b3e1b2872050a3b69 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Mar 2021 11:57:59 -0400 Subject: [PATCH 2/8] Enable attaching circuit terminations to clouds --- netbox/circuits/api/serializers.py | 14 ++- netbox/circuits/filters.py | 4 + netbox/circuits/forms.py | 14 ++- netbox/circuits/migrations/0027_cloud.py | 10 ++ netbox/circuits/models.py | 23 +++- .../circuits/circuittermination_edit.html | 21 +++- netbox/templates/circuits/cloud.html | 6 + .../circuits/inc/circuit_termination.html | 115 ++++++++++-------- 8 files changed, 140 insertions(+), 67 deletions(-) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 556721c94c1..5469049db87 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -63,12 +63,13 @@ class Meta: class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') site = NestedSiteSerializer() + cloud = NestedCloudSerializer() class Meta: model = CircuitTermination fields = [ - 'id', 'url', 'display', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', + 'id', 'url', 'display', 'site', 'cloud', 'port_speed', 'upstream_speed', 'xconnect_id', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', ] @@ -93,13 +94,14 @@ class Meta: class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') circuit = NestedCircuitSerializer() - site = NestedSiteSerializer() + site = NestedSiteSerializer(required=False) + cloud = NestedCloudSerializer(required=False) cable = NestedCableSerializer(read_only=True) class Meta: model = CircuitTermination fields = [ - 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', - 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied', + 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'cloud', 'port_speed', 'upstream_speed', + 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied', ] diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 376cc2af7b9..6a6b2c012d6 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -221,6 +221,10 @@ class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, Path to_field_name='slug', label='Site (slug)', ) + cloud_id = django_filters.ModelMultipleChoiceFilter( + queryset=Cloud.objects.all(), + label='Cloud (ID)', + ) class Meta: model = CircuitTermination diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 295a3ea6313..7285dad965c 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -423,13 +423,18 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): query_params={ 'region_id': '$region', 'group_id': '$site_group', - } + }, + required=False + ) + cloud = DynamicModelChoiceField( + queryset=Cloud.objects.all(), + required=False ) class Meta: model = CircuitTermination fields = [ - 'term_side', 'region', 'site_group', 'site', 'mark_connected', 'port_speed', 'upstream_speed', + 'term_side', 'region', 'site_group', 'site', 'cloud', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', ] help_texts = { @@ -442,3 +447,8 @@ class Meta: 'port_speed': SelectSpeedWidget(), 'upstream_speed': SelectSpeedWidget(), } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['cloud'].widget.add_query_param('provider_id', self.instance.circuit.provider_id) diff --git a/netbox/circuits/migrations/0027_cloud.py b/netbox/circuits/migrations/0027_cloud.py index 36cceb7cad2..889b5151e3e 100644 --- a/netbox/circuits/migrations/0027_cloud.py +++ b/netbox/circuits/migrations/0027_cloud.py @@ -37,4 +37,14 @@ class Migration(migrations.Migration): name='cloud', unique_together={('provider', 'name')}, ), + migrations.AddField( + model_name='circuittermination', + name='cloud', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='circuits.cloud'), + ), + migrations.AlterField( + model_name='circuittermination', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='dcim.site'), + ), ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index d2f8a5b1d50..b13dd9603c8 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -1,3 +1,4 @@ +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse @@ -300,7 +301,16 @@ class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): site = models.ForeignKey( to='dcim.Site', on_delete=models.PROTECT, - related_name='circuit_terminations' + related_name='circuit_terminations', + blank=True, + null=True + ) + cloud = models.ForeignKey( + to=Cloud, + on_delete=models.PROTECT, + related_name='circuit_terminations', + blank=True, + null=True ) port_speed = models.PositiveIntegerField( verbose_name='Port speed (Kbps)', @@ -335,7 +345,16 @@ class Meta: unique_together = ['circuit', 'term_side'] def __str__(self): - return 'Side {}'.format(self.get_term_side_display()) + return f"Side {self.get_term_side_display()}" + + def clean(self): + super().clean() + + # Must define either site *or* cloud + if self.site is None and self.cloud is None: + raise ValidationError("A circuit termination must attach to either a site or a cloud.") + if self.site and self.cloud: + raise ValidationError("A circuit termination cannot attach to both a site and a cloud.") def to_objectchange(self, action): # Annotate the parent Circuit diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 4e737d16d7b..ebad75976e0 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -6,7 +6,7 @@ {% block form %}
    -
    Location
    +
    Termination
    @@ -26,9 +26,22 @@

    {{ form.term_side.value }}

    - {% render_field form.region %} - {% render_field form.site_group %} - {% render_field form.site %} + {% with cloud_tab_active=form.initial.cloud %} + +
    +
    + {% render_field form.region %} + {% render_field form.site_group %} + {% render_field form.site %} +
    +
    + {% render_field form.cloud %} +
    +
    + {% endwith %} {% render_field form.mark_connected %}
    diff --git a/netbox/templates/circuits/cloud.html b/netbox/templates/circuits/cloud.html index 61dec5ead21..268f6438780 100644 --- a/netbox/templates/circuits/cloud.html +++ b/netbox/templates/circuits/cloud.html @@ -17,6 +17,12 @@ Cloud + + + + diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 762dd166218..acfc4ee22d7 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -26,62 +26,71 @@ {% if termination %}
    Provider + {{ object.provider }} +
    Name {{ object.name }}
    - - - - - - - + + + + + + - + + + {% else %} + + + + + {% endif %}
    Site - {% if termination.site.region %} - {{ termination.site.region }} / - {% endif %} - {{ termination.site }} -
    Termination - {% if termination.mark_connected %} - - Marked as connected - {% elif termination.cable %} - {% if perms.dcim.delete_cable %} - + {% if termination.site %} +
    Site + {% if termination.site.region %} + {{ termination.site.region }} / {% endif %} - {{ termination.cable }} - - - - {% with peer=termination.get_cable_peer %} - to - {% if peer.device %} - {{ peer.device }} - {% elif peer.circuit %} - {{ peer.circuit }} + {{ termination.site }} +
    Termination + {% if termination.mark_connected %} + + Marked as connected + {% elif termination.cable %} + {% if perms.dcim.delete_cable %} + {% endif %} - ({{ peer }}) - {% endwith %} - {% else %} - {% if perms.dcim.add_cable %} -
    - - - - -
    + {{ termination.cable }} + + + + {% with peer=termination.get_cable_peer %} + to + {% if peer.device %} + {{ peer.device }} + {% elif peer.circuit %} + {{ peer.circuit }} + {% endif %} + ({{ peer }}) + {% endwith %} + {% else %} + {% if perms.dcim.add_cable %} +
    + + + + +
    + {% endif %} + Not defined {% endif %} - Not defined - {% endif %} -
    Cloud + {{ termination.cloud }} +
    Speed From 872e936924bae39e6316c1b106697cd63b58fe74 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Mar 2021 13:54:05 -0400 Subject: [PATCH 3/8] Add termination FKs on Circuit model --- netbox/circuits/api/views.py | 3 +- netbox/circuits/migrations/0027_cloud.py | 15 ++++++++ .../0028_cache_circuit_terminations.py | 37 ++++++++++++++++++ netbox/circuits/models.py | 38 +++++++++++-------- netbox/circuits/querysets.py | 17 --------- netbox/circuits/signals.py | 16 ++++---- netbox/circuits/tables.py | 16 ++++---- netbox/circuits/views.py | 6 +-- netbox/netbox/constants.py | 2 +- 9 files changed, 96 insertions(+), 54 deletions(-) create mode 100644 netbox/circuits/migrations/0028_cache_circuit_terminations.py delete mode 100644 netbox/circuits/querysets.py diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 373b3e18dde..0adbfcb0e2d 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -48,8 +48,7 @@ class CircuitTypeViewSet(CustomFieldModelViewSet): class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.prefetch_related( - Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related('site')), - 'type', 'tenant', 'provider', + 'type', 'tenant', 'provider', 'termination_a', 'termination_z' ).prefetch_related('tags') serializer_class = serializers.CircuitSerializer filterset_class = filters.CircuitFilterSet diff --git a/netbox/circuits/migrations/0027_cloud.py b/netbox/circuits/migrations/0027_cloud.py index 889b5151e3e..893371f8f26 100644 --- a/netbox/circuits/migrations/0027_cloud.py +++ b/netbox/circuits/migrations/0027_cloud.py @@ -12,6 +12,7 @@ class Migration(migrations.Migration): ] operations = [ + # Create the new Cloud model migrations.CreateModel( name='Cloud', fields=[ @@ -37,6 +38,8 @@ class Migration(migrations.Migration): name='cloud', unique_together={('provider', 'name')}, ), + + # Add cloud FK to CircuitTermination migrations.AddField( model_name='circuittermination', name='cloud', @@ -47,4 +50,16 @@ class Migration(migrations.Migration): name='site', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='dcim.site'), ), + + # Add FKs to CircuitTermination on Circuit + migrations.AddField( + model_name='circuit', + name='termination_a', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.circuittermination'), + ), + migrations.AddField( + model_name='circuit', + name='termination_z', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.circuittermination'), + ), ] diff --git a/netbox/circuits/migrations/0028_cache_circuit_terminations.py b/netbox/circuits/migrations/0028_cache_circuit_terminations.py new file mode 100644 index 00000000000..49631da07b0 --- /dev/null +++ b/netbox/circuits/migrations/0028_cache_circuit_terminations.py @@ -0,0 +1,37 @@ +import sys + +from django.db import migrations + + +def cache_circuit_terminations(apps, schema_editor): + Circuit = apps.get_model('circuits', 'Circuit') + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + + if 'test' not in sys.argv: + print(f"\n Caching circuit terminations...", flush=True) + + a_terminations = { + ct.circuit_id: ct.pk for ct in CircuitTermination.objects.filter(term_side='A') + } + z_terminations = { + ct.circuit_id: ct.pk for ct in CircuitTermination.objects.filter(term_side='Z') + } + for circuit in Circuit.objects.all(): + Circuit.objects.filter(pk=circuit.pk).update( + termination_a_id=a_terminations.get(circuit.pk), + termination_z_id=z_terminations.get(circuit.pk), + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0027_cloud'), + ] + + operations = [ + migrations.RunPython( + code=cache_circuit_terminations, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index b13dd9603c8..c2ff711268e 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -9,7 +9,6 @@ from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet from .choices import * -from .querysets import CircuitQuerySet __all__ = ( @@ -236,7 +235,25 @@ class Circuit(PrimaryModel): blank=True ) - objects = CircuitQuerySet.as_manager() + # Cache associated CircuitTerminations + termination_a = models.ForeignKey( + to='circuits.CircuitTermination', + on_delete=models.SET_NULL, + related_name='+', + editable=False, + blank=True, + null=True + ) + termination_z = models.ForeignKey( + to='circuits.CircuitTermination', + on_delete=models.SET_NULL, + related_name='+', + editable=False, + blank=True, + null=True + ) + + objects = RestrictedQuerySet.as_manager() csv_headers = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', @@ -271,20 +288,6 @@ def to_csv(self): def get_status_class(self): return CircuitStatusChoices.CSS_CLASSES.get(self.status) - 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') - @extras_features('webhooks') class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): @@ -345,6 +348,9 @@ class Meta: unique_together = ['circuit', 'term_side'] def __str__(self): + if self.site: + return str(self.site) + return str(self.cloud) return f"Side {self.get_term_side_display()}" def clean(self): diff --git a/netbox/circuits/querysets.py b/netbox/circuits/querysets.py deleted file mode 100644 index 8a9bd50a407..00000000000 --- a/netbox/circuits/querysets.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.db.models import OuterRef, Subquery - -from utilities.querysets import RestrictedQuerySet - - -class CircuitQuerySet(RestrictedQuerySet): - - def annotate_sites(self): - """ - Annotate the A and Z termination site names for ordering. - """ - from circuits.models import CircuitTermination - _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk')) - return self.annotate( - a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]), - z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]), - ) diff --git a/netbox/circuits/signals.py b/netbox/circuits/signals.py index 86db2140021..7c9832d5b0a 100644 --- a/netbox/circuits/signals.py +++ b/netbox/circuits/signals.py @@ -1,17 +1,17 @@ -from django.db.models.signals import post_delete, post_save +from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone from .models import Circuit, CircuitTermination -@receiver((post_save, post_delete), sender=CircuitTermination) +@receiver(post_save, sender=CircuitTermination) def update_circuit(instance, **kwargs): """ - When a CircuitTermination has been modified, update the last_updated time of its parent Circuit. + When a CircuitTermination has been modified, update its parent Circuit. """ - circuits = Circuit.objects.filter(pk=instance.circuit_id) - time = timezone.now() - for circuit in circuits: - circuit.last_updated = time - circuit.save() + fields = { + 'last_updated': timezone.now(), + f'termination_{instance.term_side.lower()}': instance.pk, + } + Circuit.objects.filter(pk=instance.circuit_id).update(**fields) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 94894368ea3..00b4613a74b 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -83,11 +83,11 @@ class CircuitTable(BaseTable): ) status = ChoiceFieldColumn() tenant = TenantColumn() - a_side = tables.Column( - verbose_name='A Side' + termination_a = tables.Column( + verbose_name='Side A' ) - z_side = tables.Column( - verbose_name='Z Side' + termination_z = tables.Column( + verbose_name='Side Z' ) tags = TagColumn( url_name='circuits:circuit_list' @@ -96,7 +96,9 @@ class CircuitTable(BaseTable): class Meta(BaseTable.Meta): model = Circuit fields = ( - 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'install_date', 'commit_rate', - 'description', 'tags', + 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', + 'commit_rate', 'description', 'tags', + ) + default_columns = ( + 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', ) - default_columns = ('pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'description') diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 2484a84e421..b67bff81bfb 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -33,7 +33,7 @@ def get_extra_context(self, request, instance): provider=instance ).prefetch_related( 'type', 'tenant', 'terminations__site' - ).annotate_sites() + ) circuits_table = tables.CircuitTable(circuits) circuits_table.columns.hide('provider') @@ -172,8 +172,8 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView): class CircuitListView(generic.ObjectListView): queryset = Circuit.objects.prefetch_related( - 'provider', 'type', 'tenant', 'terminations' - ).annotate_sites() + 'provider', 'type', 'tenant', 'termination_a', 'termination_z' + ) filterset = filters.CircuitFilterSet filterset_form = forms.CircuitFilterForm table = tables.CircuitTable diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index 2a466b4cd54..797a11965e2 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -40,7 +40,7 @@ ('circuit', { 'queryset': Circuit.objects.prefetch_related( 'type', 'provider', 'tenant', 'terminations__site' - ).annotate_sites(), + ), 'filterset': CircuitFilterSet, 'table': CircuitTable, 'url': 'circuits:circuit_list', From 2e97bf48c5ef46f78f19c200b933b55d330bab41 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Mar 2021 14:05:32 -0400 Subject: [PATCH 4/8] Include circuits list on cloud view --- netbox/circuits/views.py | 23 +++++++++++++++++++++++ netbox/templates/circuits/cloud.html | 25 ++++++++++++++++--------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index b67bff81bfb..78069bae081 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,5 +1,6 @@ from django.contrib import messages from django.db import transaction +from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render from django_tables2 import RequestConfig @@ -95,6 +96,28 @@ class CloudListView(generic.ObjectListView): class CloudView(generic.ObjectView): queryset = Cloud.objects.all() + def get_extra_context(self, request, instance): + circuits = Circuit.objects.restrict(request.user, 'view').filter( + Q(termination_a__cloud=instance.pk) | + Q(termination_z__cloud=instance.pk) + ).prefetch_related( + 'type', 'tenant', 'terminations__site' + ) + + circuits_table = tables.CircuitTable(circuits) + circuits_table.columns.hide('termination_a') + circuits_table.columns.hide('termination_z') + + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': get_paginate_count(request) + } + RequestConfig(request, paginate).configure(circuits_table) + + return { + 'circuits_table': circuits_table, + } + class CloudEditView(generic.ObjectEditView): queryset = Cloud.objects.all() diff --git a/netbox/templates/circuits/cloud.html b/netbox/templates/circuits/cloud.html index 268f6438780..532118bf866 100644 --- a/netbox/templates/circuits/cloud.html +++ b/netbox/templates/circuits/cloud.html @@ -33,6 +33,18 @@
    +
    +
    + Comments +
    +
    + {% if object.comments %} + {{ object.comments|render_markdown }} + {% else %} + None + {% endif %} +
    +
    {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:cloud_list' %} {% plugin_left_page object %} @@ -40,18 +52,13 @@
    - Comments -
    -
    - {% if object.comments %} - {{ object.comments|render_markdown }} - {% else %} - None - {% endif %} + Circuits
    + {% include 'inc/table.html' with table=circuits_table %}
    + {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %} {% plugin_right_page object %} -
    +
    From d45a17247d2b6435e3c6635c67f44fc570db0aae Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Mar 2021 14:32:28 -0400 Subject: [PATCH 5/8] Add circuit cloud filters & tests --- netbox/circuits/filters.py | 5 +++ netbox/circuits/forms.py | 11 +++++- netbox/circuits/tests/test_filters.py | 51 ++++++++++++++++++++++----- 3 files changed, 57 insertions(+), 10 deletions(-) diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 6a6b2c012d6..0efd2f3314c 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -132,6 +132,11 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe to_field_name='slug', label='Provider (slug)', ) + cloud_id = django_filters.ModelMultipleChoiceFilter( + field_name='terminations__cloud', + queryset=Cloud.objects.all(), + label='Cloud (ID)', + ) type_id = django_filters.ModelMultipleChoiceFilter( queryset=CircuitType.objects.all(), label='Circuit type (ID)', diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 7285dad965c..d818ec0f6e4 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -357,7 +357,8 @@ class Meta: class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Circuit field_order = [ - 'q', 'type_id', 'provider_id', 'status', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id', 'commit_rate', + 'q', 'type_id', 'provider_id', 'cloud_id', 'status', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id', + 'commit_rate', ] q = forms.CharField( required=False, @@ -373,6 +374,14 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm required=False, label=_('Provider') ) + cloud_id = DynamicModelMultipleChoiceField( + queryset=Cloud.objects.all(), + required=False, + query_params={ + 'provider_id': '$provider_id' + }, + label=_('Cloud') + ) status = forms.MultipleChoiceField( choices=CircuitStatusChoices, required=False, diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py index af465c42765..880139baf7f 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -186,6 +186,13 @@ def setUpTestData(cls): ) Provider.objects.bulk_create(providers) + clouds = ( + Cloud(name='Cloud 1', provider=providers[1]), + Cloud(name='Cloud 2', provider=providers[1]), + Cloud(name='Cloud 3', provider=providers[1]), + ) + Cloud.objects.bulk_create(clouds) + circuits = ( Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE), Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE), @@ -200,6 +207,9 @@ def setUpTestData(cls): CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'), CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'), + CircuitTermination(circuit=circuits[3], cloud=clouds[0], term_side='A'), + CircuitTermination(circuit=circuits[4], cloud=clouds[1], term_side='A'), + CircuitTermination(circuit=circuits[5], cloud=clouds[2], term_side='A'), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -226,6 +236,11 @@ def test_provider(self): params = {'provider': [provider.slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_cloud(self): + clouds = Cloud.objects.all()[:2] + params = {'cloud_id': [clouds[0].pk, clouds[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): circuit_type = CircuitType.objects.first() params = {'type_id': [circuit_type.pk]} @@ -281,14 +296,14 @@ class CircuitTerminationTestCase(TestCase): def setUpTestData(cls): sites = ( - Site(name='Test Site 1', slug='test-site-1'), - Site(name='Test Site 2', slug='test-site-2'), - Site(name='Test Site 3', slug='test-site-3'), + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), ) Site.objects.bulk_create(sites) circuit_types = ( - CircuitType(name='Test Circuit Type 1', slug='test-circuit-type-1'), + CircuitType(name='Circuit Type 1', slug='circuit-type-1'), ) CircuitType.objects.bulk_create(circuit_types) @@ -297,10 +312,20 @@ def setUpTestData(cls): ) Provider.objects.bulk_create(providers) + clouds = ( + Cloud(name='Cloud 1', provider=providers[0]), + Cloud(name='Cloud 2', provider=providers[0]), + Cloud(name='Cloud 3', provider=providers[0]), + ) + Cloud.objects.bulk_create(clouds) + circuits = ( - Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 1'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 2'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 3'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 2'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 3'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'), ) Circuit.objects.bulk_create(circuits) @@ -311,6 +336,9 @@ def setUpTestData(cls): CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'), CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'), CircuitTermination(circuit=circuits[2], site=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'), + CircuitTermination(circuit=circuits[3], cloud=clouds[0], term_side='A'), + CircuitTermination(circuit=circuits[4], cloud=clouds[1], term_side='A'), + CircuitTermination(circuit=circuits[5], cloud=clouds[2], term_side='A'), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -318,7 +346,7 @@ def setUpTestData(cls): def test_term_side(self): params = {'term_side': 'A'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_port_speed(self): params = {'port_speed': ['1000', '2000']} @@ -344,6 +372,11 @@ def test_site(self): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_cloud(self): + clouds = Cloud.objects.all()[:2] + params = {'cloud_id': [clouds[0].pk, clouds[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_cabled(self): params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -352,7 +385,7 @@ def test_connected(self): params = {'connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) class CloudTestCase(TestCase): From 89c487de65df47e9484caed7fc11ebbea64eeb13 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Mar 2021 14:43:07 -0400 Subject: [PATCH 6/8] Documentation and changelog for #5986 --- docs/core-functionality/circuits.md | 1 + docs/models/circuits/circuittermination.md | 6 +++--- docs/models/circuits/cloud.md | 5 +++++ docs/release-notes/version-2.11.md | 8 ++++++++ 4 files changed, 17 insertions(+), 3 deletions(-) create mode 100644 docs/models/circuits/cloud.md diff --git a/docs/core-functionality/circuits.md b/docs/core-functionality/circuits.md index 43b911308b6..67388dba44f 100644 --- a/docs/core-functionality/circuits.md +++ b/docs/core-functionality/circuits.md @@ -1,6 +1,7 @@ # Circuits {!docs/models/circuits/provider.md!} +{!docs/models/circuits/cloud.md!} --- diff --git a/docs/models/circuits/circuittermination.md b/docs/models/circuits/circuittermination.md index 1c0dbfe1893..c1ec09cae3b 100644 --- a/docs/models/circuits/circuittermination.md +++ b/docs/models/circuits/circuittermination.md @@ -2,9 +2,9 @@ The association of a circuit with a particular site and/or device is modeled separately as a circuit termination. A circuit may have up to two terminations, labeled A and Z. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites. -Each circuit termination is tied to a site, and may optionally be connected via a cable to a specific device interface or port within that site. Each termination must be assigned a port speed, and can optionally be assigned an upstream speed if it differs from the downstream speed (a common scenario with e.g. DOCSIS cable modems). Fields are also available to track cross-connect and patch panel details. +Each circuit termination is attached to either a site or a cloud. Site terminations may optionally be connected via a cable to a specific device interface or port within that site. Each termination must be assigned a port speed, and can optionally be assigned an upstream speed if it differs from the downstream speed (a common scenario with e.g. DOCSIS cable modems). Fields are also available to track cross-connect and patch panel details. -In adherence with NetBox's philosophy of closely modeling the real world, a circuit may terminate only to a physical interface. For example, circuits may not terminate to LAG interfaces, which are virtual in nature. In such cases, a separate physical circuit is associated with each LAG member interface and each needs to be modeled discretely. +In adherence with NetBox's philosophy of closely modeling the real world, a circuit may be connected only to a physical interface. For example, circuits may not terminate to LAG interfaces, which are virtual in nature. In such cases, a separate physical circuit is associated with each LAG member interface and each needs to be modeled discretely. !!! note - A circuit in NetBox represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit, with one end terminating within the provider's infrastructure. + A circuit in NetBox represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit, with one end terminating within the provider's infrastructure. The cloud model is ideal for representing these networks. diff --git a/docs/models/circuits/cloud.md b/docs/models/circuits/cloud.md new file mode 100644 index 00000000000..c4b3cec5e5f --- /dev/null +++ b/docs/models/circuits/cloud.md @@ -0,0 +1,5 @@ +# Clouds + +A cloud represents an abstract portion of network topology, just like in a topology diagram. For example, a cloud may be used to represent a provider's MPLS network. + +Each cloud must be assigned to a provider. A circuit may terminate to either a cloud or to a site. diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 6bfdd414bdb..c80b74296b3 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -70,6 +70,10 @@ This release introduces the new Site Group model, which can be used to organize The ObjectChange model (which is used to record the creation, modification, and deletion of NetBox objects) now explicitly records the pre-change and post-change state of each object, rather than only the post-change state. This was done to present a more clear depiction of each change being made, and to prevent the erroneous association of a previous unlogged change with its successor. +#### Improved Change Logging ([#5986](https://github.com/netbox-community/netbox/issues/5986)) + +A new cloud model has been introduced for representing the boundary of a network that exists outside the scope of NetBox. This is analogous to using a cloud icon on a topology drawing to represent an abstracted network. Each cloud must be assigned to a provider, and circuits can terminate to either clouds or sites. + ### Enhancements * [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models @@ -108,6 +112,10 @@ The ObjectChange model (which is used to record the creation, modification, and * Added `_occupied` read-only boolean field as common attribute for determining whether an object is occupied * Renamed RackGroup to Location * The `/dcim/rack-groups/` endpoint is now `/dcim/locations/` +* circuits.CircuitTermination + * Added the `cloud` field +* circuits.Cloud + * Added the `/api/circuits/clouds/` endpoint * dcim.Device * Added the `location` field * dcim.Interface From d45edcd216b81cce9fb37a12f9d80b71973b2358 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Mar 2021 14:49:06 -0400 Subject: [PATCH 7/8] Linkify circuit terminations in table --- netbox/circuits/models.py | 6 +++++- netbox/circuits/tables.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index c2ff711268e..73df7f2d491 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -351,7 +351,11 @@ def __str__(self): if self.site: return str(self.site) return str(self.cloud) - return f"Side {self.get_term_side_display()}" + + def get_absolute_url(self): + if self.site: + return self.site.get_absolute_url() + return self.cloud.get_absolute_url() def clean(self): super().clean() diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 00b4613a74b..ba113de8c9c 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -84,9 +84,11 @@ class CircuitTable(BaseTable): status = ChoiceFieldColumn() tenant = TenantColumn() termination_a = tables.Column( + linkify=True, verbose_name='Side A' ) termination_z = tables.Column( + linkify=True, verbose_name='Side Z' ) tags = TagColumn( From b6f6293b7631030b7dbc9b68ebfeb9f7c5af1de4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 18 Mar 2021 15:07:22 -0400 Subject: [PATCH 8/8] Prevent the attachment of a Cable to a CircuitTermination on a Cloud --- netbox/dcim/models/cables.py | 10 ++++++++++ netbox/dcim/tests/test_models.py | 17 ++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index c8166cb4440..c3ee5ae91ad 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -242,6 +242,16 @@ def clean(self): ): raise ValidationError("A front port cannot be connected to it corresponding rear port") + # A CircuitTermination attached to a Cloud cannot have a Cable + if isinstance(self.termination_a, CircuitTermination) and self.termination_a.cloud is not None: + raise ValidationError({ + 'termination_a_id': "Circuit terminations attached to a cloud may not be cabled." + }) + if isinstance(self.termination_b, CircuitTermination) and self.termination_b.cloud is not None: + raise ValidationError({ + 'termination_b_id': "Circuit terminations attached to a cloud may not be cabled." + }) + # Check for an existing Cable connected to either termination object if self.termination_a.cable not in (None, self): raise ValidationError("{} already has a cable attached (#{})".format( diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 815d867583a..b4454aa8af2 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -479,10 +479,13 @@ def setUp(self): device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1 ) self.provider = Provider.objects.create(name='Provider 1', slug='provider-1') + cloud = Cloud.objects.create(name='Cloud 1', provider=self.provider) self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') - self.circuit = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') - self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A') - self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z') + self.circuit1 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') + self.circuit2 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='2') + self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='A') + self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='Z') + self.circuittermination3 = CircuitTermination.objects.create(circuit=self.circuit2, cloud=cloud, term_side='A') def test_cable_creation(self): """ @@ -552,6 +555,14 @@ def test_cable_cannot_terminate_to_an_existing_connection(self): with self.assertRaises(ValidationError): cable.clean() + def test_cable_cannot_terminate_to_a_cloud_circuittermination(self): + """ + Neither side of a cable can be terminated to a CircuitTermination which is attached to a Cloud + """ + cable = Cable(termination_a=self.interface3, termination_b=self.circuittermination3) + with self.assertRaises(ValidationError): + cable.clean() + def test_rearport_connections(self): """ Test various combinations of RearPort connections.