mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-21 04:42:22 -06:00
Merge branch 'develop-2.3' into develop
This commit is contained in:
@@ -2,11 +2,12 @@ from __future__ import unicode_literals
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from circuits.constants import CIRCUIT_STATUS_CHOICES
|
||||
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
|
||||
from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from tenancy.api.serializers import NestedTenantSerializer
|
||||
from utilities.api import ValidatedModelSerializer
|
||||
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
||||
|
||||
|
||||
#
|
||||
@@ -19,7 +20,7 @@ class ProviderSerializer(CustomFieldModelSerializer):
|
||||
model = Provider
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
||||
'custom_fields',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -37,7 +38,7 @@ class WritableProviderSerializer(CustomFieldModelSerializer):
|
||||
model = Provider
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
||||
'custom_fields',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -66,14 +67,15 @@ class NestedCircuitTypeSerializer(serializers.ModelSerializer):
|
||||
|
||||
class CircuitSerializer(CustomFieldModelSerializer):
|
||||
provider = NestedProviderSerializer()
|
||||
status = ChoiceFieldSerializer(choices=CIRCUIT_STATUS_CHOICES)
|
||||
type = NestedCircuitTypeSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = [
|
||||
'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||
'custom_fields',
|
||||
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
|
||||
'comments', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -90,8 +92,8 @@ class WritableCircuitSerializer(CustomFieldModelSerializer):
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = [
|
||||
'id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||
'custom_fields',
|
||||
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
|
||||
'comments', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -3,14 +3,13 @@ from __future__ import unicode_literals
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from circuits import filters
|
||||
from circuits.models import Provider, CircuitTermination, CircuitType, Circuit
|
||||
from extras.api.serializers import RenderedGraphSerializer
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from extras.models import Graph, GRAPH_TYPE_PROVIDER
|
||||
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
|
||||
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -28,7 +27,7 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
# Providers
|
||||
#
|
||||
|
||||
class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class ProviderViewSet(CustomFieldModelViewSet):
|
||||
queryset = Provider.objects.all()
|
||||
serializer_class = serializers.ProviderSerializer
|
||||
write_serializer_class = serializers.WritableProviderSerializer
|
||||
@@ -59,7 +58,7 @@ class CircuitTypeViewSet(ModelViewSet):
|
||||
# Circuits
|
||||
#
|
||||
|
||||
class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class CircuitViewSet(CustomFieldModelViewSet):
|
||||
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')
|
||||
serializer_class = serializers.CircuitSerializer
|
||||
write_serializer_class = serializers.WritableCircuitSerializer
|
||||
@@ -70,7 +69,7 @@ class CircuitViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# Circuit Terminations
|
||||
#
|
||||
|
||||
class CircuitTerminationViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class CircuitTerminationViewSet(ModelViewSet):
|
||||
queryset = CircuitTermination.objects.select_related('circuit', 'site', 'interface__device')
|
||||
serializer_class = serializers.CircuitTerminationSerializer
|
||||
write_serializer_class = serializers.WritableCircuitTerminationSerializer
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
|
||||
# Circuit statuses
|
||||
CIRCUIT_STATUS_DEPROVISIONING = 0
|
||||
CIRCUIT_STATUS_ACTIVE = 1
|
||||
CIRCUIT_STATUS_PLANNED = 2
|
||||
CIRCUIT_STATUS_PROVISIONING = 3
|
||||
CIRCUIT_STATUS_OFFLINE = 4
|
||||
CIRCUIT_STATUS_DECOMMISSIONED = 5
|
||||
CIRCUIT_STATUS_CHOICES = [
|
||||
[CIRCUIT_STATUS_PLANNED, 'Planned'],
|
||||
[CIRCUIT_STATUS_PROVISIONING, 'Provisioning'],
|
||||
[CIRCUIT_STATUS_ACTIVE, 'Active'],
|
||||
[CIRCUIT_STATUS_OFFLINE, 'Offline'],
|
||||
[CIRCUIT_STATUS_DEPROVISIONING, 'Deprovisioning'],
|
||||
[CIRCUIT_STATUS_DECOMMISSIONED, 'Decommissioned'],
|
||||
]
|
||||
|
||||
# CircuitTermination sides
|
||||
TERM_SIDE_A = 'A'
|
||||
TERM_SIDE_Z = 'Z'
|
||||
|
||||
@@ -7,6 +7,7 @@ from dcim.models import Site
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.filters import NumericInFilter
|
||||
from .constants import CIRCUIT_STATUS_CHOICES
|
||||
from .models import Provider, Circuit, CircuitTermination, CircuitType
|
||||
|
||||
|
||||
@@ -77,6 +78,10 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Circuit type (slug)',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=CIRCUIT_STATUS_CHOICES,
|
||||
null_value=None
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
|
||||
@@ -8,9 +8,10 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, FilterChoiceField,
|
||||
SmallTextarea, SlugField,
|
||||
APISelect, add_blank_choice, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField,
|
||||
CSVChoiceField, FilterChoiceField, SmallTextarea, SlugField,
|
||||
)
|
||||
from .constants import CIRCUIT_STATUS_CHOICES
|
||||
from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
|
||||
|
||||
@@ -105,7 +106,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = [
|
||||
'cid', 'type', 'provider', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
|
||||
'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant',
|
||||
'comments',
|
||||
]
|
||||
help_texts = {
|
||||
@@ -132,6 +133,11 @@ class CircuitCSVForm(forms.ModelForm):
|
||||
'invalid_choice': 'Invalid circuit type.'
|
||||
}
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
choices=CIRCUIT_STATUS_CHOICES,
|
||||
required=False,
|
||||
help_text='Operational status'
|
||||
)
|
||||
tenant = forms.ModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False,
|
||||
@@ -144,13 +150,16 @@ class CircuitCSVForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
|
||||
fields = [
|
||||
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||
]
|
||||
|
||||
|
||||
class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False)
|
||||
provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False)
|
||||
status = forms.ChoiceField(choices=add_blank_choice(CIRCUIT_STATUS_CHOICES), required=False, initial='')
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
@@ -160,6 +169,13 @@ class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
nullable_fields = ['tenant', 'commit_rate', 'description', 'comments']
|
||||
|
||||
|
||||
def circuit_status_choices():
|
||||
status_counts = {}
|
||||
for status in Circuit.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in CIRCUIT_STATUS_CHOICES]
|
||||
|
||||
|
||||
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Circuit
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
@@ -171,6 +187,7 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
queryset=Provider.objects.annotate(filter_count=Count('circuits')),
|
||||
to_field_name='slug'
|
||||
)
|
||||
status = forms.MultipleChoiceField(choices=circuit_status_choices, required=False)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('circuits')),
|
||||
to_field_name='slug',
|
||||
|
||||
20
netbox/circuits/migrations/0010_circuit_status.py
Normal file
20
netbox/circuits/migrations/0010_circuit_status.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.9 on 2018-02-06 18:48
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0009_unicode_literals'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='circuit',
|
||||
name='status',
|
||||
field=models.PositiveSmallIntegerField(choices=[[2, 'Planned'], [3, 'Provisioning'], [1, 'Active'], [4, 'Offline'], [0, 'Deprovisioning'], [5, 'Decommissioned']], default=1),
|
||||
),
|
||||
]
|
||||
@@ -5,11 +5,12 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
|
||||
from dcim.constants import STATUS_CLASSES
|
||||
from dcim.fields import ASNField
|
||||
from extras.models import CustomFieldModel, CustomFieldValue
|
||||
from tenancy.models import Tenant
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
from .constants import *
|
||||
from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
|
||||
|
||||
|
||||
@python_2_unicode_compatible
|
||||
@@ -89,6 +90,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
||||
cid = models.CharField(max_length=50, verbose_name='Circuit ID')
|
||||
provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT)
|
||||
type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT)
|
||||
status = models.PositiveSmallIntegerField(choices=CIRCUIT_STATUS_CHOICES, default=CIRCUIT_STATUS_ACTIVE)
|
||||
tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT)
|
||||
install_date = models.DateField(blank=True, null=True, verbose_name='Date installed')
|
||||
commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)')
|
||||
@@ -96,7 +98,9 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
|
||||
csv_headers = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'description', 'comments']
|
||||
csv_headers = [
|
||||
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ['provider', 'cid']
|
||||
@@ -113,6 +117,7 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
||||
self.cid,
|
||||
self.provider.name,
|
||||
self.type.name,
|
||||
self.get_status_display(),
|
||||
self.tenant.name if self.tenant else None,
|
||||
self.install_date,
|
||||
self.commit_rate,
|
||||
@@ -120,6 +125,9 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel):
|
||||
self.comments,
|
||||
)
|
||||
|
||||
def get_status_class(self):
|
||||
return STATUS_CLASSES[self.status]
|
||||
|
||||
def _get_termination(self, side):
|
||||
for ct in self.terminations.all():
|
||||
if ct.term_side == side:
|
||||
|
||||
@@ -14,6 +14,10 @@ CIRCUITTYPE_ACTIONS = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
STATUS_LABEL = """
|
||||
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||
"""
|
||||
|
||||
|
||||
class CircuitTerminationColumn(tables.Column):
|
||||
|
||||
@@ -76,10 +80,11 @@ class CircuitTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
cid = tables.LinkColumn(verbose_name='ID')
|
||||
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
|
||||
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
|
||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||
termination_a = CircuitTerminationColumn(orderable=False, verbose_name='A Side')
|
||||
termination_z = CircuitTerminationColumn(orderable=False, verbose_name='Z Side')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Circuit
|
||||
fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description')
|
||||
fields = ('pk', 'cid', 'status', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description')
|
||||
|
||||
@@ -69,7 +69,7 @@ class ProviderTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:provider-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Provider.objects.count(), 4)
|
||||
@@ -77,6 +77,32 @@ class ProviderTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(provider4.name, data['name'])
|
||||
self.assertEqual(provider4.slug, data['slug'])
|
||||
|
||||
def test_create_provider_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Provider 4',
|
||||
'slug': 'test-provider-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test Provider 5',
|
||||
'slug': 'test-provider-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test Provider 6',
|
||||
'slug': 'test-provider-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('circuits-api:provider-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Provider.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_provider(self):
|
||||
|
||||
data = {
|
||||
@@ -85,7 +111,7 @@ class ProviderTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Provider.objects.count(), 3)
|
||||
@@ -136,7 +162,7 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuittype-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(CircuitType.objects.count(), 4)
|
||||
@@ -152,7 +178,7 @@ class CircuitTypeTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(CircuitType.objects.count(), 3)
|
||||
@@ -208,7 +234,7 @@ class CircuitTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuit-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Circuit.objects.count(), 4)
|
||||
@@ -217,6 +243,35 @@ class CircuitTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(circuit4.provider_id, data['provider'])
|
||||
self.assertEqual(circuit4.type_id, data['type'])
|
||||
|
||||
def test_create_circuit_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'cid': 'TEST0004',
|
||||
'provider': self.provider1.pk,
|
||||
'type': self.circuittype1.pk,
|
||||
},
|
||||
{
|
||||
'cid': 'TEST0005',
|
||||
'provider': self.provider1.pk,
|
||||
'type': self.circuittype1.pk,
|
||||
},
|
||||
{
|
||||
'cid': 'TEST0006',
|
||||
'provider': self.provider1.pk,
|
||||
'type': self.circuittype1.pk,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('circuits-api:circuit-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Circuit.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['cid'], data[0]['cid'])
|
||||
self.assertEqual(response.data[1]['cid'], data[1]['cid'])
|
||||
self.assertEqual(response.data[2]['cid'], data[2]['cid'])
|
||||
|
||||
def test_update_circuit(self):
|
||||
|
||||
data = {
|
||||
@@ -226,7 +281,7 @@ class CircuitTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Circuit.objects.count(), 3)
|
||||
@@ -293,7 +348,7 @@ class CircuitTerminationTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuittermination-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(CircuitTermination.objects.count(), 4)
|
||||
@@ -313,7 +368,7 @@ class CircuitTerminationTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(CircuitTermination.objects.count(), 3)
|
||||
|
||||
@@ -7,19 +7,20 @@ from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination
|
||||
from dcim.constants import (
|
||||
CONNECTION_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES, RACK_TYPE_CHOICES,
|
||||
RACK_WIDTH_CHOICES, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
|
||||
CONNECTION_STATUS_CHOICES, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_MODE_CHOICES, IFACE_ORDERING_CHOICES,
|
||||
RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES,
|
||||
)
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceType, DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||
RackReservation, RackRole, Region, Site,
|
||||
RackReservation, RackRole, Region, Site, VirtualChassis,
|
||||
)
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from ipam.models import IPAddress
|
||||
from ipam.models import IPAddress, VLAN
|
||||
from tenancy.api.serializers import NestedTenantSerializer
|
||||
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
||||
from users.api.serializers import NestedUserSerializer
|
||||
from utilities.api import ChoiceFieldSerializer, TimeZoneField, ValidatedModelSerializer
|
||||
from virtualization.models import Cluster
|
||||
|
||||
|
||||
@@ -55,15 +56,18 @@ class WritableRegionSerializer(ValidatedModelSerializer):
|
||||
#
|
||||
|
||||
class SiteSerializer(CustomFieldModelSerializer):
|
||||
status = ChoiceFieldSerializer(choices=SITE_STATUS_CHOICES)
|
||||
region = NestedRegionSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
time_zone = TimeZoneField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
|
||||
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes',
|
||||
'count_vlans', 'count_racks', 'count_devices', 'count_circuits',
|
||||
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
|
||||
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices',
|
||||
'count_circuits',
|
||||
]
|
||||
|
||||
|
||||
@@ -76,12 +80,14 @@ class NestedSiteSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class WritableSiteSerializer(CustomFieldModelSerializer):
|
||||
time_zone = TimeZoneField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address',
|
||||
'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields',
|
||||
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
|
||||
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -147,7 +153,7 @@ class RackSerializer(CustomFieldModelSerializer):
|
||||
model = Rack
|
||||
fields = [
|
||||
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width',
|
||||
'u_height', 'desc_units', 'comments', 'custom_fields',
|
||||
'u_height', 'desc_units', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -165,7 +171,7 @@ class WritableRackSerializer(CustomFieldModelSerializer):
|
||||
model = Rack
|
||||
fields = [
|
||||
'id', 'name', 'facility_id', 'site', 'group', 'tenant', 'role', 'serial', 'type', 'width', 'u_height',
|
||||
'desc_units', 'comments', 'custom_fields',
|
||||
'desc_units', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
# Omit the UniqueTogetherValidator that would be automatically added to validate (site, facility_id). This
|
||||
# prevents facility_id from being interpreted as a required field.
|
||||
@@ -215,10 +221,12 @@ class RackUnitSerializer(serializers.Serializer):
|
||||
|
||||
class RackReservationSerializer(serializers.ModelSerializer):
|
||||
rack = NestedRackSerializer()
|
||||
user = NestedUserSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = ['id', 'rack', 'units', 'created', 'user', 'description']
|
||||
fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description']
|
||||
|
||||
|
||||
class WritableRackReservationSerializer(ValidatedModelSerializer):
|
||||
@@ -423,11 +431,12 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer):
|
||||
# Platforms
|
||||
#
|
||||
|
||||
class PlatformSerializer(ValidatedModelSerializer):
|
||||
class PlatformSerializer(serializers.ModelSerializer):
|
||||
manufacturer = NestedManufacturerSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = ['id', 'name', 'slug', 'napalm_driver', 'rpc_client']
|
||||
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
|
||||
|
||||
|
||||
class NestedPlatformSerializer(serializers.ModelSerializer):
|
||||
@@ -438,6 +447,13 @@ class NestedPlatformSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
|
||||
|
||||
class WritablePlatformSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
|
||||
|
||||
|
||||
#
|
||||
# Devices
|
||||
#
|
||||
@@ -460,6 +476,16 @@ class NestedClusterSerializer(serializers.ModelSerializer):
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
# Cannot import NestedVirtualChassisSerializer due to circular dependency
|
||||
class DeviceVirtualChassisSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
|
||||
master = NestedDeviceSerializer()
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'url', 'master']
|
||||
|
||||
|
||||
class DeviceSerializer(CustomFieldModelSerializer):
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_role = NestedDeviceRoleSerializer()
|
||||
@@ -468,19 +494,21 @@ class DeviceSerializer(CustomFieldModelSerializer):
|
||||
site = NestedSiteSerializer()
|
||||
rack = NestedRackSerializer()
|
||||
face = ChoiceFieldSerializer(choices=RACK_FACE_CHOICES)
|
||||
status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
|
||||
status = ChoiceFieldSerializer(choices=DEVICE_STATUS_CHOICES)
|
||||
primary_ip = DeviceIPAddressSerializer()
|
||||
primary_ip4 = DeviceIPAddressSerializer()
|
||||
primary_ip6 = DeviceIPAddressSerializer()
|
||||
parent_device = serializers.SerializerMethodField()
|
||||
cluster = NestedClusterSerializer()
|
||||
virtual_chassis = DeviceVirtualChassisSerializer()
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = [
|
||||
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
||||
'cluster', 'comments', 'custom_fields',
|
||||
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
def get_parent_device(self, obj):
|
||||
@@ -500,7 +528,8 @@ class WritableDeviceSerializer(CustomFieldModelSerializer):
|
||||
model = Device
|
||||
fields = [
|
||||
'id', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', 'site', 'rack',
|
||||
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'comments', 'custom_fields',
|
||||
'position', 'face', 'status', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position',
|
||||
'vc_priority', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
validators = []
|
||||
|
||||
@@ -628,6 +657,15 @@ class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer):
|
||||
]
|
||||
|
||||
|
||||
# Cannot import ipam.api.NestedVLANSerializer due to circular dependency
|
||||
class InterfaceVLANSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['id', 'url', 'vid', 'name', 'display_name']
|
||||
|
||||
|
||||
class InterfaceSerializer(serializers.ModelSerializer):
|
||||
device = NestedDeviceSerializer()
|
||||
form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES)
|
||||
@@ -635,12 +673,15 @@ class InterfaceSerializer(serializers.ModelSerializer):
|
||||
is_connected = serializers.SerializerMethodField(read_only=True)
|
||||
interface_connection = serializers.SerializerMethodField(read_only=True)
|
||||
circuit_termination = InterfaceCircuitTerminationSerializer()
|
||||
untagged_vlan = InterfaceVLANSerializer()
|
||||
mode = ChoiceFieldSerializer(choices=IFACE_MODE_CHOICES)
|
||||
tagged_vlans = InterfaceVLANSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
||||
'is_connected', 'interface_connection', 'circuit_termination',
|
||||
'is_connected', 'interface_connection', 'circuit_termination', 'mode', 'untagged_vlan', 'tagged_vlans',
|
||||
]
|
||||
|
||||
def get_is_connected(self, obj):
|
||||
@@ -685,8 +726,23 @@ class WritableInterfaceSerializer(ValidatedModelSerializer):
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
||||
'mode', 'untagged_vlan', 'tagged_vlans',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
# Validate that all untagged VLANs either belong to the same site as the Interface's parent Deivce or
|
||||
# VirtualMachine, or are global.
|
||||
parent = self.instance.parent if self.instance else data.get('device') or data.get('virtual_machine')
|
||||
for vlan in data.get('tagged_vlans', []):
|
||||
if vlan.site not in [parent, None]:
|
||||
raise serializers.ValidationError(
|
||||
"Tagged VLAN {} must belong to the same site as the interface's parent device/VM, or it must be "
|
||||
"global".format(vlan)
|
||||
)
|
||||
|
||||
return super(WritableInterfaceSerializer, self).validate(data)
|
||||
|
||||
|
||||
#
|
||||
# Device bays
|
||||
@@ -771,3 +827,30 @@ class WritableInterfaceConnectionSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = InterfaceConnection
|
||||
fields = ['id', 'interface_a', 'interface_b', 'connection_status']
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisSerializer(serializers.ModelSerializer):
|
||||
master = NestedDeviceSerializer()
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'master', 'domain']
|
||||
|
||||
|
||||
class NestedVirtualChassisSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'url']
|
||||
|
||||
|
||||
class WritableVirtualChassisSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'master', 'domain']
|
||||
|
||||
@@ -60,6 +60,9 @@ router.register(r'console-connections', views.ConsoleConnectionViewSet, base_nam
|
||||
router.register(r'power-connections', views.PowerConnectionViewSet, base_name='powerconnections')
|
||||
router.register(r'interface-connections', views.InterfaceConnectionViewSet)
|
||||
|
||||
# Virtual chassis
|
||||
router.register(r'virtual-chassis', views.VirtualChassisViewSet)
|
||||
|
||||
# Miscellaneous
|
||||
router.register(r'connected-device', views.ConnectedDeviceViewSet, base_name='connected-device')
|
||||
|
||||
|
||||
@@ -3,26 +3,25 @@ from __future__ import unicode_literals
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponseBadRequest, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.mixins import ListModelMixin
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet
|
||||
from rest_framework.viewsets import GenericViewSet, ViewSet
|
||||
|
||||
from dcim import filters
|
||||
from dcim.models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||
RackReservation, RackRole, Region, Site,
|
||||
RackReservation, RackRole, Region, Site, VirtualChassis,
|
||||
)
|
||||
from extras.api.serializers import RenderedGraphSerializer
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
||||
from utilities.api import (
|
||||
IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ServiceUnavailable, WritableSerializerMixin,
|
||||
)
|
||||
from utilities.api import IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable
|
||||
from . import serializers
|
||||
from .exceptions import MissingFilterException
|
||||
|
||||
@@ -47,7 +46,7 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
# Regions
|
||||
#
|
||||
|
||||
class RegionViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class RegionViewSet(ModelViewSet):
|
||||
queryset = Region.objects.all()
|
||||
serializer_class = serializers.RegionSerializer
|
||||
write_serializer_class = serializers.WritableRegionSerializer
|
||||
@@ -58,7 +57,7 @@ class RegionViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class SiteViewSet(CustomFieldModelViewSet):
|
||||
queryset = Site.objects.select_related('region', 'tenant')
|
||||
serializer_class = serializers.SiteSerializer
|
||||
write_serializer_class = serializers.WritableSiteSerializer
|
||||
@@ -79,7 +78,7 @@ class SiteViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# Rack groups
|
||||
#
|
||||
|
||||
class RackGroupViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class RackGroupViewSet(ModelViewSet):
|
||||
queryset = RackGroup.objects.select_related('site')
|
||||
serializer_class = serializers.RackGroupSerializer
|
||||
write_serializer_class = serializers.WritableRackGroupSerializer
|
||||
@@ -100,7 +99,7 @@ class RackRoleViewSet(ModelViewSet):
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class RackViewSet(CustomFieldModelViewSet):
|
||||
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')
|
||||
serializer_class = serializers.RackSerializer
|
||||
write_serializer_class = serializers.WritableRackSerializer
|
||||
@@ -131,8 +130,8 @@ class RackViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# Rack reservations
|
||||
#
|
||||
|
||||
class RackReservationViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
queryset = RackReservation.objects.select_related('rack')
|
||||
class RackReservationViewSet(ModelViewSet):
|
||||
queryset = RackReservation.objects.select_related('rack', 'user', 'tenant')
|
||||
serializer_class = serializers.RackReservationSerializer
|
||||
write_serializer_class = serializers.WritableRackReservationSerializer
|
||||
filter_class = filters.RackReservationFilter
|
||||
@@ -156,7 +155,7 @@ class ManufacturerViewSet(ModelViewSet):
|
||||
# Device types
|
||||
#
|
||||
|
||||
class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class DeviceTypeViewSet(CustomFieldModelViewSet):
|
||||
queryset = DeviceType.objects.select_related('manufacturer')
|
||||
serializer_class = serializers.DeviceTypeSerializer
|
||||
write_serializer_class = serializers.WritableDeviceTypeSerializer
|
||||
@@ -167,42 +166,42 @@ class DeviceTypeViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# Device type components
|
||||
#
|
||||
|
||||
class ConsolePortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class ConsolePortTemplateViewSet(ModelViewSet):
|
||||
queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.ConsolePortTemplateSerializer
|
||||
write_serializer_class = serializers.WritableConsolePortTemplateSerializer
|
||||
filter_class = filters.ConsolePortTemplateFilter
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class ConsoleServerPortTemplateViewSet(ModelViewSet):
|
||||
queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.ConsoleServerPortTemplateSerializer
|
||||
write_serializer_class = serializers.WritableConsoleServerPortTemplateSerializer
|
||||
filter_class = filters.ConsoleServerPortTemplateFilter
|
||||
|
||||
|
||||
class PowerPortTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class PowerPortTemplateViewSet(ModelViewSet):
|
||||
queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.PowerPortTemplateSerializer
|
||||
write_serializer_class = serializers.WritablePowerPortTemplateSerializer
|
||||
filter_class = filters.PowerPortTemplateFilter
|
||||
|
||||
|
||||
class PowerOutletTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class PowerOutletTemplateViewSet(ModelViewSet):
|
||||
queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.PowerOutletTemplateSerializer
|
||||
write_serializer_class = serializers.WritablePowerOutletTemplateSerializer
|
||||
filter_class = filters.PowerOutletTemplateFilter
|
||||
|
||||
|
||||
class InterfaceTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class InterfaceTemplateViewSet(ModelViewSet):
|
||||
queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.InterfaceTemplateSerializer
|
||||
write_serializer_class = serializers.WritableInterfaceTemplateSerializer
|
||||
filter_class = filters.InterfaceTemplateFilter
|
||||
|
||||
|
||||
class DeviceBayTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class DeviceBayTemplateViewSet(ModelViewSet):
|
||||
queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
|
||||
serializer_class = serializers.DeviceBayTemplateSerializer
|
||||
write_serializer_class = serializers.WritableDeviceBayTemplateSerializer
|
||||
@@ -226,6 +225,7 @@ class DeviceRoleViewSet(ModelViewSet):
|
||||
class PlatformViewSet(ModelViewSet):
|
||||
queryset = Platform.objects.all()
|
||||
serializer_class = serializers.PlatformSerializer
|
||||
write_serializer_class = serializers.WritablePlatformSerializer
|
||||
filter_class = filters.PlatformFilter
|
||||
|
||||
|
||||
@@ -233,9 +233,10 @@ class PlatformViewSet(ModelViewSet):
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class DeviceViewSet(CustomFieldModelViewSet):
|
||||
queryset = Device.objects.select_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
|
||||
'virtual_chassis__master',
|
||||
).prefetch_related(
|
||||
'primary_ip4__nat_outside', 'primary_ip6__nat_outside',
|
||||
)
|
||||
@@ -263,12 +264,7 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
import napalm
|
||||
except ImportError:
|
||||
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
|
||||
|
||||
# TODO: Remove support for NAPALM < 2.0
|
||||
try:
|
||||
from napalm.base.exceptions import ConnectAuthError, ModuleImportError
|
||||
except ImportError:
|
||||
from napalm_base.exceptions import ConnectAuthError, ModuleImportError
|
||||
from napalm.base.exceptions import ConnectAuthError, ModuleImportError
|
||||
|
||||
# Validate the configured driver
|
||||
try:
|
||||
@@ -316,35 +312,35 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# Device components
|
||||
#
|
||||
|
||||
class ConsolePortViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class ConsolePortViewSet(ModelViewSet):
|
||||
queryset = ConsolePort.objects.select_related('device', 'cs_port__device')
|
||||
serializer_class = serializers.ConsolePortSerializer
|
||||
write_serializer_class = serializers.WritableConsolePortSerializer
|
||||
filter_class = filters.ConsolePortFilter
|
||||
|
||||
|
||||
class ConsoleServerPortViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class ConsoleServerPortViewSet(ModelViewSet):
|
||||
queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device')
|
||||
serializer_class = serializers.ConsoleServerPortSerializer
|
||||
write_serializer_class = serializers.WritableConsoleServerPortSerializer
|
||||
filter_class = filters.ConsoleServerPortFilter
|
||||
|
||||
|
||||
class PowerPortViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class PowerPortViewSet(ModelViewSet):
|
||||
queryset = PowerPort.objects.select_related('device', 'power_outlet__device')
|
||||
serializer_class = serializers.PowerPortSerializer
|
||||
write_serializer_class = serializers.WritablePowerPortSerializer
|
||||
filter_class = filters.PowerPortFilter
|
||||
|
||||
|
||||
class PowerOutletViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class PowerOutletViewSet(ModelViewSet):
|
||||
queryset = PowerOutlet.objects.select_related('device', 'connected_port__device')
|
||||
serializer_class = serializers.PowerOutletSerializer
|
||||
write_serializer_class = serializers.WritablePowerOutletSerializer
|
||||
filter_class = filters.PowerOutletFilter
|
||||
|
||||
|
||||
class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class InterfaceViewSet(ModelViewSet):
|
||||
queryset = Interface.objects.select_related('device')
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
write_serializer_class = serializers.WritableInterfaceSerializer
|
||||
@@ -361,14 +357,14 @@ class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class DeviceBayViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class DeviceBayViewSet(ModelViewSet):
|
||||
queryset = DeviceBay.objects.select_related('installed_device')
|
||||
serializer_class = serializers.DeviceBaySerializer
|
||||
write_serializer_class = serializers.WritableDeviceBaySerializer
|
||||
filter_class = filters.DeviceBayFilter
|
||||
|
||||
|
||||
class InventoryItemViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class InventoryItemViewSet(ModelViewSet):
|
||||
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
|
||||
serializer_class = serializers.InventoryItemSerializer
|
||||
write_serializer_class = serializers.WritableInventoryItemSerializer
|
||||
@@ -391,13 +387,23 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
filter_class = filters.PowerConnectionFilter
|
||||
|
||||
|
||||
class InterfaceConnectionViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class InterfaceConnectionViewSet(ModelViewSet):
|
||||
queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')
|
||||
serializer_class = serializers.InterfaceConnectionSerializer
|
||||
write_serializer_class = serializers.WritableInterfaceConnectionSerializer
|
||||
filter_class = filters.InterfaceConnectionFilter
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisViewSet(ModelViewSet):
|
||||
queryset = VirtualChassis.objects.all()
|
||||
serializer_class = serializers.VirtualChassisSerializer
|
||||
write_serializer_class = serializers.WritableVirtualChassisSerializer
|
||||
|
||||
|
||||
#
|
||||
# Miscellaneous
|
||||
#
|
||||
|
||||
@@ -6,3 +6,6 @@ from django.apps import AppConfig
|
||||
class DCIMConfig(AppConfig):
|
||||
name = "dcim"
|
||||
verbose_name = "DCIM"
|
||||
|
||||
def ready(self):
|
||||
import dcim.signals
|
||||
|
||||
@@ -193,24 +193,43 @@ WIRELESS_IFACE_TYPES = [
|
||||
|
||||
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
||||
|
||||
# Device statuses
|
||||
STATUS_OFFLINE = 0
|
||||
STATUS_ACTIVE = 1
|
||||
STATUS_PLANNED = 2
|
||||
STATUS_STAGED = 3
|
||||
STATUS_FAILED = 4
|
||||
STATUS_INVENTORY = 5
|
||||
STATUS_CHOICES = [
|
||||
[STATUS_ACTIVE, 'Active'],
|
||||
[STATUS_OFFLINE, 'Offline'],
|
||||
[STATUS_PLANNED, 'Planned'],
|
||||
[STATUS_STAGED, 'Staged'],
|
||||
[STATUS_FAILED, 'Failed'],
|
||||
[STATUS_INVENTORY, 'Inventory'],
|
||||
IFACE_MODE_ACCESS = 100
|
||||
IFACE_MODE_TAGGED = 200
|
||||
IFACE_MODE_TAGGED_ALL = 300
|
||||
IFACE_MODE_CHOICES = [
|
||||
[IFACE_MODE_ACCESS, 'Access'],
|
||||
[IFACE_MODE_TAGGED, 'Tagged'],
|
||||
[IFACE_MODE_TAGGED_ALL, 'Tagged All'],
|
||||
]
|
||||
|
||||
# Bootstrap CSS classes for device stasuses
|
||||
DEVICE_STATUS_CLASSES = {
|
||||
# Device statuses
|
||||
DEVICE_STATUS_OFFLINE = 0
|
||||
DEVICE_STATUS_ACTIVE = 1
|
||||
DEVICE_STATUS_PLANNED = 2
|
||||
DEVICE_STATUS_STAGED = 3
|
||||
DEVICE_STATUS_FAILED = 4
|
||||
DEVICE_STATUS_INVENTORY = 5
|
||||
DEVICE_STATUS_CHOICES = [
|
||||
[DEVICE_STATUS_ACTIVE, 'Active'],
|
||||
[DEVICE_STATUS_OFFLINE, 'Offline'],
|
||||
[DEVICE_STATUS_PLANNED, 'Planned'],
|
||||
[DEVICE_STATUS_STAGED, 'Staged'],
|
||||
[DEVICE_STATUS_FAILED, 'Failed'],
|
||||
[DEVICE_STATUS_INVENTORY, 'Inventory'],
|
||||
]
|
||||
|
||||
# Site statuses
|
||||
SITE_STATUS_ACTIVE = 1
|
||||
SITE_STATUS_PLANNED = 2
|
||||
SITE_STATUS_RETIRED = 4
|
||||
SITE_STATUS_CHOICES = [
|
||||
[SITE_STATUS_ACTIVE, 'Active'],
|
||||
[SITE_STATUS_PLANNED, 'Planned'],
|
||||
[SITE_STATUS_RETIRED, 'Retired'],
|
||||
]
|
||||
|
||||
# Bootstrap CSS classes for device statuses
|
||||
STATUS_CLASSES = {
|
||||
0: 'warning',
|
||||
1: 'success',
|
||||
2: 'info',
|
||||
|
||||
@@ -11,13 +11,14 @@ from tenancy.models import Tenant
|
||||
from utilities.filters import NullableCharFieldFilter, NumericInFilter
|
||||
from virtualization.models import Cluster
|
||||
from .constants import (
|
||||
IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, STATUS_CHOICES, VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES,
|
||||
DEVICE_STATUS_CHOICES, IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, SITE_STATUS_CHOICES, VIRTUAL_IFACE_TYPES,
|
||||
WIRELESS_IFACE_TYPES,
|
||||
)
|
||||
from .models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer,
|
||||
InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
|
||||
RackReservation, RackRole, Region, Site,
|
||||
RackReservation, RackRole, Region, Site, VirtualChassis,
|
||||
)
|
||||
|
||||
|
||||
@@ -57,6 +58,10 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=SITE_STATUS_CHOICES,
|
||||
null_value=None
|
||||
)
|
||||
region_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
label='Region (ID)',
|
||||
@@ -88,6 +93,7 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
qs_filter = (
|
||||
Q(name__icontains=value) |
|
||||
Q(facility__icontains=value) |
|
||||
Q(description__icontains=value) |
|
||||
Q(physical_address__icontains=value) |
|
||||
Q(shipping_address__icontains=value) |
|
||||
Q(contact_name__icontains=value) |
|
||||
@@ -221,6 +227,16 @@ class RackReservationFilter(django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Group',
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||
name='tenant__slug',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label='User (ID)',
|
||||
@@ -347,6 +363,17 @@ class DeviceRoleFilter(django_filters.FilterSet):
|
||||
|
||||
|
||||
class PlatformFilter(django_filters.FilterSet):
|
||||
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='manufacturer',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
label='Manufacturer (ID)',
|
||||
)
|
||||
manufacturer = django_filters.ModelMultipleChoiceFilter(
|
||||
name='manufacturer__slug',
|
||||
queryset=Manufacturer.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Manufacturer (slug)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
@@ -438,7 +465,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
label='Device model (slug)',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=STATUS_CHOICES,
|
||||
choices=DEVICE_STATUS_CHOICES,
|
||||
null_value=None
|
||||
)
|
||||
is_full_depth = django_filters.BooleanFilter(
|
||||
@@ -465,6 +492,11 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
method='_has_primary_ip',
|
||||
label='Has a primary IP',
|
||||
)
|
||||
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='virtual_chassis',
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
label='Virtual chassis (ID)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
@@ -580,8 +612,9 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
def filter_device(self, queryset, name, value):
|
||||
try:
|
||||
device = Device.objects.select_related('device_type').get(**{name: value})
|
||||
vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
|
||||
ordering = device.device_type.interface_ordering
|
||||
return queryset.filter(device=device).order_naturally(ordering)
|
||||
return queryset.filter(pk__in=vc_interface_ids).order_naturally(ordering)
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
|
||||
@@ -650,6 +683,48 @@ class InventoryItemFilter(DeviceComponentFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class VirtualChassisFilter(django_filters.FilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='master__site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
name='master__site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site name (slug)',
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='master__tenant',
|
||||
queryset=Tenant.objects.all(),
|
||||
label='Tenant (ID)',
|
||||
)
|
||||
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||
name='master__tenant__slug',
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['domain']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = (
|
||||
Q(master__name__icontains=value) |
|
||||
Q(domain__icontains=value)
|
||||
)
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class ConsoleConnectionFilter(django_filters.FilterSet):
|
||||
site = django_filters.CharFilter(
|
||||
method='filter_site',
|
||||
|
||||
@@ -7,29 +7,32 @@ from django.contrib.auth.models import User
|
||||
from django.contrib.postgres.forms.array import SimpleArrayField
|
||||
from django.db.models import Count, Q
|
||||
from mptt.forms import TreeNodeChoiceField
|
||||
from timezone_field import TimeZoneFormField
|
||||
|
||||
from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
|
||||
from ipam.models import IPAddress
|
||||
from ipam.models import IPAddress, VLAN, VLANGroup
|
||||
from tenancy.forms import TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
|
||||
ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, ConfirmationForm, CSVChoiceField,
|
||||
ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea,
|
||||
SlugField, FilterTreeNodeMultipleChoiceField,
|
||||
APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
|
||||
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField,
|
||||
CommentField, ComponentForm, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField,
|
||||
FilterTreeNodeMultipleChoiceField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK,
|
||||
SmallTextarea, SlugField,
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from .constants import (
|
||||
CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES,
|
||||
RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, STATUS_CHOICES,
|
||||
SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES,
|
||||
CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_FF_LAG,
|
||||
IFACE_MODE_ACCESS, IFACE_MODE_CHOICES, IFACE_MODE_TAGGED_ALL, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES,
|
||||
RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
|
||||
SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES,
|
||||
)
|
||||
from .formfields import MACAddressFormField
|
||||
from .models import (
|
||||
DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
|
||||
Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
|
||||
Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation,
|
||||
RackRole, Region, Site,
|
||||
RackRole, Region, Site, VirtualChassis
|
||||
)
|
||||
|
||||
DEVICE_BY_PK_RE = '{\d+\}'
|
||||
@@ -47,6 +50,14 @@ def get_device_by_name_or_pk(name):
|
||||
return device
|
||||
|
||||
|
||||
class BulkRenameForm(forms.Form):
|
||||
"""
|
||||
An extendable form to be used for renaming device components in bulk.
|
||||
"""
|
||||
find = forms.CharField()
|
||||
replace = forms.CharField()
|
||||
|
||||
|
||||
#
|
||||
# Regions
|
||||
#
|
||||
@@ -96,8 +107,9 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = [
|
||||
'name', 'slug', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'physical_address',
|
||||
'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'description',
|
||||
'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'time_zone',
|
||||
'comments',
|
||||
]
|
||||
widgets = {
|
||||
'physical_address': SmallTextarea(attrs={'rows': 3}),
|
||||
@@ -113,6 +125,11 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
|
||||
|
||||
class SiteCSVForm(forms.ModelForm):
|
||||
status = CSVChoiceField(
|
||||
choices=DEVICE_STATUS_CHOICES,
|
||||
required=False,
|
||||
help_text='Operational status'
|
||||
)
|
||||
region = forms.ModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -144,17 +161,28 @@ class SiteCSVForm(forms.ModelForm):
|
||||
|
||||
class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
status = forms.ChoiceField(choices=add_blank_choice(SITE_STATUS_CHOICES), required=False, initial='')
|
||||
region = TreeNodeChoiceField(queryset=Region.objects.all(), required=False)
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
time_zone = TimeZoneFormField(required=False)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = ['region', 'tenant', 'asn']
|
||||
nullable_fields = ['region', 'tenant', 'asn', 'description', 'time_zone']
|
||||
|
||||
|
||||
def site_status_choices():
|
||||
status_counts = {}
|
||||
for status in Site.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in SITE_STATUS_CHOICES]
|
||||
|
||||
|
||||
class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Site
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
status = forms.MultipleChoiceField(choices=site_status_choices, required=False)
|
||||
region = FilterTreeNodeMultipleChoiceField(
|
||||
queryset=Region.objects.annotate(filter_count=Count('sites')),
|
||||
to_field_name='slug',
|
||||
@@ -372,13 +400,13 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
# Rack reservations
|
||||
#
|
||||
|
||||
class RackReservationForm(BootstrapMixin, forms.ModelForm):
|
||||
class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
|
||||
units = SimpleArrayField(forms.IntegerField(), widget=ArrayFieldSelectMultiple(attrs={'size': 10}))
|
||||
user = forms.ModelChoiceField(queryset=User.objects.order_by('username'))
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = ['units', 'user', 'description']
|
||||
fields = ['units', 'user', 'tenant_group', 'tenant', 'description']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -408,11 +436,17 @@ class RackReservationFilterForm(BootstrapMixin, forms.Form):
|
||||
label='Rack group',
|
||||
null_label='-- None --'
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('rackreservations')),
|
||||
to_field_name='slug',
|
||||
null_label='-- None --'
|
||||
)
|
||||
|
||||
|
||||
class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=RackReservation.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
user = forms.ModelChoiceField(queryset=User.objects.order_by('username'), required=False)
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
|
||||
class Meta:
|
||||
@@ -661,7 +695,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = ['name', 'slug', 'napalm_driver', 'rpc_client']
|
||||
fields = ['name', 'slug', 'manufacturer', 'napalm_driver', 'rpc_client']
|
||||
|
||||
|
||||
class PlatformCSVForm(forms.ModelForm):
|
||||
@@ -672,6 +706,7 @@ class PlatformCSVForm(forms.ModelForm):
|
||||
fields = Platform.csv_headers
|
||||
help_texts = {
|
||||
'name': 'Platform name',
|
||||
'manufacturer': 'Manufacturer name',
|
||||
}
|
||||
|
||||
|
||||
@@ -757,32 +792,35 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
# Compile list of choices for primary IPv4 and IPv6 addresses
|
||||
for family in [4, 6]:
|
||||
ip_choices = [(None, '---------')]
|
||||
|
||||
# Gather PKs of all interfaces belonging to this Device or a peer VirtualChassis member
|
||||
interface_ids = self.instance.vc_interfaces.values('pk')
|
||||
|
||||
# Collect interface IPs
|
||||
interface_ips = IPAddress.objects.select_related('interface').filter(
|
||||
family=family, interface__device=self.instance
|
||||
family=family, interface_id__in=interface_ids
|
||||
)
|
||||
if interface_ips:
|
||||
ip_choices.append(
|
||||
('Interface IPs', [
|
||||
(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips
|
||||
])
|
||||
)
|
||||
ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips]
|
||||
ip_choices.append(('Interface IPs', ip_list))
|
||||
# Collect NAT IPs
|
||||
nat_ips = IPAddress.objects.select_related('nat_inside').filter(
|
||||
family=family, nat_inside__interface__device=self.instance
|
||||
family=family, nat_inside__interface__in=interface_ids
|
||||
)
|
||||
if nat_ips:
|
||||
ip_choices.append(
|
||||
('NAT IPs', [
|
||||
(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips
|
||||
])
|
||||
)
|
||||
ip_list = [(ip.id, '{} ({})'.format(ip.address, ip.nat_inside.address)) for ip in nat_ips]
|
||||
ip_choices.append(('NAT IPs', ip_list))
|
||||
self.fields['primary_ip{}'.format(family)].choices = ip_choices
|
||||
|
||||
# If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device
|
||||
# can be flipped from one face to another.
|
||||
self.fields['position'].widget.attrs['api-url'] += '&exclude={}'.format(self.instance.pk)
|
||||
|
||||
# Limit platform by manufacturer
|
||||
self.fields['platform'].queryset = Platform.objects.filter(
|
||||
Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer)
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
# An object that doesn't exist yet can't have any IPs assigned to it
|
||||
@@ -795,10 +833,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
pk = self.instance.pk if self.instance.pk else None
|
||||
try:
|
||||
if self.is_bound and self.data.get('rack') and str(self.data.get('face')):
|
||||
position_choices = Rack.objects.get(pk=self.data['rack'])\
|
||||
position_choices = Rack.objects.get(pk=self.data['rack']) \
|
||||
.get_rack_units(face=self.data.get('face'), exclude=pk)
|
||||
elif self.initial.get('rack') and str(self.initial.get('face')):
|
||||
position_choices = Rack.objects.get(pk=self.initial['rack'])\
|
||||
position_choices = Rack.objects.get(pk=self.initial['rack']) \
|
||||
.get_rack_units(face=self.initial.get('face'), exclude=pk)
|
||||
else:
|
||||
position_choices = []
|
||||
@@ -858,8 +896,8 @@ class BaseDeviceCSVForm(forms.ModelForm):
|
||||
}
|
||||
)
|
||||
status = CSVChoiceField(
|
||||
choices=STATUS_CHOICES,
|
||||
help_text='Operational status of device'
|
||||
choices=DEVICE_STATUS_CHOICES,
|
||||
help_text='Operational status'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -995,7 +1033,7 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role')
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False)
|
||||
status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='')
|
||||
status = forms.ChoiceField(choices=add_blank_choice(DEVICE_STATUS_CHOICES), required=False, initial='')
|
||||
serial = forms.CharField(max_length=50, required=False, label='Serial Number')
|
||||
|
||||
class Meta:
|
||||
@@ -1006,7 +1044,7 @@ def device_status_choices():
|
||||
status_counts = {}
|
||||
for status in Device.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES]
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in DEVICE_STATUS_CHOICES]
|
||||
|
||||
|
||||
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
@@ -1333,6 +1371,10 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
class ConsoleServerPortBulkDisconnectForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
@@ -1594,6 +1636,10 @@ class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
class PowerOutletBulkDisconnectForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=PowerOutlet.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
@@ -1602,11 +1648,58 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
|
||||
# Interfaces
|
||||
#
|
||||
|
||||
class InterfaceForm(BootstrapMixin, forms.ModelForm):
|
||||
class InterfaceForm(BootstrapMixin, forms.ModelForm, ChainedFieldsMixin):
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label='VLAN site',
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
|
||||
)
|
||||
)
|
||||
vlan_group = ChainedModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
required=False,
|
||||
label='VLAN group',
|
||||
widget=APISelect(
|
||||
attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
|
||||
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
|
||||
)
|
||||
)
|
||||
untagged_vlan = ChainedModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('group', 'vlan_group'),
|
||||
),
|
||||
required=False,
|
||||
label='Untagged VLAN',
|
||||
widget=APISelect(
|
||||
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
|
||||
)
|
||||
)
|
||||
tagged_vlans = ChainedModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('group', 'vlan_group'),
|
||||
),
|
||||
required=False,
|
||||
label='Tagged VLANs',
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description']
|
||||
fields = [
|
||||
'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
||||
'mode', 'site', 'vlan_group', 'untagged_vlan', 'tagged_vlans',
|
||||
]
|
||||
widgets = {
|
||||
'device': forms.HiddenInput(),
|
||||
}
|
||||
@@ -1614,18 +1707,70 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(InterfaceForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces belonging to this device
|
||||
# Limit LAG choices to interfaces belonging to this device (or VC master)
|
||||
if self.is_bound:
|
||||
device = Device.objects.get(pk=self.data['device'])
|
||||
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
|
||||
device_id=self.data['device'], form_factor=IFACE_FF_LAG
|
||||
device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG
|
||||
)
|
||||
else:
|
||||
device = self.instance.device
|
||||
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
|
||||
device=self.instance.device, form_factor=IFACE_FF_LAG
|
||||
device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG
|
||||
)
|
||||
|
||||
# Limit the queryset for the site to only include the interface's device's site
|
||||
if device and device.site:
|
||||
self.fields['site'].queryset = Site.objects.filter(pk=device.site.id)
|
||||
self.fields['site'].initial = None
|
||||
else:
|
||||
self.fields['site'].queryset = Site.objects.none()
|
||||
self.fields['site'].initial = None
|
||||
|
||||
class InterfaceCreateForm(ComponentForm):
|
||||
# Limit the initial vlan choices
|
||||
if self.is_bound:
|
||||
filter_dict = {
|
||||
'group_id': self.data.get('vlan_group') or None,
|
||||
'site_id': self.data.get('site') or None,
|
||||
}
|
||||
elif self.initial.get('untagged_vlan'):
|
||||
filter_dict = {
|
||||
'group_id': self.instance.untagged_vlan.group,
|
||||
'site_id': self.instance.untagged_vlan.site,
|
||||
}
|
||||
elif self.initial.get('tagged_vlans'):
|
||||
filter_dict = {
|
||||
'group_id': self.instance.tagged_vlans.first().group,
|
||||
'site_id': self.instance.tagged_vlans.first().site,
|
||||
}
|
||||
else:
|
||||
filter_dict = {
|
||||
'group_id': None,
|
||||
'site_id': None,
|
||||
}
|
||||
|
||||
self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
|
||||
self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
|
||||
|
||||
def clean_tagged_vlans(self):
|
||||
"""
|
||||
Because tagged_vlans is a many-to-many relationship, validation must be done in the form
|
||||
"""
|
||||
if self.cleaned_data['mode'] == IFACE_MODE_ACCESS and self.cleaned_data['tagged_vlans']:
|
||||
raise forms.ValidationError(
|
||||
"An Access interface cannot have tagged VLANs."
|
||||
)
|
||||
|
||||
if self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL and self.cleaned_data['tagged_vlans']:
|
||||
raise forms.ValidationError(
|
||||
"Interface mode Tagged All implies all VLANs are tagged. "
|
||||
"Do not select any tagged VLANs."
|
||||
)
|
||||
|
||||
return self.cleaned_data['tagged_vlans']
|
||||
|
||||
|
||||
class InterfaceCreateForm(ComponentForm, ChainedFieldsMixin):
|
||||
name_pattern = ExpandableNameField(label='Name')
|
||||
form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
|
||||
enabled = forms.BooleanField(required=False)
|
||||
@@ -1638,6 +1783,51 @@ class InterfaceCreateForm(ComponentForm):
|
||||
help_text='This interface is used only for out-of-band management'
|
||||
)
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label='VLAN Site',
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
|
||||
)
|
||||
)
|
||||
vlan_group = ChainedModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
required=False,
|
||||
label='VLAN group',
|
||||
widget=APISelect(
|
||||
attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
|
||||
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
|
||||
)
|
||||
)
|
||||
untagged_vlan = ChainedModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('group', 'vlan_group'),
|
||||
),
|
||||
required=False,
|
||||
label='Untagged VLAN',
|
||||
widget=APISelect(
|
||||
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
|
||||
)
|
||||
)
|
||||
tagged_vlans = ChainedModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('group', 'vlan_group'),
|
||||
),
|
||||
required=False,
|
||||
label='Tagged VLANs',
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
|
||||
)
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -1647,16 +1837,49 @@ class InterfaceCreateForm(ComponentForm):
|
||||
|
||||
super(InterfaceCreateForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces belonging to this device
|
||||
# Limit LAG choices to interfaces belonging to this device (or its VC master)
|
||||
if self.parent is not None:
|
||||
self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
|
||||
device=self.parent, form_factor=IFACE_FF_LAG
|
||||
device__in=[self.parent, self.parent.get_vc_master()], form_factor=IFACE_FF_LAG
|
||||
)
|
||||
else:
|
||||
self.fields['lag'].queryset = Interface.objects.none()
|
||||
|
||||
# Limit the queryset for the site to only include the interface's device's site
|
||||
if self.parent is not None and self.parent.site:
|
||||
self.fields['site'].queryset = Site.objects.filter(pk=self.parent.site.id)
|
||||
self.fields['site'].initial = None
|
||||
else:
|
||||
self.fields['site'].queryset = Site.objects.none()
|
||||
self.fields['site'].initial = None
|
||||
|
||||
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
# Limit the initial vlan choices
|
||||
if self.is_bound:
|
||||
filter_dict = {
|
||||
'group_id': self.data.get('vlan_group') or None,
|
||||
'site_id': self.data.get('site') or None,
|
||||
}
|
||||
elif self.initial.get('untagged_vlan'):
|
||||
filter_dict = {
|
||||
'group_id': self.untagged_vlan.group,
|
||||
'site_id': self.untagged_vlan.site,
|
||||
}
|
||||
elif self.initial.get('tagged_vlans'):
|
||||
filter_dict = {
|
||||
'group_id': self.tagged_vlans.first().group,
|
||||
'site_id': self.tagged_vlans.first().site,
|
||||
}
|
||||
else:
|
||||
filter_dict = {
|
||||
'group_id': None,
|
||||
'site_id': None,
|
||||
}
|
||||
|
||||
self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
|
||||
self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
|
||||
|
||||
|
||||
class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm, ChainedFieldsMixin):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
|
||||
form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
|
||||
@@ -1665,28 +1888,104 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
|
||||
mgmt_only = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect, label='Management only')
|
||||
description = forms.CharField(max_length=100, required=False)
|
||||
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label='VLAN Site',
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'vlan_group', 'nullable': 'true'},
|
||||
)
|
||||
)
|
||||
vlan_group = ChainedModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
required=False,
|
||||
label='VLAN group',
|
||||
widget=APISelect(
|
||||
attrs={'filter-for': 'untagged_vlan tagged_vlans', 'nullable': 'true'},
|
||||
api_url='/api/ipam/vlan-groups/?site_id={{site}}',
|
||||
)
|
||||
)
|
||||
untagged_vlan = ChainedModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('group', 'vlan_group'),
|
||||
),
|
||||
required=False,
|
||||
label='Untagged VLAN',
|
||||
widget=APISelect(
|
||||
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
|
||||
)
|
||||
)
|
||||
tagged_vlans = ChainedModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('group', 'vlan_group'),
|
||||
),
|
||||
required=False,
|
||||
label='Tagged VLANs',
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/ipam/vlans/?site_id={{site}}&group_id={{vlan_group}}',
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = ['lag', 'mtu', 'description']
|
||||
nullable_fields = ['lag', 'mtu', 'description', 'untagged_vlan', 'tagged_vlans']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Limit LAG choices to interfaces which belong to the parent device.
|
||||
# Limit LAG choices to interfaces which belong to the parent device (or VC master)
|
||||
device = None
|
||||
if self.initial.get('device'):
|
||||
try:
|
||||
device = Device.objects.get(pk=self.initial.get('device'))
|
||||
except Device.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
device = Device.objects.get(pk=self.data.get('device'))
|
||||
except Device.DoesNotExist:
|
||||
pass
|
||||
if device is not None:
|
||||
interface_ordering = device.device_type.interface_ordering
|
||||
self.fields['lag'].queryset = Interface.objects.order_naturally(method=interface_ordering).filter(
|
||||
device=device, form_factor=IFACE_FF_LAG
|
||||
device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG
|
||||
)
|
||||
else:
|
||||
self.fields['lag'].choices = []
|
||||
|
||||
# Limit the queryset for the site to only include the interface's device's site
|
||||
if device and device.site:
|
||||
self.fields['site'].queryset = Site.objects.filter(pk=device.site.id)
|
||||
self.fields['site'].initial = None
|
||||
else:
|
||||
self.fields['site'].queryset = Site.objects.none()
|
||||
self.fields['site'].initial = None
|
||||
|
||||
if self.is_bound:
|
||||
filter_dict = {
|
||||
'group_id': self.data.get('vlan_group') or None,
|
||||
'site_id': self.data.get('site') or None,
|
||||
}
|
||||
else:
|
||||
filter_dict = {
|
||||
'group_id': None,
|
||||
'site_id': None,
|
||||
}
|
||||
|
||||
self.fields['untagged_vlan'].queryset = VLAN.objects.filter(**filter_dict)
|
||||
self.fields['tagged_vlans'].queryset = VLAN.objects.filter(**filter_dict)
|
||||
|
||||
|
||||
class InterfaceBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
class InterfaceBulkDisconnectForm(ConfirmationForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
@@ -1857,11 +2156,6 @@ class InterfaceConnectionCSVForm(forms.ModelForm):
|
||||
return interface
|
||||
|
||||
|
||||
class InterfaceConnectionDeletionForm(ConfirmationForm):
|
||||
# Used for HTTP redirect upon successful deletion
|
||||
device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False)
|
||||
|
||||
|
||||
#
|
||||
# Device bays
|
||||
#
|
||||
@@ -1900,6 +2194,10 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
|
||||
).exclude(pk=device_bay.device.pk)
|
||||
|
||||
|
||||
class DeviceBayBulkRenameForm(BulkRenameForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=DeviceBay.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
#
|
||||
# Connections
|
||||
#
|
||||
@@ -1972,3 +2270,128 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form):
|
||||
to_field_name='slug',
|
||||
null_label='-- None --'
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class DeviceSelectionForm(forms.Form):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
|
||||
|
||||
class VirtualChassisForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['master', 'domain']
|
||||
widgets = {
|
||||
'master': SelectWithPK,
|
||||
}
|
||||
|
||||
|
||||
class BaseVCMemberFormSet(forms.BaseModelFormSet):
|
||||
|
||||
def clean(self):
|
||||
super(BaseVCMemberFormSet, self).clean()
|
||||
|
||||
# Check for duplicate VC position values
|
||||
vc_position_list = []
|
||||
for form in self.forms:
|
||||
vc_position = form.cleaned_data['vc_position']
|
||||
if vc_position in vc_position_list:
|
||||
error_msg = 'A virtual chassis member already exists in position {}.'.format(vc_position)
|
||||
form.add_error('vc_position', error_msg)
|
||||
vc_position_list.append(vc_position)
|
||||
|
||||
|
||||
class DeviceVCMembershipForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['vc_position', 'vc_priority']
|
||||
labels = {
|
||||
'vc_position': 'Position',
|
||||
'vc_priority': 'Priority',
|
||||
}
|
||||
|
||||
def __init__(self, validate_vc_position=False, *args, **kwargs):
|
||||
super(DeviceVCMembershipForm, self).__init__(*args, **kwargs)
|
||||
|
||||
# Require VC position (only required when the Device is a VirtualChassis member)
|
||||
self.fields['vc_position'].required = True
|
||||
|
||||
# Validation of vc_position is optional. This is only required when adding a new member to an existing
|
||||
# VirtualChassis. Otherwise, vc_position validation is handled by BaseVCMemberFormSet.
|
||||
self.validate_vc_position = validate_vc_position
|
||||
|
||||
def clean_vc_position(self):
|
||||
vc_position = self.cleaned_data['vc_position']
|
||||
|
||||
if self.validate_vc_position:
|
||||
conflicting_members = Device.objects.filter(
|
||||
virtual_chassis=self.instance.virtual_chassis,
|
||||
vc_position=vc_position
|
||||
)
|
||||
if conflicting_members.exists():
|
||||
raise forms.ValidationError(
|
||||
'A virtual chassis member already exists in position {}.'.format(vc_position)
|
||||
)
|
||||
|
||||
return vc_position
|
||||
|
||||
|
||||
class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
|
||||
site = forms.ModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site',
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
attrs={'filter-for': 'rack'}
|
||||
)
|
||||
)
|
||||
rack = ChainedModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
),
|
||||
label='Rack',
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/racks/?site_id={{site}}',
|
||||
attrs={'filter-for': 'device', 'nullable': 'true'}
|
||||
)
|
||||
)
|
||||
device = ChainedModelChoiceField(
|
||||
queryset=Device.objects.filter(virtual_chassis__isnull=True),
|
||||
chains=(
|
||||
('site', 'site'),
|
||||
('rack', 'rack'),
|
||||
),
|
||||
label='Device',
|
||||
widget=APISelect(
|
||||
api_url='/api/dcim/devices/?site_id={{site}}&rack_id={{rack}}',
|
||||
display_field='display_name',
|
||||
disabled_indicator='virtual_chassis'
|
||||
)
|
||||
)
|
||||
|
||||
def clean_device(self):
|
||||
device = self.cleaned_data['device']
|
||||
if device.virtual_chassis is not None:
|
||||
raise forms.ValidationError("Device {} is already assigned to a virtual chassis.".format(device))
|
||||
return device
|
||||
|
||||
|
||||
class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = VirtualChassis
|
||||
q = forms.CharField(required=False, label='Search')
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug',
|
||||
null_label='-- None --',
|
||||
)
|
||||
|
||||
32
netbox/dcim/migrations/0050_interface_vlan_tagging.py
Normal file
32
netbox/dcim/migrations/0050_interface_vlan_tagging.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2017-11-10 20:10
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0020_ipaddress_add_role_carp'),
|
||||
('dcim', '0049_rackreservation_change_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='mode',
|
||||
field=models.PositiveSmallIntegerField(blank=True, choices=[[100, 'Access'], [200, 'Tagged'], [300, 'Tagged All']], null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='tagged_vlans',
|
||||
field=models.ManyToManyField(blank=True, related_name='interfaces_as_tagged', to='ipam.VLAN', verbose_name='Tagged VLANs'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='untagged_vlan',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces_as_untagged', to='ipam.VLAN', verbose_name='Untagged VLAN'),
|
||||
),
|
||||
]
|
||||
22
netbox/dcim/migrations/0051_rackreservation_tenant.py
Normal file
22
netbox/dcim/migrations/0051_rackreservation_tenant.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2017-11-15 18:56
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('tenancy', '0003_unicode_literals'),
|
||||
('dcim', '0050_interface_vlan_tagging'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='rackreservation',
|
||||
name='tenant',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='rackreservations', to='tenancy.Tenant'),
|
||||
),
|
||||
]
|
||||
44
netbox/dcim/migrations/0052_virtual_chassis.py
Normal file
44
netbox/dcim/migrations/0052_virtual_chassis.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2017-11-27 17:27
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0051_rackreservation_tenant'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VirtualChassis',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('domain', models.CharField(blank=True, max_length=30)),
|
||||
('master', models.OneToOneField(default=1, on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device')),
|
||||
],
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='virtual_chassis',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='members', to='dcim.VirtualChassis'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='vc_position',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='vc_priority',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MaxValueValidator(255)]),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='device',
|
||||
unique_together=set([('virtual_chassis', 'vc_position'), ('rack', 'position', 'face')]),
|
||||
),
|
||||
]
|
||||
26
netbox/dcim/migrations/0053_platform_manufacturer.py
Normal file
26
netbox/dcim/migrations/0053_platform_manufacturer.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2017-12-19 20:56
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0052_virtual_chassis'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='platform',
|
||||
name='manufacturer',
|
||||
field=models.ForeignKey(blank=True, help_text='Optionally limit this platform to devices of a certain manufacturer', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='platforms', to='dcim.Manufacturer'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='platform',
|
||||
name='napalm_driver',
|
||||
field=models.CharField(blank=True, help_text='The name of the NAPALM driver to use when interacting with devices', max_length=50, verbose_name='NAPALM driver'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.6 on 2018-01-25 18:21
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import timezone_field.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0053_platform_manufacturer'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='status',
|
||||
field=models.PositiveSmallIntegerField(choices=[[1, 'Active'], [2, 'Planned'], [4, 'Retired']], default=1),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='time_zone',
|
||||
field=timezone_field.fields.TimeZoneField(blank=True),
|
||||
),
|
||||
]
|
||||
25
netbox/dcim/migrations/0055_virtualchassis_ordering.py
Normal file
25
netbox/dcim/migrations/0055_virtualchassis_ordering.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11.9 on 2018-02-21 14:41
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0054_site_status_timezone_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='virtualchassis',
|
||||
options={'ordering': ['master'], 'verbose_name_plural': 'virtual chassis'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='virtualchassis',
|
||||
name='master',
|
||||
field=models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device'),
|
||||
),
|
||||
]
|
||||
@@ -14,6 +14,7 @@ from django.db.models import Count, Q, ObjectDoesNotExist
|
||||
from django.urls import reverse
|
||||
from django.utils.encoding import python_2_unicode_compatible
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
from circuits.models import Circuit
|
||||
from extras.models import CustomFieldModel, CustomFieldValue, ImageAttachment
|
||||
@@ -79,10 +80,13 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
||||
"""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
status = models.PositiveSmallIntegerField(choices=SITE_STATUS_CHOICES, default=SITE_STATUS_ACTIVE)
|
||||
region = models.ForeignKey('Region', related_name='sites', blank=True, null=True, on_delete=models.SET_NULL)
|
||||
tenant = models.ForeignKey(Tenant, related_name='sites', blank=True, null=True, on_delete=models.PROTECT)
|
||||
facility = models.CharField(max_length=50, blank=True)
|
||||
asn = ASNField(blank=True, null=True, verbose_name='ASN')
|
||||
time_zone = TimeZoneField(blank=True)
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
physical_address = models.CharField(max_length=200, blank=True)
|
||||
shipping_address = models.CharField(max_length=200, blank=True)
|
||||
contact_name = models.CharField(max_length=50, blank=True)
|
||||
@@ -95,8 +99,8 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
||||
objects = SiteManager()
|
||||
|
||||
csv_headers = [
|
||||
'name', 'slug', 'region', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name',
|
||||
'contact_phone', 'contact_email', 'comments',
|
||||
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
|
||||
'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
@@ -112,10 +116,13 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
self.get_status_display(),
|
||||
self.region.name if self.region else None,
|
||||
self.tenant.name if self.tenant else None,
|
||||
self.facility,
|
||||
self.asn,
|
||||
self.time_zone,
|
||||
self.description,
|
||||
self.physical_address,
|
||||
self.shipping_address,
|
||||
self.contact_name,
|
||||
@@ -124,6 +131,9 @@ class Site(CreatedUpdatedModel, CustomFieldModel):
|
||||
self.comments,
|
||||
)
|
||||
|
||||
def get_status_class(self):
|
||||
return STATUS_CLASSES[self.status]
|
||||
|
||||
@property
|
||||
def count_prefixes(self):
|
||||
return self.prefixes.count()
|
||||
@@ -431,6 +441,7 @@ class RackReservation(models.Model):
|
||||
rack = models.ForeignKey('Rack', related_name='reservations', on_delete=models.CASCADE)
|
||||
units = ArrayField(models.PositiveSmallIntegerField())
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='rackreservations', on_delete=models.PROTECT)
|
||||
user = models.ForeignKey(User, on_delete=models.PROTECT)
|
||||
description = models.CharField(max_length=100)
|
||||
|
||||
@@ -785,18 +796,33 @@ class DeviceRole(models.Model):
|
||||
@python_2_unicode_compatible
|
||||
class Platform(models.Model):
|
||||
"""
|
||||
Platform refers to the software or firmware running on a Device; for example, "Cisco IOS-XR" or "Juniper Junos".
|
||||
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
|
||||
NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by
|
||||
specifying an remote procedure call (RPC) client.
|
||||
specifying a NAPALM driver.
|
||||
"""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
napalm_driver = models.CharField(max_length=50, blank=True, verbose_name='NAPALM driver',
|
||||
help_text="The name of the NAPALM driver to use when interacting with devices.")
|
||||
rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True,
|
||||
verbose_name='Legacy RPC client')
|
||||
manufacturer = models.ForeignKey(
|
||||
to='Manufacturer',
|
||||
related_name='platforms',
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Optionally limit this platform to devices of a certain manufacturer"
|
||||
)
|
||||
napalm_driver = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
verbose_name='NAPALM driver',
|
||||
help_text="The name of the NAPALM driver to use when interacting with devices"
|
||||
)
|
||||
rpc_client = models.CharField(
|
||||
max_length=30,
|
||||
choices=RPC_CLIENT_CHOICES,
|
||||
blank=True,
|
||||
verbose_name="Legacy RPC client"
|
||||
)
|
||||
|
||||
csv_headers = ['name', 'slug', 'napalm_driver']
|
||||
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver']
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
@@ -811,6 +837,7 @@ class Platform(models.Model):
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
self.manufacturer.name if self.manufacturer else None,
|
||||
self.napalm_driver,
|
||||
)
|
||||
|
||||
@@ -851,7 +878,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
help_text='The lowest-numbered unit occupied by the device'
|
||||
)
|
||||
face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face')
|
||||
status = models.PositiveSmallIntegerField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status')
|
||||
status = models.PositiveSmallIntegerField(choices=DEVICE_STATUS_CHOICES, default=DEVICE_STATUS_ACTIVE, verbose_name='Status')
|
||||
primary_ip4 = models.OneToOneField(
|
||||
'ipam.IPAddress', related_name='primary_ip4_for', on_delete=models.SET_NULL, blank=True, null=True,
|
||||
verbose_name='Primary IPv4'
|
||||
@@ -867,6 +894,23 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
virtual_chassis = models.ForeignKey(
|
||||
to='VirtualChassis',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='members',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
vc_position = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MaxValueValidator(255)]
|
||||
)
|
||||
vc_priority = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MaxValueValidator(255)]
|
||||
)
|
||||
comments = models.TextField(blank=True)
|
||||
custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id')
|
||||
images = GenericRelation(ImageAttachment)
|
||||
@@ -880,7 +924,10 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
unique_together = ['rack', 'position', 'face']
|
||||
unique_together = [
|
||||
['rack', 'position', 'face'],
|
||||
['virtual_chassis', 'vc_position'],
|
||||
]
|
||||
permissions = (
|
||||
('napalm_read', 'Read-only access to devices via NAPALM'),
|
||||
('napalm_write', 'Read/write access to devices via NAPALM'),
|
||||
@@ -949,29 +996,36 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
except DeviceType.DoesNotExist:
|
||||
pass
|
||||
|
||||
# Validate primary IPv4 address
|
||||
if self.primary_ip4 and (
|
||||
self.primary_ip4.interface is None or
|
||||
self.primary_ip4.interface.device != self
|
||||
) and (
|
||||
self.primary_ip4.nat_inside.interface is None or
|
||||
self.primary_ip4.nat_inside.interface.device != self
|
||||
):
|
||||
raise ValidationError({
|
||||
'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip4),
|
||||
})
|
||||
# Validate primary IP addresses
|
||||
vc_interfaces = self.vc_interfaces.all()
|
||||
if self.primary_ip4:
|
||||
if self.primary_ip4.interface in vc_interfaces:
|
||||
pass
|
||||
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
|
||||
pass
|
||||
else:
|
||||
raise ValidationError({
|
||||
'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format(
|
||||
self.primary_ip4),
|
||||
})
|
||||
if self.primary_ip6:
|
||||
if self.primary_ip6.interface in vc_interfaces:
|
||||
pass
|
||||
elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces:
|
||||
pass
|
||||
else:
|
||||
raise ValidationError({
|
||||
'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(
|
||||
self.primary_ip6),
|
||||
})
|
||||
|
||||
# Validate primary IPv6 address
|
||||
if self.primary_ip6 and (
|
||||
self.primary_ip6.interface is None or
|
||||
self.primary_ip6.interface.device != self
|
||||
) and (
|
||||
self.primary_ip6.nat_inside.interface is None or
|
||||
self.primary_ip6.nat_inside.interface.device != self
|
||||
):
|
||||
raise ValidationError({
|
||||
'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format(self.primary_ip6),
|
||||
})
|
||||
# Validate manufacturer/platform
|
||||
if self.device_type and self.platform:
|
||||
if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
|
||||
raise ValidationError({
|
||||
'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
|
||||
"to {}.".format(self.platform.manufacturer, self.device_type.manufacturer)
|
||||
})
|
||||
|
||||
# A Device can only be assigned to a Cluster in the same Site (or no Site)
|
||||
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
|
||||
@@ -979,6 +1033,12 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
'cluster': "The assigned cluster belongs to a different site ({})".format(self.cluster.site)
|
||||
})
|
||||
|
||||
# Validate virtual chassis assignment
|
||||
if self.virtual_chassis and self.vc_position is None:
|
||||
raise ValidationError({
|
||||
'vc_position': "A device assigned to a virtual chassis must have its position defined."
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
is_new = not bool(self.pk)
|
||||
@@ -1038,6 +1098,8 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
def display_name(self):
|
||||
if self.name:
|
||||
return self.name
|
||||
elif self.virtual_chassis and self.virtual_chassis.master.name:
|
||||
return "{}:{}".format(self.virtual_chassis.master, self.vc_position)
|
||||
elif hasattr(self, 'device_type'):
|
||||
return "{}".format(self.device_type)
|
||||
return ""
|
||||
@@ -1062,6 +1124,23 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_vc_master(self):
|
||||
"""
|
||||
If this Device is a VirtualChassis member, return the VC master. Otherwise, return None.
|
||||
"""
|
||||
return self.virtual_chassis.master if self.virtual_chassis else None
|
||||
|
||||
@property
|
||||
def vc_interfaces(self):
|
||||
"""
|
||||
Return a QuerySet matching all Interfaces assigned to this Device or, if this Device is a VC master, to another
|
||||
Device belonging to the same VirtualChassis.
|
||||
"""
|
||||
filter = Q(device=self)
|
||||
if self.virtual_chassis and self.virtual_chassis.master == self:
|
||||
filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
|
||||
return Interface.objects.filter(filter)
|
||||
|
||||
def get_children(self):
|
||||
"""
|
||||
Return the set of child Devices installed in DeviceBays within this Device.
|
||||
@@ -1069,7 +1148,7 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
|
||||
return Device.objects.filter(parent_bay__device=self.pk)
|
||||
|
||||
def get_status_class(self):
|
||||
return DEVICE_STATUS_CLASSES[self.status]
|
||||
return STATUS_CLASSES[self.status]
|
||||
|
||||
def get_rpc_client(self):
|
||||
"""
|
||||
@@ -1104,6 +1183,9 @@ class ConsolePort(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.cs_port.device.identifier if self.cs_port else None,
|
||||
@@ -1144,6 +1226,9 @@ class ConsoleServerPort(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that the parent device's DeviceType is a console server
|
||||
@@ -1180,6 +1265,9 @@ class PowerPort(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.power_outlet.device.identifier if self.power_outlet else None,
|
||||
@@ -1220,6 +1308,9 @@ class PowerOutlet(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that the parent device's DeviceType is a PDU
|
||||
@@ -1275,6 +1366,24 @@ class Interface(models.Model):
|
||||
help_text="This interface is used only for out-of-band management"
|
||||
)
|
||||
description = models.CharField(max_length=100, blank=True)
|
||||
mode = models.PositiveSmallIntegerField(
|
||||
choices=IFACE_MODE_CHOICES,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
untagged_vlan = models.ForeignKey(
|
||||
to='ipam.VLAN',
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='Untagged VLAN',
|
||||
related_name='interfaces_as_untagged'
|
||||
)
|
||||
tagged_vlans = models.ManyToManyField(
|
||||
to='ipam.VLAN',
|
||||
blank=True,
|
||||
verbose_name='Tagged VLANs',
|
||||
related_name='interfaces_as_tagged'
|
||||
)
|
||||
|
||||
objects = InterfaceQuerySet.as_manager()
|
||||
|
||||
@@ -1285,6 +1394,9 @@ class Interface(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.parent.get_absolute_url()
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that the parent device's DeviceType is a network device
|
||||
@@ -1314,8 +1426,8 @@ class Interface(models.Model):
|
||||
"Disconnect the interface or choose a suitable form factor."
|
||||
})
|
||||
|
||||
# An interface's LAG must belong to the same device
|
||||
if self.lag and self.lag.device != self.device:
|
||||
# An interface's LAG must belong to the same device (or VC master)
|
||||
if self.lag and self.lag.device not in [self.device, self.device.get_vc_master()]:
|
||||
raise ValidationError({
|
||||
'lag': "The selected LAG interface ({}) belongs to a different device ({}).".format(
|
||||
self.lag.name, self.lag.device.name
|
||||
@@ -1336,6 +1448,13 @@ class Interface(models.Model):
|
||||
)
|
||||
})
|
||||
|
||||
# Validate untagged VLAN
|
||||
if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
|
||||
raise ValidationError({
|
||||
'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
|
||||
"device/VM, or it must be global".format(self.untagged_vlan)
|
||||
})
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
return self.device or self.virtual_machine
|
||||
@@ -1439,6 +1558,9 @@ class DeviceBay(models.Model):
|
||||
def __str__(self):
|
||||
return '{} - {}'.format(self.device.name, self.name)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate that the parent Device can have DeviceBays
|
||||
@@ -1488,6 +1610,9 @@ class InventoryItem(models.Model):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.device.name or '{' + self.device.pk + '}',
|
||||
@@ -1499,3 +1624,42 @@ class InventoryItem(models.Model):
|
||||
self.discovered,
|
||||
self.description,
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
@python_2_unicode_compatible
|
||||
class VirtualChassis(models.Model):
|
||||
"""
|
||||
A collection of Devices which operate with a shared control plane (e.g. a switch stack).
|
||||
"""
|
||||
master = models.OneToOneField(
|
||||
to='Device',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='vc_master_for'
|
||||
)
|
||||
domain = models.CharField(
|
||||
max_length=30,
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['master']
|
||||
verbose_name_plural = 'virtual chassis'
|
||||
|
||||
def __str__(self):
|
||||
return str(self.master) if hasattr(self, 'master') else 'New Virtual Chassis'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.master.get_absolute_url()
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
|
||||
# VirtualChassis.)
|
||||
if self.pk and self.master not in self.members.all():
|
||||
raise ValidationError({
|
||||
'master': "The selected master is not assigned to this virtual chassis."
|
||||
})
|
||||
|
||||
23
netbox/dcim/signals.py
Normal file
23
netbox/dcim/signals.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db.models.signals import post_save, pre_delete
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .models import Device, VirtualChassis
|
||||
|
||||
|
||||
@receiver(post_save, sender=VirtualChassis)
|
||||
def assign_virtualchassis_master(instance, created, **kwargs):
|
||||
"""
|
||||
When a VirtualChassis is created, automatically assign its master device to the VC.
|
||||
"""
|
||||
if created:
|
||||
Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=1)
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=VirtualChassis)
|
||||
def clear_virtualchassis_members(instance, **kwargs):
|
||||
"""
|
||||
When a VirtualChassis is deleted, nullify the vc_position and vc_priority fields of its prior members.
|
||||
"""
|
||||
Device.objects.filter(virtual_chassis=instance.pk).update(vc_position=None, vc_priority=None)
|
||||
@@ -9,6 +9,7 @@ from .models import (
|
||||
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, InventoryItem, Manufacturer, Platform,
|
||||
PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site,
|
||||
VirtualChassis,
|
||||
)
|
||||
|
||||
REGION_LINK = """
|
||||
@@ -113,7 +114,7 @@ DEVICE_ROLE = """
|
||||
<label class="label" style="background-color: #{{ record.device_role.color }}">{{ value }}</label>
|
||||
"""
|
||||
|
||||
DEVICE_STATUS = """
|
||||
STATUS_LABEL = """
|
||||
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||
"""
|
||||
|
||||
@@ -132,6 +133,12 @@ UTILIZATION_GRAPH = """
|
||||
{% utilization_graph value %}
|
||||
"""
|
||||
|
||||
VIRTUALCHASSIS_ACTIONS = """
|
||||
{% if perms.dcim.change_virtualchassis %}
|
||||
<a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# Regions
|
||||
@@ -160,27 +167,13 @@ class RegionTable(BaseTable):
|
||||
class SiteTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn()
|
||||
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
|
||||
region = tables.TemplateColumn(template_code=SITE_REGION_LINK)
|
||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Site
|
||||
fields = ('pk', 'name', 'facility', 'region', 'tenant', 'asn')
|
||||
|
||||
|
||||
class SiteDetailTable(SiteTable):
|
||||
rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks')
|
||||
device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices')
|
||||
prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes')
|
||||
vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs')
|
||||
circuit_count = tables.Column(accessor=Accessor('count_circuits'), orderable=False, verbose_name='Circuits')
|
||||
vm_count = tables.Column(accessor=Accessor('count_vms'), orderable=False, verbose_name='VMs')
|
||||
|
||||
class Meta(SiteTable.Meta):
|
||||
fields = (
|
||||
'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count',
|
||||
'vlan_count', 'circuit_count', 'vm_count',
|
||||
)
|
||||
fields = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description')
|
||||
|
||||
|
||||
#
|
||||
@@ -270,6 +263,7 @@ class RackImportTable(BaseTable):
|
||||
|
||||
class RackReservationTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
||||
unit_list = tables.Column(orderable=False, verbose_name='Units')
|
||||
actions = tables.TemplateColumn(
|
||||
@@ -278,7 +272,7 @@ class RackReservationTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackReservation
|
||||
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'description', 'actions')
|
||||
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -289,13 +283,14 @@ class ManufacturerTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
devicetype_count = tables.Column(verbose_name='Device Types')
|
||||
platform_count = tables.Column(verbose_name='Platforms')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Manufacturer
|
||||
fields = ('pk', 'name', 'devicetype_count', 'slug', 'actions')
|
||||
fields = ('pk', 'name', 'devicetype_count', 'platform_count', 'slug', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -437,7 +432,7 @@ class PlatformTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Platform
|
||||
fields = ('pk', 'name', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'actions')
|
||||
fields = ('pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'actions')
|
||||
|
||||
|
||||
#
|
||||
@@ -447,7 +442,7 @@ class PlatformTable(BaseTable):
|
||||
class DeviceTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.TemplateColumn(template_code=DEVICE_LINK)
|
||||
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
|
||||
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
|
||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')])
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
||||
@@ -474,7 +469,7 @@ class DeviceDetailTable(DeviceTable):
|
||||
|
||||
class DeviceImportTable(BaseTable):
|
||||
name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name')
|
||||
status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status')
|
||||
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
|
||||
tenant = tables.TemplateColumn(template_code=COL_TENANT)
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
|
||||
@@ -587,3 +582,22 @@ class InventoryItemTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = InventoryItem
|
||||
fields = ('pk', 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description')
|
||||
|
||||
|
||||
#
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
master = tables.LinkColumn()
|
||||
member_count = tables.Column(verbose_name='Members')
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=VIRTUALCHASSIS_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VirtualChassis
|
||||
fields = ('pk', 'master', 'domain', 'member_count', 'actions')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,7 +26,7 @@ class DeviceTestCase(TestCase):
|
||||
'face': RACK_FACE_FRONT,
|
||||
'position': 41,
|
||||
'platform': get_id(Platform, 'juniper-junos'),
|
||||
'status': STATUS_ACTIVE,
|
||||
'status': DEVICE_STATUS_ACTIVE,
|
||||
})
|
||||
self.assertTrue(test.is_valid(), test.fields['position'].choices)
|
||||
self.assertTrue(test.save())
|
||||
@@ -43,7 +43,7 @@ class DeviceTestCase(TestCase):
|
||||
'face': RACK_FACE_FRONT,
|
||||
'position': 1,
|
||||
'platform': get_id(Platform, 'juniper-junos'),
|
||||
'status': STATUS_ACTIVE,
|
||||
'status': DEVICE_STATUS_ACTIVE,
|
||||
})
|
||||
self.assertFalse(test.is_valid())
|
||||
|
||||
@@ -59,7 +59,7 @@ class DeviceTestCase(TestCase):
|
||||
'face': None,
|
||||
'position': None,
|
||||
'platform': None,
|
||||
'status': STATUS_ACTIVE,
|
||||
'status': DEVICE_STATUS_ACTIVE,
|
||||
})
|
||||
self.assertTrue(test.is_valid())
|
||||
self.assertTrue(test.save())
|
||||
@@ -76,7 +76,7 @@ class DeviceTestCase(TestCase):
|
||||
'face': RACK_FACE_REAR,
|
||||
'position': None,
|
||||
'platform': None,
|
||||
'status': STATUS_ACTIVE,
|
||||
'status': DEVICE_STATUS_ACTIVE,
|
||||
})
|
||||
self.assertTrue(test.is_valid())
|
||||
self.assertTrue(test.save())
|
||||
|
||||
@@ -140,8 +140,8 @@ urlpatterns = [
|
||||
url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/connect/$', views.ConsolePortConnectView.as_view(), name='consoleport_connect'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/disconnect/$', views.ConsolePortDisconnectView.as_view(), name='consoleport_disconnect'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
|
||||
|
||||
@@ -150,17 +150,18 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/connect/$', views.ConsoleServerPortConnectView.as_view(), name='consoleserverport_connect'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/disconnect/$', views.ConsoleServerPortDisconnectView.as_view(), name='consoleserverport_disconnect'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
|
||||
url(r'^console-server-ports/rename/$', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
|
||||
|
||||
# Power ports
|
||||
url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.powerport_connect, name='powerport_connect'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/connect/$', views.PowerPortConnectView.as_view(), name='powerport_connect'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/disconnect/$', views.PowerPortDisconnectView.as_view(), name='powerport_disconnect'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
|
||||
|
||||
@@ -169,10 +170,11 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/connect/$', views.PowerOutletConnectView.as_view(), name='poweroutlet_connect'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/disconnect/$', views.PowerOutletDisconnectView.as_view(), name='poweroutlet_disconnect'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
|
||||
url(r'^power-outlets/rename/$', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
|
||||
|
||||
# Interfaces
|
||||
url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
|
||||
@@ -180,10 +182,11 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
|
||||
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'),
|
||||
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'),
|
||||
url(r'^devices/(?P<pk>\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'),
|
||||
url(r'^interface-connections/(?P<pk>\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
|
||||
url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
|
||||
|
||||
# Device bays
|
||||
url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
|
||||
@@ -191,8 +194,9 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
|
||||
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
|
||||
url(r'^device-bays/rename/$', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
|
||||
|
||||
# Inventory items
|
||||
url(r'^inventory-items/$', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
|
||||
@@ -211,4 +215,12 @@ urlpatterns = [
|
||||
url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
|
||||
url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'),
|
||||
|
||||
# Virtual chassis
|
||||
url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
|
||||
url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
|
||||
url(r'^virtual-chassis/(?P<pk>\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
|
||||
url(r'^virtual-chassis/(?P<pk>\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
|
||||
url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
|
||||
url(r'^virtual-chassis-members/(?P<pk>\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
|
||||
|
||||
]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,12 +6,12 @@ from django.shortcuts import get_object_or_404
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet, ViewSet
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
|
||||
|
||||
from extras import filters
|
||||
from extras.models import CustomField, ExportTemplate, Graph, ImageAttachment, ReportResult, TopologyMap, UserAction
|
||||
from extras.reports import get_report, get_reports
|
||||
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, WritableSerializerMixin
|
||||
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ class CustomFieldModelViewSet(ModelViewSet):
|
||||
# Graphs
|
||||
#
|
||||
|
||||
class GraphViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class GraphViewSet(ModelViewSet):
|
||||
queryset = Graph.objects.all()
|
||||
serializer_class = serializers.GraphSerializer
|
||||
write_serializer_class = serializers.WritableGraphSerializer
|
||||
@@ -75,7 +75,7 @@ class GraphViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
# Export templates
|
||||
#
|
||||
|
||||
class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class ExportTemplateViewSet(ModelViewSet):
|
||||
queryset = ExportTemplate.objects.all()
|
||||
serializer_class = serializers.ExportTemplateSerializer
|
||||
filter_class = filters.ExportTemplateFilter
|
||||
@@ -85,7 +85,7 @@ class ExportTemplateViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
# Topology maps
|
||||
#
|
||||
|
||||
class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class TopologyMapViewSet(ModelViewSet):
|
||||
queryset = TopologyMap.objects.select_related('site')
|
||||
serializer_class = serializers.TopologyMapSerializer
|
||||
write_serializer_class = serializers.WritableTopologyMapSerializer
|
||||
@@ -115,7 +115,7 @@ class TopologyMapViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
# Image attachments
|
||||
#
|
||||
|
||||
class ImageAttachmentViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class ImageAttachmentViewSet(ModelViewSet):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
serializer_class = serializers.ImageAttachmentSerializer
|
||||
write_serializer_class = serializers.WritableImageAttachmentSerializer
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.db import transaction
|
||||
from ncclient.transport.errors import AuthenticationError
|
||||
from paramiko import AuthenticationException
|
||||
|
||||
from dcim.models import Device, InventoryItem, Site, STATUS_ACTIVE
|
||||
from dcim.models import DEVICE_STATUS_ACTIVE, Device, InventoryItem, Site
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@@ -41,7 +41,7 @@ class Command(BaseCommand):
|
||||
self.password = getpass("Password: ")
|
||||
|
||||
# Attempt to inventory only active devices
|
||||
device_list = Device.objects.filter(status=STATUS_ACTIVE)
|
||||
device_list = Device.objects.filter(status=DEVICE_STATUS_ACTIVE)
|
||||
|
||||
# --site: Include only devices belonging to specified site(s)
|
||||
if options['site']:
|
||||
|
||||
@@ -54,7 +54,7 @@ class GraphTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('extras-api:graph-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Graph.objects.count(), 4)
|
||||
@@ -63,6 +63,35 @@ class GraphTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(graph4.name, data['name'])
|
||||
self.assertEqual(graph4.source, data['source'])
|
||||
|
||||
def test_create_graph_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'type': GRAPH_TYPE_SITE,
|
||||
'name': 'Test Graph 4',
|
||||
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
|
||||
},
|
||||
{
|
||||
'type': GRAPH_TYPE_SITE,
|
||||
'name': 'Test Graph 5',
|
||||
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5',
|
||||
},
|
||||
{
|
||||
'type': GRAPH_TYPE_SITE,
|
||||
'name': 'Test Graph 6',
|
||||
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('extras-api:graph-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Graph.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_graph(self):
|
||||
|
||||
data = {
|
||||
@@ -72,7 +101,7 @@ class GraphTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Graph.objects.count(), 3)
|
||||
@@ -135,7 +164,7 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('extras-api:exporttemplate-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ExportTemplate.objects.count(), 4)
|
||||
@@ -144,6 +173,35 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(exporttemplate4.name, data['name'])
|
||||
self.assertEqual(exporttemplate4.template_code, data['template_code'])
|
||||
|
||||
def test_create_exporttemplate_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'content_type': self.content_type.pk,
|
||||
'name': 'Test Export Template 4',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
},
|
||||
{
|
||||
'content_type': self.content_type.pk,
|
||||
'name': 'Test Export Template 5',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
},
|
||||
{
|
||||
'content_type': self.content_type.pk,
|
||||
'name': 'Test Export Template 6',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('extras-api:exporttemplate-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ExportTemplate.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_exporttemplate(self):
|
||||
|
||||
data = {
|
||||
@@ -153,7 +211,7 @@ class ExportTemplateTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(ExportTemplate.objects.count(), 3)
|
||||
|
||||
@@ -3,9 +3,11 @@ from __future__ import unicode_literals
|
||||
from collections import OrderedDict
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
|
||||
from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer
|
||||
from dcim.models import Interface
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from ipam.constants import (
|
||||
IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES,
|
||||
@@ -25,7 +27,10 @@ class VRFSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields']
|
||||
fields = [
|
||||
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class NestedVRFSerializer(serializers.ModelSerializer):
|
||||
@@ -40,7 +45,9 @@ class WritableVRFSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields']
|
||||
fields = [
|
||||
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
@@ -90,7 +97,9 @@ class AggregateSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
|
||||
fields = [
|
||||
'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class NestedAggregateSerializer(serializers.ModelSerializer):
|
||||
@@ -105,7 +114,7 @@ class WritableAggregateSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields']
|
||||
fields = ['id', 'prefix', 'rir', 'date_added', 'description', 'custom_fields', 'created', 'last_updated']
|
||||
|
||||
|
||||
#
|
||||
@@ -165,7 +174,7 @@ class VLANSerializer(CustomFieldModelSerializer):
|
||||
model = VLAN
|
||||
fields = [
|
||||
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'display_name',
|
||||
'custom_fields',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -181,7 +190,10 @@ class WritableVLANSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = ['id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields']
|
||||
fields = [
|
||||
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
validators = []
|
||||
|
||||
def validate(self, data):
|
||||
@@ -215,7 +227,7 @@ class PrefixSerializer(CustomFieldModelSerializer):
|
||||
model = Prefix
|
||||
fields = [
|
||||
'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
||||
'custom_fields',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -233,23 +245,47 @@ class WritablePrefixSerializer(CustomFieldModelSerializer):
|
||||
model = Prefix
|
||||
fields = [
|
||||
'id', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
||||
'custom_fields',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class AvailablePrefixSerializer(serializers.Serializer):
|
||||
|
||||
def to_representation(self, instance):
|
||||
if self.context.get('vrf'):
|
||||
vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data
|
||||
else:
|
||||
vrf = None
|
||||
return OrderedDict([
|
||||
('family', instance.version),
|
||||
('prefix', str(instance)),
|
||||
('vrf', vrf),
|
||||
])
|
||||
|
||||
|
||||
#
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressInterfaceSerializer(InterfaceSerializer):
|
||||
class IPAddressInterfaceSerializer(serializers.ModelSerializer):
|
||||
url = serializers.SerializerMethodField() # We're imitating a HyperlinkedIdentityField here
|
||||
device = NestedDeviceSerializer()
|
||||
virtual_machine = NestedVirtualMachineSerializer()
|
||||
|
||||
class Meta(InterfaceSerializer.Meta):
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'device', 'virtual_machine', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address',
|
||||
'mgmt_only', 'description', 'is_connected', 'interface_connection', 'circuit_termination',
|
||||
'id', 'url', 'device', 'virtual_machine', 'name',
|
||||
]
|
||||
|
||||
def get_url(self, obj):
|
||||
"""
|
||||
Return a link to the Interface via either the DCIM API if the parent is a Device, or via the virtualization API
|
||||
if the parent is a VirtualMachine.
|
||||
"""
|
||||
url_name = 'dcim-api:interface-detail' if obj.device else 'virtualization-api:interface-detail'
|
||||
return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request'])
|
||||
|
||||
|
||||
class IPAddressSerializer(CustomFieldModelSerializer):
|
||||
vrf = NestedVRFSerializer()
|
||||
@@ -262,7 +298,7 @@ class IPAddressSerializer(CustomFieldModelSerializer):
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
|
||||
'nat_outside', 'custom_fields',
|
||||
'nat_outside', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -284,7 +320,7 @@ class WritableIPAddressSerializer(CustomFieldModelSerializer):
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
|
||||
'custom_fields',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -314,7 +350,10 @@ class ServiceSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
||||
fields = [
|
||||
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
|
||||
# TODO: Figure out how to use model validation with ManyToManyFields. Calling clean() yields a ValueError.
|
||||
@@ -322,4 +361,7 @@ class WritableServiceSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = ['id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description']
|
||||
fields = [
|
||||
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
@@ -6,12 +6,11 @@ from rest_framework import status
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from ipam import filters
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
|
||||
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -33,7 +32,7 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class VRFViewSet(CustomFieldModelViewSet):
|
||||
queryset = VRF.objects.select_related('tenant')
|
||||
serializer_class = serializers.VRFSerializer
|
||||
write_serializer_class = serializers.WritableVRFSerializer
|
||||
@@ -54,7 +53,7 @@ class RIRViewSet(ModelViewSet):
|
||||
# Aggregates
|
||||
#
|
||||
|
||||
class AggregateViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class AggregateViewSet(CustomFieldModelViewSet):
|
||||
queryset = Aggregate.objects.select_related('rir')
|
||||
serializer_class = serializers.AggregateSerializer
|
||||
write_serializer_class = serializers.WritableAggregateSerializer
|
||||
@@ -75,12 +74,72 @@ class RoleViewSet(ModelViewSet):
|
||||
# Prefixes
|
||||
#
|
||||
|
||||
class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class PrefixViewSet(CustomFieldModelViewSet):
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
|
||||
serializer_class = serializers.PrefixSerializer
|
||||
write_serializer_class = serializers.WritablePrefixSerializer
|
||||
filter_class = filters.PrefixFilter
|
||||
|
||||
@detail_route(url_path='available-prefixes', methods=['get', 'post'])
|
||||
def available_prefixes(self, request, pk=None):
|
||||
"""
|
||||
A convenience method for returning available child prefixes within a parent.
|
||||
"""
|
||||
prefix = get_object_or_404(Prefix, pk=pk)
|
||||
available_prefixes = prefix.get_available_prefixes()
|
||||
|
||||
if request.method == 'POST':
|
||||
|
||||
# Permissions check
|
||||
if not request.user.has_perm('ipam.add_prefix'):
|
||||
raise PermissionDenied()
|
||||
|
||||
# Normalize to a list of objects
|
||||
requested_prefixes = request.data if isinstance(request.data, list) else [request.data]
|
||||
|
||||
# Allocate prefixes to the requested objects based on availability within the parent
|
||||
for requested_prefix in requested_prefixes:
|
||||
|
||||
# Find the first available prefix equal to or larger than the requested size
|
||||
for available_prefix in available_prefixes.iter_cidrs():
|
||||
if requested_prefix['prefix_length'] >= available_prefix.prefixlen:
|
||||
allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length'])
|
||||
requested_prefix['prefix'] = allocated_prefix
|
||||
requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None
|
||||
break
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Insufficient space is available to accommodate the requested prefix size(s)"
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Remove the allocated prefix from the list of available prefixes
|
||||
available_prefixes.remove(allocated_prefix)
|
||||
|
||||
# Initialize the serializer with a list or a single object depending on what was requested
|
||||
if isinstance(request.data, list):
|
||||
serializer = serializers.WritablePrefixSerializer(data=requested_prefixes, many=True)
|
||||
else:
|
||||
serializer = serializers.WritablePrefixSerializer(data=requested_prefixes[0])
|
||||
|
||||
# Create the new Prefix(es)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
else:
|
||||
|
||||
serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={
|
||||
'request': request,
|
||||
'vrf': prefix.vrf,
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@detail_route(url_path='available-ips', methods=['get', 'post'])
|
||||
def available_ips(self, request, pk=None):
|
||||
"""
|
||||
@@ -97,28 +156,39 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
if not request.user.has_perm('ipam.add_ipaddress'):
|
||||
raise PermissionDenied()
|
||||
|
||||
# Find the first available IP address in the prefix
|
||||
try:
|
||||
ipaddress = list(prefix.get_available_ips())[0]
|
||||
except IndexError:
|
||||
# Normalize to a list of objects
|
||||
requested_ips = request.data if isinstance(request.data, list) else [request.data]
|
||||
|
||||
# Determine if the requested number of IPs is available
|
||||
available_ips = list(prefix.get_available_ips())
|
||||
if len(available_ips) < len(requested_ips):
|
||||
return Response(
|
||||
{
|
||||
"detail": "There are no available IPs within this prefix ({})".format(prefix)
|
||||
"detail": "An insufficient number of IP addresses are available within the prefix {} ({} "
|
||||
"requested, {} available)".format(prefix, len(requested_ips), len(available_ips))
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Create the new IP address
|
||||
data = request.data.copy()
|
||||
data['address'] = '{}/{}'.format(ipaddress, prefix.prefix.prefixlen)
|
||||
data['vrf'] = prefix.vrf.pk if prefix.vrf else None
|
||||
serializer = serializers.WritableIPAddressSerializer(data=data)
|
||||
# Assign addresses from the list of available IPs and copy VRF assignment from the parent prefix
|
||||
for requested_ip in requested_ips:
|
||||
requested_ip['address'] = available_ips.pop(0)
|
||||
requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None
|
||||
|
||||
# Initialize the serializer with a list or a single object depending on what was requested
|
||||
if isinstance(request.data, list):
|
||||
serializer = serializers.WritableIPAddressSerializer(data=requested_ips, many=True)
|
||||
else:
|
||||
serializer = serializers.WritableIPAddressSerializer(data=requested_ips[0])
|
||||
|
||||
# Create the new IP address(es)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Determine the maximum amount of IPs to return
|
||||
# Determine the maximum number of IPs to return
|
||||
else:
|
||||
try:
|
||||
limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT))
|
||||
@@ -146,11 +216,11 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
queryset = IPAddress.objects.select_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside'
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine'
|
||||
).prefetch_related(
|
||||
'interface__device', 'interface__virtual_machine'
|
||||
'nat_outside'
|
||||
)
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
write_serializer_class = serializers.WritableIPAddressSerializer
|
||||
@@ -161,7 +231,7 @@ class IPAddressViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# VLAN groups
|
||||
#
|
||||
|
||||
class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class VLANGroupViewSet(ModelViewSet):
|
||||
queryset = VLANGroup.objects.select_related('site')
|
||||
serializer_class = serializers.VLANGroupSerializer
|
||||
write_serializer_class = serializers.WritableVLANGroupSerializer
|
||||
@@ -172,7 +242,7 @@ class VLANGroupViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
# VLANs
|
||||
#
|
||||
|
||||
class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class VLANViewSet(CustomFieldModelViewSet):
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
|
||||
serializer_class = serializers.VLANSerializer
|
||||
write_serializer_class = serializers.WritableVLANSerializer
|
||||
@@ -183,7 +253,7 @@ class VLANViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# Services
|
||||
#
|
||||
|
||||
class ServiceViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class ServiceViewSet(ModelViewSet):
|
||||
queryset = Service.objects.select_related('device')
|
||||
serializer_class = serializers.ServiceSerializer
|
||||
write_serializer_class = serializers.WritableServiceSerializer
|
||||
|
||||
@@ -99,11 +99,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
# TODO: Deprecate in v2.3.0
|
||||
parent = django_filters.CharFilter(
|
||||
method='search_within_include',
|
||||
label='Parent prefix (deprecated)',
|
||||
)
|
||||
within = django_filters.CharFilter(
|
||||
method='search_within',
|
||||
label='Within prefix',
|
||||
@@ -262,16 +257,15 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
to_field_name='slug',
|
||||
label='Tenant (slug)',
|
||||
)
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='interface__device',
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
device = django_filters.CharFilter(
|
||||
method='filter_device',
|
||||
name='name',
|
||||
label='Device',
|
||||
)
|
||||
device = django_filters.ModelMultipleChoiceFilter(
|
||||
name='interface__device__name',
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
device_id = django_filters.NumberFilter(
|
||||
method='filter_device',
|
||||
name='pk',
|
||||
label='Device (ID)',
|
||||
)
|
||||
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
|
||||
name='interface__virtual_machine',
|
||||
@@ -324,6 +318,14 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
return queryset
|
||||
return queryset.filter(address__net_mask_length=value)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
try:
|
||||
device = Device.objects.select_related('device_type').get(**{name: value})
|
||||
vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')]
|
||||
return queryset.filter(interface_id__in=vc_interface_ids)
|
||||
except Device.DoesNotExist:
|
||||
return queryset.none()
|
||||
|
||||
|
||||
class VLANGroupFilter(django_filters.FilterSet):
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
|
||||
@@ -931,8 +931,9 @@ class ServiceForm(BootstrapMixin, forms.ModelForm):
|
||||
|
||||
# Limit IP address choices to those assigned to interfaces of the parent device/VM
|
||||
if self.instance.device:
|
||||
vc_interface_ids = [i['id'] for i in self.instance.device.vc_interfaces.values('id')]
|
||||
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
|
||||
interface__device=self.instance.device
|
||||
interface_id__in=vc_interface_ids
|
||||
)
|
||||
elif self.instance.virtual_machine:
|
||||
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
|
||||
|
||||
@@ -47,7 +47,7 @@ class VRFTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vrf-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VRF.objects.count(), 4)
|
||||
@@ -55,6 +55,32 @@ class VRFTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(vrf4.name, data['name'])
|
||||
self.assertEqual(vrf4.rd, data['rd'])
|
||||
|
||||
def test_create_vrf_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test VRF 4',
|
||||
'rd': '65000:4',
|
||||
},
|
||||
{
|
||||
'name': 'Test VRF 5',
|
||||
'rd': '65000:5',
|
||||
},
|
||||
{
|
||||
'name': 'Test VRF 6',
|
||||
'rd': '65000:6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:vrf-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VRF.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_vrf(self):
|
||||
|
||||
data = {
|
||||
@@ -63,7 +89,7 @@ class VRFTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vrf-detail', kwargs={'pk': self.vrf1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(VRF.objects.count(), 3)
|
||||
@@ -114,7 +140,7 @@ class RIRTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:rir-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(RIR.objects.count(), 4)
|
||||
@@ -122,6 +148,32 @@ class RIRTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(rir4.name, data['name'])
|
||||
self.assertEqual(rir4.slug, data['slug'])
|
||||
|
||||
def test_create_rir_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test RIR 4',
|
||||
'slug': 'test-rir-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test RIR 5',
|
||||
'slug': 'test-rir-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test RIR 6',
|
||||
'slug': 'test-rir-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:rir-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(RIR.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_rir(self):
|
||||
|
||||
data = {
|
||||
@@ -130,7 +182,7 @@ class RIRTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:rir-detail', kwargs={'pk': self.rir1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(RIR.objects.count(), 3)
|
||||
@@ -183,7 +235,7 @@ class AggregateTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:aggregate-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Aggregate.objects.count(), 4)
|
||||
@@ -191,6 +243,32 @@ class AggregateTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(str(aggregate4.prefix), data['prefix'])
|
||||
self.assertEqual(aggregate4.rir_id, data['rir'])
|
||||
|
||||
def test_create_aggregate_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'prefix': '100.0.0.0/8',
|
||||
'rir': self.rir1.pk,
|
||||
},
|
||||
{
|
||||
'prefix': '101.0.0.0/8',
|
||||
'rir': self.rir1.pk,
|
||||
},
|
||||
{
|
||||
'prefix': '102.0.0.0/8',
|
||||
'rir': self.rir1.pk,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:aggregate-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Aggregate.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['prefix'], data[0]['prefix'])
|
||||
self.assertEqual(response.data[1]['prefix'], data[1]['prefix'])
|
||||
self.assertEqual(response.data[2]['prefix'], data[2]['prefix'])
|
||||
|
||||
def test_update_aggregate(self):
|
||||
|
||||
data = {
|
||||
@@ -199,7 +277,7 @@ class AggregateTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:aggregate-detail', kwargs={'pk': self.aggregate1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Aggregate.objects.count(), 3)
|
||||
@@ -250,7 +328,7 @@ class RoleTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:role-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Role.objects.count(), 4)
|
||||
@@ -258,6 +336,32 @@ class RoleTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(role4.name, data['name'])
|
||||
self.assertEqual(role4.slug, data['slug'])
|
||||
|
||||
def test_create_role_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Role 4',
|
||||
'slug': 'test-role-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test Role 5',
|
||||
'slug': 'test-role-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test Role 6',
|
||||
'slug': 'test-role-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:role-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Role.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_role(self):
|
||||
|
||||
data = {
|
||||
@@ -266,7 +370,7 @@ class RoleTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:role-detail', kwargs={'pk': self.role1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Role.objects.count(), 3)
|
||||
@@ -324,7 +428,7 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:prefix-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Prefix.objects.count(), 4)
|
||||
@@ -335,6 +439,29 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(prefix4.vlan_id, data['vlan'])
|
||||
self.assertEqual(prefix4.role_id, data['role'])
|
||||
|
||||
def test_create_prefix_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'prefix': '10.0.1.0/24',
|
||||
},
|
||||
{
|
||||
'prefix': '10.0.2.0/24',
|
||||
},
|
||||
{
|
||||
'prefix': '10.0.3.0/24',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:prefix-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Prefix.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['prefix'], data[0]['prefix'])
|
||||
self.assertEqual(response.data[1]['prefix'], data[1]['prefix'])
|
||||
self.assertEqual(response.data[2]['prefix'], data[2]['prefix'])
|
||||
|
||||
def test_update_prefix(self):
|
||||
|
||||
data = {
|
||||
@@ -346,7 +473,7 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefix1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Prefix.objects.count(), 3)
|
||||
@@ -365,7 +492,73 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Prefix.objects.count(), 2)
|
||||
|
||||
def test_available_ips(self):
|
||||
def test_list_available_prefixes(self):
|
||||
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/24'))
|
||||
Prefix.objects.create(prefix=IPNetwork('192.0.2.64/26'))
|
||||
Prefix.objects.create(prefix=IPNetwork('192.0.2.192/27'))
|
||||
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
|
||||
|
||||
# Retrieve all available IPs
|
||||
response = self.client.get(url, **self.header)
|
||||
available_prefixes = ['192.0.2.0/26', '192.0.2.128/26', '192.0.2.224/27']
|
||||
for i, p in enumerate(response.data):
|
||||
self.assertEqual(p['prefix'], available_prefixes[i])
|
||||
|
||||
def test_create_single_available_prefix(self):
|
||||
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
|
||||
|
||||
# Create four available prefixes with individual requests
|
||||
prefixes_to_be_created = [
|
||||
'192.0.2.0/30',
|
||||
'192.0.2.4/30',
|
||||
'192.0.2.8/30',
|
||||
'192.0.2.12/30',
|
||||
]
|
||||
for i in range(4):
|
||||
data = {
|
||||
'prefix_length': 30,
|
||||
'description': 'Test Prefix {}'.format(i + 1)
|
||||
}
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['prefix'], prefixes_to_be_created[i])
|
||||
self.assertEqual(response.data['description'], data['description'])
|
||||
|
||||
# Try to create one more prefix
|
||||
response = self.client.post(url, {'prefix_length': 30}, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('detail', response.data)
|
||||
|
||||
def test_create_multiple_available_prefixes(self):
|
||||
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
|
||||
|
||||
# Try to create five /30s (only four are available)
|
||||
data = [
|
||||
{'prefix_length': 30, 'description': 'Test Prefix 1'},
|
||||
{'prefix_length': 30, 'description': 'Test Prefix 2'},
|
||||
{'prefix_length': 30, 'description': 'Test Prefix 3'},
|
||||
{'prefix_length': 30, 'description': 'Test Prefix 4'},
|
||||
{'prefix_length': 30, 'description': 'Test Prefix 5'},
|
||||
]
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('detail', response.data)
|
||||
|
||||
# Verify that no prefixes were created (the entire /28 is still available)
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(response.data[0]['prefix'], '192.0.2.0/28')
|
||||
|
||||
# Create four /30s in a single request
|
||||
response = self.client.post(url, data[:4], format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(len(response.data), 4)
|
||||
|
||||
def test_list_available_ips(self):
|
||||
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
|
||||
@@ -380,12 +573,17 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(len(response.data), 6) # 8 - 2 because prefix.is_pool = False
|
||||
|
||||
# Create all six available IPs
|
||||
for i in range(6):
|
||||
def test_create_single_available_ip(self):
|
||||
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
|
||||
|
||||
# Create all four available IPs with individual requests
|
||||
for i in range(1, 5):
|
||||
data = {
|
||||
'description': 'Test IP {}'.format(i)
|
||||
}
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['description'], data['description'])
|
||||
|
||||
@@ -394,6 +592,27 @@ class PrefixTest(HttpStatusMixin, APITestCase):
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('detail', response.data)
|
||||
|
||||
def test_create_multiple_available_ips(self):
|
||||
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/29'), is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
|
||||
|
||||
# Try to create nine IPs (only eight are available)
|
||||
data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 10)] # 9 IPs
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertIn('detail', response.data)
|
||||
|
||||
# Verify that no IPs were created (eight are still available)
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(len(response.data), 8)
|
||||
|
||||
# Create all eight available IPs in a single request
|
||||
data = [{'description': 'Test IP {}'.format(i)} for i in range(1, 9)] # 8 IPs
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(len(response.data), 8)
|
||||
|
||||
|
||||
class IPAddressTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
@@ -430,7 +649,7 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:ipaddress-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(IPAddress.objects.count(), 4)
|
||||
@@ -438,6 +657,29 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(str(ipaddress4.address), data['address'])
|
||||
self.assertEqual(ipaddress4.vrf_id, data['vrf'])
|
||||
|
||||
def test_create_ipaddress_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'address': '192.168.0.4/24',
|
||||
},
|
||||
{
|
||||
'address': '192.168.0.5/24',
|
||||
},
|
||||
{
|
||||
'address': '192.168.0.6/24',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:ipaddress-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(IPAddress.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['address'], data[0]['address'])
|
||||
self.assertEqual(response.data[1]['address'], data[1]['address'])
|
||||
self.assertEqual(response.data[2]['address'], data[2]['address'])
|
||||
|
||||
def test_update_ipaddress(self):
|
||||
|
||||
data = {
|
||||
@@ -446,7 +688,7 @@ class IPAddressTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:ipaddress-detail', kwargs={'pk': self.ipaddress1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(IPAddress.objects.count(), 3)
|
||||
@@ -497,7 +739,7 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vlangroup-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VLANGroup.objects.count(), 4)
|
||||
@@ -505,6 +747,32 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(vlangroup4.name, data['name'])
|
||||
self.assertEqual(vlangroup4.slug, data['slug'])
|
||||
|
||||
def test_create_vlangroup_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test VLAN Group 4',
|
||||
'slug': 'test-vlan-group-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test VLAN Group 5',
|
||||
'slug': 'test-vlan-group-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test VLAN Group 6',
|
||||
'slug': 'test-vlan-group-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:vlangroup-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VLANGroup.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_vlangroup(self):
|
||||
|
||||
data = {
|
||||
@@ -513,7 +781,7 @@ class VLANGroupTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vlangroup-detail', kwargs={'pk': self.vlangroup1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(VLANGroup.objects.count(), 3)
|
||||
@@ -564,7 +832,7 @@ class VLANTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vlan-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VLAN.objects.count(), 4)
|
||||
@@ -572,6 +840,32 @@ class VLANTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(vlan4.vid, data['vid'])
|
||||
self.assertEqual(vlan4.name, data['name'])
|
||||
|
||||
def test_create_vlan_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'vid': 4,
|
||||
'name': 'Test VLAN 4',
|
||||
},
|
||||
{
|
||||
'vid': 5,
|
||||
'name': 'Test VLAN 5',
|
||||
},
|
||||
{
|
||||
'vid': 6,
|
||||
'name': 'Test VLAN 6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:vlan-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VLAN.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_vlan(self):
|
||||
|
||||
data = {
|
||||
@@ -580,7 +874,7 @@ class VLANTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(VLAN.objects.count(), 3)
|
||||
@@ -649,7 +943,7 @@ class ServiceTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:service-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Service.objects.count(), 4)
|
||||
@@ -659,6 +953,38 @@ class ServiceTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(service4.protocol, data['protocol'])
|
||||
self.assertEqual(service4.port, data['port'])
|
||||
|
||||
def test_create_service_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'device': self.device1.pk,
|
||||
'name': 'Test Service 4',
|
||||
'protocol': IP_PROTOCOL_TCP,
|
||||
'port': 4,
|
||||
},
|
||||
{
|
||||
'device': self.device1.pk,
|
||||
'name': 'Test Service 5',
|
||||
'protocol': IP_PROTOCOL_TCP,
|
||||
'port': 5,
|
||||
},
|
||||
{
|
||||
'device': self.device1.pk,
|
||||
'name': 'Test Service 6',
|
||||
'protocol': IP_PROTOCOL_TCP,
|
||||
'port': 6,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('ipam-api:service-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Service.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_service(self):
|
||||
|
||||
data = {
|
||||
@@ -669,7 +995,7 @@ class ServiceTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('ipam-api:service-detail', kwargs={'pk': self.service1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Service.objects.count(), 3)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
from django.contrib.messages import constants as messages
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
@@ -12,8 +14,15 @@ except ImportError:
|
||||
"Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
|
||||
)
|
||||
|
||||
# Raise a deprecation warning for Python 2.x
|
||||
if sys.version_info[0] < 3:
|
||||
warnings.warn(
|
||||
"Support for Python 2 will be removed in NetBox v2.5. Please consider migration to Python 3 at your earliest "
|
||||
"opportunity. Guidance is available in the documentation at http://netbox.readthedocs.io/.",
|
||||
DeprecationWarning
|
||||
)
|
||||
|
||||
VERSION = '2.2.11-dev'
|
||||
VERSION = '2.3.0-dev'
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@@ -125,6 +134,7 @@ INSTALLED_APPS = (
|
||||
'mptt',
|
||||
'rest_framework',
|
||||
'rest_framework_swagger',
|
||||
'timezone_field',
|
||||
'circuits',
|
||||
'dcim',
|
||||
'ipam',
|
||||
|
||||
@@ -121,7 +121,7 @@ input[name="pk"] {
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table > tbody > tr > th.pk, .table > tbody > tr > td.pk {
|
||||
th.pk, td.pk {
|
||||
padding-bottom: 6px;
|
||||
padding-top: 10px;
|
||||
width: 30px;
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
I hope you love Font Awesome. If you've found it useful, please do me a favor and check out my latest project,
|
||||
Fort Awesome (https://fortawesome.com). It makes it easy to put the perfect icons on your website. Choose from our awesome,
|
||||
comprehensive icon sets or copy and paste your own.
|
||||
|
||||
Please. Check it out.
|
||||
|
||||
-Dave Gandy
|
||||
@@ -1,14 +1,24 @@
|
||||
$(document).ready(function() {
|
||||
|
||||
// "Toggle all" checkbox (table header)
|
||||
$('#toggle_all').click(function() {
|
||||
$('td input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
|
||||
// "Toggle" checkbox for object lists (PK column)
|
||||
$('input:checkbox.toggle').click(function() {
|
||||
$(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
|
||||
|
||||
// Show the "select all" box if present
|
||||
if ($(this).is(':checked')) {
|
||||
$('#select_all_box').removeClass('hidden');
|
||||
} else {
|
||||
$('#select_all').prop('checked', false);
|
||||
}
|
||||
});
|
||||
|
||||
// Uncheck the "toggle" and "select all" checkboxes if an item is unchecked
|
||||
$('input:checkbox[name=pk]').click(function (event) {
|
||||
if (!$(this).attr('checked')) {
|
||||
$('input:checkbox.toggle, #select_all').prop('checked', false);
|
||||
}
|
||||
});
|
||||
|
||||
// Enable hidden buttons when "select all" is checked
|
||||
$('#select_all').click(function() {
|
||||
if ($(this).is(':checked')) {
|
||||
@@ -17,21 +27,6 @@ $(document).ready(function() {
|
||||
$('#select_all_box').find('button').prop('disabled', 'disabled');
|
||||
}
|
||||
});
|
||||
// Uncheck the "toggle all" checkbox if an item is unchecked
|
||||
$('input:checkbox[name=pk]').click(function (event) {
|
||||
if (!$(this).attr('checked')) {
|
||||
$('#select_all, #toggle_all').prop('checked', false);
|
||||
}
|
||||
});
|
||||
|
||||
// Simple "Toggle all" button (panel)
|
||||
$('button.toggle').click(function() {
|
||||
var selected = $(this).attr('selected');
|
||||
$(this).closest('form').find('input:checkbox[name=pk]').prop('checked', !selected);
|
||||
$(this).attr('selected', !selected);
|
||||
$(this).children('span').toggleClass('glyphicon-unchecked glyphicon-check');
|
||||
return false;
|
||||
});
|
||||
|
||||
// Slugify
|
||||
function slugify(s, num_chars) {
|
||||
@@ -71,59 +66,65 @@ $(document).ready(function() {
|
||||
$('select[filter-for]').change(function() {
|
||||
|
||||
// Resolve child field by ID specified in parent
|
||||
var child_name = $(this).attr('filter-for');
|
||||
var child_field = $('#id_' + child_name);
|
||||
var child_selected = child_field.val();
|
||||
var child_names = $(this).attr('filter-for');
|
||||
var parent = this;
|
||||
|
||||
// Wipe out any existing options within the child field and create a default option
|
||||
child_field.empty();
|
||||
if (!child_field.attr('multiple')) {
|
||||
child_field.append($("<option></option>").attr("value", "").text("---------"));
|
||||
}
|
||||
// allow more than one child
|
||||
$.each(child_names.split(" "), function(_, child_name){
|
||||
|
||||
if ($(this).val() || $(this).attr('nullable') == 'true') {
|
||||
var api_url = child_field.attr('api-url') + '&limit=1000';
|
||||
var disabled_indicator = child_field.attr('disabled-indicator');
|
||||
var initial_value = child_field.attr('initial');
|
||||
var display_field = child_field.attr('display-field') || 'name';
|
||||
var child_field = $('#id_' + child_name);
|
||||
var child_selected = child_field.val();
|
||||
|
||||
// Determine the filter fields needed to make an API call
|
||||
var filter_regex = /\{\{([a-z_]+)\}\}/g;
|
||||
var match;
|
||||
var rendered_url = api_url;
|
||||
while (match = filter_regex.exec(api_url)) {
|
||||
var filter_field = $('#id_' + match[1]);
|
||||
if (filter_field.val()) {
|
||||
rendered_url = rendered_url.replace(match[0], filter_field.val());
|
||||
} else if (filter_field.attr('nullable') == 'true') {
|
||||
rendered_url = rendered_url.replace(match[0], '0');
|
||||
}
|
||||
// Wipe out any existing options within the child field and create a default option
|
||||
child_field.empty();
|
||||
if (!child_field.attr('multiple')) {
|
||||
child_field.append($("<option></option>").attr("value", "").text("---------"));
|
||||
}
|
||||
|
||||
// If all URL variables have been replaced, make the API call
|
||||
if (rendered_url.search('{{') < 0) {
|
||||
console.log(child_name + ": Fetching " + rendered_url);
|
||||
$.ajax({
|
||||
url: rendered_url,
|
||||
dataType: 'json',
|
||||
success: function(response, status) {
|
||||
$.each(response.results, function(index, choice) {
|
||||
var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
|
||||
if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
|
||||
option.attr("disabled", "disabled");
|
||||
} else if (choice.id == child_selected) {
|
||||
option.attr("selected", "selected");
|
||||
}
|
||||
child_field.append(option);
|
||||
});
|
||||
if ($(parent).val() || $(parent).attr('nullable') == 'true') {
|
||||
var api_url = child_field.attr('api-url') + '&limit=1000';
|
||||
var disabled_indicator = child_field.attr('disabled-indicator');
|
||||
var initial_value = child_field.attr('initial');
|
||||
var display_field = child_field.attr('display-field') || 'name';
|
||||
|
||||
// Determine the filter fields needed to make an API call
|
||||
var filter_regex = /\{\{([a-z_]+)\}\}/g;
|
||||
var match;
|
||||
var rendered_url = api_url;
|
||||
while (match = filter_regex.exec(api_url)) {
|
||||
var filter_field = $('#id_' + match[1]);
|
||||
if (filter_field.val()) {
|
||||
rendered_url = rendered_url.replace(match[0], filter_field.val());
|
||||
} else if (filter_field.attr('nullable') == 'true') {
|
||||
rendered_url = rendered_url.replace(match[0], '0');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// If all URL variables have been replaced, make the API call
|
||||
if (rendered_url.search('{{') < 0) {
|
||||
console.log(child_name + ": Fetching " + rendered_url);
|
||||
$.ajax({
|
||||
url: rendered_url,
|
||||
dataType: 'json',
|
||||
success: function(response, status) {
|
||||
$.each(response.results, function(index, choice) {
|
||||
var option = $("<option></option>").attr("value", choice.id).text(choice[display_field]);
|
||||
if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) {
|
||||
option.attr("disabled", "disabled");
|
||||
} else if (choice.id == child_selected) {
|
||||
option.attr("selected", "selected");
|
||||
}
|
||||
child_field.append(option);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Trigger change event in case the child field is the parent of another field
|
||||
child_field.change();
|
||||
// Trigger change event in case the child field is the parent of another field
|
||||
child_field.change();
|
||||
});
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
4
netbox/project-static/js/jquery-3.2.1.min.js
vendored
4
netbox/project-static/js/jquery-3.2.1.min.js
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/js/jquery-3.3.1.min.js
vendored
Normal file
2
netbox/project-static/js/jquery-3.3.1.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -45,13 +45,20 @@ class WritableSecretSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Secret
|
||||
fields = ['id', 'device', 'role', 'name', 'plaintext']
|
||||
fields = ['id', 'device', 'role', 'name', 'plaintext', 'hash', 'created', 'last_updated']
|
||||
validators = []
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
# Encrypt plaintext data using the master key provided from the view context
|
||||
if data.get('plaintext'):
|
||||
s = Secret(plaintext=data['plaintext'])
|
||||
s.encrypt(self.context['master_key'])
|
||||
data['ciphertext'] = s.ciphertext
|
||||
data['hash'] = s.hash
|
||||
|
||||
# Validate uniqueness of name if one has been provided.
|
||||
if data.get('name', None):
|
||||
if data.get('name'):
|
||||
validator = UniqueTogetherValidator(queryset=Secret.objects.all(), fields=('device', 'role', 'name'))
|
||||
validator.set_context(self)
|
||||
validator(data)
|
||||
|
||||
@@ -7,12 +7,12 @@ from django.http import HttpResponseBadRequest
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet, ViewSet
|
||||
from rest_framework.viewsets import ViewSet
|
||||
|
||||
from secrets import filters
|
||||
from secrets.exceptions import InvalidKey
|
||||
from secrets.models import Secret, SecretRole, SessionKey, UserKey
|
||||
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
|
||||
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||
from . import serializers
|
||||
|
||||
ERR_USERKEY_MISSING = "No UserKey found for the current user."
|
||||
@@ -44,7 +44,7 @@ class SecretRoleViewSet(ModelViewSet):
|
||||
# Secrets
|
||||
#
|
||||
|
||||
class SecretViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class SecretViewSet(ModelViewSet):
|
||||
queryset = Secret.objects.select_related(
|
||||
'device__primary_ip4', 'device__primary_ip6', 'role',
|
||||
).prefetch_related(
|
||||
@@ -56,17 +56,13 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
|
||||
master_key = None
|
||||
|
||||
def _get_encrypted_fields(self, serializer):
|
||||
"""
|
||||
Since we can't call encrypt() on the serializer like we can on the Secret model, we need to calculate the
|
||||
ciphertext and hash values by encrypting a dummy copy. These can be passed to the serializer's save() method.
|
||||
"""
|
||||
s = Secret(plaintext=serializer.validated_data['plaintext'])
|
||||
s.encrypt(self.master_key)
|
||||
return ({
|
||||
'ciphertext': s.ciphertext,
|
||||
'hash': s.hash,
|
||||
})
|
||||
def get_serializer_context(self):
|
||||
|
||||
# Make the master key available to the serializer for encrypting plaintext values
|
||||
context = super(SecretViewSet, self).get_serializer_context()
|
||||
context['master_key'] = self.master_key
|
||||
|
||||
return context
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
|
||||
@@ -128,12 +124,6 @@ class SecretViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(**self._get_encrypted_fields(serializer))
|
||||
|
||||
def perform_update(self, serializer):
|
||||
serializer.save(**self._get_encrypted_fields(serializer))
|
||||
|
||||
|
||||
class GetSessionKeyViewSet(ViewSet):
|
||||
"""
|
||||
|
||||
@@ -81,12 +81,12 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
|
||||
def test_create_secretrole(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test SecretRole 4',
|
||||
'slug': 'test-secretrole-4',
|
||||
'name': 'Test Secret Role 4',
|
||||
'slug': 'test-secret-role-4',
|
||||
}
|
||||
|
||||
url = reverse('secrets-api:secretrole-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(SecretRole.objects.count(), 4)
|
||||
@@ -94,6 +94,32 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(secretrole4.name, data['name'])
|
||||
self.assertEqual(secretrole4.slug, data['slug'])
|
||||
|
||||
def test_create_secretrole_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Secret Role 4',
|
||||
'slug': 'test-secret-role-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test Secret Role 5',
|
||||
'slug': 'test-secret-role-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test Secret Role 6',
|
||||
'slug': 'test-secret-role-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('secrets-api:secretrole-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(SecretRole.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_secretrole(self):
|
||||
|
||||
data = {
|
||||
@@ -102,7 +128,7 @@ class SecretRoleTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('secrets-api:secretrole-detail', kwargs={'pk': self.secretrole1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(SecretRole.objects.count(), 3)
|
||||
@@ -138,9 +164,9 @@ class SecretTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
self.plaintext = {
|
||||
'secret1': 'Secret#1Plaintext',
|
||||
'secret2': 'Secret#2Plaintext',
|
||||
'secret3': 'Secret#3Plaintext',
|
||||
'secret1': 'Secret #1 Plaintext',
|
||||
'secret2': 'Secret #2 Plaintext',
|
||||
'secret3': 'Secret #3 Plaintext',
|
||||
}
|
||||
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
@@ -187,11 +213,12 @@ class SecretTest(HttpStatusMixin, APITestCase):
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'role': self.secretrole1.pk,
|
||||
'plaintext': 'Secret#4Plaintext',
|
||||
'name': 'Test Secret 4',
|
||||
'plaintext': 'Secret #4 Plaintext',
|
||||
}
|
||||
|
||||
url = reverse('secrets-api:secret-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(response.data['plaintext'], data['plaintext'])
|
||||
@@ -201,6 +228,38 @@ class SecretTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(secret4.role_id, data['role'])
|
||||
self.assertEqual(secret4.plaintext, data['plaintext'])
|
||||
|
||||
def test_create_secret_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'role': self.secretrole1.pk,
|
||||
'name': 'Test Secret 4',
|
||||
'plaintext': 'Secret #4 Plaintext',
|
||||
},
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'role': self.secretrole1.pk,
|
||||
'name': 'Test Secret 5',
|
||||
'plaintext': 'Secret #5 Plaintext',
|
||||
},
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'role': self.secretrole1.pk,
|
||||
'name': 'Test Secret 6',
|
||||
'plaintext': 'Secret #6 Plaintext',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('secrets-api:secret-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Secret.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['plaintext'], data[0]['plaintext'])
|
||||
self.assertEqual(response.data[1]['plaintext'], data[1]['plaintext'])
|
||||
self.assertEqual(response.data[2]['plaintext'], data[2]['plaintext'])
|
||||
|
||||
def test_update_secret(self):
|
||||
|
||||
data = {
|
||||
@@ -210,7 +269,7 @@ class SecretTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['plaintext'], data['plaintext'])
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="{% static 'js/jquery-3.2.1.min.js' %}"></script>
|
||||
<script src="{% static 'js/jquery-3.3.1.min.js' %}"></script>
|
||||
<script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"></script>
|
||||
<script src="{% static 'bootstrap-3.3.7-dist/js/bootstrap.min.js' %}"></script>
|
||||
<script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
<strong>Circuit</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td>
|
||||
<span class="label label-{{ circuit.get_status_class }}">{{ circuit.get_status_display }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Provider</td>
|
||||
<td>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
{% render_field form.provider %}
|
||||
{% render_field form.cid %}
|
||||
{% render_field form.type %}
|
||||
{% render_field form.status %}
|
||||
{% render_field form.install_date %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label" for="id_commit_rate">{{ form.commit_rate.label }}</label>
|
||||
|
||||
55
netbox/templates/dcim/bulk_rename.html
Normal file
55
netbox/templates/dcim/bulk_rename.html
Normal file
@@ -0,0 +1,55 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% block title %}Renaming {{ selected_objects|length }} {{ obj_type_plural|bettertitle }}{% endblock %}</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-7">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Current Name</th>
|
||||
<th>New Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for obj in selected_objects %}
|
||||
<tr{% if obj.new_name and obj.name != obj.new_name %} class="success"{% endif %}>
|
||||
<td>{{ obj.name }}</td>
|
||||
<td>{{ obj.new_name }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<form action="" method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Errors</strong></div>
|
||||
<div class="panel-body">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Rename</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_form form %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group text-right">
|
||||
<div class="col-md-12">
|
||||
<button type="submit" name="_preview" class="btn btn-primary">Preview</button>
|
||||
{% if '_preview' in request.POST and not form.errors %}
|
||||
<button type="submit" name="_apply" class="btn btn-primary">Apply</button>
|
||||
{% endif %}
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -98,6 +98,46 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% if vc_members %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Virtual Chassis</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Position</th>
|
||||
<th>Master</th>
|
||||
<th>Priority</th>
|
||||
</tr>
|
||||
{% for vc_member in vc_members %}
|
||||
<tr{% if vc_member == device %} class="info"{% endif %}>
|
||||
<td>
|
||||
<a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
|
||||
</td>
|
||||
<td><span class="badge badge-default">{{ vc_member.vc_position }}</span></td>
|
||||
<td>{% if device.virtual_chassis.master == vc_member %}<i class="fa fa-check"></i>{% endif %}</td>
|
||||
<td>{{ vc_member.vc_priority|default:"" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<div class="panel-footer text-right">
|
||||
{% if perms.dcim.change_virtualchassis %}
|
||||
<a href="{% url 'dcim:virtualchassis_add_member' pk=device.virtual_chassis.pk %}?site={{ device.site.pk }}&rack={{ device.rack.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Member
|
||||
</a>
|
||||
<a href="{% url 'dcim:virtualchassis_edit' pk=device.virtual_chassis.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Virtual Chassis
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.dcim.delete_virtualchassis %}
|
||||
<a href="{% url 'dcim:virtualchassis_delete' pk=device.virtual_chassis.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Virtual Chassis
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Management</strong>
|
||||
@@ -339,45 +379,48 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Device Bays</strong>
|
||||
<div class="pull-right">
|
||||
{% if perms.dcim.change_devicebay and device_bays|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_devicebay and device_bays|length > 10 %}
|
||||
</div>
|
||||
<table class="table table-hover table-headings panel-body component-list">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %}
|
||||
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th colspan="2">Installed Device</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for devicebay in device_bays %}
|
||||
{% include 'dcim/inc/devicebay.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">— No device bays defined —</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="panel-footer">
|
||||
{% if device_bays and perms.dcim.change_devicebay %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if device_bays and perms.dcim.delete_devicebay %}
|
||||
<button type="submit" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_devicebay %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table class="table table-hover panel-body component-list">
|
||||
{% for devicebay in device_bays %}
|
||||
{% include 'dcim/inc/devicebay.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4">No device bays defined</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if perms.dcim.add_devicebay or perms.dcim.delete_devicebay %}
|
||||
<div class="panel-footer">
|
||||
{% if device_bays and perms.dcim.delete_devicebay %}
|
||||
<button type="submit" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_devicebay %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:devicebay_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add device bays
|
||||
</a>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if perms.dcim.delete_devicebay %}
|
||||
</form>
|
||||
@@ -396,66 +439,61 @@
|
||||
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
|
||||
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
|
||||
</button>
|
||||
{% if perms.dcim.change_interface and interfaces|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interface and interfaces|length > 10 %}
|
||||
</div>
|
||||
</div>
|
||||
<table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
|
||||
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th>LAG</th>
|
||||
<th>Description</th>
|
||||
<th>MTU</th>
|
||||
<th>MAC Address</th>
|
||||
<th colspan="2">Connection</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for iface in interfaces %}
|
||||
{% include 'dcim/inc/interface.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="9" class="text-center text-muted">— No interfaces defined —</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="panel-footer">
|
||||
{% if interfaces and perms.dcim.change_interface %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if interfaces and perms.dcim.delete_interfaceconnection %}
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if interfaces and perms.dcim.delete_interface %}
|
||||
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interface %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table id="interfaces_table" class="table table-hover panel-body component-list">
|
||||
<tr class="table-headings">
|
||||
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
|
||||
<th></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th>LAG</th>
|
||||
<th>Description</th>
|
||||
<th>MTU</th>
|
||||
<th>MAC Address</th>
|
||||
<th colspan="2">Connection</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for iface in interfaces %}
|
||||
{% include 'dcim/inc/interface.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="8">No interfaces defined</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if perms.dcim.add_interface or perms.dcim.delete_interface %}
|
||||
<div class="panel-footer">
|
||||
{% if interfaces and perms.dcim.change_interface %}
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:interface_bulk_edit' pk=device.pk %}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if interfaces and perms.dcim.delete_interfaceconnection %}
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:interface_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if interfaces and perms.dcim.delete_interface %}
|
||||
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interface %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:interface_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
|
||||
</a>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if perms.dcim.delete_interface %}
|
||||
</form>
|
||||
@@ -469,58 +507,51 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Console Server Ports</strong>
|
||||
<div class="pull-right">
|
||||
{% if perms.dcim.change_consoleserverport and cs_ports|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %}
|
||||
</div>
|
||||
<table class="table table-hover table-headings panel-body component-list">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
|
||||
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th colspan="2">Connection</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for csp in cs_ports %}
|
||||
{% include 'dcim/inc/consoleserverport.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">— No console server ports defined —</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="panel-footer">
|
||||
{% if cs_ports and perms.dcim.change_consoleport %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if cs_ports and perms.dcim.delete_consoleserverport %}
|
||||
<button type="submit" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_consoleserverport %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<table class="table table-hover panel-body component-list">
|
||||
<tr class="table-headings">
|
||||
{% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %}
|
||||
<th></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th colspan="2">Connection</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for csp in cs_ports %}
|
||||
{% include 'dcim/inc/consoleserverport.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4">No console server ports defined</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if perms.dcim.add_consoleserverport or perms.dcim.delete_consoleserverport %}
|
||||
<div class="panel-footer">
|
||||
{% if cs_ports and perms.dcim.change_consoleport %}
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if cs_ports and perms.dcim.delete_consoleserverport %}
|
||||
<button type="submit" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_consoleserverport %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:consoleserverport_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console server ports
|
||||
</a>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if perms.dcim.delete_consoleserverport %}
|
||||
</form>
|
||||
@@ -534,58 +565,51 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Power Outlets</strong>
|
||||
<div class="pull-right">
|
||||
{% if perms.dcim.change_poweroutlet and power_outlets|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %}
|
||||
</div>
|
||||
<table class="table table-hover table-headings panel-body component-list">
|
||||
<thead>
|
||||
<tr>
|
||||
{% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
|
||||
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th colspan="2">Connection</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for po in power_outlets %}
|
||||
{% include 'dcim/inc/poweroutlet.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted">— No power outlets defined —</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="panel-footer">
|
||||
{% if power_outlets and perms.dcim.change_powerport %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if power_outlets and perms.dcim.delete_poweroutlet %}
|
||||
<button type="submit" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_poweroutlet %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<table class="table table-hover panel-body component-list">
|
||||
<tr class="table-headings">
|
||||
{% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %}
|
||||
<th></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th colspan="2">Connection</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for po in power_outlets %}
|
||||
{% include 'dcim/inc/poweroutlet.html' %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4">No power outlets defined</td>
|
||||
</tr> text-nowrap
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% if perms.dcim.add_poweroutlet or perms.dcim.delete_poweroutlet %}
|
||||
<div class="panel-footer">
|
||||
{% if power_outlets and perms.dcim.change_powerport %}
|
||||
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if power_outlets and perms.dcim.delete_poweroutlet %}
|
||||
<button type="submit" class="btn btn-danger btn-xs">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_poweroutlet %}
|
||||
<div class="pull-right">
|
||||
<a href="{% url 'dcim:poweroutlet_add' pk=device.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add power outlets
|
||||
</a>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if perms.dcim.delete_poweroutlet %}
|
||||
</form>
|
||||
|
||||
@@ -16,4 +16,9 @@
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_virtualchassis %}
|
||||
<button type="submit" name="_edit" formaction="{% url 'dcim:virtualchassis_add' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-primary btn-sm">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Create Virtual Chassis
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</td>
|
||||
{% endif %}
|
||||
<td>
|
||||
<i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay }}
|
||||
<i class="fa fa-fw fa-{% if devicebay.installed_device %}dot-circle-o{% else %}circle-o{% endif %}"></i> {{ devicebay.name }}
|
||||
</td>
|
||||
{% if devicebay.installed_device %}
|
||||
<td>
|
||||
@@ -19,7 +19,7 @@
|
||||
<span class="text-muted">Vacant</span>
|
||||
</td>
|
||||
{% endif %}
|
||||
<td colspan="2" class="text-right">
|
||||
<td class="text-right">
|
||||
{% if perms.dcim.change_devicebay %}
|
||||
{% if devicebay.installed_device %}
|
||||
<a href="{% url 'dcim:devicebay_depopulate' pk=devicebay.pk %}" class="btn btn-danger btn-xs">
|
||||
|
||||
@@ -4,19 +4,6 @@
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>{{ title }}</strong>
|
||||
<div class="pull-right">
|
||||
{% if table.rows|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if table.rows|length > 10 %}
|
||||
<a href="{% url add_url pk=devicetype.pk %}{{ add_url_extra }}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
|
||||
Add {{ title }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% include 'responsive_table.html' %}
|
||||
<div class="panel-footer">
|
||||
|
||||
@@ -98,18 +98,18 @@
|
||||
<i class="fa fa-plug" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Disconnect">
|
||||
<a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Disconnect">
|
||||
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% elif iface.circuit_termination and perms.circuits.change_circuittermination %}
|
||||
<button class="btn btn-warning btn-xs interface-toggle connected" disabled="disabled" title="Circuits cannot be marked as planned or connected">
|
||||
<i class="glyphicon glyphicon-ban-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
<a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}" class="btn btn-danger btn-xs" title="Edit circuit termination">
|
||||
<a href="{% url 'circuits:circuittermination_edit' pk=iface.circuit_termination.pk %}&return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs" title="Edit circuit termination">
|
||||
<i class="glyphicon glyphicon-resize-full" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
|
||||
<a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface_a={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
|
||||
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
28
netbox/templates/dcim/interface_edit.html
Normal file
28
netbox/templates/dcim/interface_edit.html
Normal file
@@ -0,0 +1,28 @@
|
||||
{% extends 'utilities/obj_edit.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Interface</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.name %}
|
||||
{% render_field form.form_factor %}
|
||||
{% render_field form.enabled %}
|
||||
{% render_field form.lag %}
|
||||
{% render_field form.mac_address %}
|
||||
{% render_field form.mtu %}
|
||||
{% render_field form.mgmt_only %}
|
||||
{% render_field form.description %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>802.1Q Encapsulation</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.mode %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.vlan_group %}
|
||||
{% render_field form.untagged_vlan %}
|
||||
{% render_field form.tagged_vlans %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -225,12 +225,20 @@
|
||||
<table class="table table-hover panel-body">
|
||||
<tr>
|
||||
<th>Units</th>
|
||||
<th>Tenant</th>
|
||||
<th>Description</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for resv in reservations %}
|
||||
<tr>
|
||||
<td>{{ resv.unit_list }}</td>
|
||||
<td>
|
||||
{% if resv.tenant %}
|
||||
<a href="{{ resv.tenant.get_absolute_url }}">{{ resv.tenant }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ resv.description }}<br />
|
||||
<small>{{ resv.user }} · {{ resv.created }}</small>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load static from staticfiles %}
|
||||
{% load tz %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block content %}
|
||||
@@ -57,6 +58,12 @@
|
||||
<strong>Site</strong>
|
||||
</div>
|
||||
<table class="table table-hover panel-body attr-table">
|
||||
<tr>
|
||||
<td>Status</td>
|
||||
<td>
|
||||
<span class="label label-{{ site.get_status_class }}">{{ site.get_status_display }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Region</td>
|
||||
<td>
|
||||
@@ -105,6 +112,27 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Time Zone</td>
|
||||
<td>
|
||||
{% if site.time_zone %}
|
||||
{{ site.time_zone }} (UTC {{ site.time_zone|tzoffset }})<br />
|
||||
<small class="text-muted">Site time: {% timezone site.time_zone %}{% now "SHORT_DATETIME_FORMAT" %}{% endtimezone %}</small>
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Description</td>
|
||||
<td>
|
||||
{% if site.description %}
|
||||
{{ site.description }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
<div class="panel-body">
|
||||
{% render_field form.name %}
|
||||
{% render_field form.slug %}
|
||||
{% render_field form.status %}
|
||||
{% render_field form.region %}
|
||||
{% render_field form.facility %}
|
||||
{% render_field form.asn %}
|
||||
{% render_field form.time_zone %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
|
||||
35
netbox/templates/dcim/virtualchassis_add_member.html
Normal file
35
netbox/templates/dcim/virtualchassis_add_member.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block content %}
|
||||
<form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
<h3>{% block title %}Add New Member to Virtual Chassis {{ virtual_chassis }}{% endblock %}</h3>
|
||||
{% if membership_form.non_field_errors %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Errors</strong></div>
|
||||
<div class="panel-body">
|
||||
{{ membership_form.non_field_errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Add New Member</strong></div>
|
||||
<div class="table panel-body">
|
||||
{% render_form member_select_form %}
|
||||
{% render_form membership_form %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3 text-right">
|
||||
<button type="submit" name="_save" class="btn btn-primary">Save</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Add Another</button>
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
103
netbox/templates/dcim/virtualchassis_edit.html
Normal file
103
netbox/templates/dcim/virtualchassis_edit.html
Normal file
@@ -0,0 +1,103 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block content %}
|
||||
<form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
{{ pk_form.pk }}
|
||||
{{ formset.management_form }}
|
||||
<div class="row">
|
||||
<div class="col-md-8 col-md-offset-2">
|
||||
<h3>{% block title %}{% if vc_form.instance %}Editing {{ vc_form.instance }}{% else %}New Virtual Chassis{% endif %}{% endblock %}</h3>
|
||||
{% if vc_form.non_field_errors %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Errors</strong></div>
|
||||
<div class="panel-body">
|
||||
{{ vc_form.non_field_errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Virtual Chassis</strong></div>
|
||||
<div class="table panel-body">
|
||||
{% render_form vc_form %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Members</strong></div>
|
||||
<table class="table panel-body">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>ID</th>
|
||||
<th>Rack/Unit</th>
|
||||
<th>Serial</th>
|
||||
<th>Position</th>
|
||||
<th>Priority</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for form in formset %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
{% with device=form.instance virtual_chassis=vc_form.instance %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ device.get_absolute_url }}">{{ device }}</a>
|
||||
</td>
|
||||
<td>{{ device.pk }}</td>
|
||||
<td>
|
||||
{% if device.rack %}
|
||||
{{ device.rack }} / {{ device.position }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if device.serial %}
|
||||
{{ device.serial }}
|
||||
{% else %}
|
||||
<span class="text-muted">N/A</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ form.vc_position }}
|
||||
{% if form.vc_position.errors %}
|
||||
<br /><small class="text-danger">{{ form.vc_position.errors.0 }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{{ form.vc_priority }}
|
||||
{% if form.vc_priority.errors %}
|
||||
<br /><small class="text-danger">{{ form.vc_priority.errors.0 }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if virtual_chassis.pk %}
|
||||
<a href="{% url 'dcim:virtualchassis_remove_member' pk=device.pk %}?return_url={% url 'dcim:virtualchassis_edit' pk=virtual_chassis.pk %}" class="btn btn-danger btn-xs{% if virtual_chassis.master == device %} disabled{% endif %}">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-8 col-md-offset-2 text-right">
|
||||
{% if vc_form.instance.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">Update</button>
|
||||
{% else %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
{% endif %}
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
14
netbox/templates/dcim/virtualchassis_list.html
Normal file
14
netbox/templates/dcim/virtualchassis_list.html
Normal file
@@ -0,0 +1,14 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% block title %}Virtual Chassis{% endblock %}</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
{% include 'utilities/obj_table.html' %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
8
netbox/templates/dcim/virtualchassis_remove_member.html
Normal file
8
netbox/templates/dcim/virtualchassis_remove_member.html
Normal file
@@ -0,0 +1,8 @@
|
||||
{% extends 'utilities/confirmation_form.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Remove Virtual Chassis Member?{% endblock %}
|
||||
|
||||
{% block message %}
|
||||
<p>Are you sure you want to remove <strong>{{ device }}</strong> from virtual chassis {{ device.virtual_chassis }}?</p>
|
||||
{% endblock %}
|
||||
@@ -104,7 +104,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/manufacturers/,/dcim/platforms/,-connections/,/dcim/inventory-items/' %} active{% endif %}">
|
||||
<li class="dropdown{% if request.path|contains:'/dcim/device,/dcim/virtual-chassis,/dcim/manufacturers/,/dcim/platforms/,-connections/,/dcim/inventory-items/' %} active{% endif %}">
|
||||
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Devices <span class="caret"></span></a>
|
||||
<ul class="dropdown-menu">
|
||||
<li class="dropdown-header">Devices</li>
|
||||
@@ -135,6 +135,9 @@
|
||||
{% endif %}
|
||||
<a href="{% url 'dcim:platform_list' %}">Platforms</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'dcim:virtualchassis_list' %}">Virtual Chassis</a>
|
||||
</li>
|
||||
<li class="divider"></li>
|
||||
<li class="dropdown-header">Device Types</li>
|
||||
<li>
|
||||
|
||||
@@ -100,6 +100,10 @@
|
||||
<h2><a href="{% url 'dcim:rack_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.rack_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.rack_count }}</a></h2>
|
||||
<p>Racks</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-center">
|
||||
<h2><a href="{% url 'dcim:rackreservation_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.rackreservation_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.rackreservation_count }}</a></h2>
|
||||
<p>Rack reservations</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-center">
|
||||
<h2><a href="{% url 'dcim:device_list' %}?tenant={{ tenant.slug }}" class="btn {% if stats.device_count %}btn-primary{% else %}btn-default{% endif %} btn-lg">{{ stats.device_count }}</a></h2>
|
||||
<p>Devices</p>
|
||||
|
||||
@@ -235,42 +235,39 @@
|
||||
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
|
||||
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
|
||||
</button>
|
||||
{% if perms.dcim.change_interface and interfaces|length > 1 %}
|
||||
<button class="btn btn-default btn-xs toggle">
|
||||
<span class="glyphicon glyphicon-unchecked" aria-hidden="true"></span> Select all
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interface and interfaces|length > 10 %}
|
||||
<a href="{% url 'virtualization:interface_add' pk=vm.pk %}" class="btn btn-primary btn-xs">
|
||||
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add interfaces
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<table id="interfaces_table" class="table table-hover panel-body component-list">
|
||||
<tr class="table-headings">
|
||||
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
|
||||
<th></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th>LAG</th>
|
||||
<th>Description</th>
|
||||
<th>MTU</th>
|
||||
<th>MAC Address</th>
|
||||
<th colspan="2">Connection</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for iface in interfaces %}
|
||||
{% include 'dcim/inc/interface.html' with device=vm %}
|
||||
{% empty %}
|
||||
<table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
|
||||
<thead>
|
||||
<tr>
|
||||
<td colspan="6">No interfaces defined</td>
|
||||
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
|
||||
<th class="pk"><input type="checkbox" class="toggle" title="Toggle all" /></th>
|
||||
{% endif %}
|
||||
<th>Name</th>
|
||||
<th>LAG</th>
|
||||
<th>Description</th>
|
||||
<th>MTU</th>
|
||||
<th>MAC Address</th>
|
||||
<th colspan="2">Connection</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for iface in interfaces %}
|
||||
{% include 'dcim/inc/interface.html' with device=vm %}
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="9" class="text-center text-muted">— No interfaces defined —</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if perms.dcim.add_interface or perms.dcim.delete_interface %}
|
||||
<div class="panel-footer">
|
||||
{% if interfaces and perms.dcim.change_interface %}
|
||||
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ vm.get_absolute_url }}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
|
||||
</button>
|
||||
<button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=vm.pk %}" class="btn btn-warning btn-xs">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
|
||||
</button>
|
||||
|
||||
@@ -35,7 +35,7 @@ class TenantSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Tenant
|
||||
fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields']
|
||||
fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated']
|
||||
|
||||
|
||||
class NestedTenantSerializer(serializers.ModelSerializer):
|
||||
@@ -50,4 +50,4 @@ class WritableTenantSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Tenant
|
||||
fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields']
|
||||
fields = ['id', 'name', 'slug', 'group', 'description', 'comments', 'custom_fields', 'created', 'last_updated']
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from tenancy import filters
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
|
||||
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -31,7 +29,7 @@ class TenantGroupViewSet(ModelViewSet):
|
||||
# Tenants
|
||||
#
|
||||
|
||||
class TenantViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class TenantViewSet(CustomFieldModelViewSet):
|
||||
queryset = Tenant.objects.select_related('group')
|
||||
serializer_class = serializers.TenantSerializer
|
||||
write_serializer_class = serializers.WritableTenantSerializer
|
||||
|
||||
@@ -44,7 +44,7 @@ class TenantGroupTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('tenancy-api:tenantgroup-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(TenantGroup.objects.count(), 4)
|
||||
@@ -52,6 +52,32 @@ class TenantGroupTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(tenantgroup4.name, data['name'])
|
||||
self.assertEqual(tenantgroup4.slug, data['slug'])
|
||||
|
||||
def test_create_tenantgroup_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Tenant Group 4',
|
||||
'slug': 'test-tenant-group-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test Tenant Group 5',
|
||||
'slug': 'test-tenant-group-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test Tenant Group 6',
|
||||
'slug': 'test-tenant-group-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('tenancy-api:tenantgroup-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(TenantGroup.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_tenantgroup(self):
|
||||
|
||||
data = {
|
||||
@@ -60,7 +86,7 @@ class TenantGroupTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('tenancy-api:tenantgroup-detail', kwargs={'pk': self.tenantgroup1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(TenantGroup.objects.count(), 3)
|
||||
@@ -114,7 +140,7 @@ class TenantTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('tenancy-api:tenant-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Tenant.objects.count(), 4)
|
||||
@@ -123,6 +149,32 @@ class TenantTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(tenant4.slug, data['slug'])
|
||||
self.assertEqual(tenant4.group_id, data['group'])
|
||||
|
||||
def test_create_tenant_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Tenant 4',
|
||||
'slug': 'test-tenant-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test Tenant 5',
|
||||
'slug': 'test-tenant-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test Tenant 6',
|
||||
'slug': 'test-tenant-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('tenancy-api:tenant-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Tenant.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_tenant(self):
|
||||
|
||||
data = {
|
||||
@@ -132,7 +184,7 @@ class TenantTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('tenancy-api:tenant-detail', kwargs={'pk': self.tenant1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Tenant.objects.count(), 3)
|
||||
|
||||
@@ -7,7 +7,7 @@ from django.urls import reverse
|
||||
from django.views.generic import View
|
||||
|
||||
from circuits.models import Circuit
|
||||
from dcim.models import Site, Rack, Device
|
||||
from dcim.models import Site, Rack, Device, RackReservation
|
||||
from ipam.models import IPAddress, Prefix, VLAN, VRF
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
@@ -75,6 +75,7 @@ class TenantView(View):
|
||||
stats = {
|
||||
'site_count': Site.objects.filter(tenant=tenant).count(),
|
||||
'rack_count': Rack.objects.filter(tenant=tenant).count(),
|
||||
'rackreservation_count': RackReservation.objects.filter(tenant=tenant).count(),
|
||||
'device_count': Device.objects.filter(tenant=tenant).count(),
|
||||
'vrf_count': VRF.objects.filter(tenant=tenant).count(),
|
||||
'prefix_count': Prefix.objects.filter(
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from collections import OrderedDict
|
||||
import pytz
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.http import Http404
|
||||
from rest_framework import mixins
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework.permissions import BasePermission
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.serializers import Field, ModelSerializer, ValidationError
|
||||
from rest_framework.viewsets import ViewSet
|
||||
from rest_framework.viewsets import GenericViewSet, ViewSet
|
||||
|
||||
WRITE_OPERATIONS = ['create', 'update', 'partial_update', 'delete']
|
||||
|
||||
@@ -96,10 +98,51 @@ class ContentTypeFieldSerializer(Field):
|
||||
raise ValidationError("Invalid content type")
|
||||
|
||||
|
||||
class TimeZoneField(Field):
|
||||
"""
|
||||
Represent a pytz time zone.
|
||||
"""
|
||||
|
||||
def to_representation(self, obj):
|
||||
return obj.zone if obj else None
|
||||
|
||||
def to_internal_value(self, data):
|
||||
if not data:
|
||||
return ""
|
||||
try:
|
||||
return pytz.timezone(str(data))
|
||||
except pytz.exceptions.UnknownTimeZoneError:
|
||||
raise ValidationError('Invalid time zone "{}"'.format(data))
|
||||
|
||||
|
||||
#
|
||||
# Views
|
||||
# Viewsets
|
||||
#
|
||||
|
||||
class ModelViewSet(mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
GenericViewSet):
|
||||
"""
|
||||
Substitute DRF's built-in ModelViewSet for our own, which introduces a bit of additional functionality:
|
||||
1. Use an alternate serializer (if provided) for write operations
|
||||
2. Accept either a single object or a list of objects to create
|
||||
"""
|
||||
def get_serializer_class(self):
|
||||
# Check for a different serializer to use for write operations
|
||||
if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
|
||||
return self.write_serializer_class
|
||||
return self.serializer_class
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
# If a list of objects has been provided, initialize the serializer with many=True
|
||||
if isinstance(kwargs.get('data', {}), list):
|
||||
kwargs['many'] = True
|
||||
return super(ModelViewSet, self).get_serializer(*args, **kwargs)
|
||||
|
||||
|
||||
class FieldChoicesViewSet(ViewSet):
|
||||
"""
|
||||
Expose the built-in numeric values which represent static choices for a model's field.
|
||||
@@ -135,25 +178,9 @@ class FieldChoicesViewSet(ViewSet):
|
||||
return Response(self._fields)
|
||||
|
||||
def retrieve(self, request, pk):
|
||||
|
||||
if pk not in self._fields:
|
||||
raise Http404
|
||||
|
||||
return Response(self._fields[pk])
|
||||
|
||||
def get_view_name(self):
|
||||
return "Field Choices"
|
||||
|
||||
|
||||
#
|
||||
# Mixins
|
||||
#
|
||||
|
||||
class WritableSerializerMixin(object):
|
||||
"""
|
||||
Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT).
|
||||
"""
|
||||
def get_serializer_class(self):
|
||||
if self.action in WRITE_OPERATIONS and hasattr(self, 'write_serializer_class'):
|
||||
return self.write_serializer_class
|
||||
return self.serializer_class
|
||||
|
||||
7
netbox/utilities/constants.py
Normal file
7
netbox/utilities/constants.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from utilities.forms import ChainedModelMultipleChoiceField
|
||||
|
||||
|
||||
# Fields which are used on ManyToMany relationships
|
||||
M2M_FIELD_TYPES = [
|
||||
ChainedModelMultipleChoiceField,
|
||||
]
|
||||
@@ -119,7 +119,7 @@ class ColorSelect(forms.Select):
|
||||
"""
|
||||
Extends the built-in Select widget to colorize each <option>.
|
||||
"""
|
||||
option_template_name = 'colorselect_option.html'
|
||||
option_template_name = 'widgets/colorselect_option.html'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs['choices'] = COLOR_CHOICES
|
||||
@@ -144,7 +144,14 @@ class SelectWithDisabled(forms.Select):
|
||||
Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include
|
||||
'label' (string) and 'disabled' (boolean).
|
||||
"""
|
||||
option_template_name = 'selectwithdisabled_option.html'
|
||||
option_template_name = 'widgets/selectwithdisabled_option.html'
|
||||
|
||||
|
||||
class SelectWithPK(forms.Select):
|
||||
"""
|
||||
Include the primary key of each option in the option label (e.g. "Router7 (4721)").
|
||||
"""
|
||||
option_template_name = 'widgets/select_option_with_pk.html'
|
||||
|
||||
|
||||
class ArrayFieldSelectMultiple(SelectWithDisabled, forms.SelectMultiple):
|
||||
|
||||
@@ -30,4 +30,4 @@ class ToggleColumn(tables.CheckBoxColumn):
|
||||
|
||||
@property
|
||||
def header(self):
|
||||
return mark_safe('<input type="checkbox" id="toggle_all" title="Toggle all" />')
|
||||
return mark_safe('<input type="checkbox" class="toggle" title="Toggle all" />')
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<option value="{{ widget.value|stringformat:'s' }}"{% include "django/forms/widgets/attrs.html" %}>{{ widget.label }}{% if widget.value %} ({{ widget.value }}){% endif %}</option>
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import datetime
|
||||
import pytz
|
||||
|
||||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
from markdown import markdown
|
||||
@@ -117,6 +120,14 @@ def example_choices(field, arg=3):
|
||||
return ', '.join(examples) or 'None'
|
||||
|
||||
|
||||
@register.filter()
|
||||
def tzoffset(value):
|
||||
"""
|
||||
Returns the hour offset of a given time zone using the current time.
|
||||
"""
|
||||
return datetime.datetime.now(value).strftime('%z')
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
||||
@@ -9,9 +9,9 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction, IntegrityError
|
||||
from django.db.models import ProtectedError
|
||||
from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea, TypedChoiceField
|
||||
from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.template import TemplateSyntaxError
|
||||
from django.template.exceptions import TemplateSyntaxError
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.http import is_safe_url
|
||||
@@ -22,6 +22,7 @@ from django_tables2 import RequestConfig
|
||||
from extras.models import CustomField, CustomFieldValue, ExportTemplate, UserAction
|
||||
from utilities.utils import queryset_to_csv
|
||||
from utilities.forms import BootstrapMixin, CSVDataField
|
||||
from .constants import M2M_FIELD_TYPES
|
||||
from .error_handlers import handle_protectederror
|
||||
from .forms import ConfirmationForm
|
||||
from .paginator import EnhancedPaginator
|
||||
@@ -510,31 +511,55 @@ class BulkEditView(View):
|
||||
|
||||
custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else []
|
||||
standard_fields = [field for field in form.fields if field not in custom_fields and field != 'pk']
|
||||
|
||||
# Update standard fields. If a field is listed in _nullify, delete its value.
|
||||
nullified_fields = request.POST.getlist('_nullify')
|
||||
fields_to_update = {}
|
||||
for field in standard_fields:
|
||||
if field in form.nullable_fields and field in nullified_fields:
|
||||
if isinstance(form.fields[field], CharField):
|
||||
fields_to_update[field] = ''
|
||||
else:
|
||||
fields_to_update[field] = None
|
||||
elif form.cleaned_data[field] not in (None, ''):
|
||||
fields_to_update[field] = form.cleaned_data[field]
|
||||
updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update)
|
||||
|
||||
# Update custom fields for objects
|
||||
if custom_fields:
|
||||
objs_updated = self.update_custom_fields(pk_list, form, custom_fields, nullified_fields)
|
||||
if objs_updated and not updated_count:
|
||||
updated_count = objs_updated
|
||||
try:
|
||||
|
||||
if updated_count:
|
||||
msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
|
||||
messages.success(self.request, msg)
|
||||
UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
|
||||
return redirect(return_url)
|
||||
with transaction.atomic():
|
||||
|
||||
updated_count = 0
|
||||
for obj in self.cls.objects.filter(pk__in=pk_list):
|
||||
|
||||
# Update standard fields. If a field is listed in _nullify, delete its value.
|
||||
for name in standard_fields:
|
||||
if name in form.nullable_fields and name in nullified_fields:
|
||||
setattr(obj, name, '' if isinstance(form.fields[name], CharField) else None)
|
||||
elif form.cleaned_data[name] not in (None, ''):
|
||||
setattr(obj, name, form.cleaned_data[name])
|
||||
obj.full_clean()
|
||||
obj.save()
|
||||
|
||||
# Update custom fields
|
||||
obj_type = ContentType.objects.get_for_model(self.cls)
|
||||
for name in custom_fields:
|
||||
field = form.fields[name].model
|
||||
if name in form.nullable_fields and name in nullified_fields:
|
||||
CustomFieldValue.objects.filter(
|
||||
field=field, obj_type=obj_type, obj_id=obj.pk
|
||||
).delete()
|
||||
elif form.cleaned_data[name] not in [None, '']:
|
||||
try:
|
||||
cfv = CustomFieldValue.objects.get(
|
||||
field=field, obj_type=obj_type, obj_id=obj.pk
|
||||
)
|
||||
except CustomFieldValue.DoesNotExist:
|
||||
cfv = CustomFieldValue(
|
||||
field=field, obj_type=obj_type, obj_id=obj.pk
|
||||
)
|
||||
cfv.value = form.cleaned_data[name]
|
||||
cfv.save()
|
||||
|
||||
updated_count += 1
|
||||
|
||||
if updated_count:
|
||||
msg = 'Updated {} {}'.format(updated_count, self.cls._meta.verbose_name_plural)
|
||||
messages.success(self.request, msg)
|
||||
UserAction.objects.log_bulk_edit(request.user, ContentType.objects.get_for_model(self.cls), msg)
|
||||
|
||||
return redirect(return_url)
|
||||
|
||||
except ValidationError as e:
|
||||
messages.error(self.request, "{} failed validation: {}".format(obj, e))
|
||||
|
||||
else:
|
||||
initial_data = request.POST.copy()
|
||||
@@ -555,53 +580,6 @@ class BulkEditView(View):
|
||||
'return_url': return_url,
|
||||
})
|
||||
|
||||
def update_custom_fields(self, pk_list, form, fields, nullified_fields):
|
||||
obj_type = ContentType.objects.get_for_model(self.cls)
|
||||
objs_updated = False
|
||||
|
||||
for name in fields:
|
||||
|
||||
field = form.fields[name].model
|
||||
|
||||
# Setting the field to null
|
||||
if name in form.nullable_fields and name in nullified_fields:
|
||||
|
||||
# Delete all CustomFieldValues for instances of this field belonging to the selected objects.
|
||||
CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list).delete()
|
||||
objs_updated = True
|
||||
|
||||
# Updating the value of the field
|
||||
elif form.cleaned_data[name] not in [None, '']:
|
||||
|
||||
# Check for zero value (bulk editing)
|
||||
if isinstance(form.fields[name], TypedChoiceField) and form.cleaned_data[name] == 0:
|
||||
serialized_value = field.serialize_value(None)
|
||||
else:
|
||||
serialized_value = field.serialize_value(form.cleaned_data[name])
|
||||
|
||||
# Gather any pre-existing CustomFieldValues for the objects being edited.
|
||||
existing_cfvs = CustomFieldValue.objects.filter(field=field, obj_type=obj_type, obj_id__in=pk_list)
|
||||
|
||||
# Determine which objects have an existing CFV to update and which need a new CFV created.
|
||||
update_list = [cfv['obj_id'] for cfv in existing_cfvs.values()]
|
||||
create_list = list(set(pk_list) - set(update_list))
|
||||
|
||||
# Creating/updating CFVs
|
||||
if serialized_value:
|
||||
existing_cfvs.update(serialized_value=serialized_value)
|
||||
CustomFieldValue.objects.bulk_create([
|
||||
CustomFieldValue(field=field, obj_type=obj_type, obj_id=pk, serialized_value=serialized_value)
|
||||
for pk in create_list
|
||||
])
|
||||
|
||||
# Deleting CFVs
|
||||
else:
|
||||
existing_cfvs.delete()
|
||||
|
||||
objs_updated = True
|
||||
|
||||
return len(pk_list) if objs_updated else 0
|
||||
|
||||
|
||||
class BulkDeleteView(View):
|
||||
"""
|
||||
@@ -763,6 +741,26 @@ class ComponentCreateView(View):
|
||||
|
||||
if not form.errors:
|
||||
self.model.objects.bulk_create(new_components)
|
||||
|
||||
# ManyToMany relations are bulk created via the through model
|
||||
m2m_fields = [field for field in component_form.fields if type(component_form.fields[field]) in M2M_FIELD_TYPES]
|
||||
if m2m_fields:
|
||||
for field in m2m_fields:
|
||||
field_links = []
|
||||
for new_component in new_components:
|
||||
for related_obj in component_form.cleaned_data[field]:
|
||||
# The through model columns are the id's of our M2M relation objects
|
||||
through_kwargs = {}
|
||||
new_component_column = new_component.__class__.__name__ + '_id'
|
||||
related_obj_column = related_obj.__class__.__name__ + '_id'
|
||||
through_kwargs.update({
|
||||
new_component_column.lower(): new_component.id,
|
||||
related_obj_column.lower(): related_obj.id
|
||||
})
|
||||
field_link = getattr(self.model, field).through(**through_kwargs)
|
||||
field_links.append(field_link)
|
||||
getattr(self.model, field).through.objects.bulk_create(field_links)
|
||||
|
||||
messages.success(request, "Added {} {} to {}.".format(
|
||||
len(new_components), self.model._meta.verbose_name_plural, parent
|
||||
))
|
||||
@@ -779,20 +777,6 @@ class ComponentCreateView(View):
|
||||
})
|
||||
|
||||
|
||||
class ComponentEditView(ObjectEditView):
|
||||
parent_field = None
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return getattr(obj, self.parent_field).get_absolute_url()
|
||||
|
||||
|
||||
class ComponentDeleteView(ObjectDeleteView):
|
||||
parent_field = None
|
||||
|
||||
def get_return_url(self, request, obj):
|
||||
return getattr(obj, self.parent_field).get_absolute_url()
|
||||
|
||||
|
||||
class BulkComponentCreateView(View):
|
||||
"""
|
||||
Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.
|
||||
|
||||
@@ -9,7 +9,7 @@ from extras.api.customfields import CustomFieldModelSerializer
|
||||
from ipam.models import IPAddress
|
||||
from tenancy.api.serializers import NestedTenantSerializer
|
||||
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
|
||||
from virtualization.constants import STATUS_CHOICES
|
||||
from virtualization.constants import VM_STATUS_CHOICES
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ class ClusterSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Cluster
|
||||
fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields']
|
||||
fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated']
|
||||
|
||||
|
||||
class NestedClusterSerializer(serializers.ModelSerializer):
|
||||
@@ -77,7 +77,7 @@ class WritableClusterSerializer(CustomFieldModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Cluster
|
||||
fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields']
|
||||
fields = ['id', 'name', 'type', 'group', 'site', 'comments', 'custom_fields', 'created', 'last_updated']
|
||||
|
||||
|
||||
#
|
||||
@@ -94,7 +94,7 @@ class VirtualMachineIPAddressSerializer(serializers.ModelSerializer):
|
||||
|
||||
|
||||
class VirtualMachineSerializer(CustomFieldModelSerializer):
|
||||
status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
|
||||
status = ChoiceFieldSerializer(choices=VM_STATUS_CHOICES)
|
||||
cluster = NestedClusterSerializer()
|
||||
role = NestedDeviceRoleSerializer()
|
||||
tenant = NestedTenantSerializer()
|
||||
@@ -107,7 +107,7 @@ class VirtualMachineSerializer(CustomFieldModelSerializer):
|
||||
model = VirtualMachine
|
||||
fields = [
|
||||
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
||||
'vcpus', 'memory', 'disk', 'comments', 'custom_fields',
|
||||
'vcpus', 'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ class WritableVirtualMachineSerializer(CustomFieldModelSerializer):
|
||||
model = VirtualMachine
|
||||
fields = [
|
||||
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus',
|
||||
'memory', 'disk', 'comments', 'custom_fields',
|
||||
'memory', 'disk', 'comments', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from dcim.models import Interface
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from utilities.api import FieldChoicesViewSet, WritableSerializerMixin
|
||||
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||
from virtualization import filters
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
from . import serializers
|
||||
@@ -34,7 +32,7 @@ class ClusterGroupViewSet(ModelViewSet):
|
||||
serializer_class = serializers.ClusterGroupSerializer
|
||||
|
||||
|
||||
class ClusterViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class ClusterViewSet(CustomFieldModelViewSet):
|
||||
queryset = Cluster.objects.select_related('type', 'group')
|
||||
serializer_class = serializers.ClusterSerializer
|
||||
write_serializer_class = serializers.WritableClusterSerializer
|
||||
@@ -45,14 +43,14 @@ class ClusterViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
# Virtual machines
|
||||
#
|
||||
|
||||
class VirtualMachineViewSet(WritableSerializerMixin, CustomFieldModelViewSet):
|
||||
class VirtualMachineViewSet(CustomFieldModelViewSet):
|
||||
queryset = VirtualMachine.objects.all()
|
||||
serializer_class = serializers.VirtualMachineSerializer
|
||||
write_serializer_class = serializers.WritableVirtualMachineSerializer
|
||||
filter_class = filters.VirtualMachineFilter
|
||||
|
||||
|
||||
class InterfaceViewSet(WritableSerializerMixin, ModelViewSet):
|
||||
class InterfaceViewSet(ModelViewSet):
|
||||
queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine')
|
||||
serializer_class = serializers.InterfaceSerializer
|
||||
write_serializer_class = serializers.WritableInterfaceSerializer
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from dcim.constants import STATUS_ACTIVE, STATUS_OFFLINE, STATUS_STAGED
|
||||
from dcim.constants import DEVICE_STATUS_ACTIVE, DEVICE_STATUS_OFFLINE, DEVICE_STATUS_STAGED
|
||||
|
||||
# VirtualMachine statuses (replicated from Device statuses)
|
||||
STATUS_CHOICES = [
|
||||
[STATUS_ACTIVE, 'Active'],
|
||||
[STATUS_OFFLINE, 'Offline'],
|
||||
[STATUS_STAGED, 'Staged'],
|
||||
VM_STATUS_CHOICES = [
|
||||
[DEVICE_STATUS_ACTIVE, 'Active'],
|
||||
[DEVICE_STATUS_OFFLINE, 'Offline'],
|
||||
[DEVICE_STATUS_STAGED, 'Staged'],
|
||||
]
|
||||
|
||||
# Bootstrap CSS classes for VirtualMachine statuses
|
||||
|
||||
@@ -9,7 +9,7 @@ from dcim.models import DeviceRole, Interface, Platform, Site
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.filters import NumericInFilter
|
||||
from .constants import STATUS_CHOICES
|
||||
from .constants import VM_STATUS_CHOICES
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ class VirtualMachineFilter(CustomFieldFilterSet):
|
||||
label='Search',
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=STATUS_CHOICES,
|
||||
choices=VM_STATUS_CHOICES,
|
||||
null_value=None
|
||||
)
|
||||
cluster_group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
|
||||
@@ -17,7 +17,7 @@ from utilities.forms import (
|
||||
ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
|
||||
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea,
|
||||
)
|
||||
from .constants import STATUS_CHOICES
|
||||
from .constants import VM_STATUS_CHOICES
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
VIFACE_FF_CHOICES = (
|
||||
@@ -300,7 +300,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
|
||||
class VirtualMachineCSVForm(forms.ModelForm):
|
||||
status = CSVChoiceField(
|
||||
choices=STATUS_CHOICES,
|
||||
choices=VM_STATUS_CHOICES,
|
||||
required=False,
|
||||
help_text='Operational status of device'
|
||||
)
|
||||
@@ -347,7 +347,7 @@ class VirtualMachineCSVForm(forms.ModelForm):
|
||||
|
||||
class VirtualMachineBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput)
|
||||
status = forms.ChoiceField(choices=add_blank_choice(STATUS_CHOICES), required=False, initial='')
|
||||
status = forms.ChoiceField(choices=add_blank_choice(VM_STATUS_CHOICES), required=False, initial='')
|
||||
cluster = forms.ModelChoiceField(queryset=Cluster.objects.all(), required=False)
|
||||
role = forms.ModelChoiceField(queryset=DeviceRole.objects.filter(vm_role=True), required=False)
|
||||
tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False)
|
||||
@@ -365,7 +365,7 @@ def vm_status_choices():
|
||||
status_counts = {}
|
||||
for status in VirtualMachine.objects.values('status').annotate(count=Count('status')).order_by('status'):
|
||||
status_counts[status['status']] = status['count']
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in STATUS_CHOICES]
|
||||
return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in VM_STATUS_CHOICES]
|
||||
|
||||
|
||||
class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
|
||||
@@ -10,7 +10,7 @@ from django.utils.encoding import python_2_unicode_compatible
|
||||
from dcim.models import Device
|
||||
from extras.models import CustomFieldModel, CustomFieldValue
|
||||
from utilities.models import CreatedUpdatedModel
|
||||
from .constants import STATUS_ACTIVE, STATUS_CHOICES, VM_STATUS_CLASSES
|
||||
from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES
|
||||
|
||||
|
||||
#
|
||||
@@ -190,8 +190,8 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
|
||||
unique=True
|
||||
)
|
||||
status = models.PositiveSmallIntegerField(
|
||||
choices=STATUS_CHOICES,
|
||||
default=STATUS_ACTIVE,
|
||||
choices=VM_STATUS_CHOICES,
|
||||
default=DEVICE_STATUS_ACTIVE,
|
||||
verbose_name='Status'
|
||||
)
|
||||
role = models.ForeignKey(
|
||||
@@ -282,3 +282,8 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
|
||||
return self.primary_ip4
|
||||
else:
|
||||
return None
|
||||
|
||||
def site(self):
|
||||
# used when a child compent (eg Interface) needs to know its parent's site but
|
||||
# the parent could be either a device or a virtual machine
|
||||
return self.cluster.site
|
||||
|
||||
@@ -44,7 +44,7 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:clustertype-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ClusterType.objects.count(), 4)
|
||||
@@ -52,6 +52,32 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(clustertype4.name, data['name'])
|
||||
self.assertEqual(clustertype4.slug, data['slug'])
|
||||
|
||||
def test_create_clustertype_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Cluster Type 4',
|
||||
'slug': 'test-cluster-type-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test Cluster Type 5',
|
||||
'slug': 'test-cluster-type-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test Cluster Type 6',
|
||||
'slug': 'test-cluster-type-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('virtualization-api:clustertype-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ClusterType.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_clustertype(self):
|
||||
|
||||
data = {
|
||||
@@ -60,7 +86,7 @@ class ClusterTypeTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:clustertype-detail', kwargs={'pk': self.clustertype1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(ClusterType.objects.count(), 3)
|
||||
@@ -111,7 +137,7 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:clustergroup-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ClusterGroup.objects.count(), 4)
|
||||
@@ -119,6 +145,32 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(clustergroup4.name, data['name'])
|
||||
self.assertEqual(clustergroup4.slug, data['slug'])
|
||||
|
||||
def test_create_clustergroup_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Cluster Group 4',
|
||||
'slug': 'test-cluster-group-4',
|
||||
},
|
||||
{
|
||||
'name': 'Test Cluster Group 5',
|
||||
'slug': 'test-cluster-group-5',
|
||||
},
|
||||
{
|
||||
'name': 'Test Cluster Group 6',
|
||||
'slug': 'test-cluster-group-6',
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('virtualization-api:clustergroup-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ClusterGroup.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_clustergroup(self):
|
||||
|
||||
data = {
|
||||
@@ -127,7 +179,7 @@ class ClusterGroupTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:clustergroup-detail', kwargs={'pk': self.clustergroup1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(ClusterGroup.objects.count(), 3)
|
||||
@@ -182,7 +234,7 @@ class ClusterTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:cluster-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Cluster.objects.count(), 4)
|
||||
@@ -191,6 +243,35 @@ class ClusterTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(cluster4.type.pk, data['type'])
|
||||
self.assertEqual(cluster4.group.pk, data['group'])
|
||||
|
||||
def test_create_cluster_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Cluster 4',
|
||||
'type': ClusterType.objects.first().pk,
|
||||
'group': ClusterGroup.objects.first().pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Cluster 5',
|
||||
'type': ClusterType.objects.first().pk,
|
||||
'group': ClusterGroup.objects.first().pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Cluster 6',
|
||||
'type': ClusterType.objects.first().pk,
|
||||
'group': ClusterGroup.objects.first().pk,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('virtualization-api:cluster-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Cluster.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_cluster(self):
|
||||
|
||||
cluster_type2 = ClusterType.objects.create(name='Test Cluster Type 2', slug='test-cluster-type-2')
|
||||
@@ -202,7 +283,7 @@ class ClusterTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:cluster-detail', kwargs={'pk': self.cluster1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Cluster.objects.count(), 3)
|
||||
@@ -230,11 +311,11 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
|
||||
cluster_group = ClusterGroup.objects.create(name='Test Cluster Group 1', slug='test-cluster-group-1')
|
||||
cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
|
||||
self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type, group=cluster_group)
|
||||
|
||||
self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=cluster)
|
||||
self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=cluster)
|
||||
self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=cluster)
|
||||
self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=self.cluster1)
|
||||
self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=self.cluster1)
|
||||
self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=self.cluster1)
|
||||
|
||||
def test_get_virtualmachine(self):
|
||||
|
||||
@@ -254,11 +335,11 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
|
||||
|
||||
data = {
|
||||
'name': 'Test Virtual Machine 4',
|
||||
'cluster': Cluster.objects.first().pk,
|
||||
'cluster': self.cluster1.pk,
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-list')
|
||||
response = self.client.post(url, data, **self.header)
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VirtualMachine.objects.count(), 4)
|
||||
@@ -266,6 +347,32 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
|
||||
self.assertEqual(virtualmachine4.name, data['name'])
|
||||
self.assertEqual(virtualmachine4.cluster.pk, data['cluster'])
|
||||
|
||||
def test_create_virtualmachine_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Virtual Machine 4',
|
||||
'cluster': self.cluster1.pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Virtual Machine 5',
|
||||
'cluster': self.cluster1.pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Virtual Machine 6',
|
||||
'cluster': self.cluster1.pk,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(VirtualMachine.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_virtualmachine(self):
|
||||
|
||||
cluster2 = Cluster.objects.create(
|
||||
@@ -279,7 +386,7 @@ class VirtualMachineTest(HttpStatusMixin, APITestCase):
|
||||
}
|
||||
|
||||
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
|
||||
response = self.client.put(url, data, **self.header)
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(VirtualMachine.objects.count(), 3)
|
||||
|
||||
@@ -11,8 +11,8 @@ from dcim.models import Device, Interface
|
||||
from dcim.tables import DeviceTable
|
||||
from ipam.models import Service
|
||||
from utilities.views import (
|
||||
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView,
|
||||
ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
|
||||
ObjectEditView, ObjectListView,
|
||||
)
|
||||
from . import filters, forms, tables
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
@@ -325,17 +325,15 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
|
||||
template_name = 'virtualization/virtualmachine_component_add.html'
|
||||
|
||||
|
||||
class InterfaceEditView(PermissionRequiredMixin, ComponentEditView):
|
||||
class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.change_interface'
|
||||
model = Interface
|
||||
parent_field = 'virtual_machine'
|
||||
model_form = forms.InterfaceForm
|
||||
|
||||
|
||||
class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView):
|
||||
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'dcim.delete_interface'
|
||||
model = Interface
|
||||
parent_field = 'virtual_machine'
|
||||
|
||||
|
||||
class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
|
||||
Reference in New Issue
Block a user