mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-23 04:22:01 -06:00
Merge pull request #6011 from netbox-community/5986-cloud-model
Closes #5896: Introduce the cloud model
This commit is contained in:
commit
b9176adca4
@ -1,6 +1,7 @@
|
||||
# Circuits
|
||||
|
||||
{!docs/models/circuits/provider.md!}
|
||||
{!docs/models/circuits/cloud.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.
|
||||
|
5
docs/models/circuits/cloud.md
Normal file
5
docs/models/circuits/cloud.md
Normal 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.
|
@ -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
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
65
netbox/circuits/migrations/0027_cloud.py
Normal file
65
netbox/circuits/migrations/0027_cloud.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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
|
||||
),
|
||||
]
|
@ -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
|
||||
|
@ -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]),
|
||||
)
|
@ -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)
|
||||
|
@ -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')
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -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/<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}),
|
||||
|
||||
# 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
|
||||
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
|
||||
path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'),
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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.
|
||||
|
@ -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'),
|
||||
|
@ -6,7 +6,7 @@
|
||||
|
||||
{% block form %}
|
||||
<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="form-group">
|
||||
<label class="col-md-3 control-label">Provider</label>
|
||||
@ -26,9 +26,22 @@
|
||||
<p class="form-control-static">{{ form.term_side.value }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site_group %}
|
||||
{% render_field form.site %}
|
||||
{% with cloud_tab_active=form.initial.cloud %}
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
68
netbox/templates/circuits/cloud.html
Normal file
68
netbox/templates/circuits/cloud.html
Normal 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 %}
|
@ -26,62 +26,71 @@
|
||||
</div>
|
||||
{% if termination %}
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<td>Site</td>
|
||||
<td>
|
||||
{% if termination.site.region %}
|
||||
<a href="{{ termination.site.region.get_absolute_url }}">{{ termination.site.region }}</a> /
|
||||
{% endif %}
|
||||
<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>
|
||||
{% if termination.site %}
|
||||
<tr>
|
||||
<td>Site</td>
|
||||
<td>
|
||||
{% if termination.site.region %}
|
||||
<a href="{{ termination.site.region.get_absolute_url }}">{{ termination.site.region }}</a> /
|
||||
{% endif %}
|
||||
<a href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a>
|
||||
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-xs" title="Trace">
|
||||
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% with peer=termination.get_cable_peer %}
|
||||
to
|
||||
{% if peer.device %}
|
||||
<a href="{{ peer.device.get_absolute_url }}">{{ peer.device }}</a>
|
||||
{% elif peer.circuit %}
|
||||
<a href="{{ peer.circuit.get_absolute_url }}">{{ peer.circuit }}</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 %}
|
||||
({{ peer }})
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{% if perms.dcim.add_cable %}
|
||||
<div class="pull-right">
|
||||
<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>
|
||||
<a href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a>
|
||||
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-xs" title="Trace">
|
||||
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% with peer=termination.get_cable_peer %}
|
||||
to
|
||||
{% if peer.device %}
|
||||
<a href="{{ peer.device.get_absolute_url }}">{{ peer.device }}</a>
|
||||
{% elif peer.circuit %}
|
||||
<a href="{{ peer.circuit.get_absolute_url }}">{{ peer.circuit }}</a>
|
||||
{% endif %}
|
||||
({{ peer }})
|
||||
{% endwith %}
|
||||
{% else %}
|
||||
{% if perms.dcim.add_cable %}
|
||||
<div class="pull-right">
|
||||
<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 %}
|
||||
<span class="text-muted">Not defined</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td>Cloud</td>
|
||||
<td>
|
||||
<a href="{{ termination.cloud.get_absolute_url }}">{{ termination.cloud }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td>Speed</td>
|
||||
<td>
|
||||
|
@ -465,6 +465,14 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
</ul>
|
||||
</li>
|
||||
|
Loading…
Reference in New Issue
Block a user