Merge pull request #6011 from netbox-community/5986-cloud-model

Closes #5896: Introduce the cloud model
This commit is contained in:
Jeremy Stretch 2021-03-18 15:22:48 -04:00 committed by GitHub
commit b9176adca4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 893 additions and 153 deletions

View File

@ -1,6 +1,7 @@
# Circuits # Circuits
{!docs/models/circuits/provider.md!} {!docs/models/circuits/provider.md!}
{!docs/models/circuits/cloud.md!}
--- ---

View File

@ -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. 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 !!! 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.

View File

@ -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.

View File

@ -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. 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 ### Enhancements
* [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models * [#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 * Added `_occupied` read-only boolean field as common attribute for determining whether an object is occupied
* Renamed RackGroup to Location * Renamed RackGroup to Location
* The `/dcim/rack-groups/` endpoint is now `/dcim/locations/` * 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 * dcim.Device
* Added the `location` field * Added the `location` field
* dcim.Interface * dcim.Interface

View File

@ -1,16 +1,29 @@
from rest_framework import serializers from rest_framework import serializers
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from circuits.models import *
from netbox.api import WritableNestedSerializer from netbox.api import WritableNestedSerializer
__all__ = [ __all__ = [
'NestedCircuitSerializer', 'NestedCircuitSerializer',
'NestedCircuitTerminationSerializer', 'NestedCircuitTerminationSerializer',
'NestedCircuitTypeSerializer', 'NestedCircuitTypeSerializer',
'NestedCloudSerializer',
'NestedProviderSerializer', 'NestedProviderSerializer',
] ]
#
# Clouds
#
class NestedCloudSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:cloud-detail')
class Meta:
model = Provider
fields = ['id', 'url', 'display', 'name']
# #
# Providers # Providers
# #

View File

@ -1,7 +1,7 @@
from rest_framework import serializers from rest_framework import serializers
from circuits.choices import CircuitStatusChoices 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.nested_serializers import NestedCableSerializer, NestedSiteSerializer
from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer
from netbox.api import ChoiceField 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 # Circuits
# #
@ -47,12 +63,13 @@ class CircuitTypeSerializer(OrganizationalModelSerializer):
class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEndpointSerializer): class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
site = NestedSiteSerializer() site = NestedSiteSerializer()
cloud = NestedCloudSerializer()
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'id', 'url', 'display', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'connected_endpoint', 'id', 'url', 'display', 'site', 'cloud', 'port_speed', 'upstream_speed', 'xconnect_id',
'connected_endpoint_type', 'connected_endpoint_reachable', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable',
] ]
@ -77,13 +94,14 @@ class CircuitSerializer(PrimaryModelSerializer):
class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer() circuit = NestedCircuitSerializer()
site = NestedSiteSerializer() site = NestedSiteSerializer(required=False)
cloud = NestedCloudSerializer(required=False)
cable = NestedCableSerializer(read_only=True) cable = NestedCableSerializer(read_only=True)
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'cloud', 'port_speed', 'upstream_speed',
'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type',
'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied',
] ]

View File

@ -13,5 +13,8 @@ router.register('circuit-types', views.CircuitTypeViewSet)
router.register('circuits', views.CircuitViewSet) router.register('circuits', views.CircuitViewSet)
router.register('circuit-terminations', views.CircuitTerminationViewSet) router.register('circuit-terminations', views.CircuitTerminationViewSet)
# Clouds
router.register('clouds', views.CloudViewSet)
app_name = 'circuits-api' app_name = 'circuits-api'
urlpatterns = router.urls urlpatterns = router.urls

View File

@ -2,7 +2,7 @@ from django.db.models import Prefetch
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from circuits import filters from circuits import filters
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit from circuits.models import *
from dcim.api.views import PathEndpointMixin from dcim.api.views import PathEndpointMixin
from extras.api.views import CustomFieldModelViewSet from extras.api.views import CustomFieldModelViewSet
from netbox.api.views import ModelViewSet from netbox.api.views import ModelViewSet
@ -48,8 +48,7 @@ class CircuitTypeViewSet(CustomFieldModelViewSet):
class CircuitViewSet(CustomFieldModelViewSet): class CircuitViewSet(CustomFieldModelViewSet):
queryset = Circuit.objects.prefetch_related( queryset = Circuit.objects.prefetch_related(
Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related('site')), 'type', 'tenant', 'provider', 'termination_a', 'termination_z'
'type', 'tenant', 'provider',
).prefetch_related('tags') ).prefetch_related('tags')
serializer_class = serializers.CircuitSerializer serializer_class = serializers.CircuitSerializer
filterset_class = filters.CircuitFilterSet filterset_class = filters.CircuitFilterSet
@ -66,3 +65,13 @@ class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
serializer_class = serializers.CircuitTerminationSerializer serializer_class = serializers.CircuitTerminationSerializer
filterset_class = filters.CircuitTerminationFilterSet filterset_class = filters.CircuitTerminationFilterSet
brief_prefetch_fields = ['circuit'] brief_prefetch_fields = ['circuit']
#
# Clouds
#
class CloudViewSet(CustomFieldModelViewSet):
queryset = Cloud.objects.prefetch_related('tags')
serializer_class = serializers.CloudSerializer
filterset_class = filters.CloudFilterSet

View File

@ -9,12 +9,13 @@ from utilities.filters import (
BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter
) )
from .choices import * from .choices import *
from .models import Circuit, CircuitTermination, CircuitType, Provider from .models import *
__all__ = ( __all__ = (
'CircuitFilterSet', 'CircuitFilterSet',
'CircuitTerminationFilterSet', 'CircuitTerminationFilterSet',
'CircuitTypeFilterSet', 'CircuitTypeFilterSet',
'CloudFilterSet',
'ProviderFilterSet', '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 CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
class Meta: class Meta:
@ -101,6 +132,11 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
to_field_name='slug', to_field_name='slug',
label='Provider (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( type_id = django_filters.ModelMultipleChoiceFilter(
queryset=CircuitType.objects.all(), queryset=CircuitType.objects.all(),
label='Circuit type (ID)', label='Circuit type (ID)',
@ -190,6 +226,10 @@ class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, Path
to_field_name='slug', to_field_name='slug',
label='Site (slug)', label='Site (slug)',
) )
cloud_id = django_filters.ModelMultipleChoiceFilter(
queryset=Cloud.objects.all(),
label='Cloud (ID)',
)
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination

View File

@ -14,7 +14,7 @@ from utilities.forms import (
StaticSelect2, StaticSelect2Multiple, TagFilterField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
) )
from .choices import CircuitStatusChoices 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) 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 # Circuit types
# #
@ -280,7 +357,8 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = Circuit model = Circuit
field_order = [ 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( q = forms.CharField(
required=False, required=False,
@ -296,6 +374,14 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
required=False, required=False,
label=_('Provider') label=_('Provider')
) )
cloud_id = DynamicModelMultipleChoiceField(
queryset=Cloud.objects.all(),
required=False,
query_params={
'provider_id': '$provider_id'
},
label=_('Cloud')
)
status = forms.MultipleChoiceField( status = forms.MultipleChoiceField(
choices=CircuitStatusChoices, choices=CircuitStatusChoices,
required=False, required=False,
@ -346,13 +432,18 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
query_params={ query_params={
'region_id': '$region', 'region_id': '$region',
'group_id': '$site_group', 'group_id': '$site_group',
} },
required=False
)
cloud = DynamicModelChoiceField(
queryset=Cloud.objects.all(),
required=False
) )
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
fields = [ 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', 'xconnect_id', 'pp_info', 'description',
] ]
help_texts = { help_texts = {
@ -365,3 +456,8 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
'port_speed': SelectSpeedWidget(), 'port_speed': SelectSpeedWidget(),
'upstream_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)

View File

@ -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'),
),
]

View File

@ -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
),
]

View File

@ -1,3 +1,4 @@
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
@ -8,13 +9,13 @@ from extras.utils import extras_features
from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from .choices import * from .choices import *
from .querysets import CircuitQuerySet
__all__ = ( __all__ = (
'Circuit', 'Circuit',
'CircuitTermination', 'CircuitTermination',
'CircuitType', 'CircuitType',
'Cloud',
'Provider', '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') @extras_features('custom_fields', 'export_templates', 'webhooks')
class CircuitType(OrganizationalModel): class CircuitType(OrganizationalModel):
""" """
@ -181,7 +235,25 @@ class Circuit(PrimaryModel):
blank=True 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 = [ csv_headers = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
@ -216,20 +288,6 @@ class Circuit(PrimaryModel):
def get_status_class(self): def get_status_class(self):
return CircuitStatusChoices.CSS_CLASSES.get(self.status) 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') @extras_features('webhooks')
class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination):
@ -246,7 +304,16 @@ class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination):
site = models.ForeignKey( site = models.ForeignKey(
to='dcim.Site', to='dcim.Site',
on_delete=models.PROTECT, 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( port_speed = models.PositiveIntegerField(
verbose_name='Port speed (Kbps)', verbose_name='Port speed (Kbps)',
@ -281,7 +348,23 @@ class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination):
unique_together = ['circuit', 'term_side'] unique_together = ['circuit', 'term_side']
def __str__(self): 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): def to_objectchange(self, action):
# Annotate the parent Circuit # Annotate the parent Circuit

View File

@ -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]),
)

View File

@ -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.dispatch import receiver
from django.utils import timezone from django.utils import timezone
from .models import Circuit, CircuitTermination from .models import Circuit, CircuitTermination
@receiver((post_save, post_delete), sender=CircuitTermination) @receiver(post_save, sender=CircuitTermination)
def update_circuit(instance, **kwargs): 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) fields = {
time = timezone.now() 'last_updated': timezone.now(),
for circuit in circuits: f'termination_{instance.term_side.lower()}': instance.pk,
circuit.last_updated = time }
circuit.save() Circuit.objects.filter(pk=instance.circuit_id).update(**fields)

View File

@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn 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') 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 # Circuit types
# #
@ -61,11 +83,13 @@ class CircuitTable(BaseTable):
) )
status = ChoiceFieldColumn() status = ChoiceFieldColumn()
tenant = TenantColumn() tenant = TenantColumn()
a_side = tables.Column( termination_a = tables.Column(
verbose_name='A Side' linkify=True,
verbose_name='Side A'
) )
z_side = tables.Column( termination_z = tables.Column(
verbose_name='Z Side' linkify=True,
verbose_name='Side Z'
) )
tags = TagColumn( tags = TagColumn(
url_name='circuits:circuit_list' url_name='circuits:circuit_list'
@ -74,7 +98,9 @@ class CircuitTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Circuit model = Circuit
fields = ( fields = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'install_date', 'commit_rate', 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
'description', 'tags', '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')

View File

@ -1,7 +1,7 @@
from django.urls import reverse from django.urls import reverse
from circuits.choices import * from circuits.choices import *
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from circuits.models import *
from dcim.models import Site from dcim.models import Site
from utilities.testing import APITestCase, APIViewTestCases from utilities.testing import APITestCase, APIViewTestCases
@ -178,3 +178,43 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
cls.bulk_update_data = { cls.bulk_update_data = {
'port_speed': 123456 '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',
}

View File

@ -2,7 +2,7 @@ from django.test import TestCase
from circuits.choices import * from circuits.choices import *
from circuits.filters 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 dcim.models import Cable, Region, Site, SiteGroup
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
@ -186,6 +186,13 @@ class CircuitTestCase(TestCase):
) )
Provider.objects.bulk_create(providers) 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 = ( 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 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), 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[0], site=sites[0], term_side='A'),
CircuitTermination(circuit=circuits[1], site=sites[1], 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[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) CircuitTermination.objects.bulk_create(circuit_terminations)
@ -226,6 +236,11 @@ class CircuitTestCase(TestCase):
params = {'provider': [provider.slug]} params = {'provider': [provider.slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) 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): def test_type(self):
circuit_type = CircuitType.objects.first() circuit_type = CircuitType.objects.first()
params = {'type_id': [circuit_type.pk]} params = {'type_id': [circuit_type.pk]}
@ -281,14 +296,14 @@ class CircuitTerminationTestCase(TestCase):
def setUpTestData(cls): def setUpTestData(cls):
sites = ( sites = (
Site(name='Test Site 1', slug='test-site-1'), Site(name='Site 1', slug='site-1'),
Site(name='Test Site 2', slug='test-site-2'), Site(name='Site 2', slug='site-2'),
Site(name='Test Site 3', slug='test-site-3'), Site(name='Site 3', slug='site-3'),
) )
Site.objects.bulk_create(sites) Site.objects.bulk_create(sites)
circuit_types = ( 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) CircuitType.objects.bulk_create(circuit_types)
@ -297,10 +312,20 @@ class CircuitTerminationTestCase(TestCase):
) )
Provider.objects.bulk_create(providers) 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 = ( circuits = (
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 1'), Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 2'), Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 2'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 3'), 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) 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[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[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[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) CircuitTermination.objects.bulk_create(circuit_terminations)
@ -318,7 +346,7 @@ class CircuitTerminationTestCase(TestCase):
def test_term_side(self): def test_term_side(self):
params = {'term_side': 'A'} 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): def test_port_speed(self):
params = {'port_speed': ['1000', '2000']} params = {'port_speed': ['1000', '2000']}
@ -344,6 +372,11 @@ class CircuitTerminationTestCase(TestCase):
params = {'site': [sites[0].slug, sites[1].slug]} params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) 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): def test_cabled(self):
params = {'cabled': True} params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -352,4 +385,41 @@ class CircuitTerminationTestCase(TestCase):
params = {'connected': True} params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False} 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)

View File

@ -1,7 +1,7 @@
import datetime import datetime
from circuits.choices import * from circuits.choices import *
from circuits.models import Circuit, CircuitType, Provider from circuits.models import *
from utilities.testing import ViewTestCases from utilities.testing import ViewTestCases
@ -133,3 +133,45 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description', 'description': 'New description',
'comments': 'New comments', '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',
}

View File

@ -3,7 +3,7 @@ from django.urls import path
from dcim.views import CableCreateView, PathTraceView from dcim.views import CableCreateView, PathTraceView
from extras.views import ObjectChangeLogView, ObjectJournalView from extras.views import ObjectChangeLogView, ObjectJournalView
from . import views from . import views
from .models import Circuit, CircuitTermination, CircuitType, Provider from .models import *
app_name = 'circuits' app_name = 'circuits'
urlpatterns = [ urlpatterns = [
@ -20,6 +20,18 @@ urlpatterns = [
path('providers/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), path('providers/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
path('providers/<int:pk>/journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}), path('providers/<int:pk>/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/<int:pk>/', views.CloudView.as_view(), name='cloud'),
path('clouds/<int:pk>/edit/', views.CloudEditView.as_view(), name='cloud_edit'),
path('clouds/<int:pk>/delete/', views.CloudDeleteView.as_view(), name='cloud_delete'),
path('clouds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cloud_changelog', kwargs={'model': Cloud}),
path('clouds/<int:pk>/journal/', ObjectJournalView.as_view(), name='cloud_journal', kwargs={'model': Cloud}),
# Circuit types # Circuit types
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'), path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'),

View File

@ -1,5 +1,6 @@
from django.contrib import messages from django.contrib import messages
from django.db import transaction from django.db import transaction
from django.db.models import Q
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
@ -9,7 +10,7 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.utils import count_related from utilities.utils import count_related
from . import filters, forms, tables from . import filters, forms, tables
from .choices import CircuitTerminationSideChoices from .choices import CircuitTerminationSideChoices
from .models import Circuit, CircuitTermination, CircuitType, Provider from .models import *
# #
@ -33,7 +34,7 @@ class ProviderView(generic.ObjectView):
provider=instance provider=instance
).prefetch_related( ).prefetch_related(
'type', 'tenant', 'terminations__site' 'type', 'tenant', 'terminations__site'
).annotate_sites() )
circuits_table = tables.CircuitTable(circuits) circuits_table = tables.CircuitTable(circuits)
circuits_table.columns.hide('provider') circuits_table.columns.hide('provider')
@ -81,6 +82,71 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
table = tables.ProviderTable 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 # Circuit Types
# #
@ -129,8 +195,8 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
class CircuitListView(generic.ObjectListView): class CircuitListView(generic.ObjectListView):
queryset = Circuit.objects.prefetch_related( queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations' 'provider', 'type', 'tenant', 'termination_a', 'termination_z'
).annotate_sites() )
filterset = filters.CircuitFilterSet filterset = filters.CircuitFilterSet
filterset_form = forms.CircuitFilterForm filterset_form = forms.CircuitFilterForm
table = tables.CircuitTable table = tables.CircuitTable

View File

@ -242,6 +242,16 @@ class Cable(PrimaryModel):
): ):
raise ValidationError("A front port cannot be connected to it corresponding rear port") 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 # Check for an existing Cable connected to either termination object
if self.termination_a.cable not in (None, self): if self.termination_a.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format( raise ValidationError("{} already has a cable attached (#{})".format(

View File

@ -479,10 +479,13 @@ class CableTestCase(TestCase):
device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1 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') 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.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.circuit1 = 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.circuit2 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='2')
self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z') 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): def test_cable_creation(self):
""" """
@ -552,6 +555,14 @@ class CableTestCase(TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
cable.clean() 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): def test_rearport_connections(self):
""" """
Test various combinations of RearPort connections. Test various combinations of RearPort connections.

View File

@ -1,10 +1,8 @@
from collections import OrderedDict from collections import OrderedDict
from django.db.models import Count from circuits.filters import CircuitFilterSet, CloudFilterSet, ProviderFilterSet
from circuits.models import Circuit, Cloud, Provider
from circuits.filters import CircuitFilterSet, ProviderFilterSet from circuits.tables import CircuitTable, CloudTable, ProviderTable
from circuits.models import Circuit, Provider
from circuits.tables import CircuitTable, ProviderTable
from dcim.filters import ( from dcim.filters import (
CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, LocationFilterSet, CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, LocationFilterSet,
SiteFilterSet, VirtualChassisFilterSet, SiteFilterSet, VirtualChassisFilterSet,
@ -42,11 +40,17 @@ SEARCH_TYPES = OrderedDict((
('circuit', { ('circuit', {
'queryset': Circuit.objects.prefetch_related( 'queryset': Circuit.objects.prefetch_related(
'type', 'provider', 'tenant', 'terminations__site' 'type', 'provider', 'tenant', 'terminations__site'
).annotate_sites(), ),
'filterset': CircuitFilterSet, 'filterset': CircuitFilterSet,
'table': CircuitTable, 'table': CircuitTable,
'url': 'circuits:circuit_list', 'url': 'circuits:circuit_list',
}), }),
('cloud', {
'queryset': Cloud.objects.prefetch_related('provider'),
'filterset': CloudFilterSet,
'table': CloudTable,
'url': 'circuits:cloud_list',
}),
# DCIM # DCIM
('site', { ('site', {
'queryset': Site.objects.prefetch_related('region', 'tenant'), 'queryset': Site.objects.prefetch_related('region', 'tenant'),

View File

@ -6,7 +6,7 @@
{% block form %} {% block form %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"><strong>Location</strong></div> <div class="panel-heading"><strong>Termination</strong></div>
<div class="panel-body"> <div class="panel-body">
<div class="form-group"> <div class="form-group">
<label class="col-md-3 control-label">Provider</label> <label class="col-md-3 control-label">Provider</label>
@ -26,9 +26,22 @@
<p class="form-control-static">{{ form.term_side.value }}</p> <p class="form-control-static">{{ form.term_side.value }}</p>
</div> </div>
</div> </div>
{% render_field form.region %} {% with cloud_tab_active=form.initial.cloud %}
{% render_field form.site_group %} <ul class="nav nav-tabs" role="tablist">
{% render_field form.site %} <li role="presentation"{% if not cloud_tab_active %} class="active"{% endif %}><a href="#site" role="tab" data-toggle="tab">Site</a></li>
<li role="presentation"{% if cloud_tab_active %} class="active"{% endif %}><a href="#cloud" role="tab" data-toggle="tab">Cloud</a></li>
</ul>
<div class="tab-content">
<div class="tab-pane{% if not cloud_tab_active %} active{% endif %}" id="site">
{% render_field form.region %}
{% render_field form.site_group %}
{% render_field form.site %}
</div>
<div class="tab-pane{% if cloud_tab_active %} active{% endif %}" id="cloud">
{% render_field form.cloud %}
</div>
</div>
{% endwith %}
{% render_field form.mark_connected %} {% render_field form.mark_connected %}
</div> </div>
</div> </div>

View File

@ -0,0 +1,68 @@
{% extends 'generic/object.html' %}
{% load static %}
{% load helpers %}
{% load plugins %}
{% block breadcrumbs %}
<li><a href="{% url 'circuits:cloud_list' %}">Clouds</a></li>
<li><a href="{% url 'circuits:cloud_list' %}?provider_id={{ object.provider_id }}">{{ object.provider }}</a></li>
<li>{{ object }}</li>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Cloud</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Provider</td>
<td>
<a href="{{ object.provider.get_absolute_url }}">{{ object.provider }}</a>
</td>
</tr>
<tr>
<td>Name</td>
<td>{{ object.name }}</td>
</tr>
<tr>
<td>Description</td>
<td>{{ object.description }}</td>
</tr>
</table>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body rendered-markdown">
{% if object.comments %}
{{ object.comments|render_markdown }}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
{% 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 %}
</div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Circuits</strong>
</div>
{% include 'inc/table.html' with table=circuits_table %}
</div>
{% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}

View File

@ -26,62 +26,71 @@
</div> </div>
{% if termination %} {% if termination %}
<table class="table table-hover panel-body attr-table"> <table class="table table-hover panel-body attr-table">
<tr> {% if termination.site %}
<td>Site</td> <tr>
<td> <td>Site</td>
{% if termination.site.region %} <td>
<a href="{{ termination.site.region.get_absolute_url }}">{{ termination.site.region }}</a> / {% if termination.site.region %}
{% endif %} <a href="{{ termination.site.region.get_absolute_url }}">{{ termination.site.region }}</a> /
<a href="{{ termination.site.get_absolute_url }}">{{ termination.site }}</a>
</td>
</tr>
<tr>
<td>Termination</td>
<td>
{% if termination.mark_connected %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
<span class="text-muted">Marked as connected</span>
{% elif termination.cable %}
{% if perms.dcim.delete_cable %}
<div class="pull-right">
<a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-xs">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> Disconnect
</a>
</div>
{% endif %} {% endif %}
<a href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> <a href="{{ termination.site.get_absolute_url }}">{{ termination.site }}</a>
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-xs" title="Trace"> </td>
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> </tr>
</a> <tr>
{% with peer=termination.get_cable_peer %} <td>Termination</td>
to <td>
{% if peer.device %} {% if termination.mark_connected %}
<a href="{{ peer.device.get_absolute_url }}">{{ peer.device }}</a> <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
{% elif peer.circuit %} <span class="text-muted">Marked as connected</span>
<a href="{{ peer.circuit.get_absolute_url }}">{{ peer.circuit }}</a> {% elif termination.cable %}
{% if perms.dcim.delete_cable %}
<div class="pull-right">
<a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-xs">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> Disconnect
</a>
</div>
{% endif %} {% endif %}
({{ peer }}) <a href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a>
{% endwith %} <a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-xs" title="Trace">
{% else %} <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
{% if perms.dcim.add_cable %} </a>
<div class="pull-right"> {% with peer=termination.get_cable_peer %}
<span class="dropdown"> to
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> {% if peer.device %}
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect <a href="{{ peer.device.get_absolute_url }}">{{ peer.device }}</a>
</button> {% elif peer.circuit %}
<ul class="dropdown-menu dropdown-menu-right"> <a href="{{ peer.circuit.get_absolute_url }}">{{ peer.circuit }}</a>
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='interface' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Interface</a></li> {% endif %}
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='front-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Front Port</a></li> ({{ peer }})
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='rear-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Rear Port</a></li> {% endwith %}
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='circuit-termination' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Circuit Termination</a></li> {% else %}
</ul> {% if perms.dcim.add_cable %}
</span> <div class="pull-right">
</div> <span class="dropdown">
<button type="button" class="btn btn-success btn-xs dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='interface' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Interface</a></li>
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='front-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Front Port</a></li>
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='rear-port' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Rear Port</a></li>
<li><a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk termination_b_type='circuit-termination' %}?termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Circuit Termination</a></li>
</ul>
</span>
</div>
{% endif %}
<span class="text-muted">Not defined</span>
{% endif %} {% endif %}
<span class="text-muted">Not defined</span> </td>
{% endif %} </tr>
</td> {% else %}
</tr> <tr>
<td>Cloud</td>
<td>
<a href="{{ termination.cloud.get_absolute_url }}">{{ termination.cloud }}</a>
</td>
</tr>
{% endif %}
<tr> <tr>
<td>Speed</td> <td>Speed</td>
<td> <td>

View File

@ -465,6 +465,14 @@
</div> </div>
{% endif %} {% endif %}
<a href="{% url 'circuits:provider_list' %}">Providers</a> <a href="{% url 'circuits:provider_list' %}">Providers</a>
<li{% if not perms.circuits.view_cloud %} class="disabled"{% endif %}>
{% if perms.circuits.add_cloud %}
<div class="buttons pull-right">
<a href="{% url 'circuits:cloud_add' %}" class="btn btn-xs btn-success" title="Add"><i class="mdi mdi-plus-thick"></i></a>
<a href="{% url 'circuits:cloud_import' %}" class="btn btn-xs btn-info" title="Import"><i class="mdi mdi-database-import-outline"></i></a>
</div>
{% endif %}
<a href="{% url 'circuits:cloud_list' %}">Clouds</a>
</li> </li>
</ul> </ul>
</li> </li>