diff --git a/docs/data-model/dcim.md b/docs/data-model/dcim.md index e4082ebc7..3cb3ed13e 100644 --- a/docs/data-model/dcim.md +++ b/docs/data-model/dcim.md @@ -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. diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index d2432374f..db550a63b 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -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', ] diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 666f67502..9b75bc184 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -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 diff --git a/netbox/circuits/constants.py b/netbox/circuits/constants.py index 816e28e4e..c13975b06 100644 --- a/netbox/circuits/constants.py +++ b/netbox/circuits/constants.py @@ -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' diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index d4b84fc60..ca66be406 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -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)', diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 7afd1476e..29203fc8a 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -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', diff --git a/netbox/circuits/migrations/0010_circuit_status.py b/netbox/circuits/migrations/0010_circuit_status.py new file mode 100644 index 000000000..3abe5d319 --- /dev/null +++ b/netbox/circuits/migrations/0010_circuit_status.py @@ -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), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index e3a688ee5..a65fe3063 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -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: diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 9a5225d56..46dac3c31 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -14,6 +14,10 @@ CIRCUITTYPE_ACTIONS = """ {% endif %} """ +STATUS_LABEL = """ +{{ record.get_status_display }} +""" + 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') diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 0e588fe16..1228aafaa 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -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) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 5204e6a0e..edcd93ef2 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -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'] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index a03432c61..145cb7f09 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -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') diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index a74a34422..12e657e79 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -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 # diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index fb1f4ee39..ef3158508 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -6,3 +6,6 @@ from django.apps import AppConfig class DCIMConfig(AppConfig): name = "dcim" verbose_name = "DCIM" + + def ready(self): + import dcim.signals diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index b355e6c85..cea56e176 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -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', diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index f3d70edd4..0d5455aa0 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -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', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 84aec50d5..6d0892f67 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -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 --', + ) diff --git a/netbox/dcim/migrations/0050_interface_vlan_tagging.py b/netbox/dcim/migrations/0050_interface_vlan_tagging.py new file mode 100644 index 000000000..1906b9179 --- /dev/null +++ b/netbox/dcim/migrations/0050_interface_vlan_tagging.py @@ -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'), + ), + ] diff --git a/netbox/dcim/migrations/0051_rackreservation_tenant.py b/netbox/dcim/migrations/0051_rackreservation_tenant.py new file mode 100644 index 000000000..90a551eb8 --- /dev/null +++ b/netbox/dcim/migrations/0051_rackreservation_tenant.py @@ -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'), + ), + ] diff --git a/netbox/dcim/migrations/0052_virtual_chassis.py b/netbox/dcim/migrations/0052_virtual_chassis.py new file mode 100644 index 000000000..334f60ca7 --- /dev/null +++ b/netbox/dcim/migrations/0052_virtual_chassis.py @@ -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')]), + ), + ] diff --git a/netbox/dcim/migrations/0053_platform_manufacturer.py b/netbox/dcim/migrations/0053_platform_manufacturer.py new file mode 100644 index 000000000..62797716e --- /dev/null +++ b/netbox/dcim/migrations/0053_platform_manufacturer.py @@ -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'), + ), + ] diff --git a/netbox/dcim/migrations/0054_site_status_timezone_description.py b/netbox/dcim/migrations/0054_site_status_timezone_description.py new file mode 100644 index 000000000..723f61fc8 --- /dev/null +++ b/netbox/dcim/migrations/0054_site_status_timezone_description.py @@ -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), + ), + ] diff --git a/netbox/dcim/migrations/0055_virtualchassis_ordering.py b/netbox/dcim/migrations/0055_virtualchassis_ordering.py new file mode 100644 index 000000000..51cda0ff6 --- /dev/null +++ b/netbox/dcim/migrations/0055_virtualchassis_ordering.py @@ -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'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 2b33ba6fe..cfae0d6d2 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -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." + }) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py new file mode 100644 index 000000000..1e8888e97 --- /dev/null +++ b/netbox/dcim/signals.py @@ -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) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index b9efb3a28..aef6b3308 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -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 = """ """ -DEVICE_STATUS = """ +STATUS_LABEL = """ {{ record.get_status_display }} """ @@ -132,6 +133,12 @@ UTILIZATION_GRAPH = """ {% utilization_graph value %} """ +VIRTUALCHASSIS_ACTIONS = """ +{% if perms.dcim.change_virtualchassis %} + +{% 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') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 5d6f66d30..5ad3985e1 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -5,12 +5,12 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase -from dcim.constants import IFACE_FF_LAG, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT +from dcim.constants import IFACE_FF_1GE_FIXED, IFACE_FF_LAG, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, - RackReservation, RackRole, Region, Site, + RackReservation, RackRole, Region, Site, VirtualChassis, ) from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from users.models import Token @@ -51,7 +51,7 @@ class RegionTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:region-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(Region.objects.count(), 4) @@ -59,6 +59,32 @@ class RegionTest(HttpStatusMixin, APITestCase): self.assertEqual(region4.name, data['name']) self.assertEqual(region4.slug, data['slug']) + def test_create_region_bulk(self): + + data = [ + { + 'name': 'Test Region 4', + 'slug': 'test-region-4', + }, + { + 'name': 'Test Region 5', + 'slug': 'test-region-5', + }, + { + 'name': 'Test Region 6', + 'slug': 'test-region-6', + }, + ] + + url = reverse('dcim-api:region-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Region.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_region(self): data = { @@ -67,7 +93,7 @@ class RegionTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:region-detail', kwargs={'pk': self.region1.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(Region.objects.count(), 3) @@ -142,7 +168,7 @@ class SiteTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:site-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(Site.objects.count(), 4) @@ -151,6 +177,35 @@ class SiteTest(HttpStatusMixin, APITestCase): self.assertEqual(site4.slug, data['slug']) self.assertEqual(site4.region_id, data['region']) + def test_create_site_bulk(self): + + data = [ + { + 'name': 'Test Site 4', + 'slug': 'test-site-4', + 'region': self.region1.pk, + }, + { + 'name': 'Test Site 5', + 'slug': 'test-site-5', + 'region': self.region1.pk, + }, + { + 'name': 'Test Site 6', + 'slug': 'test-site-6', + 'region': self.region1.pk, + }, + ] + + url = reverse('dcim-api:site-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Site.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_site(self): data = { @@ -160,7 +215,7 @@ class SiteTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:site-detail', kwargs={'pk': self.site1.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(Site.objects.count(), 3) @@ -215,7 +270,7 @@ class RackGroupTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rackgroup-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(RackGroup.objects.count(), 4) @@ -224,6 +279,35 @@ class RackGroupTest(HttpStatusMixin, APITestCase): self.assertEqual(rackgroup4.slug, data['slug']) self.assertEqual(rackgroup4.site_id, data['site']) + def test_create_rackgroup_bulk(self): + + data = [ + { + 'name': 'Test Rack Group 4', + 'slug': 'test-rack-group-4', + 'site': self.site1.pk, + }, + { + 'name': 'Test Rack Group 5', + 'slug': 'test-rack-group-5', + 'site': self.site1.pk, + }, + { + 'name': 'Test Rack Group 6', + 'slug': 'test-rack-group-6', + 'site': self.site1.pk, + }, + ] + + url = reverse('dcim-api:rackgroup-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(RackGroup.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_rackgroup(self): data = { @@ -233,7 +317,7 @@ class RackGroupTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rackgroup-detail', kwargs={'pk': self.rackgroup1.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(RackGroup.objects.count(), 3) @@ -286,7 +370,7 @@ class RackRoleTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rackrole-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(RackRole.objects.count(), 4) @@ -295,6 +379,35 @@ class RackRoleTest(HttpStatusMixin, APITestCase): self.assertEqual(rackrole1.slug, data['slug']) self.assertEqual(rackrole1.color, data['color']) + def test_create_rackrole_bulk(self): + + data = [ + { + 'name': 'Test Rack Role 4', + 'slug': 'test-rack-role-4', + 'color': 'ffff00', + }, + { + 'name': 'Test Rack Role 5', + 'slug': 'test-rack-role-5', + 'color': 'ffff00', + }, + { + 'name': 'Test Rack Role 6', + 'slug': 'test-rack-role-6', + 'color': 'ffff00', + }, + ] + + url = reverse('dcim-api:rackrole-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(RackRole.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_rackrole(self): data = { @@ -304,7 +417,7 @@ class RackRoleTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rackrole-detail', kwargs={'pk': self.rackrole1.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(RackRole.objects.count(), 3) @@ -377,7 +490,7 @@ class RackTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rack-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(Rack.objects.count(), 4) @@ -387,6 +500,38 @@ class RackTest(HttpStatusMixin, APITestCase): self.assertEqual(rack4.group_id, data['group']) self.assertEqual(rack4.role_id, data['role']) + def test_create_rack_bulk(self): + + data = [ + { + 'name': 'Test Rack 4', + 'site': self.site1.pk, + 'group': self.rackgroup1.pk, + 'role': self.rackrole1.pk, + }, + { + 'name': 'Test Rack 5', + 'site': self.site1.pk, + 'group': self.rackgroup1.pk, + 'role': self.rackrole1.pk, + }, + { + 'name': 'Test Rack 6', + 'site': self.site1.pk, + 'group': self.rackgroup1.pk, + 'role': self.rackrole1.pk, + }, + ] + + url = reverse('dcim-api:rack-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Rack.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_rack(self): data = { @@ -397,7 +542,7 @@ class RackTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rack-detail', kwargs={'pk': self.rack1.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(Rack.objects.count(), 3) @@ -428,13 +573,13 @@ class RackReservationTest(HttpStatusMixin, APITestCase): self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.rack1 = Rack.objects.create(site=self.site1, name='Test Rack 1') self.rackreservation1 = RackReservation.objects.create( - rack=self.rack1, units=[1, 2, 3], user=user, description='First reservation', + rack=self.rack1, units=[1, 2, 3], user=user, description='Reservation #1', ) self.rackreservation2 = RackReservation.objects.create( - rack=self.rack1, units=[4, 5, 6], user=user, description='Second reservation', + rack=self.rack1, units=[4, 5, 6], user=user, description='Reservation #2', ) self.rackreservation3 = RackReservation.objects.create( - rack=self.rack1, units=[7, 8, 9], user=user, description='Third reservation', + rack=self.rack1, units=[7, 8, 9], user=user, description='Reservation #3', ) def test_get_rackreservation(self): @@ -461,7 +606,7 @@ class RackReservationTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rackreservation-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(RackReservation.objects.count(), 4) @@ -471,6 +616,38 @@ class RackReservationTest(HttpStatusMixin, APITestCase): self.assertEqual(rackreservation4.user_id, data['user']) self.assertEqual(rackreservation4.description, data['description']) + def test_create_rackreservation_bulk(self): + + data = [ + { + 'rack': self.rack1.pk, + 'units': [10, 11, 12], + 'user': self.user1.pk, + 'description': 'Reservation #4', + }, + { + 'rack': self.rack1.pk, + 'units': [13, 14, 15], + 'user': self.user1.pk, + 'description': 'Reservation #5', + }, + { + 'rack': self.rack1.pk, + 'units': [16, 17, 18], + 'user': self.user1.pk, + 'description': 'Reservation #6', + }, + ] + + url = reverse('dcim-api:rackreservation-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(RackReservation.objects.count(), 6) + self.assertEqual(response.data[0]['description'], data[0]['description']) + self.assertEqual(response.data[1]['description'], data[1]['description']) + self.assertEqual(response.data[2]['description'], data[2]['description']) + def test_update_rackreservation(self): data = { @@ -481,7 +658,7 @@ class RackReservationTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:rackreservation-detail', kwargs={'pk': self.rackreservation1.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(RackReservation.objects.count(), 3) @@ -532,7 +709,7 @@ class ManufacturerTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:manufacturer-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(Manufacturer.objects.count(), 4) @@ -540,6 +717,32 @@ class ManufacturerTest(HttpStatusMixin, APITestCase): self.assertEqual(manufacturer4.name, data['name']) self.assertEqual(manufacturer4.slug, data['slug']) + def test_create_manufacturer_bulk(self): + + data = [ + { + 'name': 'Test Manufacturer 4', + 'slug': 'test-manufacturer-4', + }, + { + 'name': 'Test Manufacturer 5', + 'slug': 'test-manufacturer-5', + }, + { + 'name': 'Test Manufacturer 6', + 'slug': 'test-manufacturer-6', + }, + ] + + url = reverse('dcim-api:manufacturer-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Manufacturer.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_manufacturer(self): data = { @@ -548,7 +751,7 @@ class ManufacturerTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:manufacturer-detail', kwargs={'pk': self.manufacturer1.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(Manufacturer.objects.count(), 3) @@ -608,7 +811,7 @@ class DeviceTypeTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicetype-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(DeviceType.objects.count(), 4) @@ -617,6 +820,35 @@ class DeviceTypeTest(HttpStatusMixin, APITestCase): self.assertEqual(devicetype4.model, data['model']) self.assertEqual(devicetype4.slug, data['slug']) + def test_create_devicetype_bulk(self): + + data = [ + { + 'manufacturer': self.manufacturer1.pk, + 'model': 'Test Device Type 4', + 'slug': 'test-device-type-4', + }, + { + 'manufacturer': self.manufacturer1.pk, + 'model': 'Test Device Type 5', + 'slug': 'test-device-type-5', + }, + { + 'manufacturer': self.manufacturer1.pk, + 'model': 'Test Device Type 6', + 'slug': 'test-device-type-6', + }, + ] + + url = reverse('dcim-api:devicetype-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(DeviceType.objects.count(), 6) + self.assertEqual(response.data[0]['model'], data[0]['model']) + self.assertEqual(response.data[1]['model'], data[1]['model']) + self.assertEqual(response.data[2]['model'], data[2]['model']) + def test_update_devicetype(self): data = { @@ -626,7 +858,7 @@ class DeviceTypeTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicetype-detail', kwargs={'pk': self.devicetype1.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(DeviceType.objects.count(), 3) @@ -688,7 +920,7 @@ class ConsolePortTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleporttemplate-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(ConsolePortTemplate.objects.count(), 4) @@ -696,6 +928,32 @@ class ConsolePortTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(consoleporttemplate4.device_type_id, data['device_type']) self.assertEqual(consoleporttemplate4.name, data['name']) + def test_create_consoleporttemplate_bulk(self): + + data = [ + { + 'device_type': self.devicetype.pk, + 'name': 'Test CP Template 4', + }, + { + 'device_type': self.devicetype.pk, + 'name': 'Test CP Template 5', + }, + { + 'device_type': self.devicetype.pk, + 'name': 'Test CP Template 6', + }, + ] + + url = reverse('dcim-api:consoleporttemplate-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ConsolePortTemplate.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_consoleporttemplate(self): data = { @@ -704,7 +962,7 @@ class ConsolePortTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleporttemplate-detail', kwargs={'pk': self.consoleporttemplate1.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(ConsolePortTemplate.objects.count(), 3) @@ -764,7 +1022,7 @@ class ConsoleServerPortTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleserverporttemplate-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(ConsoleServerPortTemplate.objects.count(), 4) @@ -772,6 +1030,32 @@ class ConsoleServerPortTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(consoleserverporttemplate4.device_type_id, data['device_type']) self.assertEqual(consoleserverporttemplate4.name, data['name']) + def test_create_consoleserverporttemplate_bulk(self): + + data = [ + { + 'device_type': self.devicetype.pk, + 'name': 'Test CSP Template 4', + }, + { + 'device_type': self.devicetype.pk, + 'name': 'Test CSP Template 5', + }, + { + 'device_type': self.devicetype.pk, + 'name': 'Test CSP Template 6', + }, + ] + + url = reverse('dcim-api:consoleserverporttemplate-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ConsoleServerPortTemplate.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_consoleserverporttemplate(self): data = { @@ -780,7 +1064,7 @@ class ConsoleServerPortTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleserverporttemplate-detail', kwargs={'pk': self.consoleserverporttemplate1.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(ConsoleServerPortTemplate.objects.count(), 3) @@ -840,7 +1124,7 @@ class PowerPortTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:powerporttemplate-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(PowerPortTemplate.objects.count(), 4) @@ -848,6 +1132,32 @@ class PowerPortTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(powerporttemplate4.device_type_id, data['device_type']) self.assertEqual(powerporttemplate4.name, data['name']) + def test_create_powerporttemplate_bulk(self): + + data = [ + { + 'device_type': self.devicetype.pk, + 'name': 'Test PP Template 4', + }, + { + 'device_type': self.devicetype.pk, + 'name': 'Test PP Template 5', + }, + { + 'device_type': self.devicetype.pk, + 'name': 'Test PP Template 6', + }, + ] + + url = reverse('dcim-api:powerporttemplate-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerPortTemplate.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_powerporttemplate(self): data = { @@ -856,7 +1166,7 @@ class PowerPortTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:powerporttemplate-detail', kwargs={'pk': self.powerporttemplate1.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(PowerPortTemplate.objects.count(), 3) @@ -916,7 +1226,7 @@ class PowerOutletTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:poweroutlettemplate-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(PowerOutletTemplate.objects.count(), 4) @@ -924,6 +1234,32 @@ class PowerOutletTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(poweroutlettemplate4.device_type_id, data['device_type']) self.assertEqual(poweroutlettemplate4.name, data['name']) + def test_create_poweroutlettemplate_bulk(self): + + data = [ + { + 'device_type': self.devicetype.pk, + 'name': 'Test PO Template 4', + }, + { + 'device_type': self.devicetype.pk, + 'name': 'Test PO Template 5', + }, + { + 'device_type': self.devicetype.pk, + 'name': 'Test PO Template 6', + }, + ] + + url = reverse('dcim-api:poweroutlettemplate-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerOutletTemplate.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_poweroutlettemplate(self): data = { @@ -932,7 +1268,7 @@ class PowerOutletTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:poweroutlettemplate-detail', kwargs={'pk': self.poweroutlettemplate1.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(PowerOutletTemplate.objects.count(), 3) @@ -992,7 +1328,7 @@ class InterfaceTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:interfacetemplate-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(InterfaceTemplate.objects.count(), 4) @@ -1000,6 +1336,32 @@ class InterfaceTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(interfacetemplate4.device_type_id, data['device_type']) self.assertEqual(interfacetemplate4.name, data['name']) + def test_create_interfacetemplate_bulk(self): + + data = [ + { + 'device_type': self.devicetype.pk, + 'name': 'Test Interface Template 4', + }, + { + 'device_type': self.devicetype.pk, + 'name': 'Test Interface Template 5', + }, + { + 'device_type': self.devicetype.pk, + 'name': 'Test Interface Template 6', + }, + ] + + url = reverse('dcim-api:interfacetemplate-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(InterfaceTemplate.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_interfacetemplate(self): data = { @@ -1008,7 +1370,7 @@ class InterfaceTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:interfacetemplate-detail', kwargs={'pk': self.interfacetemplate1.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(InterfaceTemplate.objects.count(), 3) @@ -1068,7 +1430,7 @@ class DeviceBayTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicebaytemplate-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(DeviceBayTemplate.objects.count(), 4) @@ -1076,6 +1438,32 @@ class DeviceBayTemplateTest(HttpStatusMixin, APITestCase): self.assertEqual(devicebaytemplate4.device_type_id, data['device_type']) self.assertEqual(devicebaytemplate4.name, data['name']) + def test_create_devicebaytemplate_bulk(self): + + data = [ + { + 'device_type': self.devicetype.pk, + 'name': 'Test Device Bay Template 4', + }, + { + 'device_type': self.devicetype.pk, + 'name': 'Test Device Bay Template 5', + }, + { + 'device_type': self.devicetype.pk, + 'name': 'Test Device Bay Template 6', + }, + ] + + url = reverse('dcim-api:devicebaytemplate-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(DeviceBayTemplate.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_devicebaytemplate(self): data = { @@ -1084,7 +1472,7 @@ class DeviceBayTemplateTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicebaytemplate-detail', kwargs={'pk': self.devicebaytemplate1.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(DeviceBayTemplate.objects.count(), 3) @@ -1141,7 +1529,7 @@ class DeviceRoleTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicerole-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(DeviceRole.objects.count(), 4) @@ -1150,6 +1538,35 @@ class DeviceRoleTest(HttpStatusMixin, APITestCase): self.assertEqual(devicerole4.slug, data['slug']) self.assertEqual(devicerole4.color, data['color']) + def test_create_devicerole_bulk(self): + + data = [ + { + 'name': 'Test Device Role 4', + 'slug': 'test-device-role-4', + 'color': 'ffff00', + }, + { + 'name': 'Test Device Role 5', + 'slug': 'test-device-role-5', + 'color': 'ffff00', + }, + { + 'name': 'Test Device Role 6', + 'slug': 'test-device-role-6', + 'color': 'ffff00', + }, + ] + + url = reverse('dcim-api:devicerole-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(DeviceRole.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_devicerole(self): data = { @@ -1159,7 +1576,7 @@ class DeviceRoleTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicerole-detail', kwargs={'pk': self.devicerole1.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(DeviceRole.objects.count(), 3) @@ -1211,7 +1628,7 @@ class PlatformTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:platform-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(Platform.objects.count(), 4) @@ -1219,6 +1636,32 @@ class PlatformTest(HttpStatusMixin, APITestCase): self.assertEqual(platform4.name, data['name']) self.assertEqual(platform4.slug, data['slug']) + def test_create_platform_bulk(self): + + data = [ + { + 'name': 'Test Platform 4', + 'slug': 'test-platform-4', + }, + { + 'name': 'Test Platform 5', + 'slug': 'test-platform-5', + }, + { + 'name': 'Test Platform 6', + 'slug': 'test-platform-6', + }, + ] + + url = reverse('dcim-api:platform-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Platform.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_platform(self): data = { @@ -1227,7 +1670,7 @@ class PlatformTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:platform-detail', kwargs={'pk': self.platform1.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(Platform.objects.count(), 3) @@ -1301,7 +1744,7 @@ class DeviceTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:device-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(Device.objects.count(), 4) @@ -1311,6 +1754,38 @@ class DeviceTest(HttpStatusMixin, APITestCase): self.assertEqual(device4.name, data['name']) self.assertEqual(device4.site_id, data['site']) + def test_create_device_bulk(self): + + data = [ + { + 'device_type': self.devicetype1.pk, + 'device_role': self.devicerole1.pk, + 'name': 'Test Device 4', + 'site': self.site1.pk, + }, + { + 'device_type': self.devicetype1.pk, + 'device_role': self.devicerole1.pk, + 'name': 'Test Device 5', + 'site': self.site1.pk, + }, + { + 'device_type': self.devicetype1.pk, + 'device_role': self.devicerole1.pk, + 'name': 'Test Device 6', + 'site': self.site1.pk, + }, + ] + + url = reverse('dcim-api:device-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Device.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_device(self): data = { @@ -1321,7 +1796,7 @@ class DeviceTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.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(Device.objects.count(), 3) @@ -1385,7 +1860,7 @@ class ConsolePortTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleport-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(ConsolePort.objects.count(), 4) @@ -1393,6 +1868,32 @@ class ConsolePortTest(HttpStatusMixin, APITestCase): self.assertEqual(consoleport4.device_id, data['device']) self.assertEqual(consoleport4.name, data['name']) + def test_create_consoleport_bulk(self): + + data = [ + { + 'device': self.device.pk, + 'name': 'Test Console Port 4', + }, + { + 'device': self.device.pk, + 'name': 'Test Console Port 5', + }, + { + 'device': self.device.pk, + 'name': 'Test Console Port 6', + }, + ] + + url = reverse('dcim-api:consoleport-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ConsolePort.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_consoleport(self): consoleserverport = ConsoleServerPort.objects.create(device=self.device, name='Test CS Port 1') @@ -1404,7 +1905,7 @@ class ConsolePortTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleport-detail', kwargs={'pk': self.consoleport1.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(ConsolePort.objects.count(), 3) @@ -1466,7 +1967,7 @@ class ConsoleServerPortTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleserverport-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(ConsoleServerPort.objects.count(), 4) @@ -1474,6 +1975,32 @@ class ConsoleServerPortTest(HttpStatusMixin, APITestCase): self.assertEqual(consoleserverport4.device_id, data['device']) self.assertEqual(consoleserverport4.name, data['name']) + def test_create_consoleserverport_bulk(self): + + data = [ + { + 'device': self.device.pk, + 'name': 'Test CS Port 4', + }, + { + 'device': self.device.pk, + 'name': 'Test CS Port 5', + }, + { + 'device': self.device.pk, + 'name': 'Test CS Port 6', + }, + ] + + url = reverse('dcim-api:consoleserverport-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ConsoleServerPort.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_consoleserverport(self): data = { @@ -1482,7 +2009,7 @@ class ConsoleServerPortTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:consoleserverport-detail', kwargs={'pk': self.consoleserverport1.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(ConsoleServerPort.objects.count(), 3) @@ -1543,7 +2070,7 @@ class PowerPortTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:powerport-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(PowerPort.objects.count(), 4) @@ -1551,6 +2078,32 @@ class PowerPortTest(HttpStatusMixin, APITestCase): self.assertEqual(powerport4.device_id, data['device']) self.assertEqual(powerport4.name, data['name']) + def test_create_powerport_bulk(self): + + data = [ + { + 'device': self.device.pk, + 'name': 'Test Power Port 4', + }, + { + 'device': self.device.pk, + 'name': 'Test Power Port 5', + }, + { + 'device': self.device.pk, + 'name': 'Test Power Port 6', + }, + ] + + url = reverse('dcim-api:powerport-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerPort.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_powerport(self): poweroutlet = PowerOutlet.objects.create(device=self.device, name='Test Power Outlet 1') @@ -1562,7 +2115,7 @@ class PowerPortTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:powerport-detail', kwargs={'pk': self.powerport1.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(PowerPort.objects.count(), 3) @@ -1624,7 +2177,7 @@ class PowerOutletTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:poweroutlet-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(PowerOutlet.objects.count(), 4) @@ -1632,6 +2185,32 @@ class PowerOutletTest(HttpStatusMixin, APITestCase): self.assertEqual(poweroutlet4.device_id, data['device']) self.assertEqual(poweroutlet4.name, data['name']) + def test_create_poweroutlet_bulk(self): + + data = [ + { + 'device': self.device.pk, + 'name': 'Test Power Outlet 4', + }, + { + 'device': self.device.pk, + 'name': 'Test Power Outlet 5', + }, + { + 'device': self.device.pk, + 'name': 'Test Power Outlet 6', + }, + ] + + url = reverse('dcim-api:poweroutlet-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerOutlet.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_poweroutlet(self): data = { @@ -1640,7 +2219,7 @@ class PowerOutletTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:poweroutlet-detail', kwargs={'pk': self.poweroutlet1.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(PowerOutlet.objects.count(), 3) @@ -1722,7 +2301,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:interface-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(Interface.objects.count(), 4) @@ -1730,6 +2309,32 @@ class InterfaceTest(HttpStatusMixin, APITestCase): self.assertEqual(interface4.device_id, data['device']) self.assertEqual(interface4.name, data['name']) + def test_create_interface_bulk(self): + + data = [ + { + 'device': self.device.pk, + 'name': 'Test Interface 4', + }, + { + 'device': self.device.pk, + 'name': 'Test Interface 5', + }, + { + 'device': self.device.pk, + 'name': 'Test Interface 6', + }, + ] + + url = reverse('dcim-api:interface-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(Interface.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_interface(self): lag_interface = Interface.objects.create( @@ -1743,7 +2348,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.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(Interface.objects.count(), 4) @@ -1814,7 +2419,7 @@ class DeviceBayTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicebay-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(DeviceBay.objects.count(), 4) @@ -1823,6 +2428,32 @@ class DeviceBayTest(HttpStatusMixin, APITestCase): self.assertEqual(devicebay4.name, data['name']) self.assertEqual(devicebay4.installed_device_id, data['installed_device']) + def test_create_devicebay_bulk(self): + + data = [ + { + 'device': self.parent_device.pk, + 'name': 'Test Device Bay 4', + }, + { + 'device': self.parent_device.pk, + 'name': 'Test Device Bay 5', + }, + { + 'device': self.parent_device.pk, + 'name': 'Test Device Bay 6', + }, + ] + + url = reverse('dcim-api:devicebay-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(DeviceBay.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_devicebay(self): data = { @@ -1832,7 +2463,7 @@ class DeviceBayTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:devicebay-detail', kwargs={'pk': self.devicebay1.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(DeviceBay.objects.count(), 3) @@ -1896,7 +2527,7 @@ class InventoryItemTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:inventoryitem-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(InventoryItem.objects.count(), 4) @@ -1906,6 +2537,38 @@ class InventoryItemTest(HttpStatusMixin, APITestCase): self.assertEqual(inventoryitem4.name, data['name']) self.assertEqual(inventoryitem4.manufacturer_id, data['manufacturer']) + def test_create_inventoryitem_bulk(self): + + data = [ + { + 'device': self.device.pk, + 'parent': self.inventoryitem1.pk, + 'name': 'Test Inventory Item 4', + 'manufacturer': self.manufacturer.pk, + }, + { + 'device': self.device.pk, + 'parent': self.inventoryitem1.pk, + 'name': 'Test Inventory Item 5', + 'manufacturer': self.manufacturer.pk, + }, + { + 'device': self.device.pk, + 'parent': self.inventoryitem1.pk, + 'name': 'Test Inventory Item 6', + 'manufacturer': self.manufacturer.pk, + }, + ] + + url = reverse('dcim-api:inventoryitem-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(InventoryItem.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_inventoryitem(self): data = { @@ -1916,7 +2579,7 @@ class InventoryItemTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:inventoryitem-detail', kwargs={'pk': self.inventoryitem1.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(InventoryItem.objects.count(), 3) @@ -2048,6 +2711,10 @@ class InterfaceConnectionTest(HttpStatusMixin, APITestCase): self.interface6 = Interface.objects.create(device=self.device, name='Test Interface 6') self.interface7 = Interface.objects.create(device=self.device, name='Test Interface 7') self.interface8 = Interface.objects.create(device=self.device, name='Test Interface 8') + self.interface9 = Interface.objects.create(device=self.device, name='Test Interface 9') + self.interface10 = Interface.objects.create(device=self.device, name='Test Interface 10') + self.interface11 = Interface.objects.create(device=self.device, name='Test Interface 11') + self.interface12 = Interface.objects.create(device=self.device, name='Test Interface 12') self.interfaceconnection1 = InterfaceConnection.objects.create( interface_a=self.interface1, interface_b=self.interface2 ) @@ -2081,7 +2748,7 @@ class InterfaceConnectionTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:interfaceconnection-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(InterfaceConnection.objects.count(), 4) @@ -2089,6 +2756,32 @@ class InterfaceConnectionTest(HttpStatusMixin, APITestCase): self.assertEqual(interfaceconnection4.interface_a_id, data['interface_a']) self.assertEqual(interfaceconnection4.interface_b_id, data['interface_b']) + def test_create_interfaceconnection_bulk(self): + + data = [ + { + 'interface_a': self.interface7.pk, + 'interface_b': self.interface8.pk, + }, + { + 'interface_a': self.interface9.pk, + 'interface_b': self.interface10.pk, + }, + { + 'interface_a': self.interface11.pk, + 'interface_b': self.interface12.pk, + }, + ] + + url = reverse('dcim-api:interfaceconnection-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(InterfaceConnection.objects.count(), 6) + self.assertEqual(response.data[0]['interface_a'], data[0]['interface_a']) + self.assertEqual(response.data[1]['interface_a'], data[1]['interface_a']) + self.assertEqual(response.data[2]['interface_a'], data[2]['interface_a']) + def test_update_interfaceconnection(self): new_connection_status = not self.interfaceconnection1.connection_status @@ -2100,7 +2793,7 @@ class InterfaceConnectionTest(HttpStatusMixin, APITestCase): } url = reverse('dcim-api:interfaceconnection-detail', kwargs={'pk': self.interfaceconnection1.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(InterfaceConnection.objects.count(), 3) @@ -2158,3 +2851,166 @@ class ConnectedDeviceTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['name'], self.device1.name) + + +class VirtualChassisTest(HttpStatusMixin, APITestCase): + + def setUp(self): + + user = User.objects.create(username='testuser', is_superuser=True) + token = Token.objects.create(user=user) + self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(token.key)} + + site = Site.objects.create(name='Test Site', slug='test-site') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type', slug='test-device-type' + ) + device_role = DeviceRole.objects.create( + name='Test Device Role', slug='test-device-role', color='ff0000' + ) + + # Create 9 member Devices with 12 interfaces each + self.device1 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch1', site=site + ) + self.device2 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch2', site=site + ) + self.device3 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch3', site=site + ) + self.device4 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch4', site=site + ) + self.device5 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch5', site=site + ) + self.device6 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch6', site=site + ) + self.device7 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch7', site=site + ) + self.device8 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch8', site=site + ) + self.device9 = Device.objects.create( + device_type=device_type, device_role=device_role, name='StackSwitch9', site=site + ) + for i in range(0, 13): + Interface.objects.create(device=self.device1, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device2, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device3, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device4, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device5, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device6, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device7, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device8, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + for i in range(0, 13): + Interface.objects.create(device=self.device9, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + + # Create two VirtualChassis with three members each + self.vc1 = VirtualChassis.objects.create(master=self.device1, domain='test-domain-1') + Device.objects.filter(pk=self.device2.pk).update(virtual_chassis=self.vc1, vc_position=2) + Device.objects.filter(pk=self.device3.pk).update(virtual_chassis=self.vc1, vc_position=3) + self.vc2 = VirtualChassis.objects.create(master=self.device4, domain='test-domain-2') + Device.objects.filter(pk=self.device5.pk).update(virtual_chassis=self.vc2, vc_position=2) + Device.objects.filter(pk=self.device6.pk).update(virtual_chassis=self.vc2, vc_position=3) + + def test_get_virtualchassis(self): + + url = reverse('dcim-api:virtualchassis-detail', kwargs={'pk': self.vc1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['domain'], self.vc1.domain) + + def test_list_virtualchassis(self): + + url = reverse('dcim-api:virtualchassis-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 2) + + def test_create_virtualchassis(self): + + data = { + 'master': self.device7.pk, + 'domain': 'test-domain-3', + } + + url = reverse('dcim-api:virtualchassis-list') + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(VirtualChassis.objects.count(), 3) + vc3 = VirtualChassis.objects.get(pk=response.data['id']) + self.assertEqual(vc3.master.pk, data['master']) + self.assertEqual(vc3.domain, data['domain']) + + # Verify that the master device was automatically assigned to the VC + self.assertTrue(Device.objects.filter(pk=vc3.master.pk, virtual_chassis=vc3.pk).exists()) + + def test_create_virtualchassis_bulk(self): + + data = [ + { + 'master': self.device7.pk, + 'domain': 'test-domain-3', + }, + { + 'master': self.device8.pk, + 'domain': 'test-domain-4', + }, + { + 'master': self.device9.pk, + 'domain': 'test-domain-5', + }, + ] + + url = reverse('dcim-api:virtualchassis-list') + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(VirtualChassis.objects.count(), 5) + self.assertEqual(response.data[0]['master'], data[0]['master']) + self.assertEqual(response.data[0]['domain'], data[0]['domain']) + self.assertEqual(response.data[1]['master'], data[1]['master']) + self.assertEqual(response.data[1]['domain'], data[1]['domain']) + self.assertEqual(response.data[2]['master'], data[2]['master']) + self.assertEqual(response.data[2]['domain'], data[2]['domain']) + + def test_update_virtualchassis(self): + + data = { + 'master': self.device2.pk, + 'domain': 'test-domain-x', + } + + url = reverse('dcim-api:virtualchassis-detail', kwargs={'pk': self.vc1.pk}) + response = self.client.put(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(VirtualChassis.objects.count(), 2) + vc1 = VirtualChassis.objects.get(pk=response.data['id']) + self.assertEqual(vc1.master.pk, data['master']) + self.assertEqual(vc1.domain, data['domain']) + + def test_delete_virtualchassis(self): + + url = reverse('dcim-api:virtualchassis-detail', kwargs={'pk': self.vc1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(VirtualChassis.objects.count(), 1) + + # Verify that all VC members have had their VC-related fields nullified + for d in [self.device1, self.device2, self.device3]: + self.assertTrue( + Device.objects.filter(pk=d.pk, virtual_chassis=None, vc_position=None, vc_priority=None) + ) diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index acf71411e..c8d438728 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -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()) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index bd10ad216..e7e1e41df 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -140,8 +140,8 @@ urlpatterns = [ url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), url(r'^devices/(?P\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'), url(r'^devices/(?P\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), - url(r'^console-ports/(?P\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'), - url(r'^console-ports/(?P\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'), + url(r'^console-ports/(?P\d+)/connect/$', views.ConsolePortConnectView.as_view(), name='consoleport_connect'), + url(r'^console-ports/(?P\d+)/disconnect/$', views.ConsolePortDisconnectView.as_view(), name='consoleport_disconnect'), url(r'^console-ports/(?P\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'), url(r'^console-ports/(?P\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), @@ -150,17 +150,18 @@ urlpatterns = [ url(r'^devices/(?P\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'), url(r'^devices/(?P\d+)/console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), url(r'^devices/(?P\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), - url(r'^console-server-ports/(?P\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'), - url(r'^console-server-ports/(?P\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'), + url(r'^console-server-ports/(?P\d+)/connect/$', views.ConsoleServerPortConnectView.as_view(), name='consoleserverport_connect'), + url(r'^console-server-ports/(?P\d+)/disconnect/$', views.ConsoleServerPortDisconnectView.as_view(), name='consoleserverport_disconnect'), url(r'^console-server-ports/(?P\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), url(r'^console-server-ports/(?P\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\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'), url(r'^devices/(?P\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), - url(r'^power-ports/(?P\d+)/connect/$', views.powerport_connect, name='powerport_connect'), - url(r'^power-ports/(?P\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'), + url(r'^power-ports/(?P\d+)/connect/$', views.PowerPortConnectView.as_view(), name='powerport_connect'), + url(r'^power-ports/(?P\d+)/disconnect/$', views.PowerPortDisconnectView.as_view(), name='powerport_disconnect'), url(r'^power-ports/(?P\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'), url(r'^power-ports/(?P\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'), @@ -169,10 +170,11 @@ urlpatterns = [ url(r'^devices/(?P\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'), url(r'^devices/(?P\d+)/power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), url(r'^devices/(?P\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), - url(r'^power-outlets/(?P\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'), - url(r'^power-outlets/(?P\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'), + url(r'^power-outlets/(?P\d+)/connect/$', views.PowerOutletConnectView.as_view(), name='poweroutlet_connect'), + url(r'^power-outlets/(?P\d+)/disconnect/$', views.PowerOutletDisconnectView.as_view(), name='poweroutlet_disconnect'), url(r'^power-outlets/(?P\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), url(r'^power-outlets/(?P\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\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), url(r'^devices/(?P\d+)/interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), url(r'^devices/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), - url(r'^devices/(?P\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'), - url(r'^interface-connections/(?P\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'), + url(r'^devices/(?P\d+)/interface-connections/add/$', views.InterfaceConnectionAddView.as_view(), name='interfaceconnection_add'), + url(r'^interface-connections/(?P\d+)/delete/$', views.InterfaceConnectionDeleteView.as_view(), name='interfaceconnection_delete'), url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), url(r'^interfaces/(?P\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\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), url(r'^device-bays/(?P\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'), url(r'^device-bays/(?P\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), - url(r'^device-bays/(?P\d+)/populate/$', views.devicebay_populate, name='devicebay_populate'), - url(r'^device-bays/(?P\d+)/depopulate/$', views.devicebay_depopulate, name='devicebay_depopulate'), + url(r'^device-bays/(?P\d+)/populate/$', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'), + url(r'^device-bays/(?P\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\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), + url(r'^virtual-chassis/(?P\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), + url(r'^virtual-chassis/(?P\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), + url(r'^virtual-chassis-members/(?P\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), + ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 6ac52f58d..02c87c122 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3,15 +3,16 @@ from __future__ import unicode_literals from operator import attrgetter from django.contrib import messages -from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.paginator import EmptyPage, PageNotAnInteger +from django.db import transaction from django.db.models import Count, Q +from django.forms import ModelChoiceField, ModelForm, modelformset_factory from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape -from django.utils.http import urlencode +from django.utils.http import is_safe_url, urlencode from django.utils.safestring import mark_safe from django.views.generic import View from natsort import natsorted @@ -22,8 +23,8 @@ from ipam.models import Prefix, Service, VLAN from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( - BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ComponentDeleteView, - ComponentEditView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin, + ObjectDeleteView, ObjectEditView, ObjectListView, ) from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -32,10 +33,54 @@ 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, ) +class BulkRenameView(View): + """ + An extendable view for renaming device components in bulk. + """ + model = None + form = None + template_name = 'dcim/bulk_rename.html' + + def post(self, request): + + return_url = request.GET.get('return_url') + if not return_url or not is_safe_url(url=return_url, host=request.get_host()): + return_url = 'home' + + if '_preview' in request.POST or '_apply' in request.POST: + form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')}) + selected_objects = self.model.objects.filter(pk__in=form.initial['pk']) + + if form.is_valid(): + for obj in selected_objects: + obj.new_name = obj.name.replace(form.cleaned_data['find'], form.cleaned_data['replace']) + + if '_apply' in request.POST: + for obj in selected_objects: + obj.name = obj.new_name + obj.save() + messages.success(request, "Renamed {} {}".format( + len(selected_objects), + self.model._meta.verbose_name_plural + )) + return redirect(return_url) + + else: + form = self.form(initial={'pk': request.POST.getlist('pk')}) + selected_objects = self.model.objects.filter(pk__in=form.initial['pk']) + + return render(request, self.template_name, { + 'form': form, + 'obj_type_plural': self.model._meta.verbose_name_plural, + 'selected_objects': selected_objects, + 'return_url': return_url, + }) + + class BulkDisconnectView(View): """ An extendable view for disconnection console/power/interface components in bulk. @@ -122,7 +167,7 @@ class SiteListView(ObjectListView): queryset = Site.objects.select_related('region', 'tenant') filter = filters.SiteFilter filter_form = forms.SiteFilterForm - table = tables.SiteDetailTable + table = tables.SiteTable template_name = 'dcim/site_list.html' @@ -330,7 +375,7 @@ class RackView(View): rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk) - nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True)\ + nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True) \ .select_related('device_type__manufacturer') next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first() prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first() @@ -453,7 +498,10 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class ManufacturerListView(ObjectListView): - queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types')) + queryset = Manufacturer.objects.annotate( + devicetype_count=Count('device_types', distinct=True), + platform_count=Count('platforms', distinct=True), + ) table = tables.ManufacturerTable template_name = 'dcim/manufacturer_list.html' @@ -507,29 +555,29 @@ class DeviceTypeView(View): # Component tables consoleport_table = tables.ConsolePortTemplateTable( natsorted(ConsolePortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), - show_header=False + orderable=False ) consoleserverport_table = tables.ConsoleServerPortTemplateTable( natsorted(ConsoleServerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), - show_header=False + orderable=False ) powerport_table = tables.PowerPortTemplateTable( natsorted(PowerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), - show_header=False + orderable=False ) poweroutlet_table = tables.PowerOutletTemplateTable( natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), - show_header=False + orderable=False ) interface_table = tables.InterfaceTemplateTable( list(InterfaceTemplate.objects.order_naturally( devicetype.interface_ordering ).filter(device_type=devicetype)), - show_header=False + orderable=False ) devicebay_table = tables.DeviceBayTemplateTable( natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), - show_header=False + orderable=False ) if request.user.has_perm('dcim.change_devicetype'): consoleport_table.columns.show('pk') @@ -806,27 +854,47 @@ class DeviceView(View): device = get_object_or_404(Device.objects.select_related( 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform' ), pk=pk) + + # VirtualChassis members + if device.virtual_chassis is not None: + vc_members = Device.objects.filter(virtual_chassis=device.virtual_chassis).order_by('vc_position') + else: + vc_members = [] + + # Console ports console_ports = natsorted( ConsolePort.objects.filter(device=device).select_related('cs_port__device'), key=attrgetter('name') ) + + # Console server ports cs_ports = ConsoleServerPort.objects.filter(device=device).select_related('connected_console') + + # Power ports power_ports = natsorted( PowerPort.objects.filter(device=device).select_related('power_outlet__device'), key=attrgetter('name') ) + + # Power outlets power_outlets = PowerOutlet.objects.filter(device=device).select_related('connected_port') - interfaces = Interface.objects.order_naturally( + + # Interfaces + interfaces = device.vc_interfaces.order_naturally( device.device_type.interface_ordering - ).filter( - device=device ).select_related( 'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device', 'circuit_termination__circuit' ).prefetch_related('ip_addresses') + + # Device bays device_bays = natsorted( DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'), key=attrgetter('name') ) + + # Services services = Service.objects.filter(device=device) + + # Secrets secrets = device.secrets.all() # Find up to ten devices in the same site with the same functional role for quick reference. @@ -851,6 +919,7 @@ class DeviceView(View): 'device_bays': device_bays, 'services': services, 'secrets': secrets, + 'vc_members': vc_members, 'related_devices': related_devices, 'show_graphs': show_graphs, }) @@ -997,14 +1066,32 @@ class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -@permission_required('dcim.change_consoleport') -def consoleport_connect(request, pk): +class ConsolePortConnectView(PermissionRequiredMixin, View): + permission_required = 'dcim.change_consoleport' - consoleport = get_object_or_404(ConsolePort, pk=pk) + def get(self, request, pk): - if request.method == 'POST': + consoleport = get_object_or_404(ConsolePort, pk=pk) + form = forms.ConsolePortConnectionForm(instance=consoleport, initial={ + 'site': request.GET.get('site'), + 'rack': request.GET.get('rack'), + 'console_server': request.GET.get('console_server'), + 'connection_status': CONNECTION_STATUS_CONNECTED, + }) + + return render(request, 'dcim/consoleport_connect.html', { + 'consoleport': consoleport, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), + }) + + def post(self, request, pk): + + consoleport = get_object_or_404(ConsolePort, pk=pk) form = forms.ConsolePortConnectionForm(request.POST, instance=consoleport) + if form.is_valid(): + consoleport = form.save() msg = 'Connected {} {} to {} {}'.format( consoleport.device.get_absolute_url(), @@ -1016,37 +1103,43 @@ def consoleport_connect(request, pk): ) messages.success(request, mark_safe(msg)) UserAction.objects.log_edit(request.user, consoleport, msg) + return redirect('dcim:device', pk=consoleport.device.pk) - else: - form = forms.ConsolePortConnectionForm(instance=consoleport, initial={ - 'site': request.GET.get('site'), - 'rack': request.GET.get('rack'), - 'console_server': request.GET.get('console_server'), - 'connection_status': CONNECTION_STATUS_CONNECTED, + return render(request, 'dcim/consoleport_connect.html', { + 'consoleport': consoleport, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), }) - return render(request, 'dcim/consoleport_connect.html', { - 'consoleport': consoleport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), - }) +class ConsolePortDisconnectView(PermissionRequiredMixin, View): + permission_required = 'dcim.change_consoleport' -@permission_required('dcim.change_consoleport') -def consoleport_disconnect(request, pk): + def get(self, request, pk): - consoleport = get_object_or_404(ConsolePort, pk=pk) + consoleport = get_object_or_404(ConsolePort, pk=pk) + form = ConfirmationForm() - if not consoleport.cs_port: - messages.warning( - request, "Cannot disconnect console port {}: It is not connected to anything.".format(consoleport) - ) - return redirect('dcim:device', pk=consoleport.device.pk) + if not consoleport.cs_port: + messages.warning( + request, "Cannot disconnect console port {}: It is not connected to anything.".format(consoleport) + ) + return redirect('dcim:device', pk=consoleport.device.pk) - if request.method == 'POST': + return render(request, 'dcim/consoleport_disconnect.html', { + 'consoleport': consoleport, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), + }) + + def post(self, request, pk): + + consoleport = get_object_or_404(ConsolePort, pk=pk) form = ConfirmationForm(request.POST) + if form.is_valid(): + cs_port = consoleport.cs_port consoleport.cs_port = None consoleport.connection_status = None @@ -1061,29 +1154,25 @@ def consoleport_disconnect(request, pk): ) messages.success(request, mark_safe(msg)) UserAction.objects.log_edit(request.user, consoleport, msg) + return redirect('dcim:device', pk=consoleport.device.pk) - else: - form = ConfirmationForm() - - return render(request, 'dcim/consoleport_disconnect.html', { - 'consoleport': consoleport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), - }) + return render(request, 'dcim/consoleport_disconnect.html', { + 'consoleport': consoleport, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), + }) -class ConsolePortEditView(PermissionRequiredMixin, ComponentEditView): +class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleport' model = ConsolePort - parent_field = 'device' model_form = forms.ConsolePortForm -class ConsolePortDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_consoleport' model = ConsolePort - parent_field = 'device' class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1114,14 +1203,32 @@ class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -@permission_required('dcim.change_consoleserverport') -def consoleserverport_connect(request, pk): +class ConsoleServerPortConnectView(PermissionRequiredMixin, View): + permission_required = 'dcim.change_consoleserverport' - consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) + def get(self, request, pk): - if request.method == 'POST': + consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) + form = forms.ConsoleServerPortConnectionForm(initial={ + 'site': request.GET.get('site'), + 'rack': request.GET.get('rack'), + 'device': request.GET.get('device'), + 'connection_status': CONNECTION_STATUS_CONNECTED, + }) + + return render(request, 'dcim/consoleserverport_connect.html', { + 'consoleserverport': consoleserverport, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), + }) + + def post(self, request, pk): + + consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) form = forms.ConsoleServerPortConnectionForm(request.POST) + if form.is_valid(): + consoleport = form.cleaned_data['port'] consoleport.cs_port = consoleserverport consoleport.connection_status = form.cleaned_data['connection_status'] @@ -1136,37 +1243,44 @@ def consoleserverport_connect(request, pk): ) messages.success(request, mark_safe(msg)) UserAction.objects.log_edit(request.user, consoleport, msg) + return redirect('dcim:device', pk=consoleserverport.device.pk) - else: - form = forms.ConsoleServerPortConnectionForm(initial={ - 'site': request.GET.get('site'), - 'rack': request.GET.get('rack'), - 'device': request.GET.get('device'), - 'connection_status': CONNECTION_STATUS_CONNECTED, + return render(request, 'dcim/consoleserverport_connect.html', { + 'consoleserverport': consoleserverport, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), }) - return render(request, 'dcim/consoleserverport_connect.html', { - 'consoleserverport': consoleserverport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), - }) +class ConsoleServerPortDisconnectView(PermissionRequiredMixin, View): + permission_required = 'dcim.change_consoleserverport' -@permission_required('dcim.change_consoleserverport') -def consoleserverport_disconnect(request, pk): + def get(self, request, pk): - consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) + consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) + form = ConfirmationForm() - if not hasattr(consoleserverport, 'connected_console'): - messages.warning( - request, "Cannot disconnect console server port {}: Nothing is connected to it.".format(consoleserverport) - ) - return redirect('dcim:device', pk=consoleserverport.device.pk) + if not hasattr(consoleserverport, 'connected_console'): + messages.warning( + request, + "Cannot disconnect console server port {}: Nothing is connected to it.".format(consoleserverport) + ) + return redirect('dcim:device', pk=consoleserverport.device.pk) - if request.method == 'POST': + return render(request, 'dcim/consoleserverport_disconnect.html', { + 'consoleserverport': consoleserverport, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), + }) + + def post(self, request, pk): + + consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) form = ConfirmationForm(request.POST) + if form.is_valid(): + consoleport = consoleserverport.connected_console consoleport.cs_port = None consoleport.connection_status = None @@ -1181,29 +1295,31 @@ def consoleserverport_disconnect(request, pk): ) messages.success(request, mark_safe(msg)) UserAction.objects.log_edit(request.user, consoleport, msg) + return redirect('dcim:device', pk=consoleserverport.device.pk) - else: - form = ConfirmationForm() - - return render(request, 'dcim/consoleserverport_disconnect.html', { - 'consoleserverport': consoleserverport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), - }) + return render(request, 'dcim/consoleserverport_disconnect.html', { + 'consoleserverport': consoleserverport, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), + }) -class ConsoleServerPortEditView(PermissionRequiredMixin, ComponentEditView): +class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_consoleserverport' model = ConsoleServerPort - parent_field = 'device' model_form = forms.ConsoleServerPortForm -class ConsoleServerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_consoleserverport' model = ConsoleServerPort - parent_field = 'device' + + +class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView): + permission_required = 'dcim.change_consoleserverport' + model = ConsoleServerPort + form = forms.ConsoleServerPortBulkRenameForm class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1236,14 +1352,32 @@ class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -@permission_required('dcim.change_powerport') -def powerport_connect(request, pk): +class PowerPortConnectView(PermissionRequiredMixin, View): + permission_required = 'dcim.change_powerport' - powerport = get_object_or_404(PowerPort, pk=pk) + def get(self, request, pk): - if request.method == 'POST': + powerport = get_object_or_404(PowerPort, pk=pk) + form = forms.PowerPortConnectionForm(instance=powerport, initial={ + 'site': request.GET.get('site'), + 'rack': request.GET.get('rack'), + 'pdu': request.GET.get('pdu'), + 'connection_status': CONNECTION_STATUS_CONNECTED, + }) + + return render(request, 'dcim/powerport_connect.html', { + 'powerport': powerport, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), + }) + + def post(self, request, pk): + + powerport = get_object_or_404(PowerPort, pk=pk) form = forms.PowerPortConnectionForm(request.POST, instance=powerport) + if form.is_valid(): + powerport = form.save() msg = 'Connected {} {} to {} {}'.format( powerport.device.get_absolute_url(), @@ -1255,37 +1389,43 @@ def powerport_connect(request, pk): ) messages.success(request, mark_safe(msg)) UserAction.objects.log_edit(request.user, powerport, msg) + return redirect('dcim:device', pk=powerport.device.pk) - else: - form = forms.PowerPortConnectionForm(instance=powerport, initial={ - 'site': request.GET.get('site'), - 'rack': request.GET.get('rack'), - 'pdu': request.GET.get('pdu'), - 'connection_status': CONNECTION_STATUS_CONNECTED, + return render(request, 'dcim/powerport_connect.html', { + 'powerport': powerport, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), }) - return render(request, 'dcim/powerport_connect.html', { - 'powerport': powerport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), - }) +class PowerPortDisconnectView(PermissionRequiredMixin, View): + permission_required = 'dcim.change_powerport' -@permission_required('dcim.change_powerport') -def powerport_disconnect(request, pk): + def get(self, request, pk): - powerport = get_object_or_404(PowerPort, pk=pk) + powerport = get_object_or_404(PowerPort, pk=pk) + form = ConfirmationForm() - if not powerport.power_outlet: - messages.warning( - request, "Cannot disconnect power port {}: It is not connected to an outlet.".format(powerport) - ) - return redirect('dcim:device', pk=powerport.device.pk) + if not powerport.power_outlet: + messages.warning( + request, "Cannot disconnect power port {}: It is not connected to an outlet.".format(powerport) + ) + return redirect('dcim:device', pk=powerport.device.pk) - if request.method == 'POST': + return render(request, 'dcim/powerport_disconnect.html', { + 'powerport': powerport, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), + }) + + def post(self, request, pk): + + powerport = get_object_or_404(PowerPort, pk=pk) form = ConfirmationForm(request.POST) + if form.is_valid(): + power_outlet = powerport.power_outlet powerport.power_outlet = None powerport.connection_status = None @@ -1300,29 +1440,25 @@ def powerport_disconnect(request, pk): ) messages.success(request, mark_safe(msg)) UserAction.objects.log_edit(request.user, powerport, msg) + return redirect('dcim:device', pk=powerport.device.pk) - else: - form = ConfirmationForm() - - return render(request, 'dcim/powerport_disconnect.html', { - 'powerport': powerport, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), - }) + return render(request, 'dcim/powerport_disconnect.html', { + 'powerport': powerport, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), + }) -class PowerPortEditView(PermissionRequiredMixin, ComponentEditView): +class PowerPortEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_powerport' model = PowerPort - parent_field = 'device' model_form = forms.PowerPortForm -class PowerPortDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_powerport' model = PowerPort - parent_field = 'device' class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1353,13 +1489,30 @@ class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -@permission_required('dcim.change_poweroutlet') -def poweroutlet_connect(request, pk): +class PowerOutletConnectView(PermissionRequiredMixin, View): + permission_required = 'dcim.change_poweroutlet' - poweroutlet = get_object_or_404(PowerOutlet, pk=pk) + def get(self, request, pk): - if request.method == 'POST': + poweroutlet = get_object_or_404(PowerOutlet, pk=pk) + form = forms.PowerOutletConnectionForm(initial={ + 'site': request.GET.get('site'), + 'rack': request.GET.get('rack'), + 'device': request.GET.get('device'), + 'connection_status': CONNECTION_STATUS_CONNECTED, + }) + + return render(request, 'dcim/poweroutlet_connect.html', { + 'poweroutlet': poweroutlet, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), + }) + + def post(self, request, pk): + + poweroutlet = get_object_or_404(PowerOutlet, pk=pk) form = forms.PowerOutletConnectionForm(request.POST) + if form.is_valid(): powerport = form.cleaned_data['port'] powerport.power_outlet = poweroutlet @@ -1375,37 +1528,43 @@ def poweroutlet_connect(request, pk): ) messages.success(request, mark_safe(msg)) UserAction.objects.log_edit(request.user, powerport, msg) + return redirect('dcim:device', pk=poweroutlet.device.pk) - else: - form = forms.PowerOutletConnectionForm(initial={ - 'site': request.GET.get('site'), - 'rack': request.GET.get('rack'), - 'device': request.GET.get('device'), - 'connection_status': CONNECTION_STATUS_CONNECTED, + return render(request, 'dcim/poweroutlet_connect.html', { + 'poweroutlet': poweroutlet, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), }) - return render(request, 'dcim/poweroutlet_connect.html', { - 'poweroutlet': poweroutlet, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), - }) +class PowerOutletDisconnectView(PermissionRequiredMixin, View): + permission_required = 'dcim.change_poweroutlet' -@permission_required('dcim.change_poweroutlet') -def poweroutlet_disconnect(request, pk): + def get(self, request, pk): - poweroutlet = get_object_or_404(PowerOutlet, pk=pk) + poweroutlet = get_object_or_404(PowerOutlet, pk=pk) + form = ConfirmationForm() - if not hasattr(poweroutlet, 'connected_port'): - messages.warning( - request, "Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet) - ) - return redirect('dcim:device', pk=poweroutlet.device.pk) + if not hasattr(poweroutlet, 'connected_port'): + messages.warning( + request, "Cannot disconnect power outlet {}: Nothing is connected to it.".format(poweroutlet) + ) + return redirect('dcim:device', pk=poweroutlet.device.pk) - if request.method == 'POST': + return render(request, 'dcim/poweroutlet_disconnect.html', { + 'poweroutlet': poweroutlet, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), + }) + + def post(self, request, pk): + + poweroutlet = get_object_or_404(PowerOutlet, pk=pk) form = ConfirmationForm(request.POST) + if form.is_valid(): + powerport = poweroutlet.connected_port powerport.power_outlet = None powerport.connection_status = None @@ -1420,29 +1579,31 @@ def poweroutlet_disconnect(request, pk): ) messages.success(request, mark_safe(msg)) UserAction.objects.log_edit(request.user, powerport, msg) + return redirect('dcim:device', pk=poweroutlet.device.pk) - else: - form = ConfirmationForm() - - return render(request, 'dcim/poweroutlet_disconnect.html', { - 'poweroutlet': poweroutlet, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), - }) + return render(request, 'dcim/poweroutlet_disconnect.html', { + 'poweroutlet': poweroutlet, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), + }) -class PowerOutletEditView(PermissionRequiredMixin, ComponentEditView): +class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_poweroutlet' model = PowerOutlet - parent_field = 'device' model_form = forms.PowerOutletForm -class PowerOutletDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_poweroutlet' model = PowerOutlet - parent_field = 'device' + + +class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView): + permission_required = 'dcim.change_poweroutlet' + model = PowerOutlet + form = forms.PowerOutletBulkRenameForm class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1477,17 +1638,16 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class InterfaceEditView(PermissionRequiredMixin, ComponentEditView): +class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_interface' model = Interface - parent_field = 'device' model_form = forms.InterfaceForm + template_name = 'dcim/interface_edit.html' -class InterfaceDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_interface' model = Interface - parent_field = 'device' class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView): @@ -1510,6 +1670,12 @@ class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): form = forms.InterfaceBulkEditForm +class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView): + permission_required = 'dcim.change_interface' + model = Interface + form = forms.InterfaceBulkRenameForm + + class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interface' cls = Interface @@ -1531,67 +1697,90 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): template_name = 'dcim/device_component_add.html' -class DeviceBayEditView(PermissionRequiredMixin, ComponentEditView): +class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_devicebay' model = DeviceBay - parent_field = 'device' model_form = forms.DeviceBayForm -class DeviceBayDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_devicebay' model = DeviceBay - parent_field = 'device' -@permission_required('dcim.change_devicebay') -def devicebay_populate(request, pk): +class DeviceBayPopulateView(PermissionRequiredMixin, View): + permission_required = 'dcim.change_devicebay' - device_bay = get_object_or_404(DeviceBay, pk=pk) + def get(self, request, pk): - if request.method == 'POST': + device_bay = get_object_or_404(DeviceBay, pk=pk) + form = forms.PopulateDeviceBayForm(device_bay) + + return render(request, 'dcim/devicebay_populate.html', { + 'device_bay': device_bay, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}), + }) + + def post(self, request, pk): + + device_bay = get_object_or_404(DeviceBay, pk=pk) form = forms.PopulateDeviceBayForm(device_bay, request.POST) + if form.is_valid(): device_bay.installed_device = form.cleaned_data['installed_device'] device_bay.save() + messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay)) - if not form.errors: - messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay)) - return redirect('dcim:device', pk=device_bay.device.pk) + return redirect('dcim:device', pk=device_bay.device.pk) - else: - form = forms.PopulateDeviceBayForm(device_bay) - - return render(request, 'dcim/devicebay_populate.html', { - 'device_bay': device_bay, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}), - }) + return render(request, 'dcim/devicebay_populate.html', { + 'device_bay': device_bay, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}), + }) -@permission_required('dcim.change_devicebay') -def devicebay_depopulate(request, pk): +class DeviceBayDepopulateView(PermissionRequiredMixin, View): + permission_required = 'dcim.change_devicebay' - device_bay = get_object_or_404(DeviceBay, pk=pk) + def get(self, request, pk): - if request.method == 'POST': + device_bay = get_object_or_404(DeviceBay, pk=pk) + form = ConfirmationForm() + + return render(request, 'dcim/devicebay_depopulate.html', { + 'device_bay': device_bay, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}), + }) + + def post(self, request, pk): + + device_bay = get_object_or_404(DeviceBay, pk=pk) form = ConfirmationForm(request.POST) + if form.is_valid(): + removed_device = device_bay.installed_device device_bay.installed_device = None device_bay.save() messages.success(request, "{} has been removed from {}.".format(removed_device, device_bay)) + return redirect('dcim:device', pk=device_bay.device.pk) - else: - form = ConfirmationForm() + return render(request, 'dcim/devicebay_depopulate.html', { + 'device_bay': device_bay, + 'form': form, + 'return_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}), + }) - return render(request, 'dcim/devicebay_depopulate.html', { - 'device_bay': device_bay, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': device_bay.device.pk}), - }) + +class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView): + permission_required = 'dcim.change_devicebay' + model = DeviceBay + form = forms.DeviceBayBulkRenameForm class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -1675,13 +1864,32 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie # Interface connections # -@permission_required('dcim.add_interfaceconnection') -def interfaceconnection_add(request, pk): +class InterfaceConnectionAddView(PermissionRequiredMixin, GetReturnURLMixin, View): + permission_required = 'dcim.add_interfaceconnection' + default_return_url = 'dcim:device_list' - device = get_object_or_404(Device, pk=pk) + def get(self, request, pk): - if request.method == 'POST': + device = get_object_or_404(Device, pk=pk) + form = forms.InterfaceConnectionForm(device, initial={ + 'interface_a': request.GET.get('interface_a'), + 'site_b': request.GET.get('site_b'), + 'rack_b': request.GET.get('rack_b'), + 'device_b': request.GET.get('device_b'), + 'interface_b': request.GET.get('interface_b'), + }) + + return render(request, 'dcim/interfaceconnection_edit.html', { + 'device': device, + 'form': form, + 'return_url': device.get_absolute_url(), + }) + + def post(self, request, pk): + + device = get_object_or_404(Device, pk=pk) form = forms.InterfaceConnectionForm(device, request.POST) + if form.is_valid(): interfaceconnection = form.save() @@ -1707,30 +1915,33 @@ def interfaceconnection_add(request, pk): else: return redirect('dcim:device', pk=device.pk) - else: - form = forms.InterfaceConnectionForm(device, initial={ - 'interface_a': request.GET.get('interface_a'), - 'site_b': request.GET.get('site_b'), - 'rack_b': request.GET.get('rack_b'), - 'device_b': request.GET.get('device_b'), - 'interface_b': request.GET.get('interface_b'), + return render(request, 'dcim/interfaceconnection_edit.html', { + 'device': device, + 'form': form, + 'return_url': device.get_absolute_url(), }) - return render(request, 'dcim/interfaceconnection_edit.html', { - 'device': device, - 'form': form, - 'return_url': reverse('dcim:device', kwargs={'pk': device.pk}), - }) +class InterfaceConnectionDeleteView(PermissionRequiredMixin, GetReturnURLMixin, View): + permission_required = 'dcim.delete_interfaceconnection' + default_return_url = 'dcim:device_list' -@permission_required('dcim.delete_interfaceconnection') -def interfaceconnection_delete(request, pk): + def get(self, request, pk): - interfaceconnection = get_object_or_404(InterfaceConnection, pk=pk) - device_id = request.GET.get('device', None) + interfaceconnection = get_object_or_404(InterfaceConnection, pk=pk) + form = forms.ConfirmationForm() + + return render(request, 'dcim/interfaceconnection_delete.html', { + 'interfaceconnection': interfaceconnection, + 'form': form, + 'return_url': self.get_return_url(request, interfaceconnection), + }) + + def post(self, request, pk): + + interfaceconnection = get_object_or_404(InterfaceConnection, pk=pk) + form = forms.ConfirmationForm(request.POST) - if request.method == 'POST': - form = forms.InterfaceConnectionDeletionForm(request.POST) if form.is_valid(): interfaceconnection.delete() msg = 'Disconnected {} {} from {} {}'.format( @@ -1743,29 +1954,15 @@ def interfaceconnection_delete(request, pk): ) messages.success(request, mark_safe(msg)) UserAction.objects.log_edit(request.user, interfaceconnection, msg) - if form.cleaned_data['device']: - return redirect('dcim:device', pk=form.cleaned_data['device'].pk) - else: - return redirect('dcim:device_list') - else: - form = forms.InterfaceConnectionDeletionForm(initial={ - 'device': device_id, + return redirect(self.get_return_url(request, interfaceconnection)) + + return render(request, 'dcim/interfaceconnection_delete.html', { + 'interfaceconnection': interfaceconnection, + 'form': form, + 'return_url': self.get_return_url(request, interfaceconnection), }) - # Determine where to direct user upon cancellation - if device_id: - return_url = reverse('dcim:device', kwargs={'pk': device_id}) - else: - return_url = reverse('dcim:device_list') - - return render(request, 'dcim/interfaceconnection_delete.html', { - 'interfaceconnection': interfaceconnection, - 'device_id': device_id, - 'form': form, - 'return_url': return_url, - }) - class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'dcim.change_interface' @@ -1779,7 +1976,7 @@ class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView # class ConsoleConnectionsListView(ObjectListView): - queryset = ConsolePort.objects.select_related('device', 'cs_port__device').filter(cs_port__isnull=False)\ + queryset = ConsolePort.objects.select_related('device', 'cs_port__device').filter(cs_port__isnull=False) \ .order_by('cs_port__device__name', 'cs_port__name') filter = filters.ConsoleConnectionFilter filter_form = forms.ConsoleConnectionFilterForm @@ -1788,7 +1985,7 @@ class ConsoleConnectionsListView(ObjectListView): class PowerConnectionsListView(ObjectListView): - queryset = PowerPort.objects.select_related('device', 'power_outlet__device').filter(power_outlet__isnull=False)\ + queryset = PowerPort.objects.select_related('device', 'power_outlet__device').filter(power_outlet__isnull=False) \ .order_by('power_outlet__device__name', 'power_outlet__name') filter = filters.PowerConnectionFilter filter_form = forms.PowerConnectionFilterForm @@ -1797,8 +1994,11 @@ class PowerConnectionsListView(ObjectListView): class InterfaceConnectionsListView(ObjectListView): - queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')\ - .order_by('interface_a__device__name', 'interface_a__name') + queryset = InterfaceConnection.objects.select_related( + 'interface_a__device', 'interface_b__device' + ).order_by( + 'interface_a__device__name', 'interface_a__name' + ) filter = filters.InterfaceConnectionFilter filter_form = forms.InterfaceConnectionFilterForm table = tables.InterfaceConnectionTable @@ -1817,10 +2017,9 @@ class InventoryItemListView(ObjectListView): template_name = 'dcim/inventoryitem_list.html' -class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): +class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_inventoryitem' model = InventoryItem - parent_field = 'device' model_form = forms.InventoryItemForm def alter_obj(self, obj, request, url_args, url_kwargs): @@ -1832,13 +2031,9 @@ class InventoryItemEditView(PermissionRequiredMixin, ComponentEditView): return reverse('dcim:device_inventory', kwargs={'pk': obj.device.pk}) -class InventoryItemDeleteView(PermissionRequiredMixin, ComponentDeleteView): +class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_inventoryitem' model = InventoryItem - parent_field = 'device' - - def get_return_url(self, request, obj): - return reverse('dcim:device_inventory', kwargs={'pk': obj.device.pk}) class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView): @@ -1865,3 +2060,241 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.InventoryItemTable template_name = 'dcim/inventoryitem_bulk_delete.html' default_return_url = 'dcim:inventoryitem_list' + + +# +# Virtual chassis +# + +class VirtualChassisListView(ObjectListView): + queryset = VirtualChassis.objects.annotate(member_count=Count('members')) + table = tables.VirtualChassisTable + filter = filters.VirtualChassisFilter + filter_form = forms.VirtualChassisFilterForm + template_name = 'dcim/virtualchassis_list.html' + + +class VirtualChassisCreateView(PermissionRequiredMixin, View): + permission_required = 'dcim.add_virtualchassis' + + def post(self, request): + + # Get the list of devices being added to a VirtualChassis + pk_form = forms.DeviceSelectionForm(request.POST) + pk_form.full_clean() + device_queryset = Device.objects.filter( + pk__in=pk_form.cleaned_data.get('pk') + ).select_related('rack').order_by('vc_position') + + if not device_queryset: + messages.warning(request, "No devices were selected.") + return redirect('dcim:device_list') + + VCMemberFormSet = modelformset_factory( + model=Device, + formset=forms.BaseVCMemberFormSet, + form=forms.DeviceVCMembershipForm, + extra=0 + ) + + if '_create' in request.POST: + + vc_form = forms.VirtualChassisForm(request.POST) + vc_form.fields['master'].queryset = device_queryset + formset = VCMemberFormSet(request.POST, queryset=device_queryset) + + if vc_form.is_valid() and formset.is_valid(): + + with transaction.atomic(): + + # Assign each device to the VirtualChassis before saving + virtual_chassis = vc_form.save() + devices = formset.save(commit=False) + for device in devices: + device.virtual_chassis = virtual_chassis + device.save() + + return redirect(vc_form.cleaned_data['master'].get_absolute_url()) + + else: + + vc_form = forms.VirtualChassisForm() + vc_form.fields['master'].queryset = device_queryset + formset = VCMemberFormSet(queryset=device_queryset) + + return render(request, 'dcim/virtualchassis_edit.html', { + 'pk_form': pk_form, + 'vc_form': vc_form, + 'formset': formset, + 'return_url': reverse('dcim:device_list'), + }) + + +class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View): + permission_required = 'dcim.change_virtualchassis' + + def get(self, request, pk): + + virtual_chassis = get_object_or_404(VirtualChassis, pk=pk) + VCMemberFormSet = modelformset_factory( + model=Device, + form=forms.DeviceVCMembershipForm, + formset=forms.BaseVCMemberFormSet, + extra=0 + ) + members_queryset = virtual_chassis.members.select_related('rack').order_by('vc_position') + + vc_form = forms.VirtualChassisForm(instance=virtual_chassis) + vc_form.fields['master'].queryset = members_queryset + formset = VCMemberFormSet(queryset=members_queryset) + + return render(request, 'dcim/virtualchassis_edit.html', { + 'vc_form': vc_form, + 'formset': formset, + 'return_url': self.get_return_url(request, virtual_chassis), + }) + + def post(self, request, pk): + + virtual_chassis = get_object_or_404(VirtualChassis, pk=pk) + VCMemberFormSet = modelformset_factory( + model=Device, + form=forms.DeviceVCMembershipForm, + formset=forms.BaseVCMemberFormSet, + extra=0 + ) + members_queryset = virtual_chassis.members.select_related('rack').order_by('vc_position') + + vc_form = forms.VirtualChassisForm(request.POST, instance=virtual_chassis) + vc_form.fields['master'].queryset = members_queryset + formset = VCMemberFormSet(request.POST, queryset=members_queryset) + + if vc_form.is_valid() and formset.is_valid(): + + with transaction.atomic(): + + # Save the VirtualChassis + vc_form.save() + + # Nullify the vc_position of each member first to allow reordering without raising an IntegrityError on + # duplicate positions. Then save each member instance. + members = formset.save(commit=False) + Device.objects.filter(pk__in=[m.pk for m in members]).update(vc_position=None) + for member in members: + member.save() + + return redirect(vc_form.cleaned_data['master'].get_absolute_url()) + + return render(request, 'dcim/virtualchassis_edit.html', { + 'vc_form': vc_form, + 'formset': formset, + 'return_url': self.get_return_url(request, virtual_chassis), + }) + + +class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_virtualchassis' + model = VirtualChassis + default_return_url = 'dcim:device_list' + + +class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, View): + permission_required = 'dcim.change_virtualchassis' + + def get(self, request, pk): + + virtual_chassis = get_object_or_404(VirtualChassis, pk=pk) + + initial_data = {k: request.GET[k] for k in request.GET} + member_select_form = forms.VCMemberSelectForm(initial=initial_data) + membership_form = forms.DeviceVCMembershipForm(initial=initial_data) + + return render(request, 'dcim/virtualchassis_add_member.html', { + 'virtual_chassis': virtual_chassis, + 'member_select_form': member_select_form, + 'membership_form': membership_form, + 'return_url': self.get_return_url(request, virtual_chassis), + }) + + def post(self, request, pk): + + virtual_chassis = get_object_or_404(VirtualChassis, pk=pk) + + member_select_form = forms.VCMemberSelectForm(request.POST) + + if member_select_form.is_valid(): + + device = member_select_form.cleaned_data['device'] + device.virtual_chassis = virtual_chassis + data = {k: request.POST[k] for k in ['vc_position', 'vc_priority']} + membership_form = forms.DeviceVCMembershipForm(data, validate_vc_position=True, instance=device) + + if membership_form.is_valid(): + + membership_form.save() + msg = 'Added member {}'.format(device.get_absolute_url(), escape(device)) + messages.success(request, mark_safe(msg)) + UserAction.objects.log_edit(request.user, device, msg) + + if '_addanother' in request.POST: + return redirect(request.get_full_path()) + + return redirect(self.get_return_url(request, device)) + + else: + + membership_form = forms.DeviceVCMembershipForm(request.POST) + + return render(request, 'dcim/virtualchassis_add_member.html', { + 'virtual_chassis': virtual_chassis, + 'member_select_form': member_select_form, + 'membership_form': membership_form, + 'return_url': self.get_return_url(request, virtual_chassis), + }) + + +class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, View): + permission_required = 'dcim.change_virtualchassis' + + def get(self, request, pk): + + device = get_object_or_404(Device, pk=pk, virtual_chassis__isnull=False) + form = ConfirmationForm(initial=request.GET) + + return render(request, 'dcim/virtualchassis_remove_member.html', { + 'device': device, + 'form': form, + 'return_url': self.get_return_url(request, device), + }) + + def post(self, request, pk): + + device = get_object_or_404(Device, pk=pk, virtual_chassis__isnull=False) + form = ConfirmationForm(request.POST) + + # Protect master device from being removed + virtual_chassis = VirtualChassis.objects.filter(master=device).first() + if virtual_chassis is not None: + msg = 'Unable to remove master device {} from the virtual chassis.'.format(escape(device)) + messages.error(request, mark_safe(msg)) + return redirect(device.get_absolute_url()) + + if form.is_valid(): + + Device.objects.filter(pk=device.pk).update( + virtual_chassis=None, + vc_position=None, + vc_priority=None + ) + + msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis) + messages.success(request, msg) + UserAction.objects.log_edit(request.user, device, msg) + + return redirect(self.get_return_url(request, device)) + + return render(request, 'dcim/virtualchassis_remove_member.html', { + 'device': device, + 'form': form, + 'return_url': self.get_return_url(request, device), + }) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index c8d1e58c4..252c2d12c 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -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 diff --git a/netbox/extras/management/commands/run_inventory.py b/netbox/extras/management/commands/run_inventory.py index 574f47e3b..c42bdf50a 100644 --- a/netbox/extras/management/commands/run_inventory.py +++ b/netbox/extras/management/commands/run_inventory.py @@ -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']: diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index fde9c3185..036d8143c 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -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) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e4e14e4e4..2eca51895 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -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', + ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 649e74069..f6a55b618 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -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 diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 802acafc9..005d44a84 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -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( diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index b83540c11..c6d73e6f4 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -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( diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index cba624a59..505d914a0 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -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) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a22fd7ca1..7a3f6cbec 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -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', diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 9bf583dd9..bd1570827 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -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; diff --git a/netbox/project-static/font-awesome-4.7.0/HELP-US-OUT.txt b/netbox/project-static/font-awesome-4.7.0/HELP-US-OUT.txt deleted file mode 100644 index 83d083dd7..000000000 --- a/netbox/project-static/font-awesome-4.7.0/HELP-US-OUT.txt +++ /dev/null @@ -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 diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 945a94d45..f0208df7b 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -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($("").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($("").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 = $("").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 = $("").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(); + }); }); }); diff --git a/netbox/project-static/js/jquery-3.2.1.min.js b/netbox/project-static/js/jquery-3.2.1.min.js deleted file mode 100644 index 644d35e27..000000000 --- a/netbox/project-static/js/jquery-3.2.1.min.js +++ /dev/null @@ -1,4 +0,0 @@ -/*! jQuery v3.2.1 | (c) JS Foundation and other contributors | jquery.org/license */ -!function(a,b){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=a.document?b(a,!0):function(a){if(!a.document)throw new Error("jQuery requires a window with a document");return b(a)}:b(a)}("undefined"!=typeof window?window:this,function(a,b){"use strict";var c=[],d=a.document,e=Object.getPrototypeOf,f=c.slice,g=c.concat,h=c.push,i=c.indexOf,j={},k=j.toString,l=j.hasOwnProperty,m=l.toString,n=m.call(Object),o={};function p(a,b){b=b||d;var c=b.createElement("script");c.text=a,b.head.appendChild(c).parentNode.removeChild(c)}var q="3.2.1",r=function(a,b){return new r.fn.init(a,b)},s=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,t=/^-ms-/,u=/-([a-z])/g,v=function(a,b){return b.toUpperCase()};r.fn=r.prototype={jquery:q,constructor:r,length:0,toArray:function(){return f.call(this)},get:function(a){return null==a?f.call(this):a<0?this[a+this.length]:this[a]},pushStack:function(a){var b=r.merge(this.constructor(),a);return b.prevObject=this,b},each:function(a){return r.each(this,a)},map:function(a){return this.pushStack(r.map(this,function(b,c){return a.call(b,c,b)}))},slice:function(){return this.pushStack(f.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(a){var b=this.length,c=+a+(a<0?b:0);return this.pushStack(c>=0&&c0&&b-1 in a)}var x=function(a){var b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u="sizzle"+1*new Date,v=a.document,w=0,x=0,y=ha(),z=ha(),A=ha(),B=function(a,b){return a===b&&(l=!0),0},C={}.hasOwnProperty,D=[],E=D.pop,F=D.push,G=D.push,H=D.slice,I=function(a,b){for(var c=0,d=a.length;c+~]|"+K+")"+K+"*"),S=new RegExp("="+K+"*([^\\]'\"]*?)"+K+"*\\]","g"),T=new RegExp(N),U=new RegExp("^"+L+"$"),V={ID:new RegExp("^#("+L+")"),CLASS:new RegExp("^\\.("+L+")"),TAG:new RegExp("^("+L+"|[*])"),ATTR:new RegExp("^"+M),PSEUDO:new RegExp("^"+N),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+K+"*(even|odd|(([+-]|)(\\d*)n|)"+K+"*(?:([+-]|)"+K+"*(\\d+)|))"+K+"*\\)|)","i"),bool:new RegExp("^(?:"+J+")$","i"),needsContext:new RegExp("^"+K+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+K+"*((?:-\\d)?\\d*)"+K+"*\\)|)(?=[^-]|$)","i")},W=/^(?:input|select|textarea|button)$/i,X=/^h\d$/i,Y=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,$=/[+~]/,_=new RegExp("\\\\([\\da-f]{1,6}"+K+"?|("+K+")|.)","ig"),aa=function(a,b,c){var d="0x"+b-65536;return d!==d||c?b:d<0?String.fromCharCode(d+65536):String.fromCharCode(d>>10|55296,1023&d|56320)},ba=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ca=function(a,b){return b?"\0"===a?"\ufffd":a.slice(0,-1)+"\\"+a.charCodeAt(a.length-1).toString(16)+" ":"\\"+a},da=function(){m()},ea=ta(function(a){return a.disabled===!0&&("form"in a||"label"in a)},{dir:"parentNode",next:"legend"});try{G.apply(D=H.call(v.childNodes),v.childNodes),D[v.childNodes.length].nodeType}catch(fa){G={apply:D.length?function(a,b){F.apply(a,H.call(b))}:function(a,b){var c=a.length,d=0;while(a[c++]=b[d++]);a.length=c-1}}}function ga(a,b,d,e){var f,h,j,k,l,o,r,s=b&&b.ownerDocument,w=b?b.nodeType:9;if(d=d||[],"string"!=typeof a||!a||1!==w&&9!==w&&11!==w)return d;if(!e&&((b?b.ownerDocument||b:v)!==n&&m(b),b=b||n,p)){if(11!==w&&(l=Z.exec(a)))if(f=l[1]){if(9===w){if(!(j=b.getElementById(f)))return d;if(j.id===f)return d.push(j),d}else if(s&&(j=s.getElementById(f))&&t(b,j)&&j.id===f)return d.push(j),d}else{if(l[2])return G.apply(d,b.getElementsByTagName(a)),d;if((f=l[3])&&c.getElementsByClassName&&b.getElementsByClassName)return G.apply(d,b.getElementsByClassName(f)),d}if(c.qsa&&!A[a+" "]&&(!q||!q.test(a))){if(1!==w)s=b,r=a;else if("object"!==b.nodeName.toLowerCase()){(k=b.getAttribute("id"))?k=k.replace(ba,ca):b.setAttribute("id",k=u),o=g(a),h=o.length;while(h--)o[h]="#"+k+" "+sa(o[h]);r=o.join(","),s=$.test(a)&&qa(b.parentNode)||b}if(r)try{return G.apply(d,s.querySelectorAll(r)),d}catch(x){}finally{k===u&&b.removeAttribute("id")}}}return i(a.replace(P,"$1"),b,d,e)}function ha(){var a=[];function b(c,e){return a.push(c+" ")>d.cacheLength&&delete b[a.shift()],b[c+" "]=e}return b}function ia(a){return a[u]=!0,a}function ja(a){var b=n.createElement("fieldset");try{return!!a(b)}catch(c){return!1}finally{b.parentNode&&b.parentNode.removeChild(b),b=null}}function ka(a,b){var c=a.split("|"),e=c.length;while(e--)d.attrHandle[c[e]]=b}function la(a,b){var c=b&&a,d=c&&1===a.nodeType&&1===b.nodeType&&a.sourceIndex-b.sourceIndex;if(d)return d;if(c)while(c=c.nextSibling)if(c===b)return-1;return a?1:-1}function ma(a){return function(b){var c=b.nodeName.toLowerCase();return"input"===c&&b.type===a}}function na(a){return function(b){var c=b.nodeName.toLowerCase();return("input"===c||"button"===c)&&b.type===a}}function oa(a){return function(b){return"form"in b?b.parentNode&&b.disabled===!1?"label"in b?"label"in b.parentNode?b.parentNode.disabled===a:b.disabled===a:b.isDisabled===a||b.isDisabled!==!a&&ea(b)===a:b.disabled===a:"label"in b&&b.disabled===a}}function pa(a){return ia(function(b){return b=+b,ia(function(c,d){var e,f=a([],c.length,b),g=f.length;while(g--)c[e=f[g]]&&(c[e]=!(d[e]=c[e]))})})}function qa(a){return a&&"undefined"!=typeof a.getElementsByTagName&&a}c=ga.support={},f=ga.isXML=function(a){var b=a&&(a.ownerDocument||a).documentElement;return!!b&&"HTML"!==b.nodeName},m=ga.setDocument=function(a){var b,e,g=a?a.ownerDocument||a:v;return g!==n&&9===g.nodeType&&g.documentElement?(n=g,o=n.documentElement,p=!f(n),v!==n&&(e=n.defaultView)&&e.top!==e&&(e.addEventListener?e.addEventListener("unload",da,!1):e.attachEvent&&e.attachEvent("onunload",da)),c.attributes=ja(function(a){return a.className="i",!a.getAttribute("className")}),c.getElementsByTagName=ja(function(a){return a.appendChild(n.createComment("")),!a.getElementsByTagName("*").length}),c.getElementsByClassName=Y.test(n.getElementsByClassName),c.getById=ja(function(a){return o.appendChild(a).id=u,!n.getElementsByName||!n.getElementsByName(u).length}),c.getById?(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){return a.getAttribute("id")===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c=b.getElementById(a);return c?[c]:[]}}):(d.filter.ID=function(a){var b=a.replace(_,aa);return function(a){var c="undefined"!=typeof a.getAttributeNode&&a.getAttributeNode("id");return c&&c.value===b}},d.find.ID=function(a,b){if("undefined"!=typeof b.getElementById&&p){var c,d,e,f=b.getElementById(a);if(f){if(c=f.getAttributeNode("id"),c&&c.value===a)return[f];e=b.getElementsByName(a),d=0;while(f=e[d++])if(c=f.getAttributeNode("id"),c&&c.value===a)return[f]}return[]}}),d.find.TAG=c.getElementsByTagName?function(a,b){return"undefined"!=typeof b.getElementsByTagName?b.getElementsByTagName(a):c.qsa?b.querySelectorAll(a):void 0}:function(a,b){var c,d=[],e=0,f=b.getElementsByTagName(a);if("*"===a){while(c=f[e++])1===c.nodeType&&d.push(c);return d}return f},d.find.CLASS=c.getElementsByClassName&&function(a,b){if("undefined"!=typeof b.getElementsByClassName&&p)return b.getElementsByClassName(a)},r=[],q=[],(c.qsa=Y.test(n.querySelectorAll))&&(ja(function(a){o.appendChild(a).innerHTML="",a.querySelectorAll("[msallowcapture^='']").length&&q.push("[*^$]="+K+"*(?:''|\"\")"),a.querySelectorAll("[selected]").length||q.push("\\["+K+"*(?:value|"+J+")"),a.querySelectorAll("[id~="+u+"-]").length||q.push("~="),a.querySelectorAll(":checked").length||q.push(":checked"),a.querySelectorAll("a#"+u+"+*").length||q.push(".#.+[+~]")}),ja(function(a){a.innerHTML="";var b=n.createElement("input");b.setAttribute("type","hidden"),a.appendChild(b).setAttribute("name","D"),a.querySelectorAll("[name=d]").length&&q.push("name"+K+"*[*^$|!~]?="),2!==a.querySelectorAll(":enabled").length&&q.push(":enabled",":disabled"),o.appendChild(a).disabled=!0,2!==a.querySelectorAll(":disabled").length&&q.push(":enabled",":disabled"),a.querySelectorAll("*,:x"),q.push(",.*:")})),(c.matchesSelector=Y.test(s=o.matches||o.webkitMatchesSelector||o.mozMatchesSelector||o.oMatchesSelector||o.msMatchesSelector))&&ja(function(a){c.disconnectedMatch=s.call(a,"*"),s.call(a,"[s!='']:x"),r.push("!=",N)}),q=q.length&&new RegExp(q.join("|")),r=r.length&&new RegExp(r.join("|")),b=Y.test(o.compareDocumentPosition),t=b||Y.test(o.contains)?function(a,b){var c=9===a.nodeType?a.documentElement:a,d=b&&b.parentNode;return a===d||!(!d||1!==d.nodeType||!(c.contains?c.contains(d):a.compareDocumentPosition&&16&a.compareDocumentPosition(d)))}:function(a,b){if(b)while(b=b.parentNode)if(b===a)return!0;return!1},B=b?function(a,b){if(a===b)return l=!0,0;var d=!a.compareDocumentPosition-!b.compareDocumentPosition;return d?d:(d=(a.ownerDocument||a)===(b.ownerDocument||b)?a.compareDocumentPosition(b):1,1&d||!c.sortDetached&&b.compareDocumentPosition(a)===d?a===n||a.ownerDocument===v&&t(v,a)?-1:b===n||b.ownerDocument===v&&t(v,b)?1:k?I(k,a)-I(k,b):0:4&d?-1:1)}:function(a,b){if(a===b)return l=!0,0;var c,d=0,e=a.parentNode,f=b.parentNode,g=[a],h=[b];if(!e||!f)return a===n?-1:b===n?1:e?-1:f?1:k?I(k,a)-I(k,b):0;if(e===f)return la(a,b);c=a;while(c=c.parentNode)g.unshift(c);c=b;while(c=c.parentNode)h.unshift(c);while(g[d]===h[d])d++;return d?la(g[d],h[d]):g[d]===v?-1:h[d]===v?1:0},n):n},ga.matches=function(a,b){return ga(a,null,null,b)},ga.matchesSelector=function(a,b){if((a.ownerDocument||a)!==n&&m(a),b=b.replace(S,"='$1']"),c.matchesSelector&&p&&!A[b+" "]&&(!r||!r.test(b))&&(!q||!q.test(b)))try{var d=s.call(a,b);if(d||c.disconnectedMatch||a.document&&11!==a.document.nodeType)return d}catch(e){}return ga(b,n,null,[a]).length>0},ga.contains=function(a,b){return(a.ownerDocument||a)!==n&&m(a),t(a,b)},ga.attr=function(a,b){(a.ownerDocument||a)!==n&&m(a);var e=d.attrHandle[b.toLowerCase()],f=e&&C.call(d.attrHandle,b.toLowerCase())?e(a,b,!p):void 0;return void 0!==f?f:c.attributes||!p?a.getAttribute(b):(f=a.getAttributeNode(b))&&f.specified?f.value:null},ga.escape=function(a){return(a+"").replace(ba,ca)},ga.error=function(a){throw new Error("Syntax error, unrecognized expression: "+a)},ga.uniqueSort=function(a){var b,d=[],e=0,f=0;if(l=!c.detectDuplicates,k=!c.sortStable&&a.slice(0),a.sort(B),l){while(b=a[f++])b===a[f]&&(e=d.push(f));while(e--)a.splice(d[e],1)}return k=null,a},e=ga.getText=function(a){var b,c="",d=0,f=a.nodeType;if(f){if(1===f||9===f||11===f){if("string"==typeof a.textContent)return a.textContent;for(a=a.firstChild;a;a=a.nextSibling)c+=e(a)}else if(3===f||4===f)return a.nodeValue}else while(b=a[d++])c+=e(b);return c},d=ga.selectors={cacheLength:50,createPseudo:ia,match:V,attrHandle:{},find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(a){return a[1]=a[1].replace(_,aa),a[3]=(a[3]||a[4]||a[5]||"").replace(_,aa),"~="===a[2]&&(a[3]=" "+a[3]+" "),a.slice(0,4)},CHILD:function(a){return a[1]=a[1].toLowerCase(),"nth"===a[1].slice(0,3)?(a[3]||ga.error(a[0]),a[4]=+(a[4]?a[5]+(a[6]||1):2*("even"===a[3]||"odd"===a[3])),a[5]=+(a[7]+a[8]||"odd"===a[3])):a[3]&&ga.error(a[0]),a},PSEUDO:function(a){var b,c=!a[6]&&a[2];return V.CHILD.test(a[0])?null:(a[3]?a[2]=a[4]||a[5]||"":c&&T.test(c)&&(b=g(c,!0))&&(b=c.indexOf(")",c.length-b)-c.length)&&(a[0]=a[0].slice(0,b),a[2]=c.slice(0,b)),a.slice(0,3))}},filter:{TAG:function(a){var b=a.replace(_,aa).toLowerCase();return"*"===a?function(){return!0}:function(a){return a.nodeName&&a.nodeName.toLowerCase()===b}},CLASS:function(a){var b=y[a+" "];return b||(b=new RegExp("(^|"+K+")"+a+"("+K+"|$)"))&&y(a,function(a){return b.test("string"==typeof a.className&&a.className||"undefined"!=typeof a.getAttribute&&a.getAttribute("class")||"")})},ATTR:function(a,b,c){return function(d){var e=ga.attr(d,a);return null==e?"!="===b:!b||(e+="","="===b?e===c:"!="===b?e!==c:"^="===b?c&&0===e.indexOf(c):"*="===b?c&&e.indexOf(c)>-1:"$="===b?c&&e.slice(-c.length)===c:"~="===b?(" "+e.replace(O," ")+" ").indexOf(c)>-1:"|="===b&&(e===c||e.slice(0,c.length+1)===c+"-"))}},CHILD:function(a,b,c,d,e){var f="nth"!==a.slice(0,3),g="last"!==a.slice(-4),h="of-type"===b;return 1===d&&0===e?function(a){return!!a.parentNode}:function(b,c,i){var j,k,l,m,n,o,p=f!==g?"nextSibling":"previousSibling",q=b.parentNode,r=h&&b.nodeName.toLowerCase(),s=!i&&!h,t=!1;if(q){if(f){while(p){m=b;while(m=m[p])if(h?m.nodeName.toLowerCase()===r:1===m.nodeType)return!1;o=p="only"===a&&!o&&"nextSibling"}return!0}if(o=[g?q.firstChild:q.lastChild],g&&s){m=q,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n&&j[2],m=n&&q.childNodes[n];while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if(1===m.nodeType&&++t&&m===b){k[a]=[w,n,t];break}}else if(s&&(m=b,l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),j=k[a]||[],n=j[0]===w&&j[1],t=n),t===!1)while(m=++n&&m&&m[p]||(t=n=0)||o.pop())if((h?m.nodeName.toLowerCase()===r:1===m.nodeType)&&++t&&(s&&(l=m[u]||(m[u]={}),k=l[m.uniqueID]||(l[m.uniqueID]={}),k[a]=[w,t]),m===b))break;return t-=e,t===d||t%d===0&&t/d>=0}}},PSEUDO:function(a,b){var c,e=d.pseudos[a]||d.setFilters[a.toLowerCase()]||ga.error("unsupported pseudo: "+a);return e[u]?e(b):e.length>1?(c=[a,a,"",b],d.setFilters.hasOwnProperty(a.toLowerCase())?ia(function(a,c){var d,f=e(a,b),g=f.length;while(g--)d=I(a,f[g]),a[d]=!(c[d]=f[g])}):function(a){return e(a,0,c)}):e}},pseudos:{not:ia(function(a){var b=[],c=[],d=h(a.replace(P,"$1"));return d[u]?ia(function(a,b,c,e){var f,g=d(a,null,e,[]),h=a.length;while(h--)(f=g[h])&&(a[h]=!(b[h]=f))}):function(a,e,f){return b[0]=a,d(b,null,f,c),b[0]=null,!c.pop()}}),has:ia(function(a){return function(b){return ga(a,b).length>0}}),contains:ia(function(a){return a=a.replace(_,aa),function(b){return(b.textContent||b.innerText||e(b)).indexOf(a)>-1}}),lang:ia(function(a){return U.test(a||"")||ga.error("unsupported lang: "+a),a=a.replace(_,aa).toLowerCase(),function(b){var c;do if(c=p?b.lang:b.getAttribute("xml:lang")||b.getAttribute("lang"))return c=c.toLowerCase(),c===a||0===c.indexOf(a+"-");while((b=b.parentNode)&&1===b.nodeType);return!1}}),target:function(b){var c=a.location&&a.location.hash;return c&&c.slice(1)===b.id},root:function(a){return a===o},focus:function(a){return a===n.activeElement&&(!n.hasFocus||n.hasFocus())&&!!(a.type||a.href||~a.tabIndex)},enabled:oa(!1),disabled:oa(!0),checked:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&!!a.checked||"option"===b&&!!a.selected},selected:function(a){return a.parentNode&&a.parentNode.selectedIndex,a.selected===!0},empty:function(a){for(a=a.firstChild;a;a=a.nextSibling)if(a.nodeType<6)return!1;return!0},parent:function(a){return!d.pseudos.empty(a)},header:function(a){return X.test(a.nodeName)},input:function(a){return W.test(a.nodeName)},button:function(a){var b=a.nodeName.toLowerCase();return"input"===b&&"button"===a.type||"button"===b},text:function(a){var b;return"input"===a.nodeName.toLowerCase()&&"text"===a.type&&(null==(b=a.getAttribute("type"))||"text"===b.toLowerCase())},first:pa(function(){return[0]}),last:pa(function(a,b){return[b-1]}),eq:pa(function(a,b,c){return[c<0?c+b:c]}),even:pa(function(a,b){for(var c=0;c=0;)a.push(d);return a}),gt:pa(function(a,b,c){for(var d=c<0?c+b:c;++d1?function(b,c,d){var e=a.length;while(e--)if(!a[e](b,c,d))return!1;return!0}:a[0]}function va(a,b,c){for(var d=0,e=b.length;d-1&&(f[j]=!(g[j]=l))}}else r=wa(r===g?r.splice(o,r.length):r),e?e(null,g,r,i):G.apply(g,r)})}function ya(a){for(var b,c,e,f=a.length,g=d.relative[a[0].type],h=g||d.relative[" "],i=g?1:0,k=ta(function(a){return a===b},h,!0),l=ta(function(a){return I(b,a)>-1},h,!0),m=[function(a,c,d){var e=!g&&(d||c!==j)||((b=c).nodeType?k(a,c,d):l(a,c,d));return b=null,e}];i1&&ua(m),i>1&&sa(a.slice(0,i-1).concat({value:" "===a[i-2].type?"*":""})).replace(P,"$1"),c,i0,e=a.length>0,f=function(f,g,h,i,k){var l,o,q,r=0,s="0",t=f&&[],u=[],v=j,x=f||e&&d.find.TAG("*",k),y=w+=null==v?1:Math.random()||.1,z=x.length;for(k&&(j=g===n||g||k);s!==z&&null!=(l=x[s]);s++){if(e&&l){o=0,g||l.ownerDocument===n||(m(l),h=!p);while(q=a[o++])if(q(l,g||n,h)){i.push(l);break}k&&(w=y)}c&&((l=!q&&l)&&r--,f&&t.push(l))}if(r+=s,c&&s!==r){o=0;while(q=b[o++])q(t,u,g,h);if(f){if(r>0)while(s--)t[s]||u[s]||(u[s]=E.call(i));u=wa(u)}G.apply(i,u),k&&!f&&u.length>0&&r+b.length>1&&ga.uniqueSort(i)}return k&&(w=y,j=v),t};return c?ia(f):f}return h=ga.compile=function(a,b){var c,d=[],e=[],f=A[a+" "];if(!f){b||(b=g(a)),c=b.length;while(c--)f=ya(b[c]),f[u]?d.push(f):e.push(f);f=A(a,za(e,d)),f.selector=a}return f},i=ga.select=function(a,b,c,e){var f,i,j,k,l,m="function"==typeof a&&a,n=!e&&g(a=m.selector||a);if(c=c||[],1===n.length){if(i=n[0]=n[0].slice(0),i.length>2&&"ID"===(j=i[0]).type&&9===b.nodeType&&p&&d.relative[i[1].type]){if(b=(d.find.ID(j.matches[0].replace(_,aa),b)||[])[0],!b)return c;m&&(b=b.parentNode),a=a.slice(i.shift().value.length)}f=V.needsContext.test(a)?0:i.length;while(f--){if(j=i[f],d.relative[k=j.type])break;if((l=d.find[k])&&(e=l(j.matches[0].replace(_,aa),$.test(i[0].type)&&qa(b.parentNode)||b))){if(i.splice(f,1),a=e.length&&sa(i),!a)return G.apply(c,e),c;break}}}return(m||h(a,n))(e,b,!p,c,!b||$.test(a)&&qa(b.parentNode)||b),c},c.sortStable=u.split("").sort(B).join("")===u,c.detectDuplicates=!!l,m(),c.sortDetached=ja(function(a){return 1&a.compareDocumentPosition(n.createElement("fieldset"))}),ja(function(a){return a.innerHTML="","#"===a.firstChild.getAttribute("href")})||ka("type|href|height|width",function(a,b,c){if(!c)return a.getAttribute(b,"type"===b.toLowerCase()?1:2)}),c.attributes&&ja(function(a){return a.innerHTML="",a.firstChild.setAttribute("value",""),""===a.firstChild.getAttribute("value")})||ka("value",function(a,b,c){if(!c&&"input"===a.nodeName.toLowerCase())return a.defaultValue}),ja(function(a){return null==a.getAttribute("disabled")})||ka(J,function(a,b,c){var d;if(!c)return a[b]===!0?b.toLowerCase():(d=a.getAttributeNode(b))&&d.specified?d.value:null}),ga}(a);r.find=x,r.expr=x.selectors,r.expr[":"]=r.expr.pseudos,r.uniqueSort=r.unique=x.uniqueSort,r.text=x.getText,r.isXMLDoc=x.isXML,r.contains=x.contains,r.escapeSelector=x.escape;var y=function(a,b,c){var d=[],e=void 0!==c;while((a=a[b])&&9!==a.nodeType)if(1===a.nodeType){if(e&&r(a).is(c))break;d.push(a)}return d},z=function(a,b){for(var c=[];a;a=a.nextSibling)1===a.nodeType&&a!==b&&c.push(a);return c},A=r.expr.match.needsContext;function B(a,b){return a.nodeName&&a.nodeName.toLowerCase()===b.toLowerCase()}var C=/^<([a-z][^\/\0>:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i,D=/^.[^:#\[\.,]*$/;function E(a,b,c){return r.isFunction(b)?r.grep(a,function(a,d){return!!b.call(a,d,a)!==c}):b.nodeType?r.grep(a,function(a){return a===b!==c}):"string"!=typeof b?r.grep(a,function(a){return i.call(b,a)>-1!==c}):D.test(b)?r.filter(b,a,c):(b=r.filter(b,a),r.grep(a,function(a){return i.call(b,a)>-1!==c&&1===a.nodeType}))}r.filter=function(a,b,c){var d=b[0];return c&&(a=":not("+a+")"),1===b.length&&1===d.nodeType?r.find.matchesSelector(d,a)?[d]:[]:r.find.matches(a,r.grep(b,function(a){return 1===a.nodeType}))},r.fn.extend({find:function(a){var b,c,d=this.length,e=this;if("string"!=typeof a)return this.pushStack(r(a).filter(function(){for(b=0;b1?r.uniqueSort(c):c},filter:function(a){return this.pushStack(E(this,a||[],!1))},not:function(a){return this.pushStack(E(this,a||[],!0))},is:function(a){return!!E(this,"string"==typeof a&&A.test(a)?r(a):a||[],!1).length}});var F,G=/^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]+))$/,H=r.fn.init=function(a,b,c){var e,f;if(!a)return this;if(c=c||F,"string"==typeof a){if(e="<"===a[0]&&">"===a[a.length-1]&&a.length>=3?[null,a,null]:G.exec(a),!e||!e[1]&&b)return!b||b.jquery?(b||c).find(a):this.constructor(b).find(a);if(e[1]){if(b=b instanceof r?b[0]:b,r.merge(this,r.parseHTML(e[1],b&&b.nodeType?b.ownerDocument||b:d,!0)),C.test(e[1])&&r.isPlainObject(b))for(e in b)r.isFunction(this[e])?this[e](b[e]):this.attr(e,b[e]);return this}return f=d.getElementById(e[2]),f&&(this[0]=f,this.length=1),this}return a.nodeType?(this[0]=a,this.length=1,this):r.isFunction(a)?void 0!==c.ready?c.ready(a):a(r):r.makeArray(a,this)};H.prototype=r.fn,F=r(d);var I=/^(?:parents|prev(?:Until|All))/,J={children:!0,contents:!0,next:!0,prev:!0};r.fn.extend({has:function(a){var b=r(a,this),c=b.length;return this.filter(function(){for(var a=0;a-1:1===c.nodeType&&r.find.matchesSelector(c,a))){f.push(c);break}return this.pushStack(f.length>1?r.uniqueSort(f):f)},index:function(a){return a?"string"==typeof a?i.call(r(a),this[0]):i.call(this,a.jquery?a[0]:a):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(a,b){return this.pushStack(r.uniqueSort(r.merge(this.get(),r(a,b))))},addBack:function(a){return this.add(null==a?this.prevObject:this.prevObject.filter(a))}});function K(a,b){while((a=a[b])&&1!==a.nodeType);return a}r.each({parent:function(a){var b=a.parentNode;return b&&11!==b.nodeType?b:null},parents:function(a){return y(a,"parentNode")},parentsUntil:function(a,b,c){return y(a,"parentNode",c)},next:function(a){return K(a,"nextSibling")},prev:function(a){return K(a,"previousSibling")},nextAll:function(a){return y(a,"nextSibling")},prevAll:function(a){return y(a,"previousSibling")},nextUntil:function(a,b,c){return y(a,"nextSibling",c)},prevUntil:function(a,b,c){return y(a,"previousSibling",c)},siblings:function(a){return z((a.parentNode||{}).firstChild,a)},children:function(a){return z(a.firstChild)},contents:function(a){return B(a,"iframe")?a.contentDocument:(B(a,"template")&&(a=a.content||a),r.merge([],a.childNodes))}},function(a,b){r.fn[a]=function(c,d){var e=r.map(this,b,c);return"Until"!==a.slice(-5)&&(d=c),d&&"string"==typeof d&&(e=r.filter(d,e)),this.length>1&&(J[a]||r.uniqueSort(e),I.test(a)&&e.reverse()),this.pushStack(e)}});var L=/[^\x20\t\r\n\f]+/g;function M(a){var b={};return r.each(a.match(L)||[],function(a,c){b[c]=!0}),b}r.Callbacks=function(a){a="string"==typeof a?M(a):r.extend({},a);var b,c,d,e,f=[],g=[],h=-1,i=function(){for(e=e||a.once,d=b=!0;g.length;h=-1){c=g.shift();while(++h-1)f.splice(c,1),c<=h&&h--}),this},has:function(a){return a?r.inArray(a,f)>-1:f.length>0},empty:function(){return f&&(f=[]),this},disable:function(){return e=g=[],f=c="",this},disabled:function(){return!f},lock:function(){return e=g=[],c||b||(f=c=""),this},locked:function(){return!!e},fireWith:function(a,c){return e||(c=c||[],c=[a,c.slice?c.slice():c],g.push(c),b||i()),this},fire:function(){return j.fireWith(this,arguments),this},fired:function(){return!!d}};return j};function N(a){return a}function O(a){throw a}function P(a,b,c,d){var e;try{a&&r.isFunction(e=a.promise)?e.call(a).done(b).fail(c):a&&r.isFunction(e=a.then)?e.call(a,b,c):b.apply(void 0,[a].slice(d))}catch(a){c.apply(void 0,[a])}}r.extend({Deferred:function(b){var c=[["notify","progress",r.Callbacks("memory"),r.Callbacks("memory"),2],["resolve","done",r.Callbacks("once memory"),r.Callbacks("once memory"),0,"resolved"],["reject","fail",r.Callbacks("once memory"),r.Callbacks("once memory"),1,"rejected"]],d="pending",e={state:function(){return d},always:function(){return f.done(arguments).fail(arguments),this},"catch":function(a){return e.then(null,a)},pipe:function(){var a=arguments;return r.Deferred(function(b){r.each(c,function(c,d){var e=r.isFunction(a[d[4]])&&a[d[4]];f[d[1]](function(){var a=e&&e.apply(this,arguments);a&&r.isFunction(a.promise)?a.promise().progress(b.notify).done(b.resolve).fail(b.reject):b[d[0]+"With"](this,e?[a]:arguments)})}),a=null}).promise()},then:function(b,d,e){var f=0;function g(b,c,d,e){return function(){var h=this,i=arguments,j=function(){var a,j;if(!(b=f&&(d!==O&&(h=void 0,i=[a]),c.rejectWith(h,i))}};b?k():(r.Deferred.getStackHook&&(k.stackTrace=r.Deferred.getStackHook()),a.setTimeout(k))}}return r.Deferred(function(a){c[0][3].add(g(0,a,r.isFunction(e)?e:N,a.notifyWith)),c[1][3].add(g(0,a,r.isFunction(b)?b:N)),c[2][3].add(g(0,a,r.isFunction(d)?d:O))}).promise()},promise:function(a){return null!=a?r.extend(a,e):e}},f={};return r.each(c,function(a,b){var g=b[2],h=b[5];e[b[1]]=g.add,h&&g.add(function(){d=h},c[3-a][2].disable,c[0][2].lock),g.add(b[3].fire),f[b[0]]=function(){return f[b[0]+"With"](this===f?void 0:this,arguments),this},f[b[0]+"With"]=g.fireWith}),e.promise(f),b&&b.call(f,f),f},when:function(a){var b=arguments.length,c=b,d=Array(c),e=f.call(arguments),g=r.Deferred(),h=function(a){return function(c){d[a]=this,e[a]=arguments.length>1?f.call(arguments):c,--b||g.resolveWith(d,e)}};if(b<=1&&(P(a,g.done(h(c)).resolve,g.reject,!b),"pending"===g.state()||r.isFunction(e[c]&&e[c].then)))return g.then();while(c--)P(e[c],h(c),g.reject);return g.promise()}});var Q=/^(Eval|Internal|Range|Reference|Syntax|Type|URI)Error$/;r.Deferred.exceptionHook=function(b,c){a.console&&a.console.warn&&b&&Q.test(b.name)&&a.console.warn("jQuery.Deferred exception: "+b.message,b.stack,c)},r.readyException=function(b){a.setTimeout(function(){throw b})};var R=r.Deferred();r.fn.ready=function(a){return R.then(a)["catch"](function(a){r.readyException(a)}),this},r.extend({isReady:!1,readyWait:1,ready:function(a){(a===!0?--r.readyWait:r.isReady)||(r.isReady=!0,a!==!0&&--r.readyWait>0||R.resolveWith(d,[r]))}}),r.ready.then=R.then;function S(){d.removeEventListener("DOMContentLoaded",S), -a.removeEventListener("load",S),r.ready()}"complete"===d.readyState||"loading"!==d.readyState&&!d.documentElement.doScroll?a.setTimeout(r.ready):(d.addEventListener("DOMContentLoaded",S),a.addEventListener("load",S));var T=function(a,b,c,d,e,f,g){var h=0,i=a.length,j=null==c;if("object"===r.type(c)){e=!0;for(h in c)T(a,b,h,c[h],!0,f,g)}else if(void 0!==d&&(e=!0,r.isFunction(d)||(g=!0),j&&(g?(b.call(a,d),b=null):(j=b,b=function(a,b,c){return j.call(r(a),c)})),b))for(;h1,null,!0)},removeData:function(a){return this.each(function(){X.remove(this,a)})}}),r.extend({queue:function(a,b,c){var d;if(a)return b=(b||"fx")+"queue",d=W.get(a,b),c&&(!d||Array.isArray(c)?d=W.access(a,b,r.makeArray(c)):d.push(c)),d||[]},dequeue:function(a,b){b=b||"fx";var c=r.queue(a,b),d=c.length,e=c.shift(),f=r._queueHooks(a,b),g=function(){r.dequeue(a,b)};"inprogress"===e&&(e=c.shift(),d--),e&&("fx"===b&&c.unshift("inprogress"),delete f.stop,e.call(a,g,f)),!d&&f&&f.empty.fire()},_queueHooks:function(a,b){var c=b+"queueHooks";return W.get(a,c)||W.access(a,c,{empty:r.Callbacks("once memory").add(function(){W.remove(a,[b+"queue",c])})})}}),r.fn.extend({queue:function(a,b){var c=2;return"string"!=typeof a&&(b=a,a="fx",c--),arguments.length\x20\t\r\n\f]+)/i,la=/^$|\/(?:java|ecma)script/i,ma={option:[1,""],thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};ma.optgroup=ma.option,ma.tbody=ma.tfoot=ma.colgroup=ma.caption=ma.thead,ma.th=ma.td;function na(a,b){var c;return c="undefined"!=typeof a.getElementsByTagName?a.getElementsByTagName(b||"*"):"undefined"!=typeof a.querySelectorAll?a.querySelectorAll(b||"*"):[],void 0===b||b&&B(a,b)?r.merge([a],c):c}function oa(a,b){for(var c=0,d=a.length;c-1)e&&e.push(f);else if(j=r.contains(f.ownerDocument,f),g=na(l.appendChild(f),"script"),j&&oa(g),c){k=0;while(f=g[k++])la.test(f.type||"")&&c.push(f)}return l}!function(){var a=d.createDocumentFragment(),b=a.appendChild(d.createElement("div")),c=d.createElement("input");c.setAttribute("type","radio"),c.setAttribute("checked","checked"),c.setAttribute("name","t"),b.appendChild(c),o.checkClone=b.cloneNode(!0).cloneNode(!0).lastChild.checked,b.innerHTML="",o.noCloneChecked=!!b.cloneNode(!0).lastChild.defaultValue}();var ra=d.documentElement,sa=/^key/,ta=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,ua=/^([^.]*)(?:\.(.+)|)/;function va(){return!0}function wa(){return!1}function xa(){try{return d.activeElement}catch(a){}}function ya(a,b,c,d,e,f){var g,h;if("object"==typeof b){"string"!=typeof c&&(d=d||c,c=void 0);for(h in b)ya(a,h,c,d,b[h],f);return a}if(null==d&&null==e?(e=c,d=c=void 0):null==e&&("string"==typeof c?(e=d,d=void 0):(e=d,d=c,c=void 0)),e===!1)e=wa;else if(!e)return a;return 1===f&&(g=e,e=function(a){return r().off(a),g.apply(this,arguments)},e.guid=g.guid||(g.guid=r.guid++)),a.each(function(){r.event.add(this,b,e,d,c)})}r.event={global:{},add:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=W.get(a);if(q){c.handler&&(f=c,c=f.handler,e=f.selector),e&&r.find.matchesSelector(ra,e),c.guid||(c.guid=r.guid++),(i=q.events)||(i=q.events={}),(g=q.handle)||(g=q.handle=function(b){return"undefined"!=typeof r&&r.event.triggered!==b.type?r.event.dispatch.apply(a,arguments):void 0}),b=(b||"").match(L)||[""],j=b.length;while(j--)h=ua.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n&&(l=r.event.special[n]||{},n=(e?l.delegateType:l.bindType)||n,l=r.event.special[n]||{},k=r.extend({type:n,origType:p,data:d,handler:c,guid:c.guid,selector:e,needsContext:e&&r.expr.match.needsContext.test(e),namespace:o.join(".")},f),(m=i[n])||(m=i[n]=[],m.delegateCount=0,l.setup&&l.setup.call(a,d,o,g)!==!1||a.addEventListener&&a.addEventListener(n,g)),l.add&&(l.add.call(a,k),k.handler.guid||(k.handler.guid=c.guid)),e?m.splice(m.delegateCount++,0,k):m.push(k),r.event.global[n]=!0)}},remove:function(a,b,c,d,e){var f,g,h,i,j,k,l,m,n,o,p,q=W.hasData(a)&&W.get(a);if(q&&(i=q.events)){b=(b||"").match(L)||[""],j=b.length;while(j--)if(h=ua.exec(b[j])||[],n=p=h[1],o=(h[2]||"").split(".").sort(),n){l=r.event.special[n]||{},n=(d?l.delegateType:l.bindType)||n,m=i[n]||[],h=h[2]&&new RegExp("(^|\\.)"+o.join("\\.(?:.*\\.|)")+"(\\.|$)"),g=f=m.length;while(f--)k=m[f],!e&&p!==k.origType||c&&c.guid!==k.guid||h&&!h.test(k.namespace)||d&&d!==k.selector&&("**"!==d||!k.selector)||(m.splice(f,1),k.selector&&m.delegateCount--,l.remove&&l.remove.call(a,k));g&&!m.length&&(l.teardown&&l.teardown.call(a,o,q.handle)!==!1||r.removeEvent(a,n,q.handle),delete i[n])}else for(n in i)r.event.remove(a,n+b[j],c,d,!0);r.isEmptyObject(i)&&W.remove(a,"handle events")}},dispatch:function(a){var b=r.event.fix(a),c,d,e,f,g,h,i=new Array(arguments.length),j=(W.get(this,"events")||{})[b.type]||[],k=r.event.special[b.type]||{};for(i[0]=b,c=1;c=1))for(;j!==this;j=j.parentNode||this)if(1===j.nodeType&&("click"!==a.type||j.disabled!==!0)){for(f=[],g={},c=0;c-1:r.find(e,this,null,[j]).length),g[e]&&f.push(d);f.length&&h.push({elem:j,handlers:f})}return j=this,i\x20\t\r\n\f]*)[^>]*)\/>/gi,Aa=/\s*$/g;function Ea(a,b){return B(a,"table")&&B(11!==b.nodeType?b:b.firstChild,"tr")?r(">tbody",a)[0]||a:a}function Fa(a){return a.type=(null!==a.getAttribute("type"))+"/"+a.type,a}function Ga(a){var b=Ca.exec(a.type);return b?a.type=b[1]:a.removeAttribute("type"),a}function Ha(a,b){var c,d,e,f,g,h,i,j;if(1===b.nodeType){if(W.hasData(a)&&(f=W.access(a),g=W.set(b,f),j=f.events)){delete g.handle,g.events={};for(e in j)for(c=0,d=j[e].length;c1&&"string"==typeof q&&!o.checkClone&&Ba.test(q))return a.each(function(e){var f=a.eq(e);s&&(b[0]=q.call(this,e,f.html())),Ja(f,b,c,d)});if(m&&(e=qa(b,a[0].ownerDocument,!1,a,d),f=e.firstChild,1===e.childNodes.length&&(e=f),f||d)){for(h=r.map(na(e,"script"),Fa),i=h.length;l")},clone:function(a,b,c){var d,e,f,g,h=a.cloneNode(!0),i=r.contains(a.ownerDocument,a);if(!(o.noCloneChecked||1!==a.nodeType&&11!==a.nodeType||r.isXMLDoc(a)))for(g=na(h),f=na(a),d=0,e=f.length;d0&&oa(g,!i&&na(a,"script")),h},cleanData:function(a){for(var b,c,d,e=r.event.special,f=0;void 0!==(c=a[f]);f++)if(U(c)){if(b=c[W.expando]){if(b.events)for(d in b.events)e[d]?r.event.remove(c,d):r.removeEvent(c,d,b.handle);c[W.expando]=void 0}c[X.expando]&&(c[X.expando]=void 0)}}}),r.fn.extend({detach:function(a){return Ka(this,a,!0)},remove:function(a){return Ka(this,a)},text:function(a){return T(this,function(a){return void 0===a?r.text(this):this.empty().each(function(){1!==this.nodeType&&11!==this.nodeType&&9!==this.nodeType||(this.textContent=a)})},null,a,arguments.length)},append:function(){return Ja(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ea(this,a);b.appendChild(a)}})},prepend:function(){return Ja(this,arguments,function(a){if(1===this.nodeType||11===this.nodeType||9===this.nodeType){var b=Ea(this,a);b.insertBefore(a,b.firstChild)}})},before:function(){return Ja(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this)})},after:function(){return Ja(this,arguments,function(a){this.parentNode&&this.parentNode.insertBefore(a,this.nextSibling)})},empty:function(){for(var a,b=0;null!=(a=this[b]);b++)1===a.nodeType&&(r.cleanData(na(a,!1)),a.textContent="");return this},clone:function(a,b){return a=null!=a&&a,b=null==b?a:b,this.map(function(){return r.clone(this,a,b)})},html:function(a){return T(this,function(a){var b=this[0]||{},c=0,d=this.length;if(void 0===a&&1===b.nodeType)return b.innerHTML;if("string"==typeof a&&!Aa.test(a)&&!ma[(ka.exec(a)||["",""])[1].toLowerCase()]){a=r.htmlPrefilter(a);try{for(;c1)}});function _a(a,b,c,d,e){return new _a.prototype.init(a,b,c,d,e)}r.Tween=_a,_a.prototype={constructor:_a,init:function(a,b,c,d,e,f){this.elem=a,this.prop=c,this.easing=e||r.easing._default,this.options=b,this.start=this.now=this.cur(),this.end=d,this.unit=f||(r.cssNumber[c]?"":"px")},cur:function(){var a=_a.propHooks[this.prop];return a&&a.get?a.get(this):_a.propHooks._default.get(this)},run:function(a){var b,c=_a.propHooks[this.prop];return this.options.duration?this.pos=b=r.easing[this.easing](a,this.options.duration*a,0,1,this.options.duration):this.pos=b=a,this.now=(this.end-this.start)*b+this.start,this.options.step&&this.options.step.call(this.elem,this.now,this),c&&c.set?c.set(this):_a.propHooks._default.set(this),this}},_a.prototype.init.prototype=_a.prototype,_a.propHooks={_default:{get:function(a){var b;return 1!==a.elem.nodeType||null!=a.elem[a.prop]&&null==a.elem.style[a.prop]?a.elem[a.prop]:(b=r.css(a.elem,a.prop,""),b&&"auto"!==b?b:0)},set:function(a){r.fx.step[a.prop]?r.fx.step[a.prop](a):1!==a.elem.nodeType||null==a.elem.style[r.cssProps[a.prop]]&&!r.cssHooks[a.prop]?a.elem[a.prop]=a.now:r.style(a.elem,a.prop,a.now+a.unit)}}},_a.propHooks.scrollTop=_a.propHooks.scrollLeft={set:function(a){a.elem.nodeType&&a.elem.parentNode&&(a.elem[a.prop]=a.now)}},r.easing={linear:function(a){return a},swing:function(a){return.5-Math.cos(a*Math.PI)/2},_default:"swing"},r.fx=_a.prototype.init,r.fx.step={};var ab,bb,cb=/^(?:toggle|show|hide)$/,db=/queueHooks$/;function eb(){bb&&(d.hidden===!1&&a.requestAnimationFrame?a.requestAnimationFrame(eb):a.setTimeout(eb,r.fx.interval),r.fx.tick())}function fb(){return a.setTimeout(function(){ab=void 0}),ab=r.now()}function gb(a,b){var c,d=0,e={height:a};for(b=b?1:0;d<4;d+=2-b)c=ca[d],e["margin"+c]=e["padding"+c]=a;return b&&(e.opacity=e.width=a),e}function hb(a,b,c){for(var d,e=(kb.tweeners[b]||[]).concat(kb.tweeners["*"]),f=0,g=e.length;f1)},removeAttr:function(a){return this.each(function(){r.removeAttr(this,a)})}}),r.extend({attr:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return"undefined"==typeof a.getAttribute?r.prop(a,b,c):(1===f&&r.isXMLDoc(a)||(e=r.attrHooks[b.toLowerCase()]||(r.expr.match.bool.test(b)?lb:void 0)),void 0!==c?null===c?void r.removeAttr(a,b):e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:(a.setAttribute(b,c+""),c):e&&"get"in e&&null!==(d=e.get(a,b))?d:(d=r.find.attr(a,b), -null==d?void 0:d))},attrHooks:{type:{set:function(a,b){if(!o.radioValue&&"radio"===b&&B(a,"input")){var c=a.value;return a.setAttribute("type",b),c&&(a.value=c),b}}}},removeAttr:function(a,b){var c,d=0,e=b&&b.match(L);if(e&&1===a.nodeType)while(c=e[d++])a.removeAttribute(c)}}),lb={set:function(a,b,c){return b===!1?r.removeAttr(a,c):a.setAttribute(c,c),c}},r.each(r.expr.match.bool.source.match(/\w+/g),function(a,b){var c=mb[b]||r.find.attr;mb[b]=function(a,b,d){var e,f,g=b.toLowerCase();return d||(f=mb[g],mb[g]=e,e=null!=c(a,b,d)?g:null,mb[g]=f),e}});var nb=/^(?:input|select|textarea|button)$/i,ob=/^(?:a|area)$/i;r.fn.extend({prop:function(a,b){return T(this,r.prop,a,b,arguments.length>1)},removeProp:function(a){return this.each(function(){delete this[r.propFix[a]||a]})}}),r.extend({prop:function(a,b,c){var d,e,f=a.nodeType;if(3!==f&&8!==f&&2!==f)return 1===f&&r.isXMLDoc(a)||(b=r.propFix[b]||b,e=r.propHooks[b]),void 0!==c?e&&"set"in e&&void 0!==(d=e.set(a,c,b))?d:a[b]=c:e&&"get"in e&&null!==(d=e.get(a,b))?d:a[b]},propHooks:{tabIndex:{get:function(a){var b=r.find.attr(a,"tabindex");return b?parseInt(b,10):nb.test(a.nodeName)||ob.test(a.nodeName)&&a.href?0:-1}}},propFix:{"for":"htmlFor","class":"className"}}),o.optSelected||(r.propHooks.selected={get:function(a){var b=a.parentNode;return b&&b.parentNode&&b.parentNode.selectedIndex,null},set:function(a){var b=a.parentNode;b&&(b.selectedIndex,b.parentNode&&b.parentNode.selectedIndex)}}),r.each(["tabIndex","readOnly","maxLength","cellSpacing","cellPadding","rowSpan","colSpan","useMap","frameBorder","contentEditable"],function(){r.propFix[this.toLowerCase()]=this});function pb(a){var b=a.match(L)||[];return b.join(" ")}function qb(a){return a.getAttribute&&a.getAttribute("class")||""}r.fn.extend({addClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).addClass(a.call(this,b,qb(this)))});if("string"==typeof a&&a){b=a.match(L)||[];while(c=this[i++])if(e=qb(c),d=1===c.nodeType&&" "+pb(e)+" "){g=0;while(f=b[g++])d.indexOf(" "+f+" ")<0&&(d+=f+" ");h=pb(d),e!==h&&c.setAttribute("class",h)}}return this},removeClass:function(a){var b,c,d,e,f,g,h,i=0;if(r.isFunction(a))return this.each(function(b){r(this).removeClass(a.call(this,b,qb(this)))});if(!arguments.length)return this.attr("class","");if("string"==typeof a&&a){b=a.match(L)||[];while(c=this[i++])if(e=qb(c),d=1===c.nodeType&&" "+pb(e)+" "){g=0;while(f=b[g++])while(d.indexOf(" "+f+" ")>-1)d=d.replace(" "+f+" "," ");h=pb(d),e!==h&&c.setAttribute("class",h)}}return this},toggleClass:function(a,b){var c=typeof a;return"boolean"==typeof b&&"string"===c?b?this.addClass(a):this.removeClass(a):r.isFunction(a)?this.each(function(c){r(this).toggleClass(a.call(this,c,qb(this),b),b)}):this.each(function(){var b,d,e,f;if("string"===c){d=0,e=r(this),f=a.match(L)||[];while(b=f[d++])e.hasClass(b)?e.removeClass(b):e.addClass(b)}else void 0!==a&&"boolean"!==c||(b=qb(this),b&&W.set(this,"__className__",b),this.setAttribute&&this.setAttribute("class",b||a===!1?"":W.get(this,"__className__")||""))})},hasClass:function(a){var b,c,d=0;b=" "+a+" ";while(c=this[d++])if(1===c.nodeType&&(" "+pb(qb(c))+" ").indexOf(b)>-1)return!0;return!1}});var rb=/\r/g;r.fn.extend({val:function(a){var b,c,d,e=this[0];{if(arguments.length)return d=r.isFunction(a),this.each(function(c){var e;1===this.nodeType&&(e=d?a.call(this,c,r(this).val()):a,null==e?e="":"number"==typeof e?e+="":Array.isArray(e)&&(e=r.map(e,function(a){return null==a?"":a+""})),b=r.valHooks[this.type]||r.valHooks[this.nodeName.toLowerCase()],b&&"set"in b&&void 0!==b.set(this,e,"value")||(this.value=e))});if(e)return b=r.valHooks[e.type]||r.valHooks[e.nodeName.toLowerCase()],b&&"get"in b&&void 0!==(c=b.get(e,"value"))?c:(c=e.value,"string"==typeof c?c.replace(rb,""):null==c?"":c)}}}),r.extend({valHooks:{option:{get:function(a){var b=r.find.attr(a,"value");return null!=b?b:pb(r.text(a))}},select:{get:function(a){var b,c,d,e=a.options,f=a.selectedIndex,g="select-one"===a.type,h=g?null:[],i=g?f+1:e.length;for(d=f<0?i:g?f:0;d-1)&&(c=!0);return c||(a.selectedIndex=-1),f}}}}),r.each(["radio","checkbox"],function(){r.valHooks[this]={set:function(a,b){if(Array.isArray(b))return a.checked=r.inArray(r(a).val(),b)>-1}},o.checkOn||(r.valHooks[this].get=function(a){return null===a.getAttribute("value")?"on":a.value})});var sb=/^(?:focusinfocus|focusoutblur)$/;r.extend(r.event,{trigger:function(b,c,e,f){var g,h,i,j,k,m,n,o=[e||d],p=l.call(b,"type")?b.type:b,q=l.call(b,"namespace")?b.namespace.split("."):[];if(h=i=e=e||d,3!==e.nodeType&&8!==e.nodeType&&!sb.test(p+r.event.triggered)&&(p.indexOf(".")>-1&&(q=p.split("."),p=q.shift(),q.sort()),k=p.indexOf(":")<0&&"on"+p,b=b[r.expando]?b:new r.Event(p,"object"==typeof b&&b),b.isTrigger=f?2:3,b.namespace=q.join("."),b.rnamespace=b.namespace?new RegExp("(^|\\.)"+q.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,b.result=void 0,b.target||(b.target=e),c=null==c?[b]:r.makeArray(c,[b]),n=r.event.special[p]||{},f||!n.trigger||n.trigger.apply(e,c)!==!1)){if(!f&&!n.noBubble&&!r.isWindow(e)){for(j=n.delegateType||p,sb.test(j+p)||(h=h.parentNode);h;h=h.parentNode)o.push(h),i=h;i===(e.ownerDocument||d)&&o.push(i.defaultView||i.parentWindow||a)}g=0;while((h=o[g++])&&!b.isPropagationStopped())b.type=g>1?j:n.bindType||p,m=(W.get(h,"events")||{})[b.type]&&W.get(h,"handle"),m&&m.apply(h,c),m=k&&h[k],m&&m.apply&&U(h)&&(b.result=m.apply(h,c),b.result===!1&&b.preventDefault());return b.type=p,f||b.isDefaultPrevented()||n._default&&n._default.apply(o.pop(),c)!==!1||!U(e)||k&&r.isFunction(e[p])&&!r.isWindow(e)&&(i=e[k],i&&(e[k]=null),r.event.triggered=p,e[p](),r.event.triggered=void 0,i&&(e[k]=i)),b.result}},simulate:function(a,b,c){var d=r.extend(new r.Event,c,{type:a,isSimulated:!0});r.event.trigger(d,null,b)}}),r.fn.extend({trigger:function(a,b){return this.each(function(){r.event.trigger(a,b,this)})},triggerHandler:function(a,b){var c=this[0];if(c)return r.event.trigger(a,b,c,!0)}}),r.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(a,b){r.fn[b]=function(a,c){return arguments.length>0?this.on(b,null,a,c):this.trigger(b)}}),r.fn.extend({hover:function(a,b){return this.mouseenter(a).mouseleave(b||a)}}),o.focusin="onfocusin"in a,o.focusin||r.each({focus:"focusin",blur:"focusout"},function(a,b){var c=function(a){r.event.simulate(b,a.target,r.event.fix(a))};r.event.special[b]={setup:function(){var d=this.ownerDocument||this,e=W.access(d,b);e||d.addEventListener(a,c,!0),W.access(d,b,(e||0)+1)},teardown:function(){var d=this.ownerDocument||this,e=W.access(d,b)-1;e?W.access(d,b,e):(d.removeEventListener(a,c,!0),W.remove(d,b))}}});var tb=a.location,ub=r.now(),vb=/\?/;r.parseXML=function(b){var c;if(!b||"string"!=typeof b)return null;try{c=(new a.DOMParser).parseFromString(b,"text/xml")}catch(d){c=void 0}return c&&!c.getElementsByTagName("parsererror").length||r.error("Invalid XML: "+b),c};var wb=/\[\]$/,xb=/\r?\n/g,yb=/^(?:submit|button|image|reset|file)$/i,zb=/^(?:input|select|textarea|keygen)/i;function Ab(a,b,c,d){var e;if(Array.isArray(b))r.each(b,function(b,e){c||wb.test(a)?d(a,e):Ab(a+"["+("object"==typeof e&&null!=e?b:"")+"]",e,c,d)});else if(c||"object"!==r.type(b))d(a,b);else for(e in b)Ab(a+"["+e+"]",b[e],c,d)}r.param=function(a,b){var c,d=[],e=function(a,b){var c=r.isFunction(b)?b():b;d[d.length]=encodeURIComponent(a)+"="+encodeURIComponent(null==c?"":c)};if(Array.isArray(a)||a.jquery&&!r.isPlainObject(a))r.each(a,function(){e(this.name,this.value)});else for(c in a)Ab(c,a[c],b,e);return d.join("&")},r.fn.extend({serialize:function(){return r.param(this.serializeArray())},serializeArray:function(){return this.map(function(){var a=r.prop(this,"elements");return a?r.makeArray(a):this}).filter(function(){var a=this.type;return this.name&&!r(this).is(":disabled")&&zb.test(this.nodeName)&&!yb.test(a)&&(this.checked||!ja.test(a))}).map(function(a,b){var c=r(this).val();return null==c?null:Array.isArray(c)?r.map(c,function(a){return{name:b.name,value:a.replace(xb,"\r\n")}}):{name:b.name,value:c.replace(xb,"\r\n")}}).get()}});var Bb=/%20/g,Cb=/#.*$/,Db=/([?&])_=[^&]*/,Eb=/^(.*?):[ \t]*([^\r\n]*)$/gm,Fb=/^(?:about|app|app-storage|.+-extension|file|res|widget):$/,Gb=/^(?:GET|HEAD)$/,Hb=/^\/\//,Ib={},Jb={},Kb="*/".concat("*"),Lb=d.createElement("a");Lb.href=tb.href;function Mb(a){return function(b,c){"string"!=typeof b&&(c=b,b="*");var d,e=0,f=b.toLowerCase().match(L)||[];if(r.isFunction(c))while(d=f[e++])"+"===d[0]?(d=d.slice(1)||"*",(a[d]=a[d]||[]).unshift(c)):(a[d]=a[d]||[]).push(c)}}function Nb(a,b,c,d){var e={},f=a===Jb;function g(h){var i;return e[h]=!0,r.each(a[h]||[],function(a,h){var j=h(b,c,d);return"string"!=typeof j||f||e[j]?f?!(i=j):void 0:(b.dataTypes.unshift(j),g(j),!1)}),i}return g(b.dataTypes[0])||!e["*"]&&g("*")}function Ob(a,b){var c,d,e=r.ajaxSettings.flatOptions||{};for(c in b)void 0!==b[c]&&((e[c]?a:d||(d={}))[c]=b[c]);return d&&r.extend(!0,a,d),a}function Pb(a,b,c){var d,e,f,g,h=a.contents,i=a.dataTypes;while("*"===i[0])i.shift(),void 0===d&&(d=a.mimeType||b.getResponseHeader("Content-Type"));if(d)for(e in h)if(h[e]&&h[e].test(d)){i.unshift(e);break}if(i[0]in c)f=i[0];else{for(e in c){if(!i[0]||a.converters[e+" "+i[0]]){f=e;break}g||(g=e)}f=f||g}if(f)return f!==i[0]&&i.unshift(f),c[f]}function Qb(a,b,c,d){var e,f,g,h,i,j={},k=a.dataTypes.slice();if(k[1])for(g in a.converters)j[g.toLowerCase()]=a.converters[g];f=k.shift();while(f)if(a.responseFields[f]&&(c[a.responseFields[f]]=b),!i&&d&&a.dataFilter&&(b=a.dataFilter(b,a.dataType)),i=f,f=k.shift())if("*"===f)f=i;else if("*"!==i&&i!==f){if(g=j[i+" "+f]||j["* "+f],!g)for(e in j)if(h=e.split(" "),h[1]===f&&(g=j[i+" "+h[0]]||j["* "+h[0]])){g===!0?g=j[e]:j[e]!==!0&&(f=h[0],k.unshift(h[1]));break}if(g!==!0)if(g&&a["throws"])b=g(b);else try{b=g(b)}catch(l){return{state:"parsererror",error:g?l:"No conversion from "+i+" to "+f}}}return{state:"success",data:b}}r.extend({active:0,lastModified:{},etag:{},ajaxSettings:{url:tb.href,type:"GET",isLocal:Fb.test(tb.protocol),global:!0,processData:!0,async:!0,contentType:"application/x-www-form-urlencoded; charset=UTF-8",accepts:{"*":Kb,text:"text/plain",html:"text/html",xml:"application/xml, text/xml",json:"application/json, text/javascript"},contents:{xml:/\bxml\b/,html:/\bhtml/,json:/\bjson\b/},responseFields:{xml:"responseXML",text:"responseText",json:"responseJSON"},converters:{"* text":String,"text html":!0,"text json":JSON.parse,"text xml":r.parseXML},flatOptions:{url:!0,context:!0}},ajaxSetup:function(a,b){return b?Ob(Ob(a,r.ajaxSettings),b):Ob(r.ajaxSettings,a)},ajaxPrefilter:Mb(Ib),ajaxTransport:Mb(Jb),ajax:function(b,c){"object"==typeof b&&(c=b,b=void 0),c=c||{};var e,f,g,h,i,j,k,l,m,n,o=r.ajaxSetup({},c),p=o.context||o,q=o.context&&(p.nodeType||p.jquery)?r(p):r.event,s=r.Deferred(),t=r.Callbacks("once memory"),u=o.statusCode||{},v={},w={},x="canceled",y={readyState:0,getResponseHeader:function(a){var b;if(k){if(!h){h={};while(b=Eb.exec(g))h[b[1].toLowerCase()]=b[2]}b=h[a.toLowerCase()]}return null==b?null:b},getAllResponseHeaders:function(){return k?g:null},setRequestHeader:function(a,b){return null==k&&(a=w[a.toLowerCase()]=w[a.toLowerCase()]||a,v[a]=b),this},overrideMimeType:function(a){return null==k&&(o.mimeType=a),this},statusCode:function(a){var b;if(a)if(k)y.always(a[y.status]);else for(b in a)u[b]=[u[b],a[b]];return this},abort:function(a){var b=a||x;return e&&e.abort(b),A(0,b),this}};if(s.promise(y),o.url=((b||o.url||tb.href)+"").replace(Hb,tb.protocol+"//"),o.type=c.method||c.type||o.method||o.type,o.dataTypes=(o.dataType||"*").toLowerCase().match(L)||[""],null==o.crossDomain){j=d.createElement("a");try{j.href=o.url,j.href=j.href,o.crossDomain=Lb.protocol+"//"+Lb.host!=j.protocol+"//"+j.host}catch(z){o.crossDomain=!0}}if(o.data&&o.processData&&"string"!=typeof o.data&&(o.data=r.param(o.data,o.traditional)),Nb(Ib,o,c,y),k)return y;l=r.event&&o.global,l&&0===r.active++&&r.event.trigger("ajaxStart"),o.type=o.type.toUpperCase(),o.hasContent=!Gb.test(o.type),f=o.url.replace(Cb,""),o.hasContent?o.data&&o.processData&&0===(o.contentType||"").indexOf("application/x-www-form-urlencoded")&&(o.data=o.data.replace(Bb,"+")):(n=o.url.slice(f.length),o.data&&(f+=(vb.test(f)?"&":"?")+o.data,delete o.data),o.cache===!1&&(f=f.replace(Db,"$1"),n=(vb.test(f)?"&":"?")+"_="+ub++ +n),o.url=f+n),o.ifModified&&(r.lastModified[f]&&y.setRequestHeader("If-Modified-Since",r.lastModified[f]),r.etag[f]&&y.setRequestHeader("If-None-Match",r.etag[f])),(o.data&&o.hasContent&&o.contentType!==!1||c.contentType)&&y.setRequestHeader("Content-Type",o.contentType),y.setRequestHeader("Accept",o.dataTypes[0]&&o.accepts[o.dataTypes[0]]?o.accepts[o.dataTypes[0]]+("*"!==o.dataTypes[0]?", "+Kb+"; q=0.01":""):o.accepts["*"]);for(m in o.headers)y.setRequestHeader(m,o.headers[m]);if(o.beforeSend&&(o.beforeSend.call(p,y,o)===!1||k))return y.abort();if(x="abort",t.add(o.complete),y.done(o.success),y.fail(o.error),e=Nb(Jb,o,c,y)){if(y.readyState=1,l&&q.trigger("ajaxSend",[y,o]),k)return y;o.async&&o.timeout>0&&(i=a.setTimeout(function(){y.abort("timeout")},o.timeout));try{k=!1,e.send(v,A)}catch(z){if(k)throw z;A(-1,z)}}else A(-1,"No Transport");function A(b,c,d,h){var j,m,n,v,w,x=c;k||(k=!0,i&&a.clearTimeout(i),e=void 0,g=h||"",y.readyState=b>0?4:0,j=b>=200&&b<300||304===b,d&&(v=Pb(o,y,d)),v=Qb(o,v,y,j),j?(o.ifModified&&(w=y.getResponseHeader("Last-Modified"),w&&(r.lastModified[f]=w),w=y.getResponseHeader("etag"),w&&(r.etag[f]=w)),204===b||"HEAD"===o.type?x="nocontent":304===b?x="notmodified":(x=v.state,m=v.data,n=v.error,j=!n)):(n=x,!b&&x||(x="error",b<0&&(b=0))),y.status=b,y.statusText=(c||x)+"",j?s.resolveWith(p,[m,x,y]):s.rejectWith(p,[y,x,n]),y.statusCode(u),u=void 0,l&&q.trigger(j?"ajaxSuccess":"ajaxError",[y,o,j?m:n]),t.fireWith(p,[y,x]),l&&(q.trigger("ajaxComplete",[y,o]),--r.active||r.event.trigger("ajaxStop")))}return y},getJSON:function(a,b,c){return r.get(a,b,c,"json")},getScript:function(a,b){return r.get(a,void 0,b,"script")}}),r.each(["get","post"],function(a,b){r[b]=function(a,c,d,e){return r.isFunction(c)&&(e=e||d,d=c,c=void 0),r.ajax(r.extend({url:a,type:b,dataType:e,data:c,success:d},r.isPlainObject(a)&&a))}}),r._evalUrl=function(a){return r.ajax({url:a,type:"GET",dataType:"script",cache:!0,async:!1,global:!1,"throws":!0})},r.fn.extend({wrapAll:function(a){var b;return this[0]&&(r.isFunction(a)&&(a=a.call(this[0])),b=r(a,this[0].ownerDocument).eq(0).clone(!0),this[0].parentNode&&b.insertBefore(this[0]),b.map(function(){var a=this;while(a.firstElementChild)a=a.firstElementChild;return a}).append(this)),this},wrapInner:function(a){return r.isFunction(a)?this.each(function(b){r(this).wrapInner(a.call(this,b))}):this.each(function(){var b=r(this),c=b.contents();c.length?c.wrapAll(a):b.append(a)})},wrap:function(a){var b=r.isFunction(a);return this.each(function(c){r(this).wrapAll(b?a.call(this,c):a)})},unwrap:function(a){return this.parent(a).not("body").each(function(){r(this).replaceWith(this.childNodes)}),this}}),r.expr.pseudos.hidden=function(a){return!r.expr.pseudos.visible(a)},r.expr.pseudos.visible=function(a){return!!(a.offsetWidth||a.offsetHeight||a.getClientRects().length)},r.ajaxSettings.xhr=function(){try{return new a.XMLHttpRequest}catch(b){}};var Rb={0:200,1223:204},Sb=r.ajaxSettings.xhr();o.cors=!!Sb&&"withCredentials"in Sb,o.ajax=Sb=!!Sb,r.ajaxTransport(function(b){var c,d;if(o.cors||Sb&&!b.crossDomain)return{send:function(e,f){var g,h=b.xhr();if(h.open(b.type,b.url,b.async,b.username,b.password),b.xhrFields)for(g in b.xhrFields)h[g]=b.xhrFields[g];b.mimeType&&h.overrideMimeType&&h.overrideMimeType(b.mimeType),b.crossDomain||e["X-Requested-With"]||(e["X-Requested-With"]="XMLHttpRequest");for(g in e)h.setRequestHeader(g,e[g]);c=function(a){return function(){c&&(c=d=h.onload=h.onerror=h.onabort=h.onreadystatechange=null,"abort"===a?h.abort():"error"===a?"number"!=typeof h.status?f(0,"error"):f(h.status,h.statusText):f(Rb[h.status]||h.status,h.statusText,"text"!==(h.responseType||"text")||"string"!=typeof h.responseText?{binary:h.response}:{text:h.responseText},h.getAllResponseHeaders()))}},h.onload=c(),d=h.onerror=c("error"),void 0!==h.onabort?h.onabort=d:h.onreadystatechange=function(){4===h.readyState&&a.setTimeout(function(){c&&d()})},c=c("abort");try{h.send(b.hasContent&&b.data||null)}catch(i){if(c)throw i}},abort:function(){c&&c()}}}),r.ajaxPrefilter(function(a){a.crossDomain&&(a.contents.script=!1)}),r.ajaxSetup({accepts:{script:"text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},contents:{script:/\b(?:java|ecma)script\b/},converters:{"text script":function(a){return r.globalEval(a),a}}}),r.ajaxPrefilter("script",function(a){void 0===a.cache&&(a.cache=!1),a.crossDomain&&(a.type="GET")}),r.ajaxTransport("script",function(a){if(a.crossDomain){var b,c;return{send:function(e,f){b=r(" + diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 027efa83c..1133f41f3 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -46,6 +46,12 @@ Circuit + + + +
Status + {{ circuit.get_status_display }} +
Provider diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html index 63d38ef52..8503e68f6 100644 --- a/netbox/templates/circuits/circuit_edit.html +++ b/netbox/templates/circuits/circuit_edit.html @@ -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 %}
diff --git a/netbox/templates/dcim/bulk_rename.html b/netbox/templates/dcim/bulk_rename.html new file mode 100644 index 000000000..6abcaf305 --- /dev/null +++ b/netbox/templates/dcim/bulk_rename.html @@ -0,0 +1,55 @@ +{% extends '_base.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block content %} +

{% block title %}Renaming {{ selected_objects|length }} {{ obj_type_plural|bettertitle }}{% endblock %}

+
+
+ + + + + + + + + {% for obj in selected_objects %} + + + + + {% endfor %} + +
Current NameNew Name
{{ obj.name }}{{ obj.new_name }}
+
+
+
+ {% csrf_token %} + {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
Rename
+
+ {% render_form form %} +
+
+
+
+ + {% if '_preview' in request.POST and not form.errors %} + + {% endif %} + Cancel +
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 349a3c96d..e2253d4f4 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -98,6 +98,46 @@
+ {% if vc_members %} +
+
+ Virtual Chassis +
+ + + + + + + + {% for vc_member in vc_members %} + + + + + + + {% endfor %} +
DevicePositionMasterPriority
+ {{ vc_member }} + {{ vc_member.vc_position }}{% if device.virtual_chassis.master == vc_member %}{% endif %}{{ vc_member.vc_priority|default:"" }}
+ +
+ {% endif %}
Management @@ -339,45 +379,48 @@
Device Bays -
- {% if perms.dcim.change_devicebay and device_bays|length > 1 %} - - {% endif %} - {% if perms.dcim.add_devicebay and device_bays|length > 10 %} +
+ + + + {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} + + {% endif %} + + + + + + + {% for devicebay in device_bays %} + {% include 'dcim/inc/devicebay.html' %} + {% empty %} + + + + {% endfor %} + +
NameInstalled Device
— No device bays defined —
+ - - {% for devicebay in device_bays %} - {% include 'dcim/inc/devicebay.html' %} - {% empty %} - - - - {% endfor %} -
No device bays defined
- {% if perms.dcim.add_devicebay or perms.dcim.delete_devicebay %} - - {% endif %} +
+
+ {% endif %} +
{% if perms.dcim.delete_devicebay %} @@ -396,66 +439,61 @@ - {% if perms.dcim.change_interface and interfaces|length > 1 %} - - {% endif %} - {% if perms.dcim.add_interface and interfaces|length > 10 %} +
+ + + + + {% if perms.dcim.change_interface or perms.dcim.delete_interface %} + + {% endif %} + + + + + + + + + + + {% for iface in interfaces %} + {% include 'dcim/inc/interface.html' %} + {% empty %} + + + + {% endfor %} + +
NameLAGDescriptionMTUMAC AddressConnection
— No interfaces defined —
+ - - - {% if perms.dcim.change_interface or perms.dcim.delete_interface %} - - {% endif %} - - - - - - - - - {% for iface in interfaces %} - {% include 'dcim/inc/interface.html' %} - {% empty %} - - - - {% endfor %} -
NameLAGDescriptionMTUMAC AddressConnection
No interfaces defined
- {% if perms.dcim.add_interface or perms.dcim.delete_interface %} - - {% endif %} + +
+ {% endif %} + {% if perms.dcim.delete_interface %} @@ -469,58 +507,51 @@
Console Server Ports -
- {% if perms.dcim.change_consoleserverport and cs_ports|length > 1 %} - - {% endif %} - {% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %} +
+ + + + {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} + + {% endif %} + + + + + + + {% for csp in cs_ports %} + {% include 'dcim/inc/consoleserverport.html' %} + {% empty %} + + + + {% endfor %} + +
NameConnection
— No console server ports defined —
+ +
+ {% endif %}
- - - {% if perms.dcim.change_consoleserverport or perms.dcim.delete_consoleserverport %} - - {% endif %} - - - - - {% for csp in cs_ports %} - {% include 'dcim/inc/consoleserverport.html' %} - {% empty %} - - - - {% endfor %} -
NameConnection
No console server ports defined
- {% if perms.dcim.add_consoleserverport or perms.dcim.delete_consoleserverport %} - - {% endif %}
{% if perms.dcim.delete_consoleserverport %} @@ -534,58 +565,51 @@
Power Outlets -
- {% if perms.dcim.change_poweroutlet and power_outlets|length > 1 %} - - {% endif %} - {% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %} +
+ + + + {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} + + {% endif %} + + + + + + + {% for po in power_outlets %} + {% include 'dcim/inc/poweroutlet.html' %} + {% empty %} + + + + {% endfor %} + +
NameConnection
— No power outlets defined —
+ +
+ {% endif %}
- - - {% if perms.dcim.change_poweroutlet or perms.dcim.delete_poweroutlet %} - - {% endif %} - - - - - {% for po in power_outlets %} - {% include 'dcim/inc/poweroutlet.html' %} - {% empty %} - - - text-nowrap - {% endfor %} -
NameConnection
No power outlets defined
- {% if perms.dcim.add_poweroutlet or perms.dcim.delete_poweroutlet %} - - {% endif %}
{% if perms.dcim.delete_poweroutlet %} diff --git a/netbox/templates/dcim/inc/device_table.html b/netbox/templates/dcim/inc/device_table.html index 33f7e93aa..68570fdf3 100644 --- a/netbox/templates/dcim/inc/device_table.html +++ b/netbox/templates/dcim/inc/device_table.html @@ -16,4 +16,9 @@ {% endif %} + {% if perms.dcim.add_virtualchassis %} + + {% endif %} {% endblock %} diff --git a/netbox/templates/dcim/inc/devicebay.html b/netbox/templates/dcim/inc/devicebay.html index 2a1a9708d..e6e4d3e47 100644 --- a/netbox/templates/dcim/inc/devicebay.html +++ b/netbox/templates/dcim/inc/devicebay.html @@ -5,7 +5,7 @@ {% endif %} - {{ devicebay }} + {{ devicebay.name }} {% if devicebay.installed_device %} @@ -19,7 +19,7 @@ Vacant {% endif %} - + {% if perms.dcim.change_devicebay %} {% if devicebay.installed_device %} diff --git a/netbox/templates/dcim/inc/devicetype_component_table.html b/netbox/templates/dcim/inc/devicetype_component_table.html index fa10948a6..b43a3612f 100644 --- a/netbox/templates/dcim/inc/devicetype_component_table.html +++ b/netbox/templates/dcim/inc/devicetype_component_table.html @@ -4,19 +4,6 @@
{% include 'responsive_table.html' %}