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 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..5469049db 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 # @@ -47,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', ] @@ -77,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/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..0adbfcb0e 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 @@ -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 @@ -66,3 +65,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..0efd2f331 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: @@ -101,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)', @@ -190,6 +226,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 002c73b9a..d818ec0f6 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 # @@ -280,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, @@ -296,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, @@ -346,13 +432,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 = { @@ -365,3 +456,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 new file mode 100644 index 000000000..893371f8f --- /dev/null +++ b/netbox/circuits/migrations/0027_cloud.py @@ -0,0 +1,65 @@ +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 = [ + # Create the new Cloud model + 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')}, + ), + + # Add cloud FK to CircuitTermination + 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'), + ), + + # 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 d19841e4f..73df7f2d4 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 @@ -8,13 +9,13 @@ 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__ = ( '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): """ @@ -181,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', @@ -216,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): @@ -246,7 +304,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)', @@ -281,7 +348,23 @@ class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): unique_together = ['circuit', 'term_side'] def __str__(self): - return 'Side {}'.format(self.get_term_side_display()) + if self.site: + return str(self.site) + return str(self.cloud) + + 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() + + # 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/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 efa7e4c49..ba113de8c 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 # @@ -61,11 +83,13 @@ class CircuitTable(BaseTable): ) status = ChoiceFieldColumn() tenant = TenantColumn() - a_side = tables.Column( - verbose_name='A Side' + termination_a = tables.Column( + linkify=True, + verbose_name='Side A' ) - z_side = tables.Column( - verbose_name='Z Side' + termination_z = tables.Column( + linkify=True, + verbose_name='Side Z' ) tags = TagColumn( url_name='circuits:circuit_list' @@ -74,7 +98,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/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..880139baf 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 @@ -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,4 +385,41 @@ 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): + 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..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 @@ -9,7 +10,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 * # @@ -33,7 +34,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') @@ -81,6 +82,71 @@ 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() + + 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() + 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 # @@ -129,8 +195,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/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. diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index e5b3f763c..797a11965 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, @@ -42,11 +40,17 @@ SEARCH_TYPES = OrderedDict(( ('circuit', { 'queryset': Circuit.objects.prefetch_related( 'type', 'provider', 'tenant', 'terminations__site' - ).annotate_sites(), + ), 'filterset': CircuitFilterSet, '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/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 new file mode 100644 index 000000000..532118bf8 --- /dev/null +++ b/netbox/templates/circuits/cloud.html @@ -0,0 +1,68 @@ +{% extends 'generic/object.html' %} +{% load static %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Clouds
  • +
  • {{ object.provider }}
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Cloud +
    + + + + + + + + + + + + + +
    Provider + {{ object.provider }} +
    Name{{ object.name }}
    Description{{ object.description }}
    +
    +
    +
    + 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 %} +
    +
    +
    +
    + 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 %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} 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 %} - - - - - - - + + + + + + - + + + {% 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 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