Merge branch 'develop-2.3' into develop

This commit is contained in:
Jeremy Stretch 2018-02-26 14:14:47 -05:00
commit 22f17a1424
90 changed files with 4624 additions and 1132 deletions

View File

@ -112,3 +112,11 @@ Console ports connect only to console server ports, and power ports connect only
Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. Each interface can also be designated as management-only (for out-of-band management) and assigned a short description.
Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear on rack elevations, but they are included in the "Non-Racked Devices" list within the rack view.
---
# Virtual Chassis
A virtual chassis represents a set of devices which share a single control plane: for example, a stack of switches which are managed as a single device. Each device in the virtual chassis is assigned a position and (optionally) a priority. Exactly one device is designated the virtual chassis master: This device will typically be assigned a name, secrets, services, and other attributes related to its management.
It's important to recognize the distinction between a virtual chassis and a chassis-based device. For instance, a virtual chassis is not used to model a chassis switch with removable line cards such as the Juniper EX9208, as its line cards are _not_ physically separate devices capable of operating independently.

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,3 +6,6 @@ from django.apps import AppConfig
class DCIMConfig(AppConfig):
name = "dcim"
verbose_name = "DCIM"
def ready(self):
import dcim.signals

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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']:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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();
});
});
});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

@ -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):
"""

View File

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

View File

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

View File

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

View File

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

View 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 %}

View File

@ -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">&mdash; No device bays defined &mdash;</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">&mdash; No interfaces defined &mdash;</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">&mdash; No console server ports defined &mdash;</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">&mdash; No power outlets defined &mdash;</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>

View File

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

View File

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

View File

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

View File

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

View 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 %}

View File

@ -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 }} &middot; {{ resv.created }}</small>

View File

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

View File

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

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View File

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

View File

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

View File

@ -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">&mdash; No interfaces defined &mdash;</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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
from utilities.forms import ChainedModelMultipleChoiceField
# Fields which are used on ManyToMany relationships
M2M_FIELD_TYPES = [
ChainedModelMultipleChoiceField,
]

View File

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

View File

@ -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" />')

View File

@ -0,0 +1 @@
<option value="{{ widget.value|stringformat:'s' }}"{% include "django/forms/widgets/attrs.html" %}>{{ widget.label }}{% if widget.value %} ({{ widget.value }}){% endif %}</option>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +1,2 @@
psycopg2
pycrypto

View File

@ -1,19 +1,20 @@
Django>=1.11,<2.0
django-cors-headers>=2.1
django-debug-toolbar>=1.8
django-cors-headers>=2.1.0
django-debug-toolbar>=1.9.0
django-filter>=1.1.0
django-mptt==0.8.7
django-mptt>=0.9.0
django-rest-swagger>=2.1.0
django-tables2>=1.10.0
djangorestframework>=3.6.4
graphviz>=0.6
Markdown>=2.6.7
natsort>=5.0.0
django-tables2>=1.19.0
django-timezone-field>=2.0
djangorestframework>=3.7.7
graphviz>=0.8.2
Markdown>=2.6.11
natsort>=5.2.0
ncclient==0.5.3
netaddr==0.7.18
paramiko>=2.0.0
Pillow>=4.0.0
psycopg2>=2.7.3
paramiko>=2.4.0
Pillow>=5.0.0
psycopg2-binary>=2.7.4
py-gfm>=0.1.3
pycryptodome>=3.4.7
xmltodict>=0.10.2
pycryptodome>=3.4.11
xmltodict>=0.11.0