diff --git a/docs/shell/intro.md b/docs/shell/intro.md new file mode 100644 index 000000000..df92cb7cd --- /dev/null +++ b/docs/shell/intro.md @@ -0,0 +1,194 @@ +NetBox includes a Python shell withing which objects can be directly queried, created, modified, and deleted. To enter the shell, run the following command: + +``` +./manage.py nbshell +``` + +This will launch a customized version of [the built-in Django shell](https://docs.djangoproject.com/en/dev/ref/django-admin/#shell) with all relevant NetBox models pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.) + +``` +$ ./manage.py nbshell +### NetBox interactive shell (jstretch-laptop) +### Python 2.7.6 | Django 1.11.3 | NetBox 2.1.0-dev +### lsmodels() will show available models. Use help() for more info. +``` + +The function `lsmodels()` will print a list of all available NetBox models: + +``` +>>> lsmodels() +DCIM: + ConsolePort + ConsolePortTemplate + ConsoleServerPort + ConsoleServerPortTemplate + Device + ... +``` + +## Querying Objects + +Objects are retrieved by forming a [Django queryset](https://docs.djangoproject.com/en/dev/topics/db/queries/#retrieving-objects). The base queryset for an object takes the form `.objects.all()`, which will return a (truncated) list of all objects of that type. + +``` +>>> Device.objects.all() +, , , , , '...(remaining elements truncated)...']> +``` + +Use a `for` loop to cycle through all objects in the list: + +``` +>>> for device in Device.objects.all(): +... print(device.name, device.device_type) +... +(u'TestDevice1', ) +(u'TestDevice2', ) +(u'TestDevice3', ) +(u'TestDevice4', ) +(u'TestDevice5', ) +... +``` + +To count all objects matching the query, replace `all()` with `count()`: + +``` +>>> Device.objects.count() +1274 +``` + +To retrieve a particular object (typically by its primary key or other unique field), use `get()`: + +``` +>>> Site.objects.get(pk=7) + +``` + +### Filtering Querysets + +In most cases, you want to retrieve only a specific subset of objects. To filter a queryset, replace `all()` with `filter()` and pass one or more keyword arguments. For example: + +``` +>>> Device.objects.filter(status=STATUS_ACTIVE) +, , , , , '...(remaining elements truncated)...']> +``` + +Querysets support slicing to return a specific range of objects. + +``` +>>> Device.objects.filter(status=STATUS_ACTIVE)[:3] +, , ]> +``` + +The `count()` method can be appended to the queryset to return a count of objects rather than the full list. + +``` +>>> Device.objects.filter(status=STATUS_ACTIVE).count() +982 +``` + +Relationships with other models can be traversed by concatenting field names with a double-underscore. For example, the following will return all devices assigned to the tenant named "Pied Piper." + +``` +>>> Device.objects.filter(tenant__name='Pied Piper') +``` + +This approach can span multiple levels of relations. For example, the following will return all IP addresses assigned to a device in North America: + +``` +>>> IPAddress.objects.filter(interface__device__site__region__slug='north-america') +``` + +!!! note + While the above query is functional, it is very inefficient. There are ways to optimize such requests, however they are out of the scope of this document. For more information, see the [Django queryset method reference](https://docs.djangoproject.com/en/dev/ref/models/querysets/) documentation. + +Reverse relationships can be traversed as well. For example, the following will find all devices with an interface named "em0": + +``` +>>> Device.objects.filter(interfaces__name='em0') +``` + +Character fields can be filtered against partial matches using the `contains` or `icontains` field lookup (the later of which is case-insensitive). + +``` +>>> Device.objects.filter(name__icontains='testdevice') +``` + +Similarly, numeric fields can be filtered by values less than, greater than, and/or equal to a given value. + +``` +>>> VLAN.objects.filter(vid__gt=2000) +``` + +Multiple filters can be combined to further refine a queryset. + +``` +>>> VLAN.objects.filter(vid__gt=2000, name__icontains='engineering') +``` + +To return the inverse of a filtered queryset, use `exclude()` instead of `filter()`. + +``` +>>> Device.objects.count() +4479 +>>> Device.objects.filter(status=STATUS_ACTIVE).count() +4133 +>>> Device.objects.exclude(status=STATUS_ACTIVE).count() +346 +``` + +!!! info + The examples above are intended only to provide a cursory introduction to queryset filtering. For an exhaustive list of the available filters, please consult the [Django queryset API docs](https://docs.djangoproject.com/en/dev/ref/models/querysets/). + +## Creating and Updating Objects + +New objects can be created by instantiating the desired model, defining values for all required attributes, and calling `save()` on the instance. + +``` +>>> lab1 = Site.objects.get(pk=7) +>>> myvlan = VLAN(vid=123, name='MyNewVLAN', site=lab1) +>>> myvlan.save() +``` + +Alternatively, the above can be performed as a single operation: + +``` +>>> VLAN(vid=123, name='MyNewVLAN', site=Site.objects.get(pk=7)).save() +``` + +To modify an object, retrieve it, update the desired field(s), and call `save()` again. + +``` +>>> vlan = VLAN.objects.get(pk=1280) +>>> vlan.name +u'MyNewVLAN' +>>> vlan.name = 'BetterName' +>>> vlan.save() +>>> VLAN.objects.get(pk=1280).name +u'BetterName' +``` + +!!! warning + The Django ORM provides methods to create/edit many objects at once, namely `bulk_create()` and `update()`. These are best avoided in most cases as they bypass a model's built-in validation and can easily lead to database corruption if not used carefully. + +## Deleting Objects + +To delete an object, simply call `delete()` on its instance. This will return a dictionary of all objects (including related objects) which have been deleted as a result of this operation. + +``` +>>> vlan + +>>> vlan.delete() +(1, {u'extras.CustomFieldValue': 0, u'ipam.VLAN': 1}) +``` + +To delete multiple objects at once, call `delete()` on a filtered queryset. It's a good idea to always sanity-check the count of selected objects _before_ deleting them. + +``` +>>> Device.objects.filter(name__icontains='test').count() +27 +>>> Device.objects.filter(name__icontains='test').delete() +(35, {u'extras.CustomFieldValue': 0, u'dcim.DeviceBay': 0, u'secrets.Secret': 0, u'dcim.InterfaceConnection': 4, u'extras.ImageAttachment': 0, u'dcim.Device': 27, u'dcim.Interface': 4, u'dcim.ConsolePort': 0, u'dcim.PowerPort': 0}) +``` + +!!! warning + Deletions are immediate and irreversible. Always think very carefully before calling `delete()` on an instance or queryset. diff --git a/mkdocs.yml b/mkdocs.yml index 8b77b289d..a8be998e4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,6 +23,8 @@ pages: - 'Authentication': 'api/authentication.md' - 'Working with Secrets': 'api/working-with-secrets.md' - 'Examples': 'api/examples.md' + - 'Shell': + - 'Introduction': 'shell/intro.md' markdown_extensions: - admonition: diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index f2e6d0d00..cdab3427a 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -6,6 +6,7 @@ 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 ModelValidationMixin # @@ -44,7 +45,7 @@ class WritableProviderSerializer(CustomFieldModelSerializer): # Circuit types # -class CircuitTypeSerializer(serializers.ModelSerializer): +class CircuitTypeSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = CircuitType @@ -110,7 +111,7 @@ class CircuitTerminationSerializer(serializers.ModelSerializer): ] -class WritableCircuitTerminationSerializer(serializers.ModelSerializer): +class WritableCircuitTerminationSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = CircuitTermination diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index d14080531..685fa8f9e 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -43,6 +43,7 @@ class ProviderViewSet(WritableSerializerMixin, CustomFieldModelViewSet): class CircuitTypeViewSet(ModelViewSet): queryset = CircuitType.objects.all() serializer_class = serializers.CircuitTypeSerializer + filter_class = filters.CircuitTypeFilter # diff --git a/netbox/circuits/constants.py b/netbox/circuits/constants.py new file mode 100644 index 000000000..816e28e4e --- /dev/null +++ b/netbox/circuits/constants.py @@ -0,0 +1,10 @@ +from __future__ import unicode_literals + + +# CircuitTermination sides +TERM_SIDE_A = 'A' +TERM_SIDE_Z = 'Z' +TERM_SIDE_CHOICES = ( + (TERM_SIDE_A, 'A'), + (TERM_SIDE_Z, 'Z'), +) diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 6e9e1f443..8a1b01a89 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -31,7 +31,7 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Provider - fields = ['name', 'account', 'asn'] + fields = ['name', 'slug', 'asn', 'account'] def search(self, queryset, name, value): if not value.strip(): @@ -39,10 +39,19 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): return queryset.filter( Q(name__icontains=value) | Q(account__icontains=value) | + Q(noc_contact__icontains=value) | + Q(admin_contact__icontains=value) | Q(comments__icontains=value) ) +class CircuitTypeFilter(django_filters.FilterSet): + + class Meta: + model = CircuitType + fields = ['name', 'slug'] + + class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( @@ -50,7 +59,6 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) provider_id = django_filters.ModelMultipleChoiceFilter( - name='provider', queryset=Provider.objects.all(), label='Provider (ID)', ) @@ -61,7 +69,6 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Provider (slug)', ) type_id = django_filters.ModelMultipleChoiceFilter( - name='type', queryset=CircuitType.objects.all(), label='Circuit type (ID)', ) @@ -72,7 +79,6 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Circuit type (slug)', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) @@ -96,7 +102,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Circuit - fields = ['install_date'] + fields = ['cid', 'install_date', 'commit_rate'] def search(self, queryset, name, value): if not value.strip(): @@ -111,12 +117,34 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): class CircuitTerminationFilter(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) circuit_id = django_filters.ModelMultipleChoiceFilter( - name='circuit', queryset=Circuit.objects.all(), label='Circuit', ) + site_id = django_filters.ModelMultipleChoiceFilter( + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + name='site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) class Meta: model = CircuitTermination - fields = ['term_side', 'site'] + fields = ['term_side', 'port_speed', 'upstream_speed', 'xconnect_id'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(circuit__cid__icontains=value) | + Q(xconnect_id__icontains=value) | + Q(pp_info__icontains=value) + ).distinct() diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index f2371b262..d9954e55b 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals from django import forms from django.db.models import Count -from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES +from dcim.models import Site, Device, Interface, Rack from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import TenancyForm from tenancy.models import Tenant @@ -210,7 +210,7 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm ) ) interface = ChainedModelChoiceField( - queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related( + queryset=Interface.objects.connectable().select_related( 'circuit_termination', 'connected_as_a', 'connected_as_b' ), chains=( @@ -220,7 +220,7 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm label='Interface', widget=APISelect( api_url='/api/dcim/interfaces/?device_id={{device}}&type=physical', - disabled_indicator='connection' + disabled_indicator='is_connected' ) ) @@ -244,7 +244,7 @@ class CircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm # Initialize helper selectors instance = kwargs.get('instance') if instance and instance.interface is not None: - initial = kwargs.get('initial', {}) + initial = kwargs.get('initial', {}).copy() initial['rack'] = instance.interface.device.rack initial['device'] = instance.interface.device kwargs['initial'] = initial diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 44018ae1c..1acd3f4a0 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -10,14 +10,7 @@ from extras.models import CustomFieldModel, CustomFieldValue from tenancy.models import Tenant from utilities.utils import csv_format from utilities.models import CreatedUpdatedModel - - -TERM_SIDE_A = 'A' -TERM_SIDE_Z = 'Z' -TERM_SIDE_CHOICES = ( - (TERM_SIDE_A, 'A'), - (TERM_SIDE_Z, 'Z'), -) +from .constants import * def humanize_speed(speed): diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index d09c5a7b2..58775b378 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import django_tables2 as tables from django_tables2.utils import Accessor -from utilities.tables import BaseTable, SearchTable, ToggleColumn +from utilities.tables import BaseTable, ToggleColumn from .models import Circuit, CircuitType, Provider @@ -21,19 +21,18 @@ CIRCUITTYPE_ACTIONS = """ class ProviderTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() - circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits') class Meta(BaseTable.Meta): model = Provider - fields = ('pk', 'name', 'asn', 'account', 'circuit_count') + fields = ('pk', 'name', 'asn', 'account',) -class ProviderSearchTable(SearchTable): - name = tables.LinkColumn() +class ProviderDetailTable(ProviderTable): + circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits') - class Meta(SearchTable.Meta): + class Meta(ProviderTable.Meta): model = Provider - fields = ('name', 'asn', 'account') + fields = ('pk', 'name', 'asn', 'account', 'circuit_count') # @@ -74,19 +73,3 @@ class CircuitTable(BaseTable): class Meta(BaseTable.Meta): model = Circuit fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description') - - -class CircuitSearchTable(SearchTable): - cid = tables.LinkColumn(verbose_name='ID') - provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')]) - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) - a_side = tables.LinkColumn( - 'dcim:site', accessor=Accessor('termination_a.site'), args=[Accessor('termination_a.site.slug')] - ) - z_side = tables.LinkColumn( - 'dcim:site', accessor=Accessor('termination_z.site'), args=[Accessor('termination_z.site.slug')] - ) - - class Meta(SearchTable.Meta): - model = Circuit - fields = ('cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description') diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index eda37340d..345e3379d 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -26,7 +26,7 @@ class ProviderListView(ObjectListView): queryset = Provider.objects.annotate(count_circuits=Count('circuits')) filter = filters.ProviderFilter filter_form = forms.ProviderFilterForm - table = tables.ProviderTable + table = tables.ProviderDetailTable template_name = 'circuits/provider_list.html' @@ -78,8 +78,8 @@ class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'circuits.change_provider' cls = Provider filter = filters.ProviderFilter + table = tables.ProviderTable form = forms.ProviderBulkEditForm - template_name = 'circuits/provider_bulk_edit.html' default_return_url = 'circuits:provider_list' @@ -87,6 +87,7 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_provider' cls = Provider filter = filters.ProviderFilter + table = tables.ProviderTable default_return_url = 'circuits:provider_list' @@ -116,6 +117,8 @@ class CircuitTypeEditView(CircuitTypeCreateView): class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuittype' cls = CircuitType + queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) + table = tables.CircuitTypeTable default_return_url = 'circuits:circuittype_list' @@ -182,16 +185,19 @@ class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'circuits.change_circuit' cls = Circuit + queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filter = filters.CircuitFilter + table = tables.CircuitTable form = forms.CircuitBulkEditForm - template_name = 'circuits/circuit_bulk_edit.html' default_return_url = 'circuits:circuit_list' class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuit' cls = Circuit + queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filter = filters.CircuitFilter + table = tables.CircuitTable default_return_url = 'circuits:circuit_list' diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 8ca6cab35..09da3ced7 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,19 +1,21 @@ from __future__ import unicode_literals +from collections import OrderedDict from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from ipam.models import IPAddress +from circuits.models import Circuit, CircuitTermination from dcim.models import ( CONNECTION_STATUS_CHOICES, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, IFACE_FF_CHOICES, IFACE_ORDERING_CHOICES, Interface, - InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, - PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES, + InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, + PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHOICES, ) from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceFieldSerializer +from utilities.api import ChoiceFieldSerializer, ModelValidationMixin # @@ -36,7 +38,7 @@ class RegionSerializer(serializers.ModelSerializer): fields = ['id', 'name', 'slug', 'parent'] -class WritableRegionSerializer(serializers.ModelSerializer): +class WritableRegionSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Region @@ -98,7 +100,7 @@ class NestedRackGroupSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name', 'slug'] -class WritableRackGroupSerializer(serializers.ModelSerializer): +class WritableRackGroupSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = RackGroup @@ -109,7 +111,7 @@ class WritableRackGroupSerializer(serializers.ModelSerializer): # Rack roles # -class RackRoleSerializer(serializers.ModelSerializer): +class RackRoleSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = RackRole @@ -174,6 +176,9 @@ class WritableRackSerializer(CustomFieldModelSerializer): validator.set_context(self) validator(data) + # Enforce model validation + super(WritableRackSerializer, self).validate(data) + return data @@ -211,7 +216,7 @@ class RackReservationSerializer(serializers.ModelSerializer): fields = ['id', 'rack', 'units', 'created', 'user', 'description'] -class WritableRackReservationSerializer(serializers.ModelSerializer): +class WritableRackReservationSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = RackReservation @@ -222,7 +227,7 @@ class WritableRackReservationSerializer(serializers.ModelSerializer): # Manufacturers # -class ManufacturerSerializer(serializers.ModelSerializer): +class ManufacturerSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Manufacturer @@ -287,7 +292,7 @@ class ConsolePortTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritableConsolePortTemplateSerializer(serializers.ModelSerializer): +class WritableConsolePortTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = ConsolePortTemplate @@ -306,7 +311,7 @@ class ConsoleServerPortTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritableConsoleServerPortTemplateSerializer(serializers.ModelSerializer): +class WritableConsoleServerPortTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = ConsoleServerPortTemplate @@ -325,7 +330,7 @@ class PowerPortTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritablePowerPortTemplateSerializer(serializers.ModelSerializer): +class WritablePowerPortTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = PowerPortTemplate @@ -344,7 +349,7 @@ class PowerOutletTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritablePowerOutletTemplateSerializer(serializers.ModelSerializer): +class WritablePowerOutletTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = PowerOutletTemplate @@ -364,7 +369,7 @@ class InterfaceTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only'] -class WritableInterfaceTemplateSerializer(serializers.ModelSerializer): +class WritableInterfaceTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = InterfaceTemplate @@ -383,7 +388,7 @@ class DeviceBayTemplateSerializer(serializers.ModelSerializer): fields = ['id', 'device_type', 'name'] -class WritableDeviceBayTemplateSerializer(serializers.ModelSerializer): +class WritableDeviceBayTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = DeviceBayTemplate @@ -394,7 +399,7 @@ class WritableDeviceBayTemplateSerializer(serializers.ModelSerializer): # Device roles # -class DeviceRoleSerializer(serializers.ModelSerializer): +class DeviceRoleSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = DeviceRole @@ -413,11 +418,11 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer): # Platforms # -class PlatformSerializer(serializers.ModelSerializer): +class PlatformSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Platform - fields = ['id', 'name', 'slug', 'rpc_client'] + fields = ['id', 'name', 'slug', 'napalm_driver', 'rpc_client'] class NestedPlatformSerializer(serializers.ModelSerializer): @@ -496,6 +501,9 @@ class WritableDeviceSerializer(CustomFieldModelSerializer): validator.set_context(self) validator(data) + # Enforce model validation + super(WritableDeviceSerializer, self).validate(data) + return data @@ -512,7 +520,7 @@ class ConsoleServerPortSerializer(serializers.ModelSerializer): read_only_fields = ['connected_console'] -class WritableConsoleServerPortSerializer(serializers.ModelSerializer): +class WritableConsoleServerPortSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = ConsoleServerPort @@ -532,7 +540,7 @@ class ConsolePortSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] -class WritableConsolePortSerializer(serializers.ModelSerializer): +class WritableConsolePortSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = ConsolePort @@ -552,7 +560,7 @@ class PowerOutletSerializer(serializers.ModelSerializer): read_only_fields = ['connected_port'] -class WritablePowerOutletSerializer(serializers.ModelSerializer): +class WritablePowerOutletSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = PowerOutlet @@ -572,7 +580,7 @@ class PowerPortSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] -class WritablePowerPortSerializer(serializers.ModelSerializer): +class WritablePowerPortSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = PowerPort @@ -591,28 +599,58 @@ class NestedInterfaceSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'name'] +class InterfaceNestedCircuitSerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') + + class Meta: + model = Circuit + fields = ['id', 'url', 'cid'] + + +class InterfaceCircuitTerminationSerializer(serializers.ModelSerializer): + circuit = InterfaceNestedCircuitSerializer() + + class Meta: + model = CircuitTermination + fields = [ + 'id', 'circuit', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', + ] + + class InterfaceSerializer(serializers.ModelSerializer): device = NestedDeviceSerializer() form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) lag = NestedInterfaceSerializer() - connection = serializers.SerializerMethodField(read_only=True) - connected_interface = serializers.SerializerMethodField(read_only=True) + is_connected = serializers.SerializerMethodField(read_only=True) + interface_connection = serializers.SerializerMethodField(read_only=True) + circuit_termination = InterfaceCircuitTerminationSerializer() class Meta: model = Interface fields = [ - 'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'connection', - 'connected_interface', + 'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', + 'is_connected', 'interface_connection', 'circuit_termination', ] - def get_connection(self, obj): + def get_is_connected(self, obj): + """ + Return True if the interface has a connected interface or circuit termination. + """ if obj.connection: - return NestedInterfaceConnectionSerializer(obj.connection, context=self.context).data - return None + return True + try: + circuit_termination = obj.circuit_termination + return True + except CircuitTermination.DoesNotExist: + pass + return False - def get_connected_interface(self, obj): - if obj.connected_interface: - return PeerInterfaceSerializer(obj.connected_interface, context=self.context).data + def get_interface_connection(self, obj): + if obj.connection: + return OrderedDict(( + ('interface', PeerInterfaceSerializer(obj.connected_interface, context=self.context).data), + ('status', obj.connection.connection_status), + )) return None @@ -624,14 +662,19 @@ class PeerInterfaceSerializer(serializers.ModelSerializer): class Meta: model = Interface - fields = ['id', 'url', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description'] + fields = [ + 'id', 'url', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', + 'description', + ] -class WritableInterfaceSerializer(serializers.ModelSerializer): +class WritableInterfaceSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Interface - fields = ['id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description'] + fields = [ + 'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', + ] # @@ -647,7 +690,7 @@ class DeviceBaySerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'installed_device'] -class WritableDeviceBaySerializer(serializers.ModelSerializer): +class WritableDeviceBaySerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = DeviceBay @@ -664,14 +707,20 @@ class InventoryItemSerializer(serializers.ModelSerializer): class Meta: model = InventoryItem - fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] + fields = [ + 'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', + 'description', + ] -class WritableInventoryItemSerializer(serializers.ModelSerializer): +class WritableInventoryItemSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = InventoryItem - fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] + fields = [ + 'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', + 'description', + ] # @@ -696,7 +745,7 @@ class NestedInterfaceConnectionSerializer(serializers.ModelSerializer): fields = ['id', 'url', 'connection_status'] -class WritableInterfaceConnectionSerializer(serializers.ModelSerializer): +class WritableInterfaceConnectionSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = InterfaceConnection diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 116aaa77c..d32c63bfa 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +from collections import OrderedDict from rest_framework.decorators import detail_route from rest_framework.mixins import ListModelMixin @@ -7,6 +8,7 @@ from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet from django.conf import settings +from django.http import HttpResponseBadRequest, HttpResponseForbidden from django.shortcuts import get_object_or_404 from dcim.models import ( @@ -32,6 +34,7 @@ class RegionViewSet(WritableSerializerMixin, ModelViewSet): queryset = Region.objects.all() serializer_class = serializers.RegionSerializer write_serializer_class = serializers.WritableRegionSerializer + filter_class = filters.RegionFilter # @@ -73,6 +76,7 @@ class RackGroupViewSet(WritableSerializerMixin, ModelViewSet): class RackRoleViewSet(ModelViewSet): queryset = RackRole.objects.all() serializer_class = serializers.RackRoleSerializer + filter_class = filters.RackRoleFilter # @@ -128,6 +132,7 @@ class RackReservationViewSet(WritableSerializerMixin, ModelViewSet): class ManufacturerViewSet(ModelViewSet): queryset = Manufacturer.objects.all() serializer_class = serializers.ManufacturerSerializer + filter_class = filters.ManufacturerFilter # @@ -194,6 +199,7 @@ class DeviceBayTemplateViewSet(WritableSerializerMixin, ModelViewSet): class DeviceRoleViewSet(ModelViewSet): queryset = DeviceRole.objects.all() serializer_class = serializers.DeviceRoleSerializer + filter_class = filters.DeviceRoleFilter # @@ -203,6 +209,7 @@ class DeviceRoleViewSet(ModelViewSet): class PlatformViewSet(ModelViewSet): queryset = Platform.objects.all() serializer_class = serializers.PlatformSerializer + filter_class = filters.PlatformFilter # @@ -219,27 +226,64 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): write_serializer_class = serializers.WritableDeviceSerializer filter_class = filters.DeviceFilter - @detail_route(url_path='lldp-neighbors') - def lldp_neighbors(self, request, pk): + @detail_route(url_path='napalm') + def napalm(self, request, pk): """ - Retrieve live LLDP neighbors of a device + Execute a NAPALM method on a Device """ device = get_object_or_404(Device, pk=pk) if not device.primary_ip: - raise ServiceUnavailable("No IP configured for this device.") + raise ServiceUnavailable("This device does not have a primary IP address configured.") + if device.platform is None: + raise ServiceUnavailable("No platform is configured for this device.") + if not device.platform.napalm_driver: + raise ServiceUnavailable("No NAPALM driver is configured for this device's platform ().".format( + device.platform + )) - RPC = device.get_rpc_client() - if not RPC: - raise ServiceUnavailable("No RPC client available for this platform ({}).".format(device.platform)) - - # Connect to device and retrieve inventory info + # Check that NAPALM is installed and verify the configured driver try: - with RPC(device, username=settings.NETBOX_USERNAME, password=settings.NETBOX_PASSWORD) as rpc_client: - lldp_neighbors = rpc_client.get_lldp_neighbors() - except: - raise ServiceUnavailable("Error connecting to the remote device.") + import napalm + from napalm_base.exceptions import ConnectAuthError, ModuleImportError + except ImportError: + raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.") + try: + driver = napalm.get_network_driver(device.platform.napalm_driver) + except ModuleImportError: + raise ServiceUnavailable("NAPALM driver for platform {} not found: {}.".format( + device.platform, device.platform.napalm_driver + )) - return Response(lldp_neighbors) + # Verify user permission + if not request.user.has_perm('dcim.napalm_read'): + return HttpResponseForbidden() + + # Validate requested NAPALM methods + napalm_methods = request.GET.getlist('method') + for method in napalm_methods: + if not hasattr(driver, method): + return HttpResponseBadRequest("Unknown NAPALM method: {}".format(method)) + elif not method.startswith('get_'): + return HttpResponseBadRequest("Unsupported NAPALM method: {}".format(method)) + + # Connect to the device and execute the requested methods + # TODO: Improve error handling + response = OrderedDict([(m, None) for m in napalm_methods]) + ip_address = str(device.primary_ip.address.ip) + d = driver( + hostname=ip_address, + username=settings.NETBOX_USERNAME, + password=settings.NETBOX_PASSWORD + ) + try: + d.open() + for method in napalm_methods: + response[method] = getattr(d, method)() + except Exception as e: + raise ServiceUnavailable("Error connecting to the device: {}".format(e)) + + d.close() + return Response(response) # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py new file mode 100644 index 000000000..f2c047910 --- /dev/null +++ b/netbox/dcim/constants.py @@ -0,0 +1,230 @@ +from __future__ import unicode_literals + + +# Rack types +RACK_TYPE_2POST = 100 +RACK_TYPE_4POST = 200 +RACK_TYPE_CABINET = 300 +RACK_TYPE_WALLFRAME = 1000 +RACK_TYPE_WALLCABINET = 1100 +RACK_TYPE_CHOICES = ( + (RACK_TYPE_2POST, '2-post frame'), + (RACK_TYPE_4POST, '4-post frame'), + (RACK_TYPE_CABINET, '4-post cabinet'), + (RACK_TYPE_WALLFRAME, 'Wall-mounted frame'), + (RACK_TYPE_WALLCABINET, 'Wall-mounted cabinet'), +) + +# Rack widths +RACK_WIDTH_19IN = 19 +RACK_WIDTH_23IN = 23 +RACK_WIDTH_CHOICES = ( + (RACK_WIDTH_19IN, '19 inches'), + (RACK_WIDTH_23IN, '23 inches'), +) + +# Rack faces +RACK_FACE_FRONT = 0 +RACK_FACE_REAR = 1 +RACK_FACE_CHOICES = [ + [RACK_FACE_FRONT, 'Front'], + [RACK_FACE_REAR, 'Rear'], +] + +# Parent/child device roles +SUBDEVICE_ROLE_PARENT = True +SUBDEVICE_ROLE_CHILD = False +SUBDEVICE_ROLE_CHOICES = ( + (None, 'None'), + (SUBDEVICE_ROLE_PARENT, 'Parent'), + (SUBDEVICE_ROLE_CHILD, 'Child'), +) + +# Interface ordering schemes (for device types) +IFACE_ORDERING_POSITION = 1 +IFACE_ORDERING_NAME = 2 +IFACE_ORDERING_CHOICES = [ + [IFACE_ORDERING_POSITION, 'Slot/position'], + [IFACE_ORDERING_NAME, 'Name (alphabetically)'] +] + +# Interface form factors +# Virtual +IFACE_FF_VIRTUAL = 0 +IFACE_FF_LAG = 200 +# Ethernet +IFACE_FF_100ME_FIXED = 800 +IFACE_FF_1GE_FIXED = 1000 +IFACE_FF_1GE_GBIC = 1050 +IFACE_FF_1GE_SFP = 1100 +IFACE_FF_10GE_FIXED = 1150 +IFACE_FF_10GE_SFP_PLUS = 1200 +IFACE_FF_10GE_XFP = 1300 +IFACE_FF_10GE_XENPAK = 1310 +IFACE_FF_10GE_X2 = 1320 +IFACE_FF_25GE_SFP28 = 1350 +IFACE_FF_40GE_QSFP_PLUS = 1400 +IFACE_FF_100GE_CFP = 1500 +IFACE_FF_100GE_QSFP28 = 1600 +# Wireless +IFACE_FF_80211A = 2600 +IFACE_FF_80211G = 2610 +IFACE_FF_80211N = 2620 +IFACE_FF_80211AC = 2630 +IFACE_FF_80211AD = 2640 +# Fibrechannel +IFACE_FF_1GFC_SFP = 3010 +IFACE_FF_2GFC_SFP = 3020 +IFACE_FF_4GFC_SFP = 3040 +IFACE_FF_8GFC_SFP_PLUS = 3080 +IFACE_FF_16GFC_SFP_PLUS = 3160 +# Serial +IFACE_FF_T1 = 4000 +IFACE_FF_E1 = 4010 +IFACE_FF_T3 = 4040 +IFACE_FF_E3 = 4050 +# Stacking +IFACE_FF_STACKWISE = 5000 +IFACE_FF_STACKWISE_PLUS = 5050 +IFACE_FF_FLEXSTACK = 5100 +IFACE_FF_FLEXSTACK_PLUS = 5150 +IFACE_FF_JUNIPER_VCP = 5200 +# Other +IFACE_FF_OTHER = 32767 + +IFACE_FF_CHOICES = [ + [ + 'Virtual interfaces', + [ + [IFACE_FF_VIRTUAL, 'Virtual'], + [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'], + ] + ], + [ + 'Ethernet (fixed)', + [ + [IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'], + [IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'], + [IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'], + ] + ], + [ + 'Ethernet (modular)', + [ + [IFACE_FF_1GE_GBIC, 'GBIC (1GE)'], + [IFACE_FF_1GE_SFP, 'SFP (1GE)'], + [IFACE_FF_10GE_SFP_PLUS, 'SFP+ (10GE)'], + [IFACE_FF_10GE_XFP, 'XFP (10GE)'], + [IFACE_FF_10GE_XENPAK, 'XENPAK (10GE)'], + [IFACE_FF_10GE_X2, 'X2 (10GE)'], + [IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'], + [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'], + [IFACE_FF_100GE_CFP, 'CFP (100GE)'], + [IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'], + ] + ], + [ + 'Wireless', + [ + [IFACE_FF_80211A, 'IEEE 802.11a'], + [IFACE_FF_80211G, 'IEEE 802.11b/g'], + [IFACE_FF_80211N, 'IEEE 802.11n'], + [IFACE_FF_80211AC, 'IEEE 802.11ac'], + [IFACE_FF_80211AD, 'IEEE 802.11ad'], + ] + ], + [ + 'FibreChannel', + [ + [IFACE_FF_1GFC_SFP, 'SFP (1GFC)'], + [IFACE_FF_2GFC_SFP, 'SFP (2GFC)'], + [IFACE_FF_4GFC_SFP, 'SFP (4GFC)'], + [IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'], + [IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'], + ] + ], + [ + 'Serial', + [ + [IFACE_FF_T1, 'T1 (1.544 Mbps)'], + [IFACE_FF_E1, 'E1 (2.048 Mbps)'], + [IFACE_FF_T3, 'T3 (45 Mbps)'], + [IFACE_FF_E3, 'E3 (34 Mbps)'], + ] + ], + [ + 'Stacking', + [ + [IFACE_FF_STACKWISE, 'Cisco StackWise'], + [IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'], + [IFACE_FF_FLEXSTACK, 'Cisco FlexStack'], + [IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'], + [IFACE_FF_JUNIPER_VCP, 'Juniper VCP'], + ] + ], + [ + 'Other', + [ + [IFACE_FF_OTHER, 'Other'], + ] + ], +] + +VIRTUAL_IFACE_TYPES = [ + IFACE_FF_VIRTUAL, + IFACE_FF_LAG, +] + +WIRELESS_IFACE_TYPES = [ + IFACE_FF_80211A, + IFACE_FF_80211G, + IFACE_FF_80211N, + IFACE_FF_80211AC, + IFACE_FF_80211AD, +] + +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'], +] + +# Bootstrap CSS classes for device stasuses +DEVICE_STATUS_CLASSES = { + 0: 'warning', + 1: 'success', + 2: 'info', + 3: 'primary', + 4: 'danger', + 5: 'default', +} + +# Console/power/interface connection statuses +CONNECTION_STATUS_PLANNED = False +CONNECTION_STATUS_CONNECTED = True +CONNECTION_STATUS_CHOICES = [ + [CONNECTION_STATUS_PLANNED, 'Planned'], + [CONNECTION_STATUS_CONNECTED, 'Connected'], +] + +# Platform -> RPC client mappings +RPC_CLIENT_JUNIPER_JUNOS = 'juniper-junos' +RPC_CLIENT_CISCO_IOS = 'cisco-ios' +RPC_CLIENT_OPENGEAR = 'opengear' +RPC_CLIENT_CHOICES = [ + [RPC_CLIENT_JUNIPER_JUNOS, 'Juniper Junos (NETCONF)'], + [RPC_CLIENT_CISCO_IOS, 'Cisco IOS (SSH)'], + [RPC_CLIENT_OPENGEAR, 'Opengear (SSH)'], +] diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 93a325d98..e3579085a 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals import django_filters from netaddr.core import AddrFormatError +from django.contrib.auth.models import User from django.db.models import Q from extras.filters import CustomFieldFilterSet @@ -11,11 +12,28 @@ from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, STATUS_CHOICES, IFACE_FF_LAG, Interface, InterfaceConnection, - InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, - PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, VIRTUAL_IFACE_TYPES, + InterfaceTemplate, Manufacturer, InventoryItem, NONCONNECTABLE_IFACE_TYPES, Platform, PowerOutlet, + PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, Region, Site, + VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES, ) +class RegionFilter(django_filters.FilterSet): + parent_id = NullableModelMultipleChoiceFilter( + queryset=Region.objects.all(), + label='Parent region (ID)', + ) + parent = NullableModelMultipleChoiceFilter( + queryset=Region.objects.all(), + to_field_name='slug', + label='Parent region (slug)', + ) + + class Meta: + model = Region + fields = ['name', 'slug'] + + class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( @@ -23,23 +41,19 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) region_id = NullableModelMultipleChoiceFilter( - name='region', queryset=Region.objects.all(), label='Region (ID)', ) region = NullableModelMultipleChoiceFilter( - name='region', queryset=Region.objects.all(), to_field_name='slug', label='Region (slug)', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) tenant = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), to_field_name='slug', label='Tenant (slug)', @@ -47,7 +61,7 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Site - fields = ['q', 'name', 'facility', 'asn'] + fields = ['q', 'name', 'slug', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email'] def search(self, queryset, name, value): if not value.strip(): @@ -57,6 +71,9 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): Q(facility__icontains=value) | Q(physical_address__icontains=value) | Q(shipping_address__icontains=value) | + Q(contact_name__icontains=value) | + Q(contact_phone__icontains=value) | + Q(contact_email__icontains=value) | Q(comments__icontains=value) ) try: @@ -68,7 +85,6 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): class RackGroupFilter(django_filters.FilterSet): site_id = django_filters.ModelMultipleChoiceFilter( - name='site', queryset=Site.objects.all(), label='Site (ID)', ) @@ -81,7 +97,14 @@ class RackGroupFilter(django_filters.FilterSet): class Meta: model = RackGroup - fields = ['name'] + fields = ['site_id', 'name', 'slug'] + + +class RackRoleFilter(django_filters.FilterSet): + + class Meta: + model = RackRole + fields = ['name', 'slug', 'color'] class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): @@ -91,7 +114,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='site', queryset=Site.objects.all(), label='Site (ID)', ) @@ -102,7 +124,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (slug)', ) group_id = NullableModelMultipleChoiceFilter( - name='group', queryset=RackGroup.objects.all(), label='Group (ID)', ) @@ -113,7 +134,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Group', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) @@ -124,7 +144,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (slug)', ) role_id = NullableModelMultipleChoiceFilter( - name='role', queryset=RackRole.objects.all(), label='Role (ID)', ) @@ -137,7 +156,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Rack - fields = ['u_height'] + fields = ['facility_id', 'type', 'width', 'u_height', 'desc_units'] def search(self, queryset, name, value): if not value.strip(): @@ -155,6 +174,10 @@ class RackReservationFilter(django_filters.FilterSet): method='search', label='Search', ) + rack_id = django_filters.ModelMultipleChoiceFilter( + queryset=Rack.objects.all(), + label='Rack (ID)', + ) site_id = django_filters.ModelMultipleChoiceFilter( name='rack__site', queryset=Site.objects.all(), @@ -177,15 +200,20 @@ class RackReservationFilter(django_filters.FilterSet): to_field_name='slug', label='Group', ) - rack_id = django_filters.ModelMultipleChoiceFilter( - name='rack', - queryset=Rack.objects.all(), - label='Rack (ID)', + user_id = django_filters.ModelMultipleChoiceFilter( + queryset=User.objects.all(), + label='User (ID)', + ) + user = django_filters.ModelMultipleChoiceFilter( + name='user', + queryset=User.objects.all(), + to_field_name='username', + label='User (name)', ) class Meta: model = RackReservation - fields = ['rack', 'user'] + fields = ['created'] def search(self, queryset, name, value): if not value.strip(): @@ -198,6 +226,13 @@ class RackReservationFilter(django_filters.FilterSet): ) +class ManufacturerFilter(django_filters.FilterSet): + + class Meta: + model = Manufacturer + fields = ['name', 'slug'] + + class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( @@ -205,7 +240,6 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( - name='manufacturer', queryset=Manufacturer.objects.all(), label='Manufacturer (ID)', ) @@ -219,7 +253,8 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = DeviceType fields = [ - 'model', 'part_number', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', + 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', + 'is_network_device', 'subdevice_role', ] def search(self, queryset, name, value): @@ -235,16 +270,9 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): class DeviceTypeComponentFilterSet(django_filters.FilterSet): devicetype_id = django_filters.ModelMultipleChoiceFilter( - name='device_type', queryset=DeviceType.objects.all(), label='Device type (ID)', ) - devicetype = django_filters.ModelMultipleChoiceFilter( - name='device_type', - queryset=DeviceType.objects.all(), - to_field_name='name', - label='Device type (name)', - ) class ConsolePortTemplateFilter(DeviceTypeComponentFilterSet): @@ -279,7 +307,7 @@ class InterfaceTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = InterfaceTemplate - fields = ['name', 'form_factor'] + fields = ['name', 'form_factor', 'mgmt_only'] class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet): @@ -289,18 +317,73 @@ class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet): fields = ['name'] +class DeviceRoleFilter(django_filters.FilterSet): + + class Meta: + model = DeviceRole + fields = ['name', 'slug', 'color'] + + +class PlatformFilter(django_filters.FilterSet): + + class Meta: + model = Platform + fields = ['name', 'slug'] + + class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( method='search', label='Search', ) - mac_address = django_filters.CharFilter( - method='_mac_address', - label='MAC address', + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + name='device_type__manufacturer', + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + name='device_type__manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + device_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=DeviceType.objects.all(), + label='Device type (ID)', + ) + role_id = django_filters.ModelMultipleChoiceFilter( + name='device_role_id', + queryset=DeviceRole.objects.all(), + label='Role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + name='device_role__slug', + queryset=DeviceRole.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) + tenant_id = NullableModelMultipleChoiceFilter( + queryset=Tenant.objects.all(), + label='Tenant (ID)', + ) + tenant = NullableModelMultipleChoiceFilter( + name='tenant', + queryset=Tenant.objects.all(), + to_field_name='slug', + label='Tenant (slug)', + ) + platform_id = NullableModelMultipleChoiceFilter( + queryset=Platform.objects.all(), + label='Platform (ID)', + ) + platform = NullableModelMultipleChoiceFilter( + name='platform', + queryset=Platform.objects.all(), + to_field_name='slug', + label='Platform (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='site', queryset=Site.objects.all(), label='Site (ID)', ) @@ -320,60 +403,18 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): queryset=Rack.objects.all(), label='Rack (ID)', ) - role_id = django_filters.ModelMultipleChoiceFilter( - name='device_role', - queryset=DeviceRole.objects.all(), - label='Role (ID)', - ) - role = django_filters.ModelMultipleChoiceFilter( - name='device_role__slug', - queryset=DeviceRole.objects.all(), - to_field_name='slug', - label='Role (slug)', - ) - tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = NullableModelMultipleChoiceFilter( - name='tenant', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) - device_type_id = django_filters.ModelMultipleChoiceFilter( - name='device_type', - queryset=DeviceType.objects.all(), - label='Device type (ID)', - ) - manufacturer_id = django_filters.ModelMultipleChoiceFilter( - name='device_type__manufacturer', - queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', - ) - manufacturer = django_filters.ModelMultipleChoiceFilter( - name='device_type__manufacturer__slug', - queryset=Manufacturer.objects.all(), - to_field_name='slug', - label='Manufacturer (slug)', - ) model = django_filters.ModelMultipleChoiceFilter( name='device_type__slug', queryset=DeviceType.objects.all(), to_field_name='slug', label='Device model (slug)', ) - platform_id = NullableModelMultipleChoiceFilter( - name='platform', - queryset=Platform.objects.all(), - label='Platform (ID)', + status = django_filters.MultipleChoiceFilter( + choices=STATUS_CHOICES ) - platform = NullableModelMultipleChoiceFilter( - name='platform', - queryset=Platform.objects.all(), - to_field_name='slug', - label='Platform (slug)', + is_full_depth = django_filters.BooleanFilter( + name='device_type__is_full_depth', + label='Is full depth', ) is_console_server = django_filters.BooleanFilter( name='device_type__is_console_server', @@ -387,13 +428,14 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): name='device_type__is_network_device', label='Is a network device', ) + mac_address = django_filters.CharFilter( + method='_mac_address', + label='MAC address', + ) has_primary_ip = django_filters.BooleanFilter( method='_has_primary_ip', label='Has a primary IP', ) - status = django_filters.MultipleChoiceFilter( - choices=STATUS_CHOICES - ) class Meta: model = Device @@ -433,13 +475,11 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): class DeviceComponentFilterSet(django_filters.FilterSet): - device_id = django_filters.ModelMultipleChoiceFilter( - name='device', + device_id = django_filters.ModelChoiceFilter( queryset=Device.objects.all(), label='Device (ID)', ) - device = django_filters.ModelMultipleChoiceFilter( - name='device__name', + device = django_filters.ModelChoiceFilter( queryset=Device.objects.all(), to_field_name='name', label='Device (name)', @@ -474,7 +514,21 @@ class PowerOutletFilter(DeviceComponentFilterSet): fields = ['name'] -class InterfaceFilter(DeviceComponentFilterSet): +class InterfaceFilter(django_filters.FilterSet): + """ + Not using DeviceComponentFilterSet for Interfaces because we need to glean the ordering logic from the parent + Device's DeviceType. + """ + device = django_filters.CharFilter( + method='filter_device', + name='name', + label='Device', + ) + device_id = django_filters.NumberFilter( + method='filter_device', + name='pk', + label='Device (ID)', + ) type = django_filters.CharFilter( method='filter_type', label='Interface type', @@ -491,17 +545,24 @@ class InterfaceFilter(DeviceComponentFilterSet): class Meta: model = Interface - fields = ['name', 'form_factor'] + fields = ['name', 'form_factor', 'enabled', 'mtu', 'mgmt_only'] + + def filter_device(self, queryset, name, value): + try: + device = Device.objects.select_related('device_type').get(**{name: value}) + ordering = device.device_type.interface_ordering + return queryset.filter(device=device).order_naturally(ordering) + except Device.DoesNotExist: + return queryset.none() def filter_type(self, queryset, name, value): value = value.strip().lower() - if value == 'physical': - return queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES) - elif value == 'virtual': - return queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES) - elif value == 'lag': - return queryset.filter(form_factor=IFACE_FF_LAG) - return queryset + return { + 'physical': queryset.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES), + 'virtual': queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES), + 'wireless': queryset.filter(form_factor__in=WIRELESS_IFACE_TYPES), + 'lag': queryset.filter(form_factor=IFACE_FF_LAG), + }.get(value, queryset.none()) def _mac_address(self, queryset, name, value): value = value.strip() @@ -521,10 +582,24 @@ class DeviceBayFilter(DeviceComponentFilterSet): class InventoryItemFilter(DeviceComponentFilterSet): + parent_id = NullableModelMultipleChoiceFilter( + queryset=InventoryItem.objects.all(), + label='Parent inventory item (ID)', + ) + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + 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 = InventoryItem - fields = ['name'] + fields = ['name', 'part_id', 'serial', 'asset_tag', 'discovered'] class ConsoleConnectionFilter(django_filters.FilterSet): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 057112398..440c12623 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -24,7 +24,7 @@ from .models import ( IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_FACE_CHOICES, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, RACK_WIDTH_19IN, RACK_WIDTH_23IN, - Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, VIRTUAL_IFACE_TYPES, + Region, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, SUBDEVICE_ROLE_PARENT, ) @@ -558,7 +558,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): class Meta: model = Platform - fields = ['name', 'slug', 'rpc_client'] + fields = ['name', 'slug', 'napalm_driver', 'rpc_client'] # @@ -632,7 +632,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): instance = kwargs.get('instance') # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field if instance and hasattr(instance, 'device_type'): - initial = kwargs.get('initial', {}) + initial = kwargs.get('initial', {}).copy() initial['manufacturer'] = instance.device_type.manufacturer kwargs['initial'] = initial @@ -1449,7 +1449,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface - fields = ['device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description'] + fields = ['device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description'] widgets = { 'device': forms.HiddenInput(), } @@ -1471,12 +1471,19 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): class InterfaceCreateForm(DeviceComponentForm): name_pattern = ExpandableNameField(label='Name') form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) + enabled = forms.BooleanField(required=False) lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') + mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') mac_address = MACAddressFormField(required=False, label='MAC Address') mgmt_only = forms.BooleanField(required=False, label='OOB Management') description = forms.CharField(max_length=100, required=False) def __init__(self, *args, **kwargs): + + # Set interfaces enabled by default + kwargs['initial'] = kwargs.get('initial', {}).copy() + kwargs['initial'].update({'enabled': True}) + super(InterfaceCreateForm, self).__init__(*args, **kwargs) # Limit LAG choices to interfaces belonging to this device @@ -1491,13 +1498,15 @@ class InterfaceCreateForm(DeviceComponentForm): class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput) device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput) - lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False) + enabled = forms.NullBooleanField(required=False, widget=BulkEditNullBooleanSelect) + lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') + 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) class Meta: - nullable_fields = ['lag', 'description'] + nullable_fields = ['lag', 'mtu', 'description'] def __init__(self, *args, **kwargs): super(InterfaceBulkEditForm, self).__init__(*args, **kwargs) @@ -1576,7 +1585,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor ) ) interface_b = ChainedModelChoiceField( - queryset=Interface.objects.exclude(form_factor__in=VIRTUAL_IFACE_TYPES).select_related( + queryset=Interface.objects.connectable().select_related( 'circuit_termination', 'connected_as_a', 'connected_as_b' ), chains=( @@ -1585,7 +1594,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor label='Interface', widget=APISelect( api_url='/api/dcim/interfaces/?device_id={{device_b}}&type=physical', - disabled_indicator='connection' + disabled_indicator='is_connected' ) ) @@ -1598,9 +1607,7 @@ class InterfaceConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor super(InterfaceConnectionForm, self).__init__(*args, **kwargs) # Initialize interface A choices - device_a_interfaces = Interface.objects.order_naturally().filter(device=device_a).exclude( - form_factor__in=VIRTUAL_IFACE_TYPES - ).select_related( + device_a_interfaces = Interface.objects.connectable().order_naturally().filter(device=device_a).select_related( 'circuit_termination', 'connected_as_a', 'connected_as_b' ) self.fields['interface_a'].choices = [ @@ -1689,8 +1696,7 @@ class InterfaceConnectionCSVForm(forms.ModelForm): return interface -class InterfaceConnectionDeletionForm(BootstrapMixin, forms.Form): - confirm = forms.BooleanField(required=True) +class InterfaceConnectionDeletionForm(ConfirmationForm): # Used for HTTP redirect upon successful deletion device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False) @@ -1760,4 +1766,4 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): class Meta: model = InventoryItem - fields = ['name', 'manufacturer', 'part_id', 'serial'] + fields = ['name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description'] diff --git a/netbox/dcim/migrations/0038_wireless_interfaces.py b/netbox/dcim/migrations/0038_wireless_interfaces.py new file mode 100644 index 000000000..61cdb3996 --- /dev/null +++ b/netbox/dcim/migrations/0038_wireless_interfaces.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-06-16 21:38 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0037_unicode_literals'), + ] + + operations = [ + migrations.AlterField( + model_name='interface', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='form_factor', + field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP']]], ['Other', [[32767, 'Other']]]], default=1200), + ), + ] diff --git a/netbox/dcim/migrations/0039_interface_add_enabled_mtu.py b/netbox/dcim/migrations/0039_interface_add_enabled_mtu.py new file mode 100644 index 000000000..4cc7e9616 --- /dev/null +++ b/netbox/dcim/migrations/0039_interface_add_enabled_mtu.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-06-23 17:05 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0038_wireless_interfaces'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='enabled', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='interface', + name='mtu', + field=models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU'), + ), + ] diff --git a/netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py b/netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py new file mode 100644 index 000000000..c7d49fe2c --- /dev/null +++ b/netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11 on 2017-06-23 20:44 +from __future__ import unicode_literals + +from django.db import migrations, models +import utilities.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0039_interface_add_enabled_mtu'), + ] + + operations = [ + migrations.AddField( + model_name='inventoryitem', + name='asset_tag', + field=utilities.fields.NullableCharField(blank=True, help_text='A unique tag used to identify this item', max_length=50, null=True, unique=True, verbose_name='Asset tag'), + ), + migrations.AddField( + model_name='inventoryitem', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/netbox/dcim/migrations/0041_napalm_integration.py b/netbox/dcim/migrations/0041_napalm_integration.py new file mode 100644 index 000000000..73ca8f3ee --- /dev/null +++ b/netbox/dcim/migrations/0041_napalm_integration.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.3 on 2017-07-14 17:26 +from __future__ import unicode_literals + +from django.db import migrations, models + + +def rpc_client_to_napalm_driver(apps, schema_editor): + """ + Migrate legacy RPC clients to their respective NAPALM drivers + """ + Platform = apps.get_model('dcim', 'Platform') + + Platform.objects.filter(rpc_client='juniper-junos').update(napalm_driver='junos') + Platform.objects.filter(rpc_client='cisco-ios').update(napalm_driver='ios') + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0040_inventoryitem_add_asset_tag_description'), + ] + + operations = [ + migrations.AlterModelOptions( + name='device', + options={'ordering': ['name'], 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))}, + ), + migrations.AddField( + 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'), + ), + migrations.AlterField( + model_name='platform', + name='rpc_client', + field=models.CharField(blank=True, choices=[['juniper-junos', 'Juniper Junos (NETCONF)'], ['cisco-ios', 'Cisco IOS (SSH)'], ['opengear', 'Opengear (SSH)']], max_length=30, verbose_name='Legacy RPC client'), + ), + migrations.RunPython(rpc_client_to_napalm_driver), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 6411c6bff..8dd11e663 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -24,204 +24,10 @@ from utilities.fields import ColorField, NullableCharField from utilities.managers import NaturalOrderByManager from utilities.models import CreatedUpdatedModel from utilities.utils import csv_format +from .constants import * from .fields import ASNField, MACAddressField -RACK_TYPE_2POST = 100 -RACK_TYPE_4POST = 200 -RACK_TYPE_CABINET = 300 -RACK_TYPE_WALLFRAME = 1000 -RACK_TYPE_WALLCABINET = 1100 -RACK_TYPE_CHOICES = ( - (RACK_TYPE_2POST, '2-post frame'), - (RACK_TYPE_4POST, '4-post frame'), - (RACK_TYPE_CABINET, '4-post cabinet'), - (RACK_TYPE_WALLFRAME, 'Wall-mounted frame'), - (RACK_TYPE_WALLCABINET, 'Wall-mounted cabinet'), -) - -RACK_WIDTH_19IN = 19 -RACK_WIDTH_23IN = 23 -RACK_WIDTH_CHOICES = ( - (RACK_WIDTH_19IN, '19 inches'), - (RACK_WIDTH_23IN, '23 inches'), -) - -RACK_FACE_FRONT = 0 -RACK_FACE_REAR = 1 -RACK_FACE_CHOICES = [ - [RACK_FACE_FRONT, 'Front'], - [RACK_FACE_REAR, 'Rear'], -] - -SUBDEVICE_ROLE_PARENT = True -SUBDEVICE_ROLE_CHILD = False -SUBDEVICE_ROLE_CHOICES = ( - (None, 'None'), - (SUBDEVICE_ROLE_PARENT, 'Parent'), - (SUBDEVICE_ROLE_CHILD, 'Child'), -) - -IFACE_ORDERING_POSITION = 1 -IFACE_ORDERING_NAME = 2 -IFACE_ORDERING_CHOICES = [ - [IFACE_ORDERING_POSITION, 'Slot/position'], - [IFACE_ORDERING_NAME, 'Name (alphabetically)'] -] - -# Virtual -IFACE_FF_VIRTUAL = 0 -IFACE_FF_LAG = 200 -# Ethernet -IFACE_FF_100ME_FIXED = 800 -IFACE_FF_1GE_FIXED = 1000 -IFACE_FF_1GE_GBIC = 1050 -IFACE_FF_1GE_SFP = 1100 -IFACE_FF_10GE_FIXED = 1150 -IFACE_FF_10GE_SFP_PLUS = 1200 -IFACE_FF_10GE_XFP = 1300 -IFACE_FF_10GE_XENPAK = 1310 -IFACE_FF_10GE_X2 = 1320 -IFACE_FF_25GE_SFP28 = 1350 -IFACE_FF_40GE_QSFP_PLUS = 1400 -IFACE_FF_100GE_CFP = 1500 -IFACE_FF_100GE_QSFP28 = 1600 -# Fibrechannel -IFACE_FF_1GFC_SFP = 3010 -IFACE_FF_2GFC_SFP = 3020 -IFACE_FF_4GFC_SFP = 3040 -IFACE_FF_8GFC_SFP_PLUS = 3080 -IFACE_FF_16GFC_SFP_PLUS = 3160 -# Serial -IFACE_FF_T1 = 4000 -IFACE_FF_E1 = 4010 -IFACE_FF_T3 = 4040 -IFACE_FF_E3 = 4050 -# Stacking -IFACE_FF_STACKWISE = 5000 -IFACE_FF_STACKWISE_PLUS = 5050 -IFACE_FF_FLEXSTACK = 5100 -IFACE_FF_FLEXSTACK_PLUS = 5150 -IFACE_FF_JUNIPER_VCP = 5200 -# Other -IFACE_FF_OTHER = 32767 - -IFACE_FF_CHOICES = [ - [ - 'Virtual interfaces', - [ - [IFACE_FF_VIRTUAL, 'Virtual'], - [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'], - ] - ], - [ - 'Ethernet (fixed)', - [ - [IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'], - [IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'], - [IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'], - ] - ], - [ - 'Ethernet (modular)', - [ - [IFACE_FF_1GE_GBIC, 'GBIC (1GE)'], - [IFACE_FF_1GE_SFP, 'SFP (1GE)'], - [IFACE_FF_10GE_SFP_PLUS, 'SFP+ (10GE)'], - [IFACE_FF_10GE_XFP, 'XFP (10GE)'], - [IFACE_FF_10GE_XENPAK, 'XENPAK (10GE)'], - [IFACE_FF_10GE_X2, 'X2 (10GE)'], - [IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'], - [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'], - [IFACE_FF_100GE_CFP, 'CFP (100GE)'], - [IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'], - ] - ], - [ - 'FibreChannel', - [ - [IFACE_FF_1GFC_SFP, 'SFP (1GFC)'], - [IFACE_FF_2GFC_SFP, 'SFP (2GFC)'], - [IFACE_FF_4GFC_SFP, 'SFP (4GFC)'], - [IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'], - [IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'], - ] - ], - [ - 'Serial', - [ - [IFACE_FF_T1, 'T1 (1.544 Mbps)'], - [IFACE_FF_E1, 'E1 (2.048 Mbps)'], - [IFACE_FF_T3, 'T3 (45 Mbps)'], - [IFACE_FF_E3, 'E3 (34 Mbps)'], - [IFACE_FF_E3, 'E3 (34 Mbps)'], - ] - ], - [ - 'Stacking', - [ - [IFACE_FF_STACKWISE, 'Cisco StackWise'], - [IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'], - [IFACE_FF_FLEXSTACK, 'Cisco FlexStack'], - [IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'], - [IFACE_FF_JUNIPER_VCP, 'Juniper VCP'], - ] - ], - [ - 'Other', - [ - [IFACE_FF_OTHER, 'Other'], - ] - ], -] - -VIRTUAL_IFACE_TYPES = [ - IFACE_FF_VIRTUAL, - IFACE_FF_LAG, -] - -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'], -] - -DEVICE_STATUS_CLASSES = { - 0: 'warning', - 1: 'success', - 2: 'info', - 3: 'primary', - 4: 'danger', - 5: 'default', -} - -CONNECTION_STATUS_PLANNED = False -CONNECTION_STATUS_CONNECTED = True -CONNECTION_STATUS_CHOICES = [ - [CONNECTION_STATUS_PLANNED, 'Planned'], - [CONNECTION_STATUS_CONNECTED, 'Connected'], -] - -# For mapping platform -> NC client -RPC_CLIENT_JUNIPER_JUNOS = 'juniper-junos' -RPC_CLIENT_CISCO_IOS = 'cisco-ios' -RPC_CLIENT_OPENGEAR = 'opengear' -RPC_CLIENT_CHOICES = [ - [RPC_CLIENT_JUNIPER_JUNOS, 'Juniper Junos (NETCONF)'], - [RPC_CLIENT_CISCO_IOS, 'Cisco IOS (SSH)'], - [RPC_CLIENT_OPENGEAR, 'Opengear (SSH)'], -] - - # # Regions # @@ -816,7 +622,7 @@ class PowerOutletTemplate(models.Model): return self.name -class InterfaceManager(models.Manager): +class InterfaceQuerySet(models.QuerySet): def order_naturally(self, method=IFACE_ORDERING_POSITION): """ @@ -841,13 +647,12 @@ class InterfaceManager(models.Manager): The original `name` field is taken as a whole to serve as a fallback in the event interfaces do not match any of the prescribed fields. """ - queryset = self.get_queryset() - sql_col = '{}.name'.format(queryset.model._meta.db_table) + sql_col = '{}.name'.format(self.model._meta.db_table) ordering = { IFACE_ORDERING_POSITION: ('_slot', '_subslot', '_position', '_channel', '_vc', '_type', 'name'), IFACE_ORDERING_NAME: ('_type', '_slot', '_subslot', '_position', '_channel', '_vc', 'name'), }[method] - return queryset.extra(select={ + return self.extra(select={ '_type': "SUBSTRING({} FROM '^([^0-9]+)')".format(sql_col), '_slot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col), '_subslot': "CAST(SUBSTRING({} FROM '([0-9]+)\/[0-9]+(:[0-9]+)?(\.[0-9]+)?$') AS integer)".format(sql_col), @@ -856,6 +661,13 @@ class InterfaceManager(models.Manager): '_vc': "COALESCE(CAST(SUBSTRING({} FROM '\.([0-9]+)$') AS integer), 0)".format(sql_col), }).order_by(*ordering) + def connectable(self): + """ + Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or + wireless). + """ + return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES) + @python_2_unicode_compatible class InterfaceTemplate(models.Model): @@ -867,7 +679,7 @@ class InterfaceTemplate(models.Model): form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) mgmt_only = models.BooleanField(default=False, verbose_name='Management only') - objects = InterfaceManager() + objects = InterfaceQuerySet.as_manager() class Meta: ordering = ['device_type', 'name'] @@ -926,7 +738,10 @@ class Platform(models.Model): """ name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) - rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True, verbose_name='RPC client') + 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') class Meta: ordering = ['name'] @@ -997,6 +812,10 @@ class Device(CreatedUpdatedModel, CustomFieldModel): class Meta: ordering = ['name'] unique_together = ['rack', 'position', 'face'] + permissions = ( + ('napalm_read', 'Read-only access to devices via NAPALM'), + ('napalm_write', 'Read/write access to devices via NAPALM'), + ) def __str__(self): return self.display_name or super(Device, self).__str__() @@ -1308,16 +1127,27 @@ class Interface(models.Model): of an InterfaceConnection. """ device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE) - lag = models.ForeignKey('self', related_name='member_interfaces', null=True, blank=True, on_delete=models.SET_NULL, - verbose_name='Parent LAG') + lag = models.ForeignKey( + 'self', + models.SET_NULL, + related_name='member_interfaces', + null=True, + blank=True, + verbose_name='Parent LAG' + ) name = models.CharField(max_length=30) form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS) + enabled = models.BooleanField(default=True) mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address') - mgmt_only = models.BooleanField(default=False, verbose_name='OOB Management', - help_text="This interface is used only for out-of-band management") + mtu = models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='MTU') + mgmt_only = models.BooleanField( + default=False, + verbose_name='OOB Management', + help_text="This interface is used only for out-of-band management" + ) description = models.CharField(max_length=100, blank=True) - objects = InterfaceManager() + objects = InterfaceQuerySet.as_manager() class Meta: ordering = ['device', 'name'] @@ -1329,10 +1159,10 @@ class Interface(models.Model): def clean(self): # Virtual interfaces cannot be connected - if self.form_factor in VIRTUAL_IFACE_TYPES and self.is_connected: + if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.is_connected: raise ValidationError({ - 'form_factor': "Virtual interfaces cannot be connected to another interface or circuit. Disconnect the " - "interface or choose a physical form factor." + 'form_factor': "Virtual and wireless interfaces cannot be connected to another interface or circuit. " + "Disconnect the interface or choose a suitable form factor." }) # An interface's LAG must belong to the same device @@ -1344,7 +1174,7 @@ class Interface(models.Model): }) # A virtual interface cannot have a parent LAG - if self.form_factor in VIRTUAL_IFACE_TYPES and self.lag is not None: + if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.lag is not None: raise ValidationError({ 'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display()) }) @@ -1361,6 +1191,10 @@ class Interface(models.Model): def is_virtual(self): return self.form_factor in VIRTUAL_IFACE_TYPES + @property + def is_wireless(self): + return self.form_factor in WIRELESS_IFACE_TYPES + @property def is_lag(self): return self.form_factor == IFACE_FF_LAG @@ -1479,11 +1313,17 @@ class InventoryItem(models.Model): device = models.ForeignKey('Device', related_name='inventory_items', on_delete=models.CASCADE) parent = models.ForeignKey('self', related_name='child_items', blank=True, null=True, on_delete=models.CASCADE) name = models.CharField(max_length=50, verbose_name='Name') - manufacturer = models.ForeignKey('Manufacturer', related_name='inventory_items', blank=True, null=True, - on_delete=models.PROTECT) + manufacturer = models.ForeignKey( + 'Manufacturer', models.PROTECT, related_name='inventory_items', blank=True, null=True + ) part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True) serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True) + asset_tag = NullableCharField( + max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag', + help_text='A unique tag used to identify this item' + ) discovered = models.BooleanField(default=False, verbose_name='Discovered') + description = models.CharField(max_length=100, blank=True) class Meta: ordering = ['device__id', 'parent__id', 'name'] diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index c9a5ecdc9..427f0bb42 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -3,11 +3,11 @@ from __future__ import unicode_literals import django_tables2 as tables from django_tables2.utils import Accessor -from utilities.tables import BaseTable, SearchTable, ToggleColumn +from utilities.tables import BaseTable, ToggleColumn from .models import ( - ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType, - Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, RackReservation, Region, Site, + ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, + DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutlet, + PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, Region, Site, ) @@ -142,30 +142,26 @@ class SiteTable(BaseTable): name = tables.LinkColumn() region = tables.TemplateColumn(template_code=SITE_REGION_LINK) tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + + 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') - class Meta(BaseTable.Meta): - model = Site + class Meta(SiteTable.Meta): fields = ( 'pk', 'name', 'facility', 'region', 'tenant', 'asn', 'rack_count', 'device_count', 'prefix_count', 'vlan_count', 'circuit_count', ) -class SiteSearchTable(SearchTable): - name = tables.LinkColumn() - region = tables.TemplateColumn(template_code=SITE_REGION_LINK) - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) - - class Meta(SearchTable.Meta): - model = Site - fields = ('name', 'facility', 'region', 'tenant', 'asn') - - # # Rack groups # @@ -214,29 +210,22 @@ class RackTable(BaseTable): tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) role = tables.TemplateColumn(RACK_ROLE) u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height') - devices = tables.Column(accessor=Accessor('device_count')) - get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') class Meta(BaseTable.Meta): model = Rack + fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height') + + +class RackDetailTable(RackTable): + devices = tables.Column(accessor=Accessor('device_count')) + get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') + + class Meta(RackTable.Meta): fields = ( 'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'get_utilization' ) -class RackSearchTable(SearchTable): - name = tables.LinkColumn() - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) - group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) - role = tables.TemplateColumn(RACK_ROLE) - u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height') - - class Meta(SearchTable.Meta): - model = Rack - fields = ('name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height') - - class RackImportTable(BaseTable): name = tables.LinkColumn('dcim:rack', args=[Accessor('pk')], verbose_name='Name') site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') @@ -302,23 +291,7 @@ class DeviceTypeTable(BaseTable): model = DeviceType fields = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', - 'is_network_device', 'subdevice_role', 'instance_count' - ) - - -class DeviceTypeSearchTable(SearchTable): - model = tables.LinkColumn('dcim:devicetype', args=[Accessor('pk')], verbose_name='Device Type') - is_full_depth = tables.BooleanColumn(verbose_name='Full Depth') - is_console_server = tables.BooleanColumn(verbose_name='CS') - is_pdu = tables.BooleanColumn(verbose_name='PDU') - is_network_device = tables.BooleanColumn(verbose_name='Net') - subdevice_role = tables.TemplateColumn(SUBDEVICE_ROLE_TEMPLATE, verbose_name='Subdevice Role') - - class Meta(SearchTable.Meta): - model = DeviceType - fields = ( - 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', - 'is_network_device', 'subdevice_role', + 'is_network_device', 'subdevice_role', 'instance_count', ) @@ -333,7 +306,6 @@ class ConsolePortTemplateTable(BaseTable): model = ConsolePortTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class ConsoleServerPortTemplateTable(BaseTable): @@ -343,7 +315,6 @@ class ConsoleServerPortTemplateTable(BaseTable): model = ConsoleServerPortTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class PowerPortTemplateTable(BaseTable): @@ -353,7 +324,6 @@ class PowerPortTemplateTable(BaseTable): model = PowerPortTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class PowerOutletTemplateTable(BaseTable): @@ -363,17 +333,16 @@ class PowerOutletTemplateTable(BaseTable): model = PowerOutletTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class InterfaceTemplateTable(BaseTable): pk = ToggleColumn() + mgmt_only = tables.TemplateColumn("{% if value %}OOB Management{% endif %}") class Meta(BaseTable.Meta): model = InterfaceTemplate - fields = ('pk', 'name', 'form_factor') + fields = ('pk', 'name', 'mgmt_only', 'form_factor') empty_text = "None" - show_header = False class DeviceBayTemplateTable(BaseTable): @@ -383,7 +352,6 @@ class DeviceBayTemplateTable(BaseTable): model = DeviceBayTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False # @@ -438,32 +406,22 @@ class DeviceTable(BaseTable): 'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type', text=lambda record: record.device_type.full_name ) + + class Meta(BaseTable.Meta): + model = Device + fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type') + + +class DeviceDetailTable(DeviceTable): primary_ip = tables.TemplateColumn( orderable=False, verbose_name='IP Address', template_code=DEVICE_PRIMARY_IP ) - class Meta(BaseTable.Meta): + class Meta(DeviceTable.Meta): model = Device fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip') -class DeviceSearchTable(SearchTable): - name = tables.TemplateColumn(template_code=DEVICE_LINK) - status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) - rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')]) - device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role') - device_type = tables.LinkColumn( - 'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type', - text=lambda record: record.device_type.full_name - ) - - class Meta(SearchTable.Meta): - model = Device - fields = ('name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type') - - class DeviceImportTable(BaseTable): name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') status = tables.TemplateColumn(template_code=DEVICE_STATUS, verbose_name='Status') @@ -480,6 +438,52 @@ class DeviceImportTable(BaseTable): empty_text = False +# +# Device components +# + +class ConsolePortTable(BaseTable): + + class Meta(BaseTable.Meta): + model = ConsolePort + fields = ('name',) + + +class ConsoleServerPortTable(BaseTable): + + class Meta(BaseTable.Meta): + model = ConsoleServerPort + fields = ('name',) + + +class PowerPortTable(BaseTable): + + class Meta(BaseTable.Meta): + model = PowerPort + fields = ('name',) + + +class PowerOutletTable(BaseTable): + + class Meta(BaseTable.Meta): + model = PowerOutlet + fields = ('name',) + + +class InterfaceTable(BaseTable): + + class Meta(BaseTable.Meta): + model = Interface + fields = ('name', 'form_factor', 'lag', 'enabled', 'mgmt_only', 'description') + + +class DeviceBayTable(BaseTable): + + class Meta(BaseTable.Meta): + model = DeviceBay + fields = ('name',) + + # # Device connections # diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 53031ebbe..172f634fb 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -122,7 +122,9 @@ urlpatterns = [ url(r'^devices/(?P\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'), url(r'^devices/(?P\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'), url(r'^devices/(?P\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'), + url(r'^devices/(?P\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'), url(r'^devices/(?P\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), + url(r'^devices/(?P\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'), url(r'^devices/(?P\d+)/add-secret/$', secret_add, name='device_addsecret'), url(r'^devices/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'), url(r'^devices/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e6b77cb59..ea07138d5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -205,6 +205,8 @@ class RegionEditView(RegionCreateView): class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_region' cls = Region + queryset = Region.objects.annotate(site_count=Count('sites')) + table = tables.RegionTable default_return_url = 'dcim:region_list' @@ -216,7 +218,7 @@ class SiteListView(ObjectListView): queryset = Site.objects.select_related('region', 'tenant') filter = filters.SiteFilter filter_form = forms.SiteFilterForm - table = tables.SiteTable + table = tables.SiteDetailTable template_name = 'dcim/site_list.html' @@ -273,9 +275,10 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView): class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_site' cls = Site + queryset = Site.objects.select_related('region', 'tenant') filter = filters.SiteFilter + table = tables.SiteTable form = forms.SiteBulkEditForm - template_name = 'dcim/site_bulk_edit.html' default_return_url = 'dcim:site_list' @@ -307,7 +310,9 @@ class RackGroupEditView(RackGroupCreateView): class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackgroup' cls = RackGroup + queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')) filter = filters.RackGroupFilter + table = tables.RackGroupTable default_return_url = 'dcim:rackgroup_list' @@ -337,6 +342,8 @@ class RackRoleEditView(RackRoleCreateView): class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackrole' cls = RackRole + queryset = RackRole.objects.annotate(rack_count=Count('racks')) + table = tables.RackRoleTable default_return_url = 'dcim:rackrole_list' @@ -354,7 +361,7 @@ class RackListView(ObjectListView): ) filter = filters.RackFilter filter_form = forms.RackFilterForm - table = tables.RackTable + table = tables.RackDetailTable template_name = 'dcim/rack_list.html' @@ -455,16 +462,19 @@ class RackBulkImportView(PermissionRequiredMixin, BulkImportView): class RackBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_rack' cls = Rack + queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.RackFilter + table = tables.RackTable form = forms.RackBulkEditForm - template_name = 'dcim/rack_bulk_edit.html' default_return_url = 'dcim:rack_list' class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rack' cls = Rack + queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.RackFilter + table = tables.RackTable default_return_url = 'dcim:rack_list' @@ -510,6 +520,7 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView): class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackreservation' cls = RackReservation + table = tables.RackReservationTable default_return_url = 'dcim:rackreservation_list' @@ -539,6 +550,8 @@ class ManufacturerEditView(ManufacturerCreateView): class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_manufacturer' cls = Manufacturer + queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types')) + table = tables.ManufacturerTable default_return_url = 'dcim:manufacturer_list' @@ -562,36 +575,36 @@ class DeviceTypeView(View): # Component tables consoleport_table = tables.ConsolePortTemplateTable( - natsorted(ConsolePortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')) + natsorted(ConsolePortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + show_header=False ) consoleserverport_table = tables.ConsoleServerPortTemplateTable( - natsorted(ConsoleServerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')) + natsorted(ConsoleServerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + show_header=False ) powerport_table = tables.PowerPortTemplateTable( - natsorted(PowerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')) + natsorted(PowerPortTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + show_header=False ) poweroutlet_table = tables.PowerOutletTemplateTable( - natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')) - ) - mgmt_interface_table = tables.InterfaceTemplateTable( - list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter( - device_type=devicetype, mgmt_only=True - )) + natsorted(PowerOutletTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + show_header=False ) interface_table = tables.InterfaceTemplateTable( - list(InterfaceTemplate.objects.order_naturally(devicetype.interface_ordering).filter( - device_type=devicetype, mgmt_only=False - )) + list(InterfaceTemplate.objects.order_naturally( + devicetype.interface_ordering + ).filter(device_type=devicetype)), + show_header=False ) devicebay_table = tables.DeviceBayTemplateTable( - natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')) + natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')), + show_header=False ) if request.user.has_perm('dcim.change_devicetype'): consoleport_table.base_columns['pk'].visible = True consoleserverport_table.base_columns['pk'].visible = True powerport_table.base_columns['pk'].visible = True poweroutlet_table.base_columns['pk'].visible = True - mgmt_interface_table.base_columns['pk'].visible = True interface_table.base_columns['pk'].visible = True devicebay_table.base_columns['pk'].visible = True @@ -601,7 +614,6 @@ class DeviceTypeView(View): 'consoleserverport_table': consoleserverport_table, 'powerport_table': powerport_table, 'poweroutlet_table': poweroutlet_table, - 'mgmt_interface_table': mgmt_interface_table, 'interface_table': interface_table, 'devicebay_table': devicebay_table, }) @@ -628,16 +640,19 @@ class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView): class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_devicetype' cls = DeviceType + queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) filter = filters.DeviceTypeFilter + table = tables.DeviceTypeTable form = forms.DeviceTypeBulkEditForm - template_name = 'dcim/devicetype_bulk_edit.html' default_return_url = 'dcim:devicetype_list' class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicetype' cls = DeviceType + queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) filter = filters.DeviceTypeFilter + table = tables.DeviceTypeTable default_return_url = 'dcim:devicetype_list' @@ -660,6 +675,7 @@ class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView) parent_field = 'device_type' cls = ConsolePortTemplate parent_cls = DeviceType + table = tables.ConsolePortTemplateTable class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -675,6 +691,7 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet permission_required = 'dcim.delete_consoleserverporttemplate' cls = ConsoleServerPortTemplate parent_cls = DeviceType + table = tables.ConsoleServerPortTemplateTable class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -690,6 +707,7 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerporttemplate' cls = PowerPortTemplate parent_cls = DeviceType + table = tables.PowerPortTemplateTable class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -705,6 +723,7 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView) permission_required = 'dcim.delete_poweroutlettemplate' cls = PowerOutletTemplate parent_cls = DeviceType + table = tables.PowerOutletTemplateTable class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -720,14 +739,15 @@ class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interfacetemplate' cls = InterfaceTemplate parent_cls = DeviceType + table = tables.InterfaceTemplateTable form = forms.InterfaceTemplateBulkEditForm - template_name = 'dcim/interfacetemplate_bulk_edit.html' class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interfacetemplate' cls = InterfaceTemplate parent_cls = DeviceType + table = tables.InterfaceTemplateTable class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -743,6 +763,7 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicebaytemplate' cls = DeviceBayTemplate parent_cls = DeviceType + table = tables.DeviceBayTemplateTable # @@ -771,6 +792,8 @@ class DeviceRoleEditView(DeviceRoleCreateView): class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicerole' cls = DeviceRole + queryset = DeviceRole.objects.annotate(device_count=Count('devices')) + table = tables.DeviceRoleTable default_return_url = 'dcim:devicerole_list' @@ -800,6 +823,8 @@ class PlatformEditView(PlatformCreateView): class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_platform' cls = Platform + queryset = Platform.objects.annotate(device_count=Count('devices')) + table = tables.PlatformTable default_return_url = 'dcim:platform_list' @@ -812,7 +837,7 @@ class DeviceListView(ObjectListView): 'primary_ip4', 'primary_ip6') filter = filters.DeviceFilter filter_form = forms.DeviceFilterForm - table = tables.DeviceTable + table = tables.DeviceDetailTable template_name = 'dcim/device_list.html' @@ -835,14 +860,10 @@ class DeviceView(View): power_outlets = natsorted( PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name') ) - interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter( - device=device, mgmt_only=False - ).select_related( - 'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device', - 'circuit_termination__circuit' - ).prefetch_related('ip_addresses') - mgmt_interfaces = Interface.objects.order_naturally(device.device_type.interface_ordering).filter( - device=device, mgmt_only=True + interfaces = Interface.objects.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' @@ -873,7 +894,6 @@ class DeviceView(View): 'power_ports': power_ports, 'power_outlets': power_outlets, 'interfaces': interfaces, - 'mgmt_interfaces': mgmt_interfaces, 'device_bays': device_bays, 'services': services, 'secrets': secrets, @@ -901,7 +921,20 @@ class DeviceInventoryView(View): }) -class DeviceLLDPNeighborsView(View): +class DeviceStatusView(PermissionRequiredMixin, View): + permission_required = 'dcim.napalm_read' + + def get(self, request, pk): + + device = get_object_or_404(Device, pk=pk) + + return render(request, 'dcim/device_status.html', { + 'device': device, + }) + + +class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): + permission_required = 'dcim.napalm_read' def get(self, request, pk): @@ -920,6 +953,18 @@ class DeviceLLDPNeighborsView(View): }) +class DeviceConfigView(PermissionRequiredMixin, View): + permission_required = 'dcim.napalm_read' + + def get(self, request, pk): + + device = get_object_or_404(Device, pk=pk) + + return render(request, 'dcim/device_config.html', { + 'device': device, + }) + + class DeviceCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.add_device' model = Device @@ -968,16 +1013,19 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView): class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_device' cls = Device + queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filter = filters.DeviceFilter + table = tables.DeviceTable form = forms.DeviceBulkEditForm - template_name = 'dcim/device_bulk_edit.html' default_return_url = 'dcim:device_list' class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_device' cls = Device + queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') filter = filters.DeviceFilter + table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -1085,6 +1133,7 @@ class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleport' cls = ConsolePort parent_cls = Device + table = tables.ConsolePortTable class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): @@ -1210,6 +1259,7 @@ class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleserverport' cls = ConsoleServerPort parent_cls = Device + table = tables.ConsoleServerPortTable # @@ -1316,6 +1366,7 @@ class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerport' cls = PowerPort parent_cls = Device + table = tables.PowerPortTable class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): @@ -1443,6 +1494,7 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_poweroutlet' cls = PowerOutlet parent_cls = Device + table = tables.PowerOutletTable # @@ -1485,14 +1537,15 @@ class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interface' cls = Interface parent_cls = Device + table = tables.InterfaceTable form = forms.InterfaceBulkEditForm - template_name = 'dcim/interface_bulk_edit.html' class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_interface' cls = Interface parent_cls = Device + table = tables.InterfaceTable # @@ -1573,6 +1626,7 @@ class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicebay' cls = DeviceBay parent_cls = Device + table = tables.DeviceBayTable # diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 5a1878b77..52f127a7d 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -111,6 +111,16 @@ class CustomFieldModelSerializer(serializers.ModelSerializer): defaults={'serialized_value': custom_field.serialize_value(value)}, ) + def validate(self, data): + """ + Enforce model validation (see utilities.api.ModelValidationMixin) + """ + model_data = data.copy() + model_data.pop('custom_fields', None) + instance = self.Meta.model(**model_data) + instance.clean() + return data + def create(self, validated_data): custom_fields = validated_data.pop('custom_fields', None) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index c8b3ff6f7..39ce63524 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -10,7 +10,7 @@ from extras.models import ( ACTION_CHOICES, ExportTemplate, Graph, GRAPH_TYPE_CHOICES, ImageAttachment, TopologyMap, UserAction, ) from users.api.serializers import NestedUserSerializer -from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer +from utilities.api import ChoiceFieldSerializer, ContentTypeFieldSerializer, ModelValidationMixin # @@ -104,7 +104,7 @@ class ImageAttachmentSerializer(serializers.ModelSerializer): return serializer(obj.parent, context={'request': self.context['request']}).data -class WritableImageAttachmentSerializer(serializers.ModelSerializer): +class WritableImageAttachmentSerializer(ModelValidationMixin, serializers.ModelSerializer): content_type = ContentTypeFieldSerializer() class Meta: @@ -121,6 +121,9 @@ class WritableImageAttachmentSerializer(serializers.ModelSerializer): "Invalid parent object: {} ID {}".format(data['content_type'], data['object_id']) ) + # Enforce model validation + super(WritableImageAttachmentSerializer, self).validate(data) + return data diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py new file mode 100644 index 000000000..86da90895 --- /dev/null +++ b/netbox/extras/constants.py @@ -0,0 +1,62 @@ +from __future__ import unicode_literals + + +# Models which support custom fields +CUSTOMFIELD_MODELS = ( + 'site', 'rack', 'devicetype', 'device', # DCIM + 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM + 'provider', 'circuit', # Circuits + 'tenant', # Tenants +) + +# Custom field types +CF_TYPE_TEXT = 100 +CF_TYPE_INTEGER = 200 +CF_TYPE_BOOLEAN = 300 +CF_TYPE_DATE = 400 +CF_TYPE_URL = 500 +CF_TYPE_SELECT = 600 +CUSTOMFIELD_TYPE_CHOICES = ( + (CF_TYPE_TEXT, 'Text'), + (CF_TYPE_INTEGER, 'Integer'), + (CF_TYPE_BOOLEAN, 'Boolean (true/false)'), + (CF_TYPE_DATE, 'Date'), + (CF_TYPE_URL, 'URL'), + (CF_TYPE_SELECT, 'Selection'), +) + +# Graph types +GRAPH_TYPE_INTERFACE = 100 +GRAPH_TYPE_PROVIDER = 200 +GRAPH_TYPE_SITE = 300 +GRAPH_TYPE_CHOICES = ( + (GRAPH_TYPE_INTERFACE, 'Interface'), + (GRAPH_TYPE_PROVIDER, 'Provider'), + (GRAPH_TYPE_SITE, 'Site'), +) + +# Models which support export templates +EXPORTTEMPLATE_MODELS = [ + 'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection', # DCIM + 'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM + 'provider', 'circuit', # Circuits + 'tenant', # Tenants +] + +# User action types +ACTION_CREATE = 1 +ACTION_IMPORT = 2 +ACTION_EDIT = 3 +ACTION_BULK_EDIT = 4 +ACTION_DELETE = 5 +ACTION_BULK_DELETE = 6 +ACTION_BULK_CREATE = 7 +ACTION_CHOICES = ( + (ACTION_CREATE, 'created'), + (ACTION_BULK_CREATE, 'bulk created'), + (ACTION_IMPORT, 'imported'), + (ACTION_EDIT, 'modified'), + (ACTION_BULK_EDIT, 'bulk edited'), + (ACTION_DELETE, 'deleted'), + (ACTION_BULK_DELETE, 'bulk deleted'), +) diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py index 48448c16f..a50b1384d 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -37,19 +37,29 @@ class Command(BaseCommand): def get_namespace(self): namespace = {} - # Gather Django models from each app + # Gather Django models and constants from each app for app in APPS: self.django_models[app] = [] + + # Models app_models = sys.modules['{}.models'.format(app)] for name in dir(app_models): model = getattr(app_models, name) try: - if issubclass(model, Model): + if issubclass(model, Model) and model._meta.app_label == app: namespace[name] = model self.django_models[app].append(name) except TypeError: pass + # Constants + try: + app_constants = sys.modules['{}.constants'.format(app)] + for name in dir(app_constants): + namespace[name] = getattr(app_constants, name) + except KeyError: + pass + # Load convenience commands namespace.update({ 'lsmodels': self._lsmodels, diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 3388f99f9..9d0e636ff 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -15,62 +15,7 @@ from django.utils.encoding import python_2_unicode_compatible from django.utils.safestring import mark_safe from utilities.utils import foreground_color - - -CUSTOMFIELD_MODELS = ( - 'site', 'rack', 'devicetype', 'device', # DCIM - 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM - 'provider', 'circuit', # Circuits - 'tenant', # Tenants -) - -CF_TYPE_TEXT = 100 -CF_TYPE_INTEGER = 200 -CF_TYPE_BOOLEAN = 300 -CF_TYPE_DATE = 400 -CF_TYPE_URL = 500 -CF_TYPE_SELECT = 600 -CUSTOMFIELD_TYPE_CHOICES = ( - (CF_TYPE_TEXT, 'Text'), - (CF_TYPE_INTEGER, 'Integer'), - (CF_TYPE_BOOLEAN, 'Boolean (true/false)'), - (CF_TYPE_DATE, 'Date'), - (CF_TYPE_URL, 'URL'), - (CF_TYPE_SELECT, 'Selection'), -) - -GRAPH_TYPE_INTERFACE = 100 -GRAPH_TYPE_PROVIDER = 200 -GRAPH_TYPE_SITE = 300 -GRAPH_TYPE_CHOICES = ( - (GRAPH_TYPE_INTERFACE, 'Interface'), - (GRAPH_TYPE_PROVIDER, 'Provider'), - (GRAPH_TYPE_SITE, 'Site'), -) - -EXPORTTEMPLATE_MODELS = [ - 'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection', # DCIM - 'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM - 'provider', 'circuit', # Circuits - 'tenant', # Tenants -] - -ACTION_CREATE = 1 -ACTION_IMPORT = 2 -ACTION_EDIT = 3 -ACTION_BULK_EDIT = 4 -ACTION_DELETE = 5 -ACTION_BULK_DELETE = 6 -ACTION_BULK_CREATE = 7 -ACTION_CHOICES = ( - (ACTION_CREATE, 'created'), - (ACTION_BULK_CREATE, 'bulk created'), - (ACTION_IMPORT, 'imported'), - (ACTION_EDIT, 'modified'), - (ACTION_BULK_EDIT, 'bulk edited'), - (ACTION_DELETE, 'deleted'), - (ACTION_BULK_DELETE, 'bulk deleted'), -) +from .constants import * # diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index f4493719f..1374d3552 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,4 +1,5 @@ from __future__ import unicode_literals +from collections import OrderedDict from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator @@ -6,11 +7,11 @@ from rest_framework.validators import UniqueTogetherValidator from dcim.api.serializers import NestedDeviceSerializer, InterfaceSerializer, NestedSiteSerializer from extras.api.customfields import CustomFieldModelSerializer from ipam.models import ( - Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, - Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF, + Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, IP_PROTOCOL_CHOICES, Prefix, + PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF, ) from tenancy.api.serializers import NestedTenantSerializer -from utilities.api import ChoiceFieldSerializer +from utilities.api import ChoiceFieldSerializer, ModelValidationMixin # @@ -22,7 +23,7 @@ class VRFSerializer(CustomFieldModelSerializer): class Meta: model = VRF - fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'custom_fields'] + fields = ['id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'display_name', 'custom_fields'] class NestedVRFSerializer(serializers.ModelSerializer): @@ -44,7 +45,7 @@ class WritableVRFSerializer(CustomFieldModelSerializer): # Roles # -class RoleSerializer(serializers.ModelSerializer): +class RoleSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Role @@ -63,7 +64,7 @@ class NestedRoleSerializer(serializers.ModelSerializer): # RIRs # -class RIRSerializer(serializers.ModelSerializer): +class RIRSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = RIR @@ -141,6 +142,9 @@ class WritableVLANGroupSerializer(serializers.ModelSerializer): validator.set_context(self) validator(data) + # Enforce model validation + super(WritableVLANGroupSerializer, self).validate(data) + return data @@ -187,6 +191,9 @@ class WritableVLANSerializer(CustomFieldModelSerializer): validator.set_context(self) validator(data) + # Enforce model validation + super(WritableVLANSerializer, self).validate(data) + return data @@ -236,12 +243,13 @@ class IPAddressSerializer(CustomFieldModelSerializer): vrf = NestedVRFSerializer() tenant = NestedTenantSerializer() status = ChoiceFieldSerializer(choices=IPADDRESS_STATUS_CHOICES) + role = ChoiceFieldSerializer(choices=IPADDRESS_ROLE_CHOICES) interface = InterfaceSerializer() class Meta: model = IPAddress fields = [ - 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside', + 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside', 'nat_outside', 'custom_fields', ] @@ -261,7 +269,24 @@ class WritableIPAddressSerializer(CustomFieldModelSerializer): class Meta: model = IPAddress - fields = ['id', 'address', 'vrf', 'tenant', 'status', 'interface', 'description', 'nat_inside', 'custom_fields'] + fields = [ + 'id', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside', + 'custom_fields', + ] + + +class AvailableIPSerializer(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', self.context['prefix'].version), + ('address', '{}/{}'.format(instance, self.context['prefix'].prefixlen)), + ('vrf', vrf), + ]) # @@ -278,6 +303,7 @@ class ServiceSerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] +# TODO: Figure out how to use ModelValidationMixin with ManyToManyFields. Calling clean() yields a ValueError. class WritableServiceSerializer(serializers.ModelSerializer): class Meta: diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 87c1996a1..87511d5c5 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,7 +1,14 @@ from __future__ import unicode_literals +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 django.conf import settings +from django.shortcuts import get_object_or_404 + from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam import filters from extras.api.views import CustomFieldModelViewSet @@ -20,15 +27,6 @@ class VRFViewSet(WritableSerializerMixin, CustomFieldModelViewSet): filter_class = filters.VRFFilter -# -# Roles -# - -class RoleViewSet(ModelViewSet): - queryset = Role.objects.all() - serializer_class = serializers.RoleSerializer - - # # RIRs # @@ -50,6 +48,16 @@ class AggregateViewSet(WritableSerializerMixin, CustomFieldModelViewSet): filter_class = filters.AggregateFilter +# +# Roles +# + +class RoleViewSet(ModelViewSet): + queryset = Role.objects.all() + serializer_class = serializers.RoleSerializer + filter_class = filters.RoleFilter + + # # Prefixes # @@ -60,6 +68,62 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet): write_serializer_class = serializers.WritablePrefixSerializer filter_class = filters.PrefixFilter + @detail_route(url_path='available-ips', methods=['get', 'post']) + def available_ips(self, request, pk=None): + """ + A convenience method for returning available IP addresses within a prefix. By default, the number of IPs + returned will be equivalent to PAGINATE_COUNT. An arbitrary limit (up to MAX_PAGE_SIZE, if set) may be passed, + however results will not be paginated. + """ + prefix = get_object_or_404(Prefix, pk=pk) + + # Create the next available IP within the prefix + if request.method == 'POST': + + # Permissions check + 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: + return Response( + { + "detail": "There are no available IPs within this prefix ({})".format(prefix) + }, + 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 + serializer = serializers.WritableIPAddressSerializer(data=data) + 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 + else: + try: + limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT)) + except ValueError: + limit = settings.PAGINATE_COUNT + if settings.MAX_PAGE_SIZE: + limit = min(limit, settings.MAX_PAGE_SIZE) + + # Calculate available IPs within the prefix + ip_list = list(prefix.get_available_ips())[:limit] + serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={ + 'request': request, + 'prefix': prefix.prefix, + 'vrf': prefix.vrf, + }) + + return Response(serializer.data) + # # IP addresses diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py new file mode 100644 index 000000000..3beb18823 --- /dev/null +++ b/netbox/ipam/constants.py @@ -0,0 +1,78 @@ +from __future__ import unicode_literals + + +# IP address families +AF_CHOICES = ( + (4, 'IPv4'), + (6, 'IPv6'), +) + +# Prefix statuses +PREFIX_STATUS_CONTAINER = 0 +PREFIX_STATUS_ACTIVE = 1 +PREFIX_STATUS_RESERVED = 2 +PREFIX_STATUS_DEPRECATED = 3 +PREFIX_STATUS_CHOICES = ( + (PREFIX_STATUS_CONTAINER, 'Container'), + (PREFIX_STATUS_ACTIVE, 'Active'), + (PREFIX_STATUS_RESERVED, 'Reserved'), + (PREFIX_STATUS_DEPRECATED, 'Deprecated') +) + +# IP address statuses +IPADDRESS_STATUS_ACTIVE = 1 +IPADDRESS_STATUS_RESERVED = 2 +IPADDRESS_STATUS_DEPRECATED = 3 +IPADDRESS_STATUS_DHCP = 5 +IPADDRESS_STATUS_CHOICES = ( + (IPADDRESS_STATUS_ACTIVE, 'Active'), + (IPADDRESS_STATUS_RESERVED, 'Reserved'), + (IPADDRESS_STATUS_DEPRECATED, 'Deprecated'), + (IPADDRESS_STATUS_DHCP, 'DHCP') +) + +# IP address roles +IPADDRESS_ROLE_LOOPBACK = 10 +IPADDRESS_ROLE_SECONDARY = 20 +IPADDRESS_ROLE_ANYCAST = 30 +IPADDRESS_ROLE_VIP = 40 +IPADDRESS_ROLE_VRRP = 41 +IPADDRESS_ROLE_HSRP = 42 +IPADDRESS_ROLE_GLBP = 43 +IPADDRESS_ROLE_CHOICES = ( + (IPADDRESS_ROLE_LOOPBACK, 'Loopback'), + (IPADDRESS_ROLE_SECONDARY, 'Secondary'), + (IPADDRESS_ROLE_ANYCAST, 'Anycast'), + (IPADDRESS_ROLE_VIP, 'VIP'), + (IPADDRESS_ROLE_VRRP, 'VRRP'), + (IPADDRESS_ROLE_HSRP, 'HSRP'), + (IPADDRESS_ROLE_GLBP, 'GLBP'), +) + +# VLAN statuses +VLAN_STATUS_ACTIVE = 1 +VLAN_STATUS_RESERVED = 2 +VLAN_STATUS_DEPRECATED = 3 +VLAN_STATUS_CHOICES = ( + (VLAN_STATUS_ACTIVE, 'Active'), + (VLAN_STATUS_RESERVED, 'Reserved'), + (VLAN_STATUS_DEPRECATED, 'Deprecated') +) + +# Bootstrap CSS classes for various statuses +STATUS_CHOICE_CLASSES = { + 0: 'default', + 1: 'primary', + 2: 'info', + 3: 'danger', + 4: 'warning', + 5: 'success', +} + +# IP protocols (for services) +IP_PROTOCOL_TCP = 6 +IP_PROTOCOL_UDP = 17 +IP_PROTOCOL_CHOICES = ( + (IP_PROTOCOL_TCP, 'TCP'), + (IP_PROTOCOL_UDP, 'UDP'), +) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 11c19b7ee..045ca1df4 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -11,8 +11,8 @@ from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter from .models import ( - Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, - VLAN_STATUS_CHOICES, VLANGroup, VRF, + Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, + Service, VLAN, VLAN_STATUS_CHOICES, VLANGroup, VRF, ) @@ -23,7 +23,6 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) @@ -45,7 +44,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = VRF - fields = ['name', 'rd'] + fields = ['name', 'rd', 'enforce_unique'] class RIRFilter(django_filters.FilterSet): @@ -53,7 +52,7 @@ class RIRFilter(django_filters.FilterSet): class Meta: model = RIR - fields = ['is_private'] + fields = ['name', 'slug', 'is_private'] class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): @@ -63,7 +62,6 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) rir_id = django_filters.ModelMultipleChoiceFilter( - name='rir', queryset=RIR.objects.all(), label='RIR (ID)', ) @@ -90,6 +88,13 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): return queryset.filter(qs_filter) +class RoleFilter(django_filters.FilterSet): + + class Meta: + model = Role + fields = ['name', 'slug'] + + class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( @@ -105,7 +110,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Mask length', ) vrf_id = NullableModelMultipleChoiceFilter( - name='vrf_id', queryset=VRF.objects.all(), label='VRF', ) @@ -116,7 +120,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='VRF (RD)', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) @@ -127,7 +130,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (slug)', ) site_id = NullableModelMultipleChoiceFilter( - name='site', queryset=Site.objects.all(), label='Site (ID)', ) @@ -138,7 +140,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (slug)', ) vlan_id = NullableModelMultipleChoiceFilter( - name='vlan', queryset=VLAN.objects.all(), label='VLAN (ID)', ) @@ -147,7 +148,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): label='VLAN number (1-4095)', ) role_id = NullableModelMultipleChoiceFilter( - name='role', queryset=Role.objects.all(), label='Role (ID)', ) @@ -163,7 +163,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Prefix - fields = ['family'] + fields = ['family', 'is_pool'] def search(self, queryset, name, value): if not value.strip(): @@ -207,7 +207,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Mask length', ) vrf_id = NullableModelMultipleChoiceFilter( - name='vrf_id', queryset=VRF.objects.all(), label='VRF', ) @@ -218,7 +217,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): label='VRF (RD)', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) @@ -240,13 +238,15 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Device (name)', ) interface_id = django_filters.ModelMultipleChoiceFilter( - name='interface', queryset=Interface.objects.all(), label='Interface (ID)', ) status = django_filters.MultipleChoiceFilter( choices=IPADDRESS_STATUS_CHOICES ) + role = django_filters.MultipleChoiceFilter( + choices=IPADDRESS_ROLE_CHOICES + ) class Meta: model = IPAddress @@ -281,7 +281,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): class VLANGroupFilter(django_filters.FilterSet): site_id = NullableModelMultipleChoiceFilter( - name='site', queryset=Site.objects.all(), label='Site (ID)', ) @@ -294,7 +293,7 @@ class VLANGroupFilter(django_filters.FilterSet): class Meta: model = VLANGroup - fields = ['name'] + fields = ['name', 'slug'] class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): @@ -304,7 +303,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) site_id = NullableModelMultipleChoiceFilter( - name='site', queryset=Site.objects.all(), label='Site (ID)', ) @@ -315,7 +313,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (slug)', ) group_id = NullableModelMultipleChoiceFilter( - name='group', queryset=VLANGroup.objects.all(), label='Group (ID)', ) @@ -326,7 +323,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Group', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) @@ -337,7 +333,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (slug)', ) role_id = NullableModelMultipleChoiceFilter( - name='role', queryset=Role.objects.all(), label='Role (ID)', ) @@ -353,7 +348,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = VLAN - fields = ['name', 'vid'] + fields = ['vid', 'name'] def search(self, queryset, name, value): if not value.strip(): @@ -368,7 +363,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): class ServiceFilter(django_filters.FilterSet): device_id = django_filters.ModelMultipleChoiceFilter( - name='device', queryset=Device.objects.all(), label='Device (ID)', ) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 0a987e735..e19376e8e 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -14,8 +14,8 @@ from utilities.forms import ( add_blank_choice, ) from .models import ( - Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, - VLANGroup, VLAN_STATUS_CHOICES, VRF, + Aggregate, IPAddress, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, + Service, VLAN, VLANGroup, VLAN_STATUS_CHOICES, VRF, ) @@ -217,7 +217,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): # Initialize helper selectors instance = kwargs.get('instance') - initial = kwargs.get('initial', {}) + initial = kwargs.get('initial', {}).copy() if instance and instance.vlan is not None: initial['vlan_group'] = instance.vlan.group kwargs['initial'] = initial @@ -484,7 +484,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'status', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack', + 'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_device', 'nat_site', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', ] @@ -492,7 +492,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) # Initialize helper selectors instance = kwargs.get('instance') - initial = kwargs.get('initial', {}) + initial = kwargs.get('initial', {}).copy() if instance and instance.interface is not None: initial['interface_site'] = instance.interface.device.site initial['interface_rack'] = instance.interface.device.rack @@ -562,7 +562,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = IPAddress - fields = ['address', 'status', 'vrf', 'description', 'tenant_group', 'tenant'] + fields = ['address', 'vrf', 'status', 'role', 'description', 'tenant_group', 'tenant'] def __init__(self, *args, **kwargs): super(IPAddressBulkAddForm, self).__init__(*args, **kwargs) @@ -592,6 +592,11 @@ class IPAddressCSVForm(forms.ModelForm): choices=IPADDRESS_STATUS_CHOICES, help_text='Operational status' ) + role = CSVChoiceField( + choices=IPADDRESS_ROLE_CHOICES, + required=False, + help_text='Functional role' + ) device = FlexibleModelChoiceField( queryset=Device.objects.all(), required=False, @@ -612,7 +617,7 @@ class IPAddressCSVForm(forms.ModelForm): class Meta: model = IPAddress - fields = ['address', 'vrf', 'tenant', 'status', 'device', 'interface_name', 'is_primary', 'description'] + fields = ['address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description'] def clean(self): @@ -665,10 +670,11 @@ class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) status = forms.ChoiceField(choices=add_blank_choice(IPADDRESS_STATUS_CHOICES), required=False) + role = forms.ChoiceField(choices=add_blank_choice(IPADDRESS_ROLE_CHOICES), required=False) description = forms.CharField(max_length=100, required=False) class Meta: - nullable_fields = ['vrf', 'tenant', 'description'] + nullable_fields = ['vrf', 'role', 'tenant', 'description'] def ipaddress_status_choices(): @@ -678,6 +684,13 @@ def ipaddress_status_choices(): return [(s[0], '{} ({})'.format(s[1], status_counts.get(s[0], 0))) for s in IPADDRESS_STATUS_CHOICES] +def ipaddress_role_choices(): + role_counts = {} + for role in IPAddress.objects.values('role').annotate(count=Count('role')).order_by('role'): + role_counts[role['role']] = role['count'] + return [(r[0], '{} ({})'.format(r[1], role_counts.get(r[0], 0))) for r in IPADDRESS_ROLE_CHOICES] + + class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): model = IPAddress q = forms.CharField(required=False, label='Search') @@ -698,6 +711,7 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): null_option=(0, 'None') ) status = forms.MultipleChoiceField(choices=ipaddress_status_choices, required=False) + role = forms.MultipleChoiceField(choices=ipaddress_role_choices, required=False) # diff --git a/netbox/ipam/migrations/0017_ipaddress_roles.py b/netbox/ipam/migrations/0017_ipaddress_roles.py new file mode 100644 index 000000000..d91c3daa9 --- /dev/null +++ b/netbox/ipam/migrations/0017_ipaddress_roles.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-06-16 19:37 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0016_unicode_literals'), + ] + + operations = [ + migrations.AddField( + model_name='ipaddress', + name='role', + field=models.PositiveSmallIntegerField(blank=True, choices=[(10, 'Loopback'), (20, 'Secondary'), (30, 'Anycast'), (40, 'VIP'), (41, 'VRRP'), (42, 'HSRP'), (43, 'GLBP')], help_text='The functional role of this IP', null=True, verbose_name='Role'), + ), + migrations.AlterField( + model_name='ipaddress', + name='status', + field=models.PositiveSmallIntegerField(choices=[(1, 'Active'), (2, 'Reserved'), (3, 'Deprecated'), (5, 'DHCP')], default=1, help_text='The operational status of this IP', verbose_name='Status'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 208d46c4c..04853c5da 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals - -from netaddr import IPNetwork, IPSet +import netaddr from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation @@ -17,63 +16,10 @@ from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel from utilities.sql import NullsFirstQuerySet from utilities.utils import csv_format +from .constants import * from .fields import IPNetworkField, IPAddressField -AF_CHOICES = ( - (4, 'IPv4'), - (6, 'IPv6'), -) - -PREFIX_STATUS_CONTAINER = 0 -PREFIX_STATUS_ACTIVE = 1 -PREFIX_STATUS_RESERVED = 2 -PREFIX_STATUS_DEPRECATED = 3 -PREFIX_STATUS_CHOICES = ( - (PREFIX_STATUS_CONTAINER, 'Container'), - (PREFIX_STATUS_ACTIVE, 'Active'), - (PREFIX_STATUS_RESERVED, 'Reserved'), - (PREFIX_STATUS_DEPRECATED, 'Deprecated') -) - -IPADDRESS_STATUS_ACTIVE = 1 -IPADDRESS_STATUS_RESERVED = 2 -IPADDRESS_STATUS_DEPRECATED = 3 -IPADDRESS_STATUS_DHCP = 5 -IPADDRESS_STATUS_CHOICES = ( - (IPADDRESS_STATUS_ACTIVE, 'Active'), - (IPADDRESS_STATUS_RESERVED, 'Reserved'), - (IPADDRESS_STATUS_DEPRECATED, 'Deprecated'), - (IPADDRESS_STATUS_DHCP, 'DHCP') -) - -VLAN_STATUS_ACTIVE = 1 -VLAN_STATUS_RESERVED = 2 -VLAN_STATUS_DEPRECATED = 3 -VLAN_STATUS_CHOICES = ( - (VLAN_STATUS_ACTIVE, 'Active'), - (VLAN_STATUS_RESERVED, 'Reserved'), - (VLAN_STATUS_DEPRECATED, 'Deprecated') -) - -STATUS_CHOICE_CLASSES = { - 0: 'default', - 1: 'primary', - 2: 'info', - 3: 'danger', - 4: 'warning', - 5: 'success', -} - - -IP_PROTOCOL_TCP = 6 -IP_PROTOCOL_UDP = 17 -IP_PROTOCOL_CHOICES = ( - (IP_PROTOCOL_TCP, 'TCP'), - (IP_PROTOCOL_UDP, 'UDP'), -) - - @python_2_unicode_compatible class VRF(CreatedUpdatedModel, CustomFieldModel): """ @@ -97,7 +43,7 @@ class VRF(CreatedUpdatedModel, CustomFieldModel): verbose_name_plural = 'VRFs' def __str__(self): - return self.name + return self.display_name or super(VRF, self).__str__() def get_absolute_url(self): return reverse('ipam:vrf', args=[self.pk]) @@ -111,6 +57,12 @@ class VRF(CreatedUpdatedModel, CustomFieldModel): self.description, ]) + @property + def display_name(self): + if self.name and self.rd: + return "{} ({})".format(self.name, self.rd) + return None + @python_2_unicode_compatible class RIR(models.Model): @@ -207,7 +159,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel): Determine the prefix utilization of the aggregate and return it as a percentage. """ queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix)) - child_prefixes = IPSet([p.prefix for p in queryset]) + child_prefixes = netaddr.IPSet([p.prefix for p in queryset]) return int(float(child_prefixes.size) / self.prefix.size * 100) @@ -364,6 +316,29 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): def get_duplicates(self): return Prefix.objects.filter(vrf=self.vrf, prefix=str(self.prefix)).exclude(pk=self.pk) + def get_child_ips(self): + """ + Return all IPAddresses within this Prefix. + """ + return IPAddress.objects.filter(address__net_contained_or_equal=str(self.prefix), vrf=self.vrf) + + def get_available_ips(self): + """ + Return all available IPs within this prefix as an IPSet. + """ + prefix = netaddr.IPSet(self.prefix) + child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]) + available_ips = prefix - child_ips + + # Remove unusable IPs from non-pool prefixes + if not self.is_pool: + available_ips -= netaddr.IPSet([ + netaddr.IPAddress(self.prefix.first), + netaddr.IPAddress(self.prefix.last), + ]) + + return available_ips + def get_utilization(self): """ Determine the utilization of the prefix and return it as a percentage. For Prefixes with a status of @@ -371,7 +346,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): """ if self.status == PREFIX_STATUS_CONTAINER: queryset = Prefix.objects.filter(prefix__net_contained=str(self.prefix), vrf=self.vrf) - child_prefixes = IPSet([p.prefix for p in queryset]) + child_prefixes = netaddr.IPSet([p.prefix for p in queryset]) return int(float(child_prefixes.size) / self.prefix.size * 100) else: child_count = IPAddress.objects.filter( @@ -386,11 +361,11 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): def new_subnet(self): if self.family == 4: if self.prefix.prefixlen <= 30: - return IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1)) + return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1)) return None if self.family == 6: if self.prefix.prefixlen <= 126: - return IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1)) + return netaddr.IPNetwork('{}/{}'.format(self.prefix.network, self.prefix.prefixlen + 1)) return None @@ -425,7 +400,13 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VRF') tenant = models.ForeignKey(Tenant, related_name='ip_addresses', blank=True, null=True, on_delete=models.PROTECT) - status = models.PositiveSmallIntegerField('Status', choices=IPADDRESS_STATUS_CHOICES, default=1) + status = models.PositiveSmallIntegerField( + 'Status', choices=IPADDRESS_STATUS_CHOICES, default=IPADDRESS_STATUS_ACTIVE, + help_text='The operational status of this IP' + ) + role = models.PositiveSmallIntegerField( + 'Role', choices=IPADDRESS_ROLE_CHOICES, blank=True, null=True, help_text='The functional role of this IP' + ) interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True, null=True) nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True, @@ -436,7 +417,9 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): objects = IPAddressManager() - csv_headers = ['address', 'vrf', 'tenant', 'status', 'device', 'interface_name', 'is_primary', 'description'] + csv_headers = [ + 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'interface_name', 'is_primary', 'description', + ] class Meta: ordering = ['family', 'address'] @@ -488,6 +471,7 @@ class IPAddress(CreatedUpdatedModel, CustomFieldModel): self.vrf.rd if self.vrf else None, self.tenant.name if self.tenant else None, self.get_status_display(), + self.get_role_display(), self.device.identifier if self.device else None, self.interface.name if self.interface else None, is_primary, diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index acb29f9e0..65ab5b2e4 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -3,7 +3,7 @@ from __future__ import unicode_literals import django_tables2 as tables from django_tables2.utils import Accessor -from utilities.tables import BaseTable, SearchTable, ToggleColumn +from utilities.tables import BaseTable, ToggleColumn from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF @@ -152,16 +152,6 @@ class VRFTable(BaseTable): fields = ('pk', 'name', 'rd', 'tenant', 'description') -class VRFSearchTable(SearchTable): - name = tables.LinkColumn() - rd = tables.Column(verbose_name='RD') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) - - class Meta(SearchTable.Meta): - model = VRF - fields = ('name', 'rd', 'tenant', 'description') - - # # RIRs # @@ -171,6 +161,14 @@ class RIRTable(BaseTable): name = tables.LinkColumn(verbose_name='Name') is_private = tables.BooleanColumn(verbose_name='Private') aggregate_count = tables.Column(verbose_name='Aggregates') + actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') + + class Meta(BaseTable.Meta): + model = RIR + fields = ('pk', 'name', 'is_private', 'aggregate_count', 'actions') + + +class RIRDetailTable(RIRTable): stats_total = tables.Column(accessor='stats.total', verbose_name='Total', footer=lambda table: sum(r.stats['total'] for r in table.data)) stats_active = tables.Column(accessor='stats.active', verbose_name='Active', @@ -182,12 +180,12 @@ class RIRTable(BaseTable): stats_available = tables.Column(accessor='stats.available', verbose_name='Available', footer=lambda table: sum(r.stats['available'] for r in table.data)) utilization = tables.TemplateColumn(template_code=RIR_UTILIZATION, verbose_name='Utilization') - actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') - class Meta(BaseTable.Meta): - model = RIR - fields = ('pk', 'name', 'is_private', 'aggregate_count', 'stats_total', 'stats_active', 'stats_reserved', - 'stats_deprecated', 'stats_available', 'utilization', 'actions') + class Meta(RIRTable.Meta): + fields = ( + 'pk', 'name', 'is_private', 'aggregate_count', 'stats_total', 'stats_active', 'stats_reserved', + 'stats_deprecated', 'stats_available', 'utilization', 'actions', + ) # @@ -197,24 +195,21 @@ class RIRTable(BaseTable): class AggregateTable(BaseTable): pk = ToggleColumn() prefix = tables.LinkColumn(verbose_name='Aggregate') - child_count = tables.Column(verbose_name='Prefixes') - get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added') class Meta(BaseTable.Meta): model = Aggregate + fields = ('pk', 'prefix', 'rir', 'date_added', 'description') + + +class AggregateDetailTable(AggregateTable): + child_count = tables.Column(verbose_name='Prefixes') + get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') + + class Meta(AggregateTable.Meta): fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description') -class AggregateSearchTable(SearchTable): - prefix = tables.LinkColumn(verbose_name='Aggregate') - date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added') - - class Meta(SearchTable.Meta): - model = Aggregate - fields = ('prefix', 'rir', 'date_added', 'description') - - # # Roles # @@ -241,7 +236,6 @@ class PrefixTable(BaseTable): prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}}) status = tables.TemplateColumn(STATUS_LABEL) vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') - get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') tenant = tables.TemplateColumn(TENANT_LINK) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN') @@ -249,37 +243,17 @@ class PrefixTable(BaseTable): class Meta(BaseTable.Meta): model = Prefix - fields = ('pk', 'prefix', 'status', 'vrf', 'get_utilization', 'tenant', 'site', 'vlan', 'role', 'description') + fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description') row_attrs = { 'class': lambda record: 'success' if not record.pk else '', } -class PrefixBriefTable(BaseTable): - prefix = tables.TemplateColumn(PREFIX_LINK_BRIEF) - vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global') - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) - status = tables.TemplateColumn(STATUS_LABEL) - vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')]) +class PrefixDetailTable(PrefixTable): + get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') - class Meta(BaseTable.Meta): - model = Prefix - fields = ('prefix', 'vrf', 'status', 'site', 'vlan', 'role') - orderable = False - - -class PrefixSearchTable(SearchTable): - prefix = tables.TemplateColumn(PREFIX_LINK, attrs={'th': {'style': 'padding-left: 17px'}}) - status = tables.TemplateColumn(STATUS_LABEL) - vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') - tenant = tables.TemplateColumn(TENANT_LINK) - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) - vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN') - role = tables.TemplateColumn(PREFIX_ROLE_LINK) - - class Meta(SearchTable.Meta): - model = Prefix - fields = ('prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description') + class Meta(PrefixTable.Meta): + fields = ('pk', 'prefix', 'status', 'vrf', 'get_utilization', 'tenant', 'site', 'vlan', 'role', 'description') # @@ -292,43 +266,26 @@ class IPAddressTable(BaseTable): status = tables.TemplateColumn(STATUS_LABEL) vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') tenant = tables.TemplateColumn(TENANT_LINK) - nat_inside = tables.LinkColumn( - 'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)' - ) device = tables.TemplateColumn(IPADDRESS_DEVICE, orderable=False) + interface = tables.Column(orderable=False) class Meta(BaseTable.Meta): model = IPAddress - fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'nat_inside', 'device', 'description') + fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'device', 'interface', 'description') row_attrs = { 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', } -class IPAddressBriefTable(BaseTable): - address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address') - device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False) - interface = tables.Column(orderable=False) +class IPAddressDetailTable(IPAddressTable): nat_inside = tables.LinkColumn( 'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)' ) - class Meta(BaseTable.Meta): - model = IPAddress - fields = ('address', 'device', 'interface', 'nat_inside') - - -class IPAddressSearchTable(SearchTable): - address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address') - status = tables.TemplateColumn(STATUS_LABEL) - vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') - tenant = tables.TemplateColumn(TENANT_LINK) - device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False) - interface = tables.Column(orderable=False) - - class Meta(SearchTable.Meta): - model = IPAddress - fields = ('address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description') + class Meta(IPAddressTable.Meta): + fields = ( + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'device', 'interface', 'description', + ) # @@ -358,24 +315,17 @@ class VLANTable(BaseTable): vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID') site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') - prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) status = tables.TemplateColumn(STATUS_LABEL) role = tables.TemplateColumn(VLAN_ROLE_LINK) class Meta(BaseTable.Meta): model = VLAN + fields = ('pk', 'vid', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description') + + +class VLANDetailTable(VLANTable): + prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes') + + class Meta(VLANTable.Meta): fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description') - - -class VLANSearchTable(SearchTable): - vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID') - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) - group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) - status = tables.TemplateColumn(STATUS_LABEL) - role = tables.TemplateColumn(VLAN_ROLE_LINK) - - class Meta(SearchTable.Meta): - model = VLAN - fields = ('vid', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 0b6814b4a..1a40b95a5 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -367,6 +367,35 @@ class PrefixTest(HttpStatusMixin, APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(Prefix.objects.count(), 2) + def test_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}) + + # Retrieve all available IPs + response = self.client.get(url, **self.header) + self.assertEqual(len(response.data), 8) # 8 because prefix.is_pool = True + + # Change the prefix to not be a pool and try again + prefix.is_pool = False + prefix.save() + 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): + data = { + 'description': 'Test IP {}'.format(i) + } + response = self.client.post(url, data, **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(response.data['description'], data['description']) + + # Try to create one more IP + response = self.client.post(url, {}, **self.header) + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertIn('detail', response.data) + class IPAddressTest(HttpStatusMixin, APITestCase): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 74432d180..05f16aa35 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -103,8 +103,8 @@ class VRFView(View): def get(self, request, pk): vrf = get_object_or_404(VRF.objects.all(), pk=pk) - prefix_table = tables.PrefixBriefTable( - list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role')) + prefix_table = tables.PrefixTable( + list(Prefix.objects.filter(vrf=vrf).select_related('site', 'role')), orderable=False ) prefix_table.exclude = ('vrf',) @@ -142,16 +142,19 @@ class VRFBulkImportView(PermissionRequiredMixin, BulkImportView): class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_vrf' cls = VRF + queryset = VRF.objects.select_related('tenant') filter = filters.VRFFilter + table = tables.VRFTable form = forms.VRFBulkEditForm - template_name = 'ipam/vrf_bulk_edit.html' default_return_url = 'ipam:vrf_list' class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vrf' cls = VRF + queryset = VRF.objects.select_related('tenant') filter = filters.VRFFilter + table = tables.VRFTable default_return_url = 'ipam:vrf_list' @@ -163,7 +166,7 @@ class RIRListView(ObjectListView): queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) filter = filters.RIRFilter filter_form = forms.RIRFilterForm - table = tables.RIRTable + table = tables.RIRDetailTable template_name = 'ipam/rir_list.html' def alter_queryset(self, request): @@ -259,7 +262,9 @@ class RIREditView(RIRCreateView): class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_rir' cls = RIR + queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) filter = filters.RIRFilter + table = tables.RIRTable default_return_url = 'ipam:rir_list' @@ -273,7 +278,7 @@ class AggregateListView(ObjectListView): }) filter = filters.AggregateFilter filter_form = forms.AggregateFilterForm - table = tables.AggregateTable + table = tables.AggregateDetailTable template_name = 'ipam/aggregate_list.html' def extra_context(self): @@ -360,16 +365,19 @@ class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView): class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_aggregate' cls = Aggregate + queryset = Aggregate.objects.select_related('rir') filter = filters.AggregateFilter + table = tables.AggregateTable form = forms.AggregateBulkEditForm - template_name = 'ipam/aggregate_bulk_edit.html' default_return_url = 'ipam:aggregate_list' class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_aggregate' cls = Aggregate + queryset = Aggregate.objects.select_related('rir') filter = filters.AggregateFilter + table = tables.AggregateTable default_return_url = 'ipam:aggregate_list' @@ -399,6 +407,7 @@ class RoleEditView(RoleCreateView): class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_role' cls = Role + table = tables.RoleTable default_return_url = 'ipam:role_list' @@ -410,7 +419,7 @@ class PrefixListView(ObjectListView): queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filter = filters.PrefixFilter filter_form = forms.PrefixFilterForm - table = tables.PrefixTable + table = tables.PrefixDetailTable template_name = 'ipam/prefix_list.html' def alter_queryset(self, request): @@ -445,7 +454,7 @@ class PrefixView(View): ).select_related( 'site', 'role' ).annotate_depth() - parent_prefix_table = tables.PrefixBriefTable(parent_prefixes) + parent_prefix_table = tables.PrefixTable(list(parent_prefixes), orderable=False) parent_prefix_table.exclude = ('vrf',) # Duplicate prefixes table @@ -456,7 +465,7 @@ class PrefixView(View): ).select_related( 'site', 'role' ) - duplicate_prefix_table = tables.PrefixBriefTable(list(duplicate_prefixes)) + duplicate_prefix_table = tables.PrefixTable(list(duplicate_prefixes), orderable=False) duplicate_prefix_table.exclude = ('vrf',) # Child prefixes table @@ -564,16 +573,19 @@ class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView): class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_prefix' cls = Prefix + queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filter = filters.PrefixFilter + table = tables.PrefixTable form = forms.PrefixBulkEditForm - template_name = 'ipam/prefix_bulk_edit.html' default_return_url = 'ipam:prefix_list' class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_prefix' cls = Prefix + queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filter = filters.PrefixFilter + table = tables.PrefixTable default_return_url = 'ipam:prefix_list' @@ -585,7 +597,7 @@ class IPAddressListView(ObjectListView): queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device', 'nat_inside') filter = filters.IPAddressFilter filter_form = forms.IPAddressFilterForm - table = tables.IPAddressTable + table = tables.IPAddressDetailTable template_name = 'ipam/ipaddress_list.html' @@ -601,7 +613,7 @@ class IPAddressView(View): ).select_related( 'site', 'role' ) - parent_prefixes_table = tables.PrefixBriefTable(list(parent_prefixes)) + parent_prefixes_table = tables.PrefixTable(list(parent_prefixes), orderable=False) parent_prefixes_table.exclude = ('vrf',) # Duplicate IPs table @@ -612,7 +624,7 @@ class IPAddressView(View): ).select_related( 'interface__device', 'nat_inside' ) - duplicate_ips_table = tables.IPAddressBriefTable(list(duplicate_ips)) + duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False) # Related IP table related_ips = IPAddress.objects.select_related( @@ -622,7 +634,7 @@ class IPAddressView(View): ).filter( vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address) ) - related_ips_table = tables.IPAddressBriefTable(list(related_ips)) + related_ips_table = tables.IPAddressTable(list(related_ips), orderable=False) return render(request, 'ipam/ipaddress.html', { 'ipaddress': ipaddress, @@ -669,16 +681,19 @@ class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView): class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_ipaddress' cls = IPAddress + queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device') filter = filters.IPAddressFilter + table = tables.IPAddressTable form = forms.IPAddressBulkEditForm - template_name = 'ipam/ipaddress_bulk_edit.html' default_return_url = 'ipam:ipaddress_list' class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_ipaddress' cls = IPAddress + queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device') filter = filters.IPAddressFilter + table = tables.IPAddressTable default_return_url = 'ipam:ipaddress_list' @@ -710,7 +725,9 @@ class VLANGroupEditView(VLANGroupCreateView): class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vlangroup' cls = VLANGroup + queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans')) filter = filters.VLANGroupFilter + table = tables.VLANGroupTable default_return_url = 'ipam:vlangroup_list' @@ -722,7 +739,7 @@ class VLANListView(ObjectListView): queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes') filter = filters.VLANFilter filter_form = forms.VLANFilterForm - table = tables.VLANTable + table = tables.VLANDetailTable template_name = 'ipam/vlan_list.html' @@ -734,7 +751,7 @@ class VLANView(View): 'site__region', 'tenant__group', 'role' ), pk=pk) prefixes = Prefix.objects.filter(vlan=vlan).select_related('vrf', 'site', 'role') - prefix_table = tables.PrefixBriefTable(list(prefixes)) + prefix_table = tables.PrefixTable(list(prefixes), orderable=False) prefix_table.exclude = ('vlan',) return render(request, 'ipam/vlan.html', { @@ -771,16 +788,19 @@ class VLANBulkImportView(PermissionRequiredMixin, BulkImportView): class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_vlan' cls = VLAN + queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.VLANFilter + table = tables.VLANTable form = forms.VLANBulkEditForm - template_name = 'ipam/vlan_bulk_edit.html' default_return_url = 'ipam:vlan_list' class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vlan' cls = VLAN + queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.VLANFilter + table = tables.VLANTable default_return_url = 'ipam:vlan_list' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c413002ab..b3f0b5187 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ except ImportError: ) -VERSION = '2.0.11-dev' +VERSION = '2.1.0-dev' # Import required configuration parameters ALLOWED_HOSTS = DATABASE = SECRET_KEY = None diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 6f642063b..d5224b462 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -11,20 +11,20 @@ from django.views.generic import View from circuits.filters import CircuitFilter, ProviderFilter from circuits.models import Circuit, Provider -from circuits.tables import CircuitSearchTable, ProviderSearchTable +from circuits.tables import CircuitTable, ProviderTable from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site -from dcim.tables import DeviceSearchTable, DeviceTypeSearchTable, RackSearchTable, SiteSearchTable +from dcim.tables import DeviceTable, DeviceTypeTable, RackTable, SiteTable from extras.models import TopologyMap, UserAction from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF -from ipam.tables import AggregateSearchTable, IPAddressSearchTable, PrefixSearchTable, VLANSearchTable, VRFSearchTable +from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable from secrets.filters import SecretFilter from secrets.models import Secret -from secrets.tables import SecretSearchTable +from secrets.tables import SecretTable from tenancy.filters import TenantFilter from tenancy.models import Tenant -from tenancy.tables import TenantSearchTable +from tenancy.tables import TenantTable from .forms import SearchForm @@ -34,83 +34,85 @@ SEARCH_TYPES = OrderedDict(( ('provider', { 'queryset': Provider.objects.all(), 'filter': ProviderFilter, - 'table': ProviderSearchTable, + 'table': ProviderTable, 'url': 'circuits:provider_list', }), ('circuit', { 'queryset': Circuit.objects.select_related('type', 'provider', 'tenant').prefetch_related('terminations__site'), 'filter': CircuitFilter, - 'table': CircuitSearchTable, + 'table': CircuitTable, 'url': 'circuits:circuit_list', }), # DCIM ('site', { 'queryset': Site.objects.select_related('region', 'tenant'), 'filter': SiteFilter, - 'table': SiteSearchTable, + 'table': SiteTable, 'url': 'dcim:site_list', }), ('rack', { 'queryset': Rack.objects.select_related('site', 'group', 'tenant', 'role'), 'filter': RackFilter, - 'table': RackSearchTable, + 'table': RackTable, 'url': 'dcim:rack_list', }), ('devicetype', { 'queryset': DeviceType.objects.select_related('manufacturer'), 'filter': DeviceTypeFilter, - 'table': DeviceTypeSearchTable, + 'table': DeviceTypeTable, 'url': 'dcim:devicetype_list', }), ('device', { - 'queryset': Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack'), + 'queryset': Device.objects.select_related( + 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack' + ), 'filter': DeviceFilter, - 'table': DeviceSearchTable, + 'table': DeviceTable, 'url': 'dcim:device_list', }), # IPAM ('vrf', { 'queryset': VRF.objects.select_related('tenant'), 'filter': VRFFilter, - 'table': VRFSearchTable, + 'table': VRFTable, 'url': 'ipam:vrf_list', }), ('aggregate', { 'queryset': Aggregate.objects.select_related('rir'), 'filter': AggregateFilter, - 'table': AggregateSearchTable, + 'table': AggregateTable, 'url': 'ipam:aggregate_list', }), ('prefix', { 'queryset': Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), 'filter': PrefixFilter, - 'table': PrefixSearchTable, + 'table': PrefixTable, 'url': 'ipam:prefix_list', }), ('ipaddress', { 'queryset': IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device'), 'filter': IPAddressFilter, - 'table': IPAddressSearchTable, + 'table': IPAddressTable, 'url': 'ipam:ipaddress_list', }), ('vlan', { 'queryset': VLAN.objects.select_related('site', 'group', 'tenant', 'role'), 'filter': VLANFilter, - 'table': VLANSearchTable, + 'table': VLANTable, 'url': 'ipam:vlan_list', }), # Secrets ('secret', { 'queryset': Secret.objects.select_related('role', 'device'), 'filter': SecretFilter, - 'table': SecretSearchTable, + 'table': SecretTable, 'url': 'secrets:secret_list', }), # Tenancy ('tenant', { 'queryset': Tenant.objects.select_related('group'), 'filter': TenantFilter, - 'table': TenantSearchTable, + 'table': TenantTable, 'url': 'tenancy:tenant_list', }), )) @@ -189,7 +191,7 @@ class SearchView(View): # Construct the results table for this object type filtered_queryset = filter_cls({'q': form.cleaned_data['q']}, queryset=queryset).qs - table = table(filtered_queryset) + table = table(filtered_queryset, orderable=False) table.paginate(per_page=SEARCH_MAX_RESULTS) if table.page: diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index f725dafa3..573c1c967 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -333,6 +333,31 @@ table.component-list tr.ipaddress:hover td { background-color: #e6f7f7; } +/* AJAX loader */ +.loading { + position: fixed; + display: none; + z-index: 999; + height: 2em; + width: 2em; + overflow: show; + margin: auto; + top: 0; + left: 0; + bottom: 0; + right: 0; +} +.loading:before { + content: ''; + display: block; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0,0,0,0.3); +} + /* Misc */ .banner-bottom { margin-bottom: 50px; diff --git a/netbox/project-static/img/ajax-loader.gif b/netbox/project-static/img/ajax-loader.gif new file mode 100644 index 000000000..24f1e8658 Binary files /dev/null and b/netbox/project-static/img/ajax-loader.gif differ diff --git a/netbox/project-static/js/jquery-3.2.0.min.js b/netbox/project-static/js/jquery-3.2.1.min.js similarity index 50% rename from netbox/project-static/js/jquery-3.2.0.min.js rename to netbox/project-static/js/jquery-3.2.1.min.js index 2ec0d1da0..644d35e27 100644 --- a/netbox/project-static/js/jquery-3.2.0.min.js +++ b/netbox/project-static/js/jquery-3.2.1.min.js @@ -1,4 +1,4 @@ -/*! jQuery v3.2.0 | (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.0",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(" - + + {% block javascript %}{% endblock %} diff --git a/netbox/templates/circuits/circuit_bulk_edit.html b/netbox/templates/circuits/circuit_bulk_edit.html deleted file mode 100644 index 2b03cda2e..000000000 --- a/netbox/templates/circuits/circuit_bulk_edit.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}Circuit Bulk Edit{% endblock %} - -{% block selected_objects_table %} - - Circuit - Type - Provider - Port speed - Commit rate - - {% for circuit in selected_objects %} - - {{ circuit }} - {{ circuit.type }} - {{ circuit.provider }} - {{ circuit.port_speed_human }} - {{ circuit.commit_rate_human }} - - {% endfor %} -{% endblock %} diff --git a/netbox/templates/circuits/provider_bulk_edit.html b/netbox/templates/circuits/provider_bulk_edit.html deleted file mode 100644 index 2e6321a09..000000000 --- a/netbox/templates/circuits/provider_bulk_edit.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}Provider Bulk Edit{% endblock %} - -{% block selected_objects_table %} - - Provider - Account - ASN - - {% for provider in selected_objects %} - - {{ provider }} - {{ provider.account }} - {{ provider.asn }} - - {% endfor %} -{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 9a73fdd18..3bf92fbca 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -7,7 +7,7 @@ {% block content %} {% include 'dcim/inc/device_header.html' with active_tab='info' %}
-
+
Device @@ -214,23 +214,9 @@
- Critical Connections + Console / Power
- {% for iface in mgmt_interfaces %} - {% include 'dcim/inc/interface.html' with icon='wrench' %} - {% empty %} - {% if device.device_type.interface_templates.exists %} - - - - {% endif %} - {% endfor %} {% for cp in console_ports %} {% include 'dcim/inc/consoleport.html' %} {% empty %} @@ -262,11 +248,6 @@
- No management interfaces defined - {% if perms.dcim.add_interface %} - - {% endif %} -
{% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
-
+
{% if device_bays or device.device_type.is_parent_device %} {% if perms.dcim.delete_devicebay %}
diff --git a/netbox/templates/dcim/device_bulk_edit.html b/netbox/templates/dcim/device_bulk_edit.html deleted file mode 100644 index 69109828a..000000000 --- a/netbox/templates/dcim/device_bulk_edit.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}Device Bulk Edit{% endblock %} - -{% block selected_objects_table %} - - Device - Type - Role - Tenant - Serial - - {% for device in selected_objects %} - - {{ device }} - {{ device.device_type.full_name }} - {{ device.device_role }} - {{ device.tenant }} - {{ device.serial }} - - {% endfor %} -{% endblock %} diff --git a/netbox/templates/dcim/device_config.html b/netbox/templates/dcim/device_config.html new file mode 100644 index 000000000..b62ff0211 --- /dev/null +++ b/netbox/templates/dcim/device_config.html @@ -0,0 +1,53 @@ +{% extends '_base.html' %} +{% load staticfiles %} + +{% block title %}{{ device }} - Config{% endblock %} + +{% block content %} + {% include 'inc/ajax_loader.html' %} + {% include 'dcim/inc/device_header.html' with active_tab='config' %} +
+
+
+
Device Configuration
+
+ +
+
+

+                        
+
+

+                        
+
+

+                        
+
+
+
+
+
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/dcim/device_inventory.html b/netbox/templates/dcim/device_inventory.html index cc3dd361b..32b15670c 100644 --- a/netbox/templates/dcim/device_inventory.html +++ b/netbox/templates/dcim/device_inventory.html @@ -51,6 +51,8 @@ Manufacturer Part Number Serial Number + Asset Tag + Description diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html index 2b95b0035..a7346c5db 100644 --- a/netbox/templates/dcim/device_lldp_neighbors.html +++ b/netbox/templates/dcim/device_lldp_neighbors.html @@ -3,64 +3,66 @@ {% block title %}{{ device }} - LLDP Neighbors{% endblock %} {% block content %} -{% include 'dcim/inc/device_header.html' with active_tab='lldp-neighbors' %} -
-
- LLDP Neighbors -
- - - - - - - - - - - - {% for iface in interfaces %} - - - {% if iface.connection %} - {% with iface.connected_interface as connected_iface %} - - - {% endwith %} - {% else %} - - {% endif %} - - + {% include 'inc/ajax_loader.html' %} + {% include 'dcim/inc/device_header.html' with active_tab='lldp-neighbors' %} +
+
+ LLDP Neighbors +
+
InterfaceConfigured DeviceConfigured InterfaceLLDP DeviceLLDP Interface
{{ iface }} - {{ connected_iface.device }} - - {{ connected_iface }} - None
+ + + + + + + - {% endfor %} - -
InterfaceConfigured DeviceConfigured InterfaceLLDP DeviceLLDP Interface
-
+ + + {% for iface in interfaces %} + + {{ iface }} + {% if iface.connection %} + {% with iface.connected_interface as connected_iface %} + + {{ connected_iface.device }} + + + {{ connected_iface }} + + {% endwith %} + {% else %} + None + {% endif %} + + + + {% endfor %} + + +
{% endblock %} {% block javascript %} +{% endblock %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 365ba2057..aac7a0622 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -33,7 +33,7 @@

{{ devicetype.manufacturer }} {{ devicetype.model }}

-
+
Chassis @@ -163,21 +163,20 @@ {% endif %}
+
+
{% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %} {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %} - {% include 'dcim/inc/devicetype_component_table.html' with table=mgmt_interface_table title='Management Interfaces' add_url='dcim:devicetype_add_interface' add_url_extra='?mgmt_only=1' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %} -
-
- {% if devicetype.is_parent_device %} + {% if devicetype.is_parent_device or devicebay_table.rows %} {% include 'dcim/inc/devicetype_component_table.html' with table=devicebay_table title='Device Bays' add_url='dcim:devicetype_add_devicebay' delete_url='dcim:devicetype_delete_devicebay' %} {% endif %} - {% if devicetype.is_network_device %} + {% if devicetype.is_network_device or interface_table.rows %} {% include 'dcim/inc/devicetype_component_table.html' with table=interface_table title='Interfaces' add_url='dcim:devicetype_add_interface' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %} {% endif %} - {% if devicetype.is_console_server %} + {% if devicetype.is_console_server or consoleserverport_table.rows %} {% include 'dcim/inc/devicetype_component_table.html' with table=consoleserverport_table title='Console Server Ports' add_url='dcim:devicetype_add_consoleserverport' delete_url='dcim:devicetype_delete_consoleserverport' %} {% endif %} - {% if devicetype.is_pdu %} + {% if devicetype.is_pdu or poweroutlet_table.rows %} {% include 'dcim/inc/devicetype_component_table.html' with table=poweroutlet_table title='Power Outlets' add_url='dcim:devicetype_add_poweroutlet' delete_url='dcim:devicetype_delete_poweroutlet' %} {% endif %}
diff --git a/netbox/templates/dcim/devicetype_bulk_edit.html b/netbox/templates/dcim/devicetype_bulk_edit.html deleted file mode 100644 index ebeb0b781..000000000 --- a/netbox/templates/dcim/devicetype_bulk_edit.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}Device Type Bulk Edit{% endblock %} - -{% block selected_objects_table %} - - Device type - Manufacturer - Height - - {% for devicetype in selected_objects %} - - {{ devicetype.model }} - {{ devicetype.manufacturer }} - {{ devicetype.u_height }}U - - {% endfor %} -{% endblock %} diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index 114f434c1..34c08af94 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -1,13 +1,7 @@ - {% if selectable and perms.dcim.change_consoleport or perms.dcim.delete_consoleport %} - - - - {% endif %} {{ cp.name }} - {% if cp.cs_port %} {{ cp.cs_port.device }} @@ -20,7 +14,7 @@ Not connected {% endif %} - + {% if perms.dcim.change_consoleport %} {% if cp.cs_port %} {% if cp.connection_status %} diff --git a/netbox/templates/dcim/inc/device_header.html b/netbox/templates/dcim/inc/device_header.html index 6861abf4a..37c5e715a 100644 --- a/netbox/templates/dcim/inc/device_header.html +++ b/netbox/templates/dcim/inc/device_header.html @@ -45,7 +45,15 @@ diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index ea6b29cb9..75d0f027d 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -1,11 +1,12 @@ - + {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %} {% endif %} - {{ iface.name }} + + {{ iface.name }} {% if iface.lag %} {{ iface.lag.name }} {% endif %} @@ -13,6 +14,7 @@ {% endif %} + {{ iface.mtu|default:"" }} {{ iface.mac_address|default:"" }} {% if iface.is_lag %} @@ -21,6 +23,8 @@ {% elif iface.is_virtual %} Virtual interface + {% elif iface.is_wireless %} + Wireless interface {% elif iface.connection %} {% with iface.connected_interface as connected_iface %} @@ -114,7 +118,7 @@ {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %} {% endif %} - + {{ ip }} {% if ip.description %} diff --git a/netbox/templates/dcim/inc/inventoryitem.html b/netbox/templates/dcim/inc/inventoryitem.html index 6aa77d1c2..8bc3149b1 100644 --- a/netbox/templates/dcim/inc/inventoryitem.html +++ b/netbox/templates/dcim/inc/inventoryitem.html @@ -1,9 +1,11 @@ {{ item.name }} {% if not item.discovered %}{% endif %} - {{ item.manufacturer|default:'' }} + {{ item.manufacturer|default:"" }} {{ item.part_id }} {{ item.serial }} + {{ item.asset_tag|default:"" }} + {{ item.description }} {% if perms.dcim.change_inventoryitem %} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index 63da4ad84..06ec9da75 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -1,13 +1,7 @@ - {% if selectable and perms.dcim.change_powerport or perms.dcim.delete_powerport %} - - - - {% endif %} {{ pp.name }} - {% if pp.power_outlet %} {{ pp.power_outlet.device }} @@ -20,7 +14,7 @@ Not connected {% endif %} - + {% if perms.dcim.change_powerport %} {% if pp.power_outlet %} {% if pp.connection_status %} diff --git a/netbox/templates/dcim/interface_bulk_edit.html b/netbox/templates/dcim/interface_bulk_edit.html deleted file mode 100644 index 7b25abb4d..000000000 --- a/netbox/templates/dcim/interface_bulk_edit.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}Interface Bulk Edit{% endblock %} - -{% block selected_objects_table %} - - Name - Form Factor - - {% for iface in selected_objects %} - - {{ iface.name }} - {{ iface.get_form_factor_display }} - - {% endfor %} -{% endblock %} diff --git a/netbox/templates/dcim/interfacetemplate_bulk_edit.html b/netbox/templates/dcim/interfacetemplate_bulk_edit.html deleted file mode 100644 index 494d35fb6..000000000 --- a/netbox/templates/dcim/interfacetemplate_bulk_edit.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}Interface Template Bulk Edit{% endblock %} - -{% block selected_objects_table %} - - Name - Form Factor - Management - - {% for iface in selected_objects %} - - {{ iface.name }} - {{ iface.get_form_factor_display }} - - {% if iface.mgmt_only %} - - {% else %} - - {% endif %} - - - {% endfor %} -{% endblock %} diff --git a/netbox/templates/dcim/rack_bulk_edit.html b/netbox/templates/dcim/rack_bulk_edit.html deleted file mode 100644 index d1a8dac0e..000000000 --- a/netbox/templates/dcim/rack_bulk_edit.html +++ /dev/null @@ -1,29 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}Rack Bulk Edit{% endblock %} - -{% block selected_objects_table %} - - Name - Site - Group - Tenant - Role - Type - Width - Height - - {% for rack in selected_objects %} - - {{ rack }} - {{ rack.site }} - {{ rack.group }} - {{ rack.tenant }} - {{ rack.role }} - {{ rack.get_type_display }} - {{ rack.get_width_display }} - {{ rack.u_height }}U - - {% endfor %} -{% endblock %} diff --git a/netbox/templates/dcim/site_bulk_edit.html b/netbox/templates/dcim/site_bulk_edit.html deleted file mode 100644 index 34523bd82..000000000 --- a/netbox/templates/dcim/site_bulk_edit.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}Site Bulk Edit{% endblock %} - -{% block selected_objects_table %} - - Site - Tenant - - {% for site in selected_objects %} - - {{ site }} - {{ site.tenant }} - - {% endfor %} -{% endblock %} diff --git a/netbox/templates/inc/ajax_loader.html b/netbox/templates/inc/ajax_loader.html new file mode 100644 index 000000000..b5b3ee2c1 --- /dev/null +++ b/netbox/templates/inc/ajax_loader.html @@ -0,0 +1,4 @@ +{% load staticfiles %} +
+ +
diff --git a/netbox/templates/ipam/aggregate_bulk_edit.html b/netbox/templates/ipam/aggregate_bulk_edit.html deleted file mode 100644 index b5c09e48f..000000000 --- a/netbox/templates/ipam/aggregate_bulk_edit.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}Aggregate Bulk Edit{% endblock %} - -{% block selected_objects_table %} - - Aggregate - RIR - Date Added - Description - - {% for aggregate in selected_objects %} - - {{ aggregate }} - {{ aggregate.rir }} - {{ aggregate.date_added }} - {{ aggregate.description }} - - {% endfor %} -{% endblock %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index e6dd489df..44c5ec5ff 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -82,6 +82,12 @@ {{ ipaddress.get_status_display }} + + Role + + {{ ipaddress.get_role_display }} + + Description diff --git a/netbox/templates/ipam/ipaddress_bulk_add.html b/netbox/templates/ipam/ipaddress_bulk_add.html index 668f495eb..78406a3f2 100644 --- a/netbox/templates/ipam/ipaddress_bulk_add.html +++ b/netbox/templates/ipam/ipaddress_bulk_add.html @@ -14,6 +14,7 @@
{% render_field pattern_form.pattern %} {% render_field model_form.status %} + {% render_field model_form.role %} {% render_field model_form.vrf %} {% render_field model_form.description %}
diff --git a/netbox/templates/ipam/ipaddress_bulk_edit.html b/netbox/templates/ipam/ipaddress_bulk_edit.html deleted file mode 100644 index 7dc0f6d1a..000000000 --- a/netbox/templates/ipam/ipaddress_bulk_edit.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}IP Address Bulk Edit{% endblock %} - -{% block selected_objects_table %} - - IP Address - VRF - Tenant - Status - Assigned - Description - - {% for ipaddress in selected_objects %} - - {{ ipaddress }} - {{ ipaddress.vrf|default:"Global" }} - {{ ipaddress.tenant }} - {{ ipaddress.get_status_display }} - {% if ipaddress.interface %}{% endif %} - {{ ipaddress.description }} - - {% endfor %} -{% endblock %} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html index 64dc22353..5a625e03c 100644 --- a/netbox/templates/ipam/ipaddress_edit.html +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -14,6 +14,7 @@
{% render_field form.address %} {% render_field form.status %} + {% render_field form.role %} {% render_field form.vrf %} {% render_field form.description %}
diff --git a/netbox/templates/ipam/prefix_bulk_edit.html b/netbox/templates/ipam/prefix_bulk_edit.html deleted file mode 100644 index b1b915b0e..000000000 --- a/netbox/templates/ipam/prefix_bulk_edit.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}Prefix Bulk Edit{% endblock %} - -{% block selected_objects_table %} - - Prefix - Site - VRF - Tenant - Status - Role - - {% for prefix in selected_objects %} - - {{ prefix }} - {{ prefix.site }} - {{ prefix.vrf|default:"Global" }} - {{ prefix.tenant }} - {{ prefix.get_status_display }} - {{ prefix.role }} - - {% endfor %} -{% endblock %} diff --git a/netbox/templates/ipam/vlan_bulk_edit.html b/netbox/templates/ipam/vlan_bulk_edit.html deleted file mode 100644 index 4829bc3cc..000000000 --- a/netbox/templates/ipam/vlan_bulk_edit.html +++ /dev/null @@ -1,25 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}VLAN Bulk Edit{% endblock %} - -{% block selected_objects_table %} - - VLAN - Site - Group - Tenant - Status - Role - - {% for vlan in selected_objects %} - - {{ vlan }} - {{ vlan.site }} - {{ vlan.group }} - {{ vlan.tenant }} - {{ vlan.get_status_display }} - {{ vlan.role }} - - {% endfor %} -{% endblock %} diff --git a/netbox/templates/ipam/vrf_bulk_edit.html b/netbox/templates/ipam/vrf_bulk_edit.html deleted file mode 100644 index 7d57ca39d..000000000 --- a/netbox/templates/ipam/vrf_bulk_edit.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}VRF Bulk Edit{% endblock %} - -{% block selected_objects_table %} - - VRF - RD - Tenant - Description - - {% for vrf in selected_objects %} - - {{ vrf.name }} - {{ vrf.rd }} - {{ vrf.tenant }} - {{ vrf.description }} - - {% endfor %} -{% endblock %} diff --git a/netbox/templates/secrets/secret_bulk_edit.html b/netbox/templates/secrets/secret_bulk_edit.html deleted file mode 100644 index 6c64b09dd..000000000 --- a/netbox/templates/secrets/secret_bulk_edit.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}Secret Bulk Edit{% endblock %} - -{% block selected_objects_table %} - - Device - Role - Name - - {% for secret in selected_objects %} - - {{ secret.device }} - {{ secret.role }} - {{ secret.name }} - - {% endfor %} -{% endblock %} diff --git a/netbox/templates/tenancy/tenant_bulk_edit.html b/netbox/templates/tenancy/tenant_bulk_edit.html deleted file mode 100644 index 25b08ad10..000000000 --- a/netbox/templates/tenancy/tenant_bulk_edit.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'utilities/bulk_edit_form.html' %} -{% load form_helpers %} - -{% block title %}Tenant Bulk Edit{% endblock %} - -{% block selected_objects_table %} - - Tenant - Group - - {% for tenant in selected_objects %} - - {{ tenant }} - {{ tenant.group }} - - {% endfor %} -{% endblock %} diff --git a/netbox/templates/utilities/confirm_bulk_delete.html b/netbox/templates/utilities/confirm_bulk_delete.html deleted file mode 100644 index 6f3100cba..000000000 --- a/netbox/templates/utilities/confirm_bulk_delete.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends 'utilities/confirmation_form.html' %} -{% load form_helpers %} - -{% block title %}Delete {{ obj_type_plural|default:"objects" }}?{% endblock %} - -{% block message %} -

- Are you sure you want to delete these {{ selected_objects|length }} {{ obj_type_plural|default:"objects" }}{% if parent_obj %} from {{ parent_obj }}{% endif %}? -

-
    - {% for obj in selected_objects %} - {% if obj.get_absolute_url %} -
  • {{ obj }}
  • - {% else %} -
  • {{ obj }}
  • - {% endif %} - {% endfor %} -
-{% endblock %} diff --git a/netbox/templates/utilities/confirmation_form.html b/netbox/templates/utilities/confirmation_form.html index 16383d6f7..9f3f4b8e6 100644 --- a/netbox/templates/utilities/confirmation_form.html +++ b/netbox/templates/utilities/confirmation_form.html @@ -5,22 +5,14 @@
- {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
{% block title %}{% endblock %}
{% block message %}

Are you sure?

{% endblock %} -
-
- -
-
Cancel diff --git a/netbox/templates/utilities/obj_bulk_delete.html b/netbox/templates/utilities/obj_bulk_delete.html new file mode 100644 index 000000000..5ec26a9c8 --- /dev/null +++ b/netbox/templates/utilities/obj_bulk_delete.html @@ -0,0 +1,38 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}Delete {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?{% endblock %} + +{% block content %} +
+
+
+
Confirm Bulk Deletion
+
+ Warning: The following operation will delete {{ table.rows|length }} {{ obj_type_plural }}. Please carefully review the {{ obj_type_plural }} to be deleted and confirm below. +
+
+
+
+
+
+
+ {% include 'inc/table.html' %} +
+
+
+
+
+ + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
+ + Cancel +
+ +
+
+{% endblock %} diff --git a/netbox/templates/utilities/bulk_edit_form.html b/netbox/templates/utilities/obj_bulk_edit.html similarity index 80% rename from netbox/templates/utilities/bulk_edit_form.html rename to netbox/templates/utilities/obj_bulk_edit.html index 3e3bbc187..e2c501aba 100644 --- a/netbox/templates/utilities/bulk_edit_form.html +++ b/netbox/templates/utilities/obj_bulk_edit.html @@ -1,8 +1,9 @@ {% extends '_base.html' %} +{% load helpers %} {% load form_helpers %} {% block content %} -

{% block title %}{% endblock %}

+

{% block title %}Editing {{ table.rows|length }} {{ obj_type_plural|bettertitle }}{% endblock %}

{% csrf_token %} {% if request.POST.return_url %} @@ -12,15 +13,12 @@ {{ field }} {% endfor %}
-
+
-
{% block selected_objects_title %}{{ selected_objects|length }} Selected For Editing{% endblock %}
- - {% block selected_objects_table %}{% endblock %} -
+ {% include 'inc/table.html' %}
-
+
{% if form.non_field_errors %}
Errors
diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 712d524c5..ef5b15a16 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -4,13 +4,14 @@ from rest_framework import serializers from extras.api.customfields import CustomFieldModelSerializer from tenancy.models import Tenant, TenantGroup +from utilities.api import ModelValidationMixin # # Tenant groups # -class TenantGroupSerializer(serializers.ModelSerializer): +class TenantGroupSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = TenantGroup diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index e5105f338..3c930bf73 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -3,8 +3,8 @@ from __future__ import unicode_literals from rest_framework.viewsets import ModelViewSet from extras.api.views import CustomFieldModelViewSet +from tenancy import filters from tenancy.models import Tenant, TenantGroup -from tenancy.filters import TenantFilter from utilities.api import WritableSerializerMixin from . import serializers @@ -16,6 +16,7 @@ from . import serializers class TenantGroupViewSet(ModelViewSet): queryset = TenantGroup.objects.all() serializer_class = serializers.TenantGroupSerializer + filter_class = filters.TenantGroupFilter # @@ -26,4 +27,4 @@ class TenantViewSet(WritableSerializerMixin, CustomFieldModelViewSet): queryset = Tenant.objects.select_related('group') serializer_class = serializers.TenantSerializer write_serializer_class = serializers.WritableTenantSerializer - filter_class = TenantFilter + filter_class = filters.TenantFilter diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 4ded4f0c4..630e936e4 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -9,6 +9,13 @@ from utilities.filters import NullableModelMultipleChoiceFilter, NumericInFilter from .models import Tenant, TenantGroup +class TenantGroupFilter(django_filters.FilterSet): + + class Meta: + model = TenantGroup + fields = ['name', 'slug'] + + class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( @@ -16,7 +23,6 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) group_id = NullableModelMultipleChoiceFilter( - name='group', queryset=TenantGroup.objects.all(), label='Group (ID)', ) diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 9950abfc2..969050841 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -102,7 +102,7 @@ class TenancyForm(ChainedFieldsMixin, forms.Form): # Initialize helper selector instance = kwargs.get('instance') if instance and instance.tenant is not None: - initial = kwargs.get('initial', {}) + initial = kwargs.get('initial', {}).copy() initial['tenant_group'] = instance.tenant.group kwargs['initial'] = initial diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 9941e269a..4ef774fb6 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -2,7 +2,7 @@ from __future__ import unicode_literals import django_tables2 as tables -from utilities.tables import BaseTable, SearchTable, ToggleColumn +from utilities.tables import BaseTable, ToggleColumn from .models import Tenant, TenantGroup @@ -43,11 +43,3 @@ class TenantTable(BaseTable): class Meta(BaseTable.Meta): model = Tenant fields = ('pk', 'name', 'group', 'description') - - -class TenantSearchTable(SearchTable): - name = tables.LinkColumn() - - class Meta(SearchTable.Meta): - model = Tenant - fields = ('name', 'group', 'description') diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index d151ff5ca..e176075cb 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -42,6 +42,8 @@ class TenantGroupEditView(TenantGroupCreateView): class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'tenancy.delete_tenantgroup' cls = TenantGroup + queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants')) + table = tables.TenantGroupTable default_return_url = 'tenancy:tenantgroup_list' @@ -113,14 +115,17 @@ class TenantBulkImportView(PermissionRequiredMixin, BulkImportView): class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'tenancy.change_tenant' cls = Tenant + queryset = Tenant.objects.select_related('group') filter = filters.TenantFilter + table = tables.TenantTable form = forms.TenantBulkEditForm - template_name = 'tenancy/tenant_bulk_edit.html' default_return_url = 'tenancy:tenant_list' class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'tenancy.delete_tenant' cls = Tenant + queryset = Tenant.objects.select_related('group') filter = filters.TenantFilter + table = tables.TenantTable default_return_url = 'tenancy:tenant_list' diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 6fcfc6949..5774584a6 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -98,6 +98,17 @@ class ContentTypeFieldSerializer(Field): raise ValidationError("Invalid content type") +class ModelValidationMixin(object): + """ + Enforce a model's validation through clean() when validating serializer data. This is necessary to ensure we're + employing the same validation logic via both forms and the API. + """ + def validate(self, attrs): + instance = self.Meta.model(**attrs) + instance.clean() + return attrs + + class WritableSerializerMixin(object): """ Allow for the use of an alternate, writable serializer class for write operations (e.g. POST, PUT). diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index bb42b7315..0fa402d52 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -510,7 +510,7 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm): """ A generic confirmation form. The form is not valid unless the confirm field is checked. """ - confirm = forms.BooleanField(required=True) + confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True) class BulkEditForm(forms.Form): diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index 1dd8969a1..579280b64 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -16,21 +16,10 @@ class BaseTable(tables.Table): if self.empty_text is None: self.empty_text = 'No {} found.'.format(self._meta.model._meta.verbose_name_plural) - class Meta: - attrs = { - 'class': 'table table-hover', - } - - -class SearchTable(tables.Table): - """ - Default table for search results - """ class Meta: attrs = { 'class': 'table table-hover table-headings', } - orderable = False class ToggleColumn(tables.CheckBoxColumn): diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 3dec16e7a..1ec21b628 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -461,7 +461,9 @@ class BulkEditView(View): cls: The model of the objects being edited parent_cls: The model of the parent object (if any) + queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) filter: FilterSet to apply when deleting by QuerySet + table: The table used to display devices being edited form: The form class used to edit objects in bulk template_name: The name of the template default_return_url: Name of the URL to which the user is redirected after editing the objects (can be overridden by @@ -469,9 +471,11 @@ class BulkEditView(View): """ cls = None parent_cls = None + queryset = None filter = None + table = None form = None - template_name = None + template_name = 'utilities/obj_bulk_edit.html' default_return_url = 'home' def get(self): @@ -537,14 +541,17 @@ class BulkEditView(View): initial_data['pk'] = pk_list form = self.form(self.cls, initial=initial_data) - selected_objects = self.cls.objects.filter(pk__in=pk_list) - if not selected_objects: + # Retrieve objects being edited + queryset = self.queryset or self.cls.objects.all() + table = self.table(queryset.filter(pk__in=pk_list), orderable=False) + if not table.rows: messages.warning(request, "No {} were selected.".format(self.cls._meta.verbose_name_plural)) return redirect(return_url) return render(request, self.template_name, { 'form': form, - 'selected_objects': selected_objects, + 'table': table, + 'obj_type_plural': self.cls._meta.verbose_name_plural, 'return_url': return_url, }) @@ -602,7 +609,9 @@ class BulkDeleteView(View): cls: The model of the objects being deleted parent_cls: The model of the parent object (if any) + queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) filter: FilterSet to apply when deleting by QuerySet + table: The table used to display devices being deleted form: The form class used to delete objects in bulk template_name: The name of the template default_return_url: Name of the URL to which the user is redirected after deleting the objects (can be overriden by @@ -610,9 +619,11 @@ class BulkDeleteView(View): """ cls = None parent_cls = None + queryset = None filter = None + table = None form = None - template_name = 'utilities/confirm_bulk_delete.html' + template_name = 'utilities/obj_bulk_delete.html' default_return_url = 'home' def post(self, request, **kwargs): @@ -660,8 +671,10 @@ class BulkDeleteView(View): else: form = form_cls(initial={'pk': pk_list, 'return_url': return_url}) - selected_objects = self.cls.objects.filter(pk__in=pk_list) - if not selected_objects: + # Retrieve objects being deleted + queryset = self.queryset or self.cls.objects.all() + table = self.table(queryset.filter(pk__in=pk_list), orderable=False) + if not table.rows: messages.warning(request, "No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural)) return redirect(return_url) @@ -669,7 +682,7 @@ class BulkDeleteView(View): 'form': form, 'parent_obj': parent_obj, 'obj_type_plural': self.cls._meta.verbose_name_plural, - 'selected_objects': selected_objects, + 'table': table, 'return_url': return_url, })