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 7c7d371ad..0fd07d31b 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 bae45e2b3..556721c94 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 ProviderSerializer(PrimaryModelSerializer): ] +# +# 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 b496796fe..4f31806bd 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -13,5 +13,8 @@ router.register('circuit-types', views.CircuitTypeViewSet) 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 c2fe3d089..373b3e18d 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -2,7 +2,7 @@ from django.db.models import Prefetch 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 03da662e7..376cc2af7 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -9,12 +9,13 @@ from utilities.filters import ( 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 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdated ) +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 002c73b9a..295a3ea63 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -14,7 +14,7 @@ from utilities.forms import ( 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 000000000..36cceb7ca --- /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 d19841e4f..d2f8a5b1d 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -15,6 +15,7 @@ __all__ = ( 'Circuit', 'CircuitTermination', 'CircuitType', + 'Cloud', 'Provider', ) @@ -91,6 +92,59 @@ class Provider(PrimaryModel): ) +# +# 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 efa7e4c49..94894368e 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -3,7 +3,7 @@ from django_tables2.utils import Accessor 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 ProviderTable(BaseTable): 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 3341c72c3..01e228f76 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 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): 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 b9e1eac45..af465c427 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -2,7 +2,7 @@ from django.test import TestCase 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 @@ class CircuitTerminationTestCase(TestCase): 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 de0d2c970..ba2d4fc22 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 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): '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 0b47b4b2c..acc3baac5 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -3,7 +3,7 @@ from django.urls import path 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 @@ urlpatterns = [ 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 b3215c029..2484a84e4 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -9,7 +9,7 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count 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 e5b3f763c..2a466b4cd 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 @@ SEARCH_TYPES = OrderedDict(( '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 000000000..61dec5ead --- /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 4fff16141..fb77dc2b6 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 556721c94..5469049db 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -63,12 +63,13 @@ class CircuitTypeSerializer(OrganizationalModelSerializer): 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 CircuitSerializer(PrimaryModelSerializer): 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 376cc2af7..6a6b2c012 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 295a3ea63..7285dad96 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 CircuitTerminationForm(BootstrapMixin, forms.ModelForm): '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 36cceb7ca..889b5151e 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 d2f8a5b1d..b13dd9603 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 CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): 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 4e737d16d..ebad75976 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 61dec5ead..268f64387 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 762dd1662..acfc4ee22 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 373b3e18d..0adbfcb0e 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 889b5151e..893371f8f 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 000000000..49631da07 --- /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 b13dd9603..c2ff71126 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -9,7 +9,6 @@ from extras.utils import extras_features 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 @@ class Circuit(PrimaryModel): 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 CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): 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 8a9bd50a4..000000000 --- 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 86db21400..7c9832d5b 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 94894368e..00b4613a7 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 2484a84e4..b67bff81b 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -33,7 +33,7 @@ class ProviderView(generic.ObjectView): 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 2a466b4cd..797a11965 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -40,7 +40,7 @@ SEARCH_TYPES = OrderedDict(( ('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 b67bff81b..78069bae0 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 268f64387..532118bf8 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 6a6b2c012..0efd2f331 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 7285dad96..d818ec0f6 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -357,7 +357,8 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit 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 af465c427..880139baf 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -186,6 +186,13 @@ class CircuitTestCase(TestCase): ) 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 @@ class CircuitTestCase(TestCase): 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 @@ class CircuitTestCase(TestCase): 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 @@ class CircuitTerminationTestCase(TestCase): ) 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 @@ class CircuitTerminationTestCase(TestCase): 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 @@ class CircuitTerminationTestCase(TestCase): 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 @@ class CircuitTerminationTestCase(TestCase): 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 @@ class CircuitTerminationTestCase(TestCase): 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 43b911308..67388dba4 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 1c0dbfe18..c1ec09cae 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 000000000..c4b3cec5e --- /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 6bfdd414b..c80b74296 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 c2ff71126..73df7f2d4 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -351,7 +351,11 @@ class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): 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 00b4613a7..ba113de8c 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 c8166cb44..c3ee5ae91 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -242,6 +242,16 @@ class Cable(PrimaryModel): ): 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 815d86758..b4454aa8a 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -479,10 +479,13 @@ class CableTestCase(TestCase): 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 @@ class CableTestCase(TestCase): 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.