Explorar el Código

Introduce the Cloud model

Jeremy Stretch hace 4 años
padre
commit
6ff8a267e9

+ 14 - 1
netbox/circuits/api/nested_serializers.py

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

+ 17 - 1
netbox/circuits/api/serializers.py

@@ -1,7 +1,7 @@
 from rest_framework import serializers
 
 from circuits.choices import CircuitStatusChoices
-from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
+from circuits.models import *
 from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
 from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer
 from netbox.api import ChoiceField
@@ -28,6 +28,22 @@ class ProviderSerializer(PrimaryModelSerializer):
         ]
 
 
+#
+# Clouds
+#
+
+class CloudSerializer(PrimaryModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='circuits-api:cloud-detail')
+    provider = NestedProviderSerializer()
+
+    class Meta:
+        model = Cloud
+        fields = [
+            'id', 'url', 'display', 'provider', 'name', 'description', 'comments', 'tags', 'custom_fields', 'created',
+            'last_updated',
+        ]
+
+
 #
 # Circuits
 #

+ 3 - 0
netbox/circuits/api/urls.py

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

+ 11 - 1
netbox/circuits/api/views.py

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

+ 32 - 1
netbox/circuits/filters.py

@@ -9,12 +9,13 @@ from utilities.filters import (
     BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter
 )
 from .choices import *
-from .models import Circuit, CircuitTermination, CircuitType, Provider
+from .models import *
 
 __all__ = (
     'CircuitFilterSet',
     'CircuitTerminationFilterSet',
     'CircuitTypeFilterSet',
+    'CloudFilterSet',
     'ProviderFilterSet',
 )
 
@@ -79,6 +80,36 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdated
         )
 
 
+class CloudFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    provider_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Provider.objects.all(),
+        label='Provider (ID)',
+    )
+    provider = django_filters.ModelMultipleChoiceFilter(
+        field_name='provider__slug',
+        queryset=Provider.objects.all(),
+        to_field_name='slug',
+        label='Provider (slug)',
+    )
+    tag = TagFilter()
+
+    class Meta:
+        model = Cloud
+        fields = ['id', 'name']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(description__icontains=value) |
+            Q(comments__icontains=value)
+        ).distinct()
+
+
 class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
 
     class Meta:

+ 78 - 1
netbox/circuits/forms.py

@@ -14,7 +14,7 @@ from utilities.forms import (
     StaticSelect2, StaticSelect2Multiple, TagFilterField,
 )
 from .choices import CircuitStatusChoices
-from .models import Circuit, CircuitTermination, CircuitType, Provider
+from .models import *
 
 
 #
@@ -128,6 +128,83 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
     tag = TagFilterField(model)
 
 
+#
+# Clouds
+#
+
+class CloudForm(BootstrapMixin, CustomFieldModelForm):
+    provider = DynamicModelChoiceField(
+        queryset=Provider.objects.all()
+    )
+    comments = CommentField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Cloud
+        fields = [
+            'provider', 'name', 'description', 'comments', 'tags',
+        ]
+        fieldsets = (
+            ('Cloud', ('provider', 'name', 'description', 'tags')),
+        )
+
+
+class CloudCSVForm(CustomFieldModelCSVForm):
+    provider = CSVModelChoiceField(
+        queryset=Provider.objects.all(),
+        to_field_name='name',
+        help_text='Assigned provider'
+    )
+
+    class Meta:
+        model = Cloud
+        fields = [
+            'provider', 'name', 'description', 'comments',
+        ]
+
+
+class CloudBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Cloud.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    provider = DynamicModelChoiceField(
+        queryset=Provider.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        max_length=100,
+        required=False
+    )
+    comments = CommentField(
+        widget=SmallTextarea,
+        label='Comments'
+    )
+
+    class Meta:
+        nullable_fields = [
+            'description', 'comments',
+        ]
+
+
+class CloudFilterForm(BootstrapMixin, CustomFieldFilterForm):
+    model = Cloud
+    field_order = ['q', 'provider_id']
+    q = forms.CharField(
+        required=False,
+        label=_('Search')
+    )
+    provider_id = DynamicModelMultipleChoiceField(
+        queryset=Provider.objects.all(),
+        required=False,
+        label=_('Provider')
+    )
+    tag = TagFilterField(model)
+
+
 #
 # Circuit types
 #

+ 40 - 0
netbox/circuits/migrations/0027_cloud.py

@@ -0,0 +1,40 @@
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0058_journalentry'),
+        ('circuits', '0026_mark_connected'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Cloud',
+            fields=[
+                ('created', models.DateField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('id', models.BigAutoField(primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=100)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('comments', models.TextField(blank=True)),
+                ('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='clouds', to='circuits.provider')),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'ordering': ('provider', 'name'),
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='cloud',
+            constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_cloud_provider_name'),
+        ),
+        migrations.AlterUniqueTogether(
+            name='cloud',
+            unique_together={('provider', 'name')},
+        ),
+    ]

+ 54 - 0
netbox/circuits/models.py

@@ -15,6 +15,7 @@ __all__ = (
     'Circuit',
     'CircuitTermination',
     'CircuitType',
+    'Cloud',
     'Provider',
 )
 
@@ -91,6 +92,59 @@ class Provider(PrimaryModel):
         )
 
 
+#
+# Clouds
+#
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+class Cloud(PrimaryModel):
+    name = models.CharField(
+        max_length=100
+    )
+    provider = models.ForeignKey(
+        to='circuits.Provider',
+        on_delete=models.PROTECT,
+        related_name='clouds'
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True
+    )
+    comments = models.TextField(
+        blank=True
+    )
+
+    csv_headers = [
+        'provider', 'name', 'description', 'comments',
+    ]
+
+    objects = RestrictedQuerySet.as_manager()
+
+    class Meta:
+        ordering = ('provider', 'name')
+        constraints = (
+            models.UniqueConstraint(
+                fields=('provider', 'name'),
+                name='circuits_cloud_provider_name'
+            ),
+        )
+        unique_together = ('provider', 'name')
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('circuits:cloud', args=[self.pk])
+
+    def to_csv(self):
+        return (
+            self.provider.name,
+            self.name,
+            self.description,
+            self.comments,
+        )
+
+
 @extras_features('custom_fields', 'export_templates', 'webhooks')
 class CircuitType(OrganizationalModel):
     """

+ 23 - 1
netbox/circuits/tables.py

@@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
 
 from tenancy.tables import TenantColumn
 from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn
-from .models import Circuit, CircuitType, Provider
+from .models import *
 
 
 #
@@ -29,6 +29,28 @@ class ProviderTable(BaseTable):
         default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
 
 
+#
+# Clouds
+#
+
+class CloudTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.Column(
+        linkify=True
+    )
+    provider = tables.Column(
+        linkify=True
+    )
+    tags = TagColumn(
+        url_name='circuits:cloud_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = Cloud
+        fields = ('pk', 'name', 'provider', 'description', 'tags')
+        default_columns = ('pk', 'name', 'provider', 'description')
+
+
 #
 # Circuit types
 #

+ 41 - 1
netbox/circuits/tests/test_api.py

@@ -1,7 +1,7 @@
 from django.urls import reverse
 
 from circuits.choices import *
-from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
+from circuits.models import *
 from dcim.models import Site
 from utilities.testing import APITestCase, APIViewTestCases
 
@@ -178,3 +178,43 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
         cls.bulk_update_data = {
             'port_speed': 123456
         }
+
+
+class CloudTest(APIViewTestCases.APIViewTestCase):
+    model = Cloud
+    brief_fields = ['display', 'id', 'name', 'url']
+
+    @classmethod
+    def setUpTestData(cls):
+        providers = (
+            Provider(name='Provider 1', slug='provider-1'),
+            Provider(name='Provider 2', slug='provider-2'),
+        )
+        Provider.objects.bulk_create(providers)
+
+        clouds = (
+            Cloud(name='Cloud 1', provider=providers[0]),
+            Cloud(name='Cloud 2', provider=providers[0]),
+            Cloud(name='Cloud 3', provider=providers[0]),
+        )
+        Cloud.objects.bulk_create(clouds)
+
+        cls.create_data = [
+            {
+                'name': 'Cloud 4',
+                'provider': providers[0].pk,
+            },
+            {
+                'name': 'Cloud 5',
+                'provider': providers[0].pk,
+            },
+            {
+                'name': 'Cloud 6',
+                'provider': providers[0].pk,
+            },
+        ]
+
+        cls.bulk_update_data = {
+            'provider': providers[1].pk,
+            'description': 'New description',
+        }

+ 38 - 1
netbox/circuits/tests/test_filters.py

@@ -2,7 +2,7 @@ from django.test import TestCase
 
 from circuits.choices import *
 from circuits.filters import *
-from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
+from circuits.models import *
 from dcim.models import Cable, Region, Site, SiteGroup
 from tenancy.models import Tenant, TenantGroup
 
@@ -353,3 +353,40 @@ class CircuitTerminationTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'connected': False}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+
+class CloudTestCase(TestCase):
+    queryset = Cloud.objects.all()
+    filterset = CloudFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        providers = (
+            Provider(name='Provider 1', slug='provider-1'),
+            Provider(name='Provider 2', slug='provider-2'),
+            Provider(name='Provider 3', slug='provider-3'),
+        )
+        Provider.objects.bulk_create(providers)
+
+        clouds = (
+            Cloud(name='Cloud 1', provider=providers[0]),
+            Cloud(name='Cloud 2', provider=providers[1]),
+            Cloud(name='Cloud 3', provider=providers[2]),
+        )
+        Cloud.objects.bulk_create(clouds)
+
+    def test_id(self):
+        params = {'id': self.queryset.values_list('pk', flat=True)[:2]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_name(self):
+        params = {'name': ['Cloud 1', 'Cloud 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_provider(self):
+        providers = Provider.objects.all()[:2]
+        params = {'provider_id': [providers[0].pk, providers[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'provider': [providers[0].slug, providers[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 43 - 1
netbox/circuits/tests/test_views.py

@@ -1,7 +1,7 @@
 import datetime
 
 from circuits.choices import *
-from circuits.models import Circuit, CircuitType, Provider
+from circuits.models import *
 from utilities.testing import ViewTestCases
 
 
@@ -133,3 +133,45 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'description': 'New description',
             'comments': 'New comments',
         }
+
+
+class CloudTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = Cloud
+
+    @classmethod
+    def setUpTestData(cls):
+
+        providers = (
+            Provider(name='Provider 1', slug='provider-1'),
+            Provider(name='Provider 2', slug='provider-2'),
+        )
+        Provider.objects.bulk_create(providers)
+
+        Cloud.objects.bulk_create([
+            Cloud(name='Cloud 1', provider=providers[0]),
+            Cloud(name='Cloud 2', provider=providers[0]),
+            Cloud(name='Cloud 3', provider=providers[0]),
+        ])
+
+        tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'Cloud X',
+            'provider': providers[1].pk,
+            'description': 'A new cloud',
+            'comments': 'Longer description goes here',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,provider,description",
+            "Cloud 4,Provider 1,Foo",
+            "Cloud 5,Provider 1,Bar",
+            "Cloud 6,Provider 1,Baz",
+        )
+
+        cls.bulk_edit_data = {
+            'provider': providers[1].pk,
+            'description': 'New description',
+            'comments': 'New comments',
+        }

+ 13 - 1
netbox/circuits/urls.py

@@ -3,7 +3,7 @@ from django.urls import path
 from dcim.views import CableCreateView, PathTraceView
 from extras.views import ObjectChangeLogView, ObjectJournalView
 from . import views
-from .models import Circuit, CircuitTermination, CircuitType, Provider
+from .models import *
 
 app_name = 'circuits'
 urlpatterns = [
@@ -20,6 +20,18 @@ urlpatterns = [
     path('providers/<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'),

+ 44 - 1
netbox/circuits/views.py

@@ -9,7 +9,7 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.utils import count_related
 from . import filters, forms, tables
 from .choices import CircuitTerminationSideChoices
-from .models import Circuit, CircuitTermination, CircuitType, Provider
+from .models import *
 
 
 #
@@ -81,6 +81,49 @@ class ProviderBulkDeleteView(generic.BulkDeleteView):
     table = tables.ProviderTable
 
 
+#
+# Clouds
+#
+
+class CloudListView(generic.ObjectListView):
+    queryset = Cloud.objects.all()
+    filterset = filters.CloudFilterSet
+    filterset_form = forms.CloudFilterForm
+    table = tables.CloudTable
+
+
+class CloudView(generic.ObjectView):
+    queryset = Cloud.objects.all()
+
+
+class CloudEditView(generic.ObjectEditView):
+    queryset = Cloud.objects.all()
+    model_form = forms.CloudForm
+
+
+class CloudDeleteView(generic.ObjectDeleteView):
+    queryset = Cloud.objects.all()
+
+
+class CloudBulkImportView(generic.BulkImportView):
+    queryset = Cloud.objects.all()
+    model_form = forms.CloudCSVForm
+    table = tables.CloudTable
+
+
+class CloudBulkEditView(generic.BulkEditView):
+    queryset = Cloud.objects.all()
+    filterset = filters.CloudFilterSet
+    table = tables.CloudTable
+    form = forms.CloudBulkEditForm
+
+
+class CloudBulkDeleteView(generic.BulkDeleteView):
+    queryset = Cloud.objects.all()
+    filterset = filters.CloudFilterSet
+    table = tables.CloudTable
+
+
 #
 # Circuit Types
 #

+ 9 - 5
netbox/netbox/constants.py

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

+ 55 - 0
netbox/templates/circuits/cloud.html

@@ -0,0 +1,55 @@
+{% 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>Name</td>
+                    <td>{{ object.name }}</td>
+                </tr>
+                <tr>
+                    <td>Description</td>
+                    <td>{{ object.description }}</td>
+                </tr>
+            </table>
+        </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>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>
+    {% plugin_right_page object %}
+    </div>
+</div>
+<div class="row">
+    <div class="col-md-12">
+        {% plugin_full_width_page object %}
+    </div>
+</div>
+{% endblock %}

+ 8 - 0
netbox/templates/inc/nav_menu.html

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