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