From cd263484c351bdd09fe36b56ff05e820eb03fa03 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Jun 2017 14:34:14 -0400 Subject: [PATCH 01/38] Fixes #1079: Order interfaces naturally via API --- netbox/dcim/filters.py | 24 +++++++++++++++++++++--- netbox/dcim/models.py | 11 +++++------ 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 93a325d98..e418d169d 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -433,12 +433,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): class DeviceComponentFilterSet(django_filters.FilterSet): - device_id = django_filters.ModelMultipleChoiceFilter( + device_id = django_filters.ModelChoiceFilter( name='device', queryset=Device.objects.all(), label='Device (ID)', ) - device = django_filters.ModelMultipleChoiceFilter( + device = django_filters.ModelChoiceFilter( name='device__name', queryset=Device.objects.all(), to_field_name='name', @@ -474,7 +474,17 @@ class PowerOutletFilter(DeviceComponentFilterSet): fields = ['name'] -class InterfaceFilter(DeviceComponentFilterSet): +class InterfaceFilter(django_filters.FilterSet): + 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', @@ -493,6 +503,14 @@ class InterfaceFilter(DeviceComponentFilterSet): model = Interface fields = ['name', 'form_factor'] + 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': diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 6411c6bff..dbfe95519 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -816,7 +816,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 +841,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), @@ -867,7 +866,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'] @@ -1317,7 +1316,7 @@ class Interface(models.Model): 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'] From 4d7f9c42c8e56c002bfe1ce8d9c05caf02b91028 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Jun 2017 14:55:59 -0400 Subject: [PATCH 02/38] Version bump for v2.1 --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e6e6327b6..b3f0b5187 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ except ImportError: ) -VERSION = '2.0.7-dev' +VERSION = '2.1.0-dev' # Import required configuration parameters ALLOWED_HOSTS = DATABASE = SECRET_KEY = None From 8bcd8c404d44dd9ca7f0916d8f61fe95c1ed04ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Jun 2017 15:00:27 -0400 Subject: [PATCH 03/38] Closes #1141: Include VRF name and RD in form selections --- netbox/ipam/api/serializers.py | 2 +- netbox/ipam/models.py | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index f4493719f..8a618fcb1 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -22,7 +22,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): diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 89ee0facc..d3ed9addd 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -97,7 +97,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 +111,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): From f427c00d946a6253ce7b4fe9997d8dfc4c05d664 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Jun 2017 16:11:13 -0400 Subject: [PATCH 04/38] Closes #819: Implemented IP address functional roles --- netbox/ipam/api/serializers.py | 12 ++++--- netbox/ipam/filters.py | 7 +++-- netbox/ipam/forms.py | 26 ++++++++++++---- .../ipam/migrations/0017_ipaddress_roles.py | 25 +++++++++++++++ netbox/ipam/models.py | 31 +++++++++++++++++-- netbox/ipam/tables.py | 4 +-- netbox/templates/ipam/ipaddress.html | 6 ++++ netbox/templates/ipam/ipaddress_bulk_add.html | 1 + netbox/templates/ipam/ipaddress_edit.html | 1 + 9 files changed, 96 insertions(+), 17 deletions(-) create mode 100644 netbox/ipam/migrations/0017_ipaddress_roles.py diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 8a618fcb1..e02e384ed 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -6,8 +6,8 @@ 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 @@ -236,12 +236,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 +262,10 @@ 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', + ] # diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 11c19b7ee..a4532edb4 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, ) @@ -247,6 +247,9 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): status = django_filters.MultipleChoiceFilter( choices=IPADDRESS_STATUS_CHOICES ) + role = django_filters.MultipleChoiceFilter( + choices=IPADDRESS_ROLE_CHOICES + ) class Meta: model = IPAddress diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index e3fe96c4c..66d563b2c 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -13,8 +13,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, ) @@ -477,7 +477,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', ] @@ -555,7 +555,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) @@ -585,6 +585,11 @@ class IPAddressCSVForm(forms.ModelForm): choices=PREFIX_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, @@ -605,7 +610,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): @@ -651,10 +656,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(): @@ -664,6 +670,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') @@ -684,6 +697,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..6ad44c146 --- /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-14 19:52 +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, 'Virtual'), (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 d3ed9addd..bc1e8fe76 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -47,6 +47,23 @@ IPADDRESS_STATUS_CHOICES = ( (IPADDRESS_STATUS_DHCP, 'DHCP') ) +IPADDRESS_ROLE_LOOPBACK = 10 +IPADDRESS_ROLE_SECONDARY = 20 +IPADDRESS_ROLE_ANYCAST = 30 +IPADDRESS_ROLE_VIRTUAL = 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_VIRTUAL, 'Virtual'), + (IPADDRESS_ROLE_VRRP, 'VRRP'), + (IPADDRESS_ROLE_HSRP, 'HSRP'), + (IPADDRESS_ROLE_GLBP, 'GLBP'), +) + VLAN_STATUS_ACTIVE = 1 VLAN_STATUS_RESERVED = 2 VLAN_STATUS_DEPRECATED = 3 @@ -65,7 +82,6 @@ STATUS_CHOICE_CLASSES = { 5: 'success', } - IP_PROTOCOL_TCP = 6 IP_PROTOCOL_UDP = 17 IP_PROTOCOL_CHOICES = ( @@ -427,7 +443,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, @@ -438,7 +460,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'] @@ -490,6 +514,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 767bd2cec..bfdab9319 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -299,7 +299,7 @@ class IPAddressTable(BaseTable): class Meta(BaseTable.Meta): model = IPAddress - fields = ('pk', 'address', 'status', 'vrf', 'tenant', 'nat_inside', 'device', 'description') + fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'device', 'description') row_attrs = { 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', } @@ -328,7 +328,7 @@ class IPAddressSearchTable(SearchTable): class Meta(SearchTable.Meta): model = IPAddress - fields = ('address', 'status', 'vrf', 'tenant', 'device', 'interface', 'description') + fields = ('address', 'vrf', 'status', 'role', 'tenant', 'device', 'interface', 'description') # 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_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 %}
From 421270f4a65a282ba8b7ccea277005612b96d942 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Jun 2017 15:37:46 -0400 Subject: [PATCH 05/38] Renamed IP address status 'virtual' to 'VIP' --- netbox/ipam/migrations/0017_ipaddress_roles.py | 4 ++-- netbox/ipam/models.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/netbox/ipam/migrations/0017_ipaddress_roles.py b/netbox/ipam/migrations/0017_ipaddress_roles.py index 6ad44c146..d91c3daa9 100644 --- a/netbox/ipam/migrations/0017_ipaddress_roles.py +++ b/netbox/ipam/migrations/0017_ipaddress_roles.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.1 on 2017-06-14 19:52 +# Generated by Django 1.11.1 on 2017-06-16 19:37 from __future__ import unicode_literals from django.db import migrations, models @@ -15,7 +15,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='ipaddress', name='role', - field=models.PositiveSmallIntegerField(blank=True, choices=[(10, 'Loopback'), (20, 'Secondary'), (30, 'Anycast'), (40, 'Virtual'), (41, 'VRRP'), (42, 'HSRP'), (43, 'GLBP')], help_text='The functional role of this IP', null=True, verbose_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', diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index bc1e8fe76..dd1de8622 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -50,7 +50,7 @@ IPADDRESS_STATUS_CHOICES = ( IPADDRESS_ROLE_LOOPBACK = 10 IPADDRESS_ROLE_SECONDARY = 20 IPADDRESS_ROLE_ANYCAST = 30 -IPADDRESS_ROLE_VIRTUAL = 40 +IPADDRESS_ROLE_VIP = 40 IPADDRESS_ROLE_VRRP = 41 IPADDRESS_ROLE_HSRP = 42 IPADDRESS_ROLE_GLBP = 43 @@ -58,7 +58,7 @@ IPADDRESS_ROLE_CHOICES = ( (IPADDRESS_ROLE_LOOPBACK, 'Loopback'), (IPADDRESS_ROLE_SECONDARY, 'Secondary'), (IPADDRESS_ROLE_ANYCAST, 'Anycast'), - (IPADDRESS_ROLE_VIRTUAL, 'Virtual'), + (IPADDRESS_ROLE_VIP, 'VIP'), (IPADDRESS_ROLE_VRRP, 'VRRP'), (IPADDRESS_ROLE_HSRP, 'HSRP'), (IPADDRESS_ROLE_GLBP, 'GLBP'), From ceb8fee0cc4cd31f1dbeab229c874ff84153e477 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Jun 2017 16:01:44 -0400 Subject: [PATCH 06/38] Moved constant definitions from models.py to constants.py --- netbox/circuits/constants.py | 10 ++ netbox/circuits/models.py | 9 +- netbox/dcim/constants.py | 205 +++++++++++++++++++++++++++++++++++ netbox/dcim/models.py | 196 +-------------------------------- netbox/extras/constants.py | 62 +++++++++++ netbox/extras/models.py | 57 +--------- netbox/ipam/constants.py | 78 +++++++++++++ netbox/ipam/models.py | 71 +----------- 8 files changed, 359 insertions(+), 329 deletions(-) create mode 100644 netbox/circuits/constants.py create mode 100644 netbox/dcim/constants.py create mode 100644 netbox/extras/constants.py create mode 100644 netbox/ipam/constants.py 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/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/dcim/constants.py b/netbox/dcim/constants.py new file mode 100644 index 000000000..01e146e3e --- /dev/null +++ b/netbox/dcim/constants.py @@ -0,0 +1,205 @@ +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 +# 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, +] + +# 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/models.py b/netbox/dcim/models.py index dbfe95519..6891e1911 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 # 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/models.py b/netbox/extras/models.py index ade251c94..8ee0fa3a3 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/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/models.py b/netbox/ipam/models.py index dd1de8622..57ad939ed 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -17,79 +17,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') -) - -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_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): """ From 789ac5dfd4b881c2a2738ffb932e70f826ffbee2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Jun 2017 17:13:33 -0400 Subject: [PATCH 07/38] Combined mgmt and non-mgmt interfaces into same list on device and device type views --- netbox/dcim/tables.py | 3 ++- netbox/dcim/views.py | 26 ++++++---------------- netbox/templates/dcim/device.html | 25 +++------------------ netbox/templates/dcim/devicetype.html | 15 ++++++------- netbox/templates/dcim/inc/consoleport.html | 8 +------ netbox/templates/dcim/inc/interface.html | 3 ++- netbox/templates/dcim/inc/powerport.html | 8 +------ 7 files changed, 23 insertions(+), 65 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index c9a5ecdc9..626bc9e7a 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -368,10 +368,11 @@ class PowerOutletTemplateTable(BaseTable): 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 diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e6b77cb59..c257129a7 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -573,15 +573,10 @@ class DeviceTypeView(View): 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 - )) - ) 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)) ) devicebay_table = tables.DeviceBayTemplateTable( natsorted(DeviceBayTemplate.objects.filter(device_type=devicetype), key=attrgetter('name')) @@ -591,7 +586,6 @@ class DeviceTypeView(View): 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 +595,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, }) @@ -835,14 +828,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 +862,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, diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index a6e5d1dbe..a4672f6d0 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/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/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index 58f5fa7de..8216e291d 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/interface.html b/netbox/templates/dcim/inc/interface.html index 86e480710..352574128 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -5,7 +5,8 @@ {% endif %} - {{ iface.name }} + + {{ iface.name }} {% if iface.lag %} {{ iface.lag.name }} {% endif %} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index ce4ac6967..4665246c7 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 %} From 68ebe85a98528b6db0582b0fc49de968dbd6198b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 16 Jun 2017 17:52:09 -0400 Subject: [PATCH 08/38] Closes #1218: Added IEEE 802.11 wireless interface types --- netbox/circuits/forms.py | 4 +-- netbox/dcim/constants.py | 27 ++++++++++++++++++- netbox/dcim/filters.py | 18 ++++++------- netbox/dcim/forms.py | 8 +++--- .../migrations/0038_wireless_interfaces.py | 25 +++++++++++++++++ netbox/dcim/models.py | 19 ++++++++++--- netbox/templates/dcim/inc/interface.html | 4 ++- 7 files changed, 83 insertions(+), 22 deletions(-) create mode 100644 netbox/dcim/migrations/0038_wireless_interfaces.py diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 89f7a598f..817ff47de 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=( diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 01e146e3e..f2c047910 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -66,6 +66,12 @@ 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 @@ -117,6 +123,16 @@ IFACE_FF_CHOICES = [ [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', [ @@ -134,7 +150,6 @@ IFACE_FF_CHOICES = [ [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)'], ] ], [ @@ -160,6 +175,16 @@ VIRTUAL_IFACE_TYPES = [ 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 diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index e418d169d..66913f182 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -11,8 +11,9 @@ 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, ) @@ -513,13 +514,12 @@ class InterfaceFilter(django_filters.FilterSet): 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() diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e05ffec50..6b0bfec1d 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, ) @@ -1574,7 +1574,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=( @@ -1596,9 +1596,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 = [ 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/models.py b/netbox/dcim/models.py index 6891e1911..b6d345b98 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -661,6 +661,13 @@ class InterfaceQuerySet(models.QuerySet): '_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): @@ -1134,10 +1141,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 @@ -1149,7 +1156,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()) }) @@ -1166,6 +1173,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 diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 352574128..25d9f6f8a 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -5,7 +5,7 @@ {% endif %} - + {{ iface.name }} {% if iface.lag %} {{ iface.lag.name }} @@ -22,6 +22,8 @@ {% elif iface.is_virtual %} Virtual interface + {% elif iface.is_wireless %} + Wireless interface {% elif iface.connection %} {% with iface.connected_interface as connected_iface %} From 87e5687d0372a50da72b6ad1ae4ce7d2a48d682d Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 19 Jun 2017 16:10:18 -0400 Subject: [PATCH 09/38] Closes #1203: Implemented query filters for all models --- netbox/circuits/api/views.py | 1 + netbox/circuits/filters.py | 42 +++++-- netbox/dcim/api/views.py | 5 + netbox/dcim/filters.py | 227 ++++++++++++++++++++++------------- netbox/ipam/api/views.py | 19 +-- netbox/ipam/filters.py | 33 ++--- netbox/secrets/api/views.py | 1 + netbox/secrets/filters.py | 9 +- netbox/tenancy/api/views.py | 5 +- netbox/tenancy/filters.py | 8 +- 10 files changed, 223 insertions(+), 127 deletions(-) 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/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/dcim/api/views.py b/netbox/dcim/api/views.py index 116aaa77c..8c888e60f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -32,6 +32,7 @@ class RegionViewSet(WritableSerializerMixin, ModelViewSet): queryset = Region.objects.all() serializer_class = serializers.RegionSerializer write_serializer_class = serializers.WritableRegionSerializer + filter_class = filters.RegionFilter # @@ -73,6 +74,7 @@ class RackGroupViewSet(WritableSerializerMixin, ModelViewSet): class RackRoleViewSet(ModelViewSet): queryset = RackRole.objects.all() serializer_class = serializers.RackRoleSerializer + filter_class = filters.RackRoleFilter # @@ -128,6 +130,7 @@ class RackReservationViewSet(WritableSerializerMixin, ModelViewSet): class ManufacturerViewSet(ModelViewSet): queryset = Manufacturer.objects.all() serializer_class = serializers.ManufacturerSerializer + filter_class = filters.ManufacturerFilter # @@ -194,6 +197,7 @@ class DeviceBayTemplateViewSet(WritableSerializerMixin, ModelViewSet): class DeviceRoleViewSet(ModelViewSet): queryset = DeviceRole.objects.all() serializer_class = serializers.DeviceRoleSerializer + filter_class = filters.DeviceRoleFilter # @@ -203,6 +207,7 @@ class DeviceRoleViewSet(ModelViewSet): class PlatformViewSet(ModelViewSet): queryset = Platform.objects.all() serializer_class = serializers.PlatformSerializer + filter_class = filters.PlatformFilter # diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 66913f182..cdb5519b7 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 @@ -17,6 +18,22 @@ from .models import ( ) +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( @@ -24,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)', @@ -48,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(): @@ -58,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: @@ -69,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)', ) @@ -82,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): @@ -92,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)', ) @@ -103,7 +124,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (slug)', ) group_id = NullableModelMultipleChoiceFilter( - name='group', queryset=RackGroup.objects.all(), label='Group (ID)', ) @@ -114,7 +134,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Group', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) @@ -125,7 +144,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (slug)', ) role_id = NullableModelMultipleChoiceFilter( - name='role', queryset=RackRole.objects.all(), label='Role (ID)', ) @@ -138,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(): @@ -156,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(), @@ -178,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(): @@ -199,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( @@ -206,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)', ) @@ -220,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): @@ -236,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): @@ -280,7 +307,7 @@ class InterfaceTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = InterfaceTemplate - fields = ['name', 'form_factor'] + fields = ['name', 'form_factor', 'mgmt_only'] class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet): @@ -290,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)', ) @@ -321,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', @@ -388,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 @@ -435,12 +476,10 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): class DeviceComponentFilterSet(django_filters.FilterSet): device_id = django_filters.ModelChoiceFilter( - name='device', queryset=Device.objects.all(), label='Device (ID)', ) device = django_filters.ModelChoiceFilter( - name='device__name', queryset=Device.objects.all(), to_field_name='name', label='Device (name)', @@ -476,6 +515,10 @@ class PowerOutletFilter(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', @@ -502,7 +545,7 @@ class InterfaceFilter(django_filters.FilterSet): class Meta: model = Interface - fields = ['name', 'form_factor'] + fields = ['name', 'form_factor', 'mgmt_only'] def filter_device(self, queryset, name, value): try: @@ -539,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', 'discovered'] class ConsoleConnectionFilter(django_filters.FilterSet): diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 87c1996a1..74d26dca1 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -20,15 +20,6 @@ class VRFViewSet(WritableSerializerMixin, CustomFieldModelViewSet): filter_class = filters.VRFFilter -# -# Roles -# - -class RoleViewSet(ModelViewSet): - queryset = Role.objects.all() - serializer_class = serializers.RoleSerializer - - # # RIRs # @@ -50,6 +41,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 # diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index a4532edb4..045ca1df4 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -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,7 +238,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Device (name)', ) interface_id = django_filters.ModelMultipleChoiceFilter( - name='interface', queryset=Interface.objects.all(), label='Interface (ID)', ) @@ -284,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)', ) @@ -297,7 +293,7 @@ class VLANGroupFilter(django_filters.FilterSet): class Meta: model = VLANGroup - fields = ['name'] + fields = ['name', 'slug'] class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): @@ -307,7 +303,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) site_id = NullableModelMultipleChoiceFilter( - name='site', queryset=Site.objects.all(), label='Site (ID)', ) @@ -318,7 +313,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (slug)', ) group_id = NullableModelMultipleChoiceFilter( - name='group', queryset=VLANGroup.objects.all(), label='Group (ID)', ) @@ -329,7 +323,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Group', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) @@ -340,7 +333,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (slug)', ) role_id = NullableModelMultipleChoiceFilter( - name='role', queryset=Role.objects.all(), label='Role (ID)', ) @@ -356,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(): @@ -371,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/secrets/api/views.py b/netbox/secrets/api/views.py index edc165aa0..52a77b87c 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -30,6 +30,7 @@ class SecretRoleViewSet(ModelViewSet): queryset = SecretRole.objects.all() serializer_class = serializers.SecretRoleSerializer permission_classes = [IsAuthenticated] + filter_class = filters.SecretRoleFilter # diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 49cc03c17..eb40e8770 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -9,6 +9,13 @@ from dcim.models import Device from utilities.filters import NumericInFilter +class SecretRoleFilter(django_filters.FilterSet): + + class Meta: + model = SecretRole + fields = ['name', 'slug'] + + class SecretFilter(django_filters.FilterSet): id__in = NumericInFilter(name='id', lookup_expr='in') q = django_filters.CharFilter( @@ -16,7 +23,6 @@ class SecretFilter(django_filters.FilterSet): label='Search', ) role_id = django_filters.ModelMultipleChoiceFilter( - name='role', queryset=SecretRole.objects.all(), label='Role (ID)', ) @@ -27,7 +33,6 @@ class SecretFilter(django_filters.FilterSet): label='Role (slug)', ) device_id = django_filters.ModelMultipleChoiceFilter( - name='device', queryset=Device.objects.all(), label='Device (ID)', ) 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)', ) From 229e6809d8e1d4ad7c2225242ed7ff03be3717cc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 23 Jun 2017 14:04:15 -0400 Subject: [PATCH 10/38] Closes #1041: Added enabled and MTU fields to the interface model --- netbox/dcim/api/serializers.py | 17 ++++++++----- netbox/dcim/filters.py | 2 +- netbox/dcim/forms.py | 15 ++++++++--- .../0039_interface_add_enabled_mtu.py | 25 +++++++++++++++++++ netbox/dcim/models.py | 19 +++++++++++--- netbox/templates/dcim/inc/interface.html | 3 ++- 6 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 netbox/dcim/migrations/0039_interface_add_enabled_mtu.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 8ca6cab35..1561b8dc9 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -7,8 +7,8 @@ from ipam.models import IPAddress 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 @@ -601,8 +601,8 @@ class InterfaceSerializer(serializers.ModelSerializer): 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', + 'connection', 'connected_interface', ] def get_connection(self, obj): @@ -624,14 +624,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 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', + ] # diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index cdb5519b7..e9d629045 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -545,7 +545,7 @@ class InterfaceFilter(django_filters.FilterSet): class Meta: model = Interface - fields = ['name', 'form_factor', 'mgmt_only'] + fields = ['name', 'form_factor', 'enabled', 'mtu', 'mgmt_only'] def filter_device(self, queryset, name, value): try: diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 6b0bfec1d..b23e4a23e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1447,7 +1447,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(), } @@ -1469,12 +1469,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', {}) + kwargs['initial'].update({'enabled': True}) + super(InterfaceCreateForm, self).__init__(*args, **kwargs) # Limit LAG choices to interfaces belonging to this device @@ -1489,13 +1496,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) 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/models.py b/netbox/dcim/models.py index b6d345b98..4f76de1e6 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1120,13 +1120,24 @@ 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 = InterfaceQuerySet.as_manager() diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 25d9f6f8a..b4c075848 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -1,4 +1,4 @@ - + {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %} @@ -14,6 +14,7 @@ {% endif %} + {{ iface.mtu|default:"" }} {{ iface.mac_address|default:"" }} {% if iface.is_lag %} From 5940feb64b3c3a72f6b72bcdba26023493c1fd41 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 23 Jun 2017 17:05:37 -0400 Subject: [PATCH 11/38] Closes #1121: Added asset_tag and description fields to inventory items --- netbox/dcim/api/serializers.py | 10 +++++-- netbox/dcim/filters.py | 2 +- netbox/dcim/forms.py | 2 +- ...inventoryitem_add_asset_tag_description.py | 26 +++++++++++++++++++ netbox/dcim/models.py | 11 ++++++-- netbox/templates/dcim/device_inventory.html | 2 ++ netbox/templates/dcim/inc/inventoryitem.html | 4 ++- 7 files changed, 50 insertions(+), 7 deletions(-) create mode 100644 netbox/dcim/migrations/0040_inventoryitem_add_asset_tag_description.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1561b8dc9..7d4891308 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -669,14 +669,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 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', + ] # diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index e9d629045..2669e7d8c 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -599,7 +599,7 @@ class InventoryItemFilter(DeviceComponentFilterSet): class Meta: model = InventoryItem - fields = ['name', 'part_id', 'serial', 'discovered'] + 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 b23e4a23e..337cea86e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1765,4 +1765,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/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/models.py b/netbox/dcim/models.py index 4f76de1e6..d8a69e4d3 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1306,11 +1306,18 @@ 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/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/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 %} From d5bb37b55274daae8347a494e1722226fbd121ec Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 28 Jun 2017 16:23:17 -0400 Subject: [PATCH 12/38] #1246: Initial work on an API endpoint to retrieve available IPs for a prefix --- netbox/ipam/api/serializers.py | 15 +++++++++++++++ netbox/ipam/api/views.py | 32 ++++++++++++++++++++++++++++++++ netbox/ipam/models.py | 34 ++++++++++++++++++++++++++++------ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e02e384ed..5a7d96352 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 @@ -268,6 +269,20 @@ class WritableIPAddressSerializer(CustomFieldModelSerializer): ] +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), + ]) + + # # Services # diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 74d26dca1..0bb6411f8 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,7 +1,12 @@ from __future__ import unicode_literals +from rest_framework.decorators import detail_route +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 @@ -61,6 +66,33 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet): write_serializer_class = serializers.WritablePrefixSerializer filter_class = filters.PrefixFilter + @detail_route(url_path='available-ips') + 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) + + # Determine the maximum amount of IPs to return + 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/models.py b/netbox/ipam/models.py index 57ad939ed..8b23f3f42 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals - -from netaddr import IPNetwork, cidr_merge +import netaddr from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation @@ -161,7 +160,7 @@ class Aggregate(CreatedUpdatedModel, CustomFieldModel): """ child_prefixes = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix)) # Remove overlapping prefixes from list of children - networks = cidr_merge([c.prefix for c in child_prefixes]) + networks = netaddr.cidr_merge([c.prefix for c in child_prefixes]) children_size = float(0) for p in networks: children_size += p.size @@ -321,11 +320,34 @@ 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 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. """ - child_count = IPAddress.objects.filter(address__net_contained_or_equal=str(self.prefix), vrf=self.vrf).count() + child_count = self.get_child_ips().count() prefix_size = self.prefix.size if self.family == 4 and self.prefix.prefixlen < 31 and not self.is_pool: prefix_size -= 2 @@ -335,11 +357,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 From a23da9f86704646b62860cb9709d37d17b20a43e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 28 Jun 2017 16:25:36 -0400 Subject: [PATCH 13/38] PEP8 fixes --- netbox/dcim/filters.py | 2 +- netbox/dcim/models.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 2669e7d8c..e3579085a 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -207,7 +207,7 @@ class RackReservationFilter(django_filters.FilterSet): user = django_filters.ModelMultipleChoiceFilter( name='user', queryset=User.objects.all(), - to_field_name = 'username', + to_field_name='username', label='User (name)', ) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index d8a69e4d3..f1506a924 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1318,7 +1318,6 @@ class InventoryItem(models.Model): discovered = models.BooleanField(default=False, verbose_name='Discovered') description = models.CharField(max_length=100, blank=True) - class Meta: ordering = ['device__id', 'parent__id', 'name'] unique_together = ['device', 'parent', 'name'] From 30d160500704cfe442fcd7ec2d1f79aa9507d371 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 30 Jun 2017 16:51:31 -0400 Subject: [PATCH 14/38] Closes #1246: Added ability to auto-create the next available IP address within a prefix --- netbox/ipam/api/views.py | 61 ++++++++++++++++++++++++++--------- netbox/ipam/models.py | 2 +- netbox/ipam/tests/test_api.py | 29 +++++++++++++++++ 3 files changed, 76 insertions(+), 16 deletions(-) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 0bb6411f8..87511d5c5 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,6 +1,8 @@ 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 @@ -66,7 +68,7 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet): write_serializer_class = serializers.WritablePrefixSerializer filter_class = filters.PrefixFilter - @detail_route(url_path='available-ips') + @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 @@ -75,23 +77,52 @@ class PrefixViewSet(WritableSerializerMixin, CustomFieldModelViewSet): """ 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 - 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) + 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, - }) + # 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) + return Response(serializer.data) # diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 8b23f3f42..add959862 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -331,7 +331,7 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): Return all available IPs within this prefix as an IPSet. """ prefix = netaddr.IPSet(self.prefix) - child_ips = netaddr.IPSet([ip.address for ip in self.get_child_ips()]) + 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 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): From 1f9806a480589764c6dca3e78e39b8d26648e3e5 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Jul 2017 17:37:24 -0400 Subject: [PATCH 15/38] Fixes #1285: Enforce model validation when creating/editing objects via the API --- netbox/circuits/api/serializers.py | 5 +-- netbox/dcim/api/serializers.py | 50 +++++++++++++++++------------- netbox/extras/api/customfields.py | 10 ++++++ netbox/extras/api/serializers.py | 7 +++-- netbox/ipam/api/serializers.py | 13 ++++++-- netbox/secrets/api/serializers.py | 6 +++- netbox/tenancy/api/serializers.py | 3 +- netbox/utilities/api.py | 11 +++++++ 8 files changed, 74 insertions(+), 31 deletions(-) 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/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 7d4891308..d0a8d4a43 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -13,7 +13,7 @@ from dcim.models import ( ) 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 +36,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 +98,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 +109,7 @@ class WritableRackGroupSerializer(serializers.ModelSerializer): # Rack roles # -class RackRoleSerializer(serializers.ModelSerializer): +class RackRoleSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = RackRole @@ -174,6 +174,9 @@ class WritableRackSerializer(CustomFieldModelSerializer): validator.set_context(self) validator(data) + # Enforce model validation + super(WritableRackSerializer, self).validate(data) + return data @@ -211,7 +214,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 +225,7 @@ class WritableRackReservationSerializer(serializers.ModelSerializer): # Manufacturers # -class ManufacturerSerializer(serializers.ModelSerializer): +class ManufacturerSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Manufacturer @@ -287,7 +290,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 +309,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 +328,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 +347,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 +367,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 +386,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 +397,7 @@ class WritableDeviceBayTemplateSerializer(serializers.ModelSerializer): # Device roles # -class DeviceRoleSerializer(serializers.ModelSerializer): +class DeviceRoleSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = DeviceRole @@ -413,7 +416,7 @@ class NestedDeviceRoleSerializer(serializers.ModelSerializer): # Platforms # -class PlatformSerializer(serializers.ModelSerializer): +class PlatformSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Platform @@ -496,6 +499,9 @@ class WritableDeviceSerializer(CustomFieldModelSerializer): validator.set_context(self) validator(data) + # Enforce model validation + super(WritableDeviceSerializer, self).validate(data) + return data @@ -512,7 +518,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 +538,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 +558,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 +578,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 @@ -630,7 +636,7 @@ class PeerInterfaceSerializer(serializers.ModelSerializer): ] -class WritableInterfaceSerializer(serializers.ModelSerializer): +class WritableInterfaceSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Interface @@ -652,7 +658,7 @@ class DeviceBaySerializer(serializers.ModelSerializer): fields = ['id', 'device', 'name', 'installed_device'] -class WritableDeviceBaySerializer(serializers.ModelSerializer): +class WritableDeviceBaySerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = DeviceBay @@ -675,7 +681,7 @@ class InventoryItemSerializer(serializers.ModelSerializer): ] -class WritableInventoryItemSerializer(serializers.ModelSerializer): +class WritableInventoryItemSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = InventoryItem @@ -707,7 +713,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/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/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 5a7d96352..1374d3552 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -11,7 +11,7 @@ from ipam.models import ( 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 # @@ -45,7 +45,7 @@ class WritableVRFSerializer(CustomFieldModelSerializer): # Roles # -class RoleSerializer(serializers.ModelSerializer): +class RoleSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Role @@ -64,7 +64,7 @@ class NestedRoleSerializer(serializers.ModelSerializer): # RIRs # -class RIRSerializer(serializers.ModelSerializer): +class RIRSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = RIR @@ -142,6 +142,9 @@ class WritableVLANGroupSerializer(serializers.ModelSerializer): validator.set_context(self) validator(data) + # Enforce model validation + super(WritableVLANGroupSerializer, self).validate(data) + return data @@ -188,6 +191,9 @@ class WritableVLANSerializer(CustomFieldModelSerializer): validator.set_context(self) validator(data) + # Enforce model validation + super(WritableVLANSerializer, self).validate(data) + return data @@ -297,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/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 3c7132d37..ff2eb1dfa 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -5,13 +5,14 @@ from rest_framework.validators import UniqueTogetherValidator from dcim.api.serializers import NestedDeviceSerializer from secrets.models import Secret, SecretRole +from utilities.api import ModelValidationMixin # # SecretRoles # -class SecretRoleSerializer(serializers.ModelSerializer): +class SecretRoleSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = SecretRole @@ -55,4 +56,7 @@ class WritableSecretSerializer(serializers.ModelSerializer): validator.set_context(self) validator(data) + # Enforce model validation + super(WritableSecretSerializer, self).validate(data) + return data 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/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). From 530789b733b0431f2244bcb83dcdf125c00bc03b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Jul 2017 11:52:36 -0400 Subject: [PATCH 16/38] #1269: Reworked interface connection serialization --- netbox/dcim/api/serializers.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d0a8d4a43..f023a8cbe 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/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 @@ -601,25 +602,28 @@ class InterfaceSerializer(serializers.ModelSerializer): device = NestedDeviceSerializer() form_factor = ChoiceFieldSerializer(choices=IFACE_FF_CHOICES) lag = NestedInterfaceSerializer() + is_connected = serializers.SerializerMethodField(read_only=True) connection = serializers.SerializerMethodField(read_only=True) - connected_interface = serializers.SerializerMethodField(read_only=True) class Meta: model = Interface fields = [ 'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', - 'connection', 'connected_interface', + 'is_connected', 'connection', ] - def get_connection(self, obj): - if obj.connection: - return NestedInterfaceConnectionSerializer(obj.connection, context=self.context).data - return None + def get_is_connected(self, obj): + return bool(obj.connection) - def get_connected_interface(self, obj): - if obj.connected_interface: - return PeerInterfaceSerializer(obj.connected_interface, context=self.context).data - return None + def get_connection(self, obj): + data = OrderedDict(( + ('interface', None), + ('status', None), + )) + if obj.connection: + data['interface'] = PeerInterfaceSerializer(obj.connected_interface, context=self.context).data + data['status'] = obj.connection.connection_status + return data class PeerInterfaceSerializer(serializers.ModelSerializer): From 8a87d60f2938ffddeab3e365bf7e7c96bb4e87f9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Jul 2017 12:07:47 -0400 Subject: [PATCH 17/38] Closes #1269: Added circuit termination to interface serializer --- netbox/dcim/api/serializers.py | 52 ++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f023a8cbe..39381fc9a 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -5,6 +5,7 @@ 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, @@ -598,32 +599,59 @@ 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() is_connected = serializers.SerializerMethodField(read_only=True) - connection = 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', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', - 'is_connected', 'connection', + 'is_connected', 'interface_connection', 'circuit_termination', ] def get_is_connected(self, obj): - return bool(obj.connection) - - def get_connection(self, obj): - data = OrderedDict(( - ('interface', None), - ('status', None), - )) + """ + Return True if the interface has a connected interface or circuit termination. + """ if obj.connection: - data['interface'] = PeerInterfaceSerializer(obj.connected_interface, context=self.context).data - data['status'] = obj.connection.connection_status - return data + return True + try: + circuit_termination = obj.circuit_termination + return True + except CircuitTermination.DoesNotExist: + pass + return False + + 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 class PeerInterfaceSerializer(serializers.ModelSerializer): From 2d0638821d6c728b30fecbc9905b1b9ee4bf5051 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Jul 2017 12:44:16 -0400 Subject: [PATCH 18/38] #1266: Exclude interfaces with existing connections or circuit terminations when creating a new connection --- netbox/circuits/forms.py | 2 +- netbox/dcim/forms.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index b298f4a98..c1ed007e2 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -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' ) ) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 337cea86e..65f73c45f 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1592,7 +1592,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' ) ) From 74828e140989156c1829dda35dab259af1cc374e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 11 Jul 2017 14:52:50 -0400 Subject: [PATCH 19/38] Fixes #1334: Fix server error when adding an interface to a device --- netbox/circuits/forms.py | 2 +- netbox/dcim/forms.py | 4 ++-- netbox/ipam/forms.py | 4 ++-- netbox/tenancy/forms.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index c1ed007e2..d9954e55b 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -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/dcim/forms.py b/netbox/dcim/forms.py index 65f73c45f..3b596b5ae 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -630,7 +630,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 @@ -1479,7 +1479,7 @@ class InterfaceCreateForm(DeviceComponentForm): def __init__(self, *args, **kwargs): # Set interfaces enabled by default - kwargs['initial'] = kwargs.get('initial', {}) + kwargs['initial'] = kwargs.get('initial', {}).copy() kwargs['initial'].update({'enabled': True}) super(InterfaceCreateForm, self).__init__(*args, **kwargs) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 7eb9a9599..f290335d2 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -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 @@ -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 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 From 1ef90902bdc40d4922b565b7c3e9cbb0940a24de Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 12 Jul 2017 14:53:52 -0400 Subject: [PATCH 20/38] Closes #1320: Remove checkbox from confirmation dialog --- netbox/dcim/forms.py | 3 +-- .../templates/utilities/confirmation_form.html | 16 ++++------------ netbox/utilities/forms.py | 2 +- 3 files changed, 6 insertions(+), 15 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 3b596b5ae..db9adffa3 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1694,8 +1694,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) 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/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): From dc68be5abf1deb9987f0a3b301d949dba6e0cf40 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 12 Jul 2017 16:42:45 -0400 Subject: [PATCH 21/38] Removed SearchTables; created DetailTables for models where needed --- netbox/circuits/tables.py | 29 ++-------- netbox/circuits/views.py | 2 +- netbox/dcim/tables.py | 90 ++++++++++------------------- netbox/dcim/views.py | 8 +-- netbox/ipam/tables.py | 114 +++++++++---------------------------- netbox/ipam/views.py | 24 ++++---- netbox/netbox/views.py | 42 +++++++------- netbox/secrets/tables.py | 10 +--- netbox/tenancy/tables.py | 10 +--- netbox/utilities/tables.py | 11 ---- 10 files changed, 106 insertions(+), 234 deletions(-) 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..cb9e95669 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' diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 626bc9e7a..30fe83c9f 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/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 ( ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Platform, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, @@ -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') @@ -296,29 +285,22 @@ class DeviceTypeTable(BaseTable): 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') - instance_count = tables.Column(verbose_name='Instances') class Meta(BaseTable.Meta): 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' + 'is_network_device', 'subdevice_role', ) -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 DeviceTypeDetailTable(DeviceTypeTable): + instance_count = tables.Column(verbose_name='Instances') - class Meta(SearchTable.Meta): - model = DeviceType + class Meta(DeviceTypeTable.Meta): fields = ( - 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', - 'is_network_device', 'subdevice_role', + 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', + 'is_network_device', 'subdevice_role', 'instance_count', ) @@ -439,32 +421,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') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c257129a7..37ed69678 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -216,7 +216,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' @@ -354,7 +354,7 @@ class RackListView(ObjectListView): ) filter = filters.RackFilter filter_form = forms.RackFilterForm - table = tables.RackTable + table = tables.RackDetailTable template_name = 'dcim/rack_list.html' @@ -550,7 +550,7 @@ class DeviceTypeListView(ObjectListView): queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) filter = filters.DeviceTypeFilter filter_form = forms.DeviceTypeFilterForm - table = tables.DeviceTypeTable + table = tables.DeviceTypeDetailTable template_name = 'dcim/devicetype_list.html' @@ -805,7 +805,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' diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index b648eb9c9..8753d5f94 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 # @@ -197,24 +187,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 +228,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 +235,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 +258,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', 'vrf', 'status', 'role', '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', 'vrf', 'status', 'role', 'tenant', 'device', 'interface', 'description') + class Meta(IPAddressTable.Meta): + fields = ( + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'device', 'interface', 'description', + ) # @@ -358,24 +307,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/views.py b/netbox/ipam/views.py index 74432d180..74f7ed9c2 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',) @@ -273,7 +273,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): @@ -410,7 +410,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 +445,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 +456,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 @@ -585,7 +585,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 +601,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 +612,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 +622,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, @@ -722,7 +722,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 +734,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', { 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/secrets/tables.py b/netbox/secrets/tables.py index 980c093b7..30424b0bb 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/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 SecretRole, Secret @@ -43,11 +43,3 @@ class SecretTable(BaseTable): class Meta(BaseTable.Meta): model = Secret fields = ('pk', 'device', 'role', 'name', 'last_updated') - - -class SecretSearchTable(SearchTable): - device = tables.LinkColumn() - - class Meta(SearchTable.Meta): - model = Secret - fields = ('device', 'role', 'name', 'last_updated') 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/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): From dd1991f2c6dee5a2b41789edcb55281aaa4156f8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Jul 2017 16:31:47 -0400 Subject: [PATCH 22/38] Closes #838: Display details of all objects being edited/deleted in bulk --- netbox/circuits/views.py | 7 ++- netbox/dcim/tables.py | 52 +++++++++++++++++-- netbox/dcim/views.py | 34 +++++++++--- netbox/ipam/views.py | 10 ++-- netbox/secrets/views.py | 3 +- .../templates/circuits/circuit_bulk_edit.html | 23 -------- .../circuits/provider_bulk_edit.html | 19 ------- netbox/templates/dcim/device_bulk_edit.html | 23 -------- .../templates/dcim/devicetype_bulk_edit.html | 19 ------- .../templates/dcim/interface_bulk_edit.html | 17 ------ .../dcim/interfacetemplate_bulk_edit.html | 25 --------- netbox/templates/dcim/rack_bulk_edit.html | 29 ----------- netbox/templates/dcim/site_bulk_edit.html | 17 ------ .../templates/ipam/aggregate_bulk_edit.html | 21 -------- .../templates/ipam/ipaddress_bulk_edit.html | 25 --------- netbox/templates/ipam/prefix_bulk_edit.html | 25 --------- netbox/templates/ipam/vlan_bulk_edit.html | 25 --------- netbox/templates/ipam/vrf_bulk_edit.html | 21 -------- .../templates/secrets/secret_bulk_edit.html | 19 ------- .../templates/tenancy/tenant_bulk_edit.html | 17 ------ .../utilities/confirm_bulk_delete.html | 19 ------- .../templates/utilities/obj_bulk_delete.html | 38 ++++++++++++++ ...bulk_edit_form.html => obj_bulk_edit.html} | 12 ++--- netbox/tenancy/views.py | 2 +- netbox/utilities/views.py | 21 +++++--- 25 files changed, 145 insertions(+), 378 deletions(-) delete mode 100644 netbox/templates/circuits/circuit_bulk_edit.html delete mode 100644 netbox/templates/circuits/provider_bulk_edit.html delete mode 100644 netbox/templates/dcim/device_bulk_edit.html delete mode 100644 netbox/templates/dcim/devicetype_bulk_edit.html delete mode 100644 netbox/templates/dcim/interface_bulk_edit.html delete mode 100644 netbox/templates/dcim/interfacetemplate_bulk_edit.html delete mode 100644 netbox/templates/dcim/rack_bulk_edit.html delete mode 100644 netbox/templates/dcim/site_bulk_edit.html delete mode 100644 netbox/templates/ipam/aggregate_bulk_edit.html delete mode 100644 netbox/templates/ipam/ipaddress_bulk_edit.html delete mode 100644 netbox/templates/ipam/prefix_bulk_edit.html delete mode 100644 netbox/templates/ipam/vlan_bulk_edit.html delete mode 100644 netbox/templates/ipam/vrf_bulk_edit.html delete mode 100644 netbox/templates/secrets/secret_bulk_edit.html delete mode 100644 netbox/templates/tenancy/tenant_bulk_edit.html delete mode 100644 netbox/templates/utilities/confirm_bulk_delete.html create mode 100644 netbox/templates/utilities/obj_bulk_delete.html rename netbox/templates/utilities/{bulk_edit_form.html => obj_bulk_edit.html} (80%) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index cb9e95669..f34abba28 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -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,7 @@ class CircuitTypeEditView(CircuitTypeCreateView): class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuittype' cls = CircuitType + table = tables.CircuitTypeTable default_return_url = 'circuits:circuittype_list' @@ -183,8 +185,8 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'circuits.change_circuit' cls = Circuit filter = filters.CircuitFilter + table = tables.CircuitTable form = forms.CircuitBulkEditForm - template_name = 'circuits/circuit_bulk_edit.html' default_return_url = 'circuits:circuit_list' @@ -192,6 +194,7 @@ class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuit' cls = Circuit filter = filters.CircuitFilter + table = tables.CircuitTable default_return_url = 'circuits:circuit_list' diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 30fe83c9f..7c13be4f0 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -5,9 +5,9 @@ from django_tables2.utils import Accessor 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, ) @@ -453,6 +453,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/views.py b/netbox/dcim/views.py index 37ed69678..d07bb1b9d 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -205,6 +205,7 @@ class RegionEditView(RegionCreateView): class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_region' cls = Region + table = tables.RegionTable default_return_url = 'dcim:region_list' @@ -274,8 +275,8 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_site' cls = Site filter = filters.SiteFilter + table = tables.SiteTable form = forms.SiteBulkEditForm - template_name = 'dcim/site_bulk_edit.html' default_return_url = 'dcim:site_list' @@ -308,6 +309,7 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackgroup' cls = RackGroup filter = filters.RackGroupFilter + table = tables.RackGroupTable default_return_url = 'dcim:rackgroup_list' @@ -337,6 +339,7 @@ class RackRoleEditView(RackRoleCreateView): class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rackrole' cls = RackRole + table = tables.RackRoleTable default_return_url = 'dcim:rackrole_list' @@ -456,8 +459,8 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_rack' cls = Rack filter = filters.RackFilter + table = tables.RackTable form = forms.RackBulkEditForm - template_name = 'dcim/rack_bulk_edit.html' default_return_url = 'dcim:rack_list' @@ -465,6 +468,7 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_rack' cls = Rack filter = filters.RackFilter + table = tables.RackTable default_return_url = 'dcim:rack_list' @@ -510,6 +514,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 +544,7 @@ class ManufacturerEditView(ManufacturerCreateView): class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_manufacturer' cls = Manufacturer + table = tables.ManufacturerTable default_return_url = 'dcim:manufacturer_list' @@ -622,8 +628,8 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_devicetype' cls = DeviceType filter = filters.DeviceTypeFilter + table = tables.DeviceTypeTable form = forms.DeviceTypeBulkEditForm - template_name = 'dcim/devicetype_bulk_edit.html' default_return_url = 'dcim:devicetype_list' @@ -631,6 +637,7 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicetype' cls = DeviceType filter = filters.DeviceTypeFilter + table = tables.DeviceTypeTable default_return_url = 'dcim:devicetype_list' @@ -653,6 +660,7 @@ class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView) parent_field = 'device_type' cls = ConsolePortTemplate parent_cls = DeviceType + table = tables.ConsolePortTemplateTable class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -668,6 +676,7 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet permission_required = 'dcim.delete_consoleserverporttemplate' cls = ConsoleServerPortTemplate parent_cls = DeviceType + table = tables.ConsoleServerPortTemplateTable class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -683,6 +692,7 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerporttemplate' cls = PowerPortTemplate parent_cls = DeviceType + table = tables.PowerPortTemplateTable class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -698,6 +708,7 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView) permission_required = 'dcim.delete_poweroutlettemplate' cls = PowerOutletTemplate parent_cls = DeviceType + table = tables.PowerOutletTemplateTable class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -713,14 +724,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): @@ -736,6 +748,7 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicebaytemplate' cls = DeviceBayTemplate parent_cls = DeviceType + table = tables.DeviceBayTemplateTable # @@ -764,6 +777,7 @@ class DeviceRoleEditView(DeviceRoleCreateView): class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicerole' cls = DeviceRole + table = tables.DeviceRoleTable default_return_url = 'dcim:devicerole_list' @@ -793,6 +807,7 @@ class PlatformEditView(PlatformCreateView): class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_platform' cls = Platform + table = tables.PlatformTable default_return_url = 'dcim:platform_list' @@ -957,8 +972,8 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_device' cls = Device filter = filters.DeviceFilter + table = tables.DeviceTable form = forms.DeviceBulkEditForm - template_name = 'dcim/device_bulk_edit.html' default_return_url = 'dcim:device_list' @@ -966,6 +981,7 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_device' cls = Device filter = filters.DeviceFilter + table = tables.DeviceTable default_return_url = 'dcim:device_list' @@ -1073,6 +1089,7 @@ class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleport' cls = ConsolePort parent_cls = Device + table = tables.ConsolePortTable class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): @@ -1198,6 +1215,7 @@ class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleserverport' cls = ConsoleServerPort parent_cls = Device + table = tables.ConsoleServerPortTable # @@ -1304,6 +1322,7 @@ class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerport' cls = PowerPort parent_cls = Device + table = tables.PowerPortTable class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): @@ -1431,6 +1450,7 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_poweroutlet' cls = PowerOutlet parent_cls = Device + table = tables.PowerOutletTable # @@ -1473,14 +1493,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 # @@ -1561,6 +1582,7 @@ class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_devicebay' cls = DeviceBay parent_cls = Device + table = tables.DeviceBayTable # diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 74f7ed9c2..a669cb428 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -143,8 +143,8 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_vrf' cls = VRF filter = filters.VRFFilter + table = tables.VRFTable form = forms.VRFBulkEditForm - template_name = 'ipam/vrf_bulk_edit.html' default_return_url = 'ipam:vrf_list' @@ -361,8 +361,8 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_aggregate' cls = Aggregate filter = filters.AggregateFilter + table = tables.AggregateTable form = forms.AggregateBulkEditForm - template_name = 'ipam/aggregate_bulk_edit.html' default_return_url = 'ipam:aggregate_list' @@ -565,8 +565,8 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_prefix' cls = Prefix filter = filters.PrefixFilter + table = tables.PrefixTable form = forms.PrefixBulkEditForm - template_name = 'ipam/prefix_bulk_edit.html' default_return_url = 'ipam:prefix_list' @@ -670,8 +670,8 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_ipaddress' cls = IPAddress filter = filters.IPAddressFilter + table = tables.IPAddressTable form = forms.IPAddressBulkEditForm - template_name = 'ipam/ipaddress_bulk_edit.html' default_return_url = 'ipam:ipaddress_list' @@ -772,8 +772,8 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'ipam.change_vlan' cls = VLAN filter = filters.VLANFilter + table = tables.VLANTable form = forms.VLANBulkEditForm - template_name = 'ipam/vlan_bulk_edit.html' default_return_url = 'ipam:vlan_list' diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index ac4226358..85d2bd9ee 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -4,7 +4,6 @@ import base64 from django.contrib import messages from django.contrib.auth.decorators import permission_required, login_required from django.contrib.auth.mixins import PermissionRequiredMixin -from django.db import transaction, IntegrityError from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -241,8 +240,8 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'secrets.change_secret' cls = Secret filter = filters.SecretFilter + table = tables.SecretTable form = forms.SecretBulkEditForm - template_name = 'secrets/secret_bulk_edit.html' default_return_url = 'secrets:secret_list' 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_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/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/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/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_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/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/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/views.py b/netbox/tenancy/views.py index d151ff5ca..6906be267 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -114,8 +114,8 @@ class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'tenancy.change_tenant' cls = Tenant filter = filters.TenantFilter + table = tables.TenantTable form = forms.TenantBulkEditForm - template_name = 'tenancy/tenant_bulk_edit.html' default_return_url = 'tenancy:tenant_list' diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 055f9fbba..e5e10731f 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -462,6 +462,7 @@ class BulkEditView(View): cls: The model of the objects being edited parent_cls: The model of the parent object (if any) 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 @@ -471,7 +472,8 @@ class BulkEditView(View): parent_cls = None filter = None form = None - template_name = None + table = None + template_name = 'utilities/obj_bulk_edit.html' default_return_url = 'home' def get(self): @@ -537,14 +539,15 @@ 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: + table = self.table(self.cls.objects.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, }) @@ -603,6 +606,7 @@ class BulkDeleteView(View): cls: The model of the objects being deleted parent_cls: The model of the parent object (if any) 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 @@ -611,8 +615,9 @@ class BulkDeleteView(View): cls = None parent_cls = 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 +665,8 @@ 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: + table = self.table(self.cls.objects.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 +674,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, }) From 39730b6834a9b0841fe91994b98667545ab3f4a6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 13 Jul 2017 17:39:28 -0400 Subject: [PATCH 23/38] Optimized performance when editing/deleting objects in bulk --- netbox/circuits/views.py | 3 +++ netbox/dcim/tables.py | 17 +---------------- netbox/dcim/views.py | 33 ++++++++++++++++++++++++++------- netbox/ipam/tables.py | 18 +++++++++++++----- netbox/ipam/views.py | 22 +++++++++++++++++++++- netbox/secrets/views.py | 5 +++++ netbox/tenancy/views.py | 5 +++++ netbox/utilities/views.py | 14 +++++++++++--- 8 files changed, 85 insertions(+), 32 deletions(-) diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index f34abba28..345e3379d 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -117,6 +117,7 @@ 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' @@ -184,6 +185,7 @@ 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 @@ -193,6 +195,7 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): 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/tables.py b/netbox/dcim/tables.py index 7c13be4f0..427f0bb42 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -285,19 +285,10 @@ class DeviceTypeTable(BaseTable): 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') + instance_count = tables.Column(verbose_name='Instances') class Meta(BaseTable.Meta): model = DeviceType - fields = ( - 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', - 'is_network_device', 'subdevice_role', - ) - - -class DeviceTypeDetailTable(DeviceTypeTable): - instance_count = tables.Column(verbose_name='Instances') - - class Meta(DeviceTypeTable.Meta): fields = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'instance_count', @@ -315,7 +306,6 @@ class ConsolePortTemplateTable(BaseTable): model = ConsolePortTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class ConsoleServerPortTemplateTable(BaseTable): @@ -325,7 +315,6 @@ class ConsoleServerPortTemplateTable(BaseTable): model = ConsoleServerPortTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class PowerPortTemplateTable(BaseTable): @@ -335,7 +324,6 @@ class PowerPortTemplateTable(BaseTable): model = PowerPortTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class PowerOutletTemplateTable(BaseTable): @@ -345,7 +333,6 @@ class PowerOutletTemplateTable(BaseTable): model = PowerOutletTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class InterfaceTemplateTable(BaseTable): @@ -356,7 +343,6 @@ class InterfaceTemplateTable(BaseTable): model = InterfaceTemplate fields = ('pk', 'name', 'mgmt_only', 'form_factor') empty_text = "None" - show_header = False class DeviceBayTemplateTable(BaseTable): @@ -366,7 +352,6 @@ class DeviceBayTemplateTable(BaseTable): model = DeviceBayTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d07bb1b9d..9af1a320c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -205,6 +205,7 @@ 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' @@ -274,6 +275,7 @@ 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 @@ -308,6 +310,7 @@ 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' @@ -339,6 +342,7 @@ 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' @@ -458,6 +462,7 @@ 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 @@ -467,6 +472,7 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView): 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' @@ -544,6 +550,7 @@ 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' @@ -556,7 +563,7 @@ class DeviceTypeListView(ObjectListView): queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) filter = filters.DeviceTypeFilter filter_form = forms.DeviceTypeFilterForm - table = tables.DeviceTypeDetailTable + table = tables.DeviceTypeTable template_name = 'dcim/devicetype_list.html' @@ -568,24 +575,30 @@ 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')) + 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)) + ).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 @@ -627,6 +640,7 @@ 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 @@ -636,6 +650,7 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView): 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' @@ -777,6 +792,7 @@ 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' @@ -807,6 +823,7 @@ 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' @@ -971,6 +988,7 @@ 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 @@ -980,6 +998,7 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): 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' diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 8753d5f94..65ab5b2e4 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -161,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', @@ -172,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', + ) # diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index a669cb428..05f16aa35 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -142,6 +142,7 @@ 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 @@ -151,7 +152,9 @@ class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): 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' @@ -360,6 +365,7 @@ 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 @@ -369,7 +375,9 @@ class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): 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' @@ -564,6 +573,7 @@ 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 @@ -573,7 +583,9 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): 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' @@ -669,6 +681,7 @@ 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 @@ -678,7 +691,9 @@ class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): 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' @@ -771,6 +788,7 @@ 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 @@ -780,7 +798,9 @@ class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): 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/secrets/views.py b/netbox/secrets/views.py index 85d2bd9ee..71cf42c13 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -55,6 +55,8 @@ class SecretRoleEditView(SecretRoleCreateView): class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'secrets.delete_secretrole' cls = SecretRole + queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) + table = tables.SecretRoleTable default_return_url = 'secrets:secretrole_list' @@ -239,6 +241,7 @@ class SecretBulkImportView(BulkImportView): class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'secrets.change_secret' cls = Secret + queryset = Secret.objects.select_related('role', 'device') filter = filters.SecretFilter table = tables.SecretTable form = forms.SecretBulkEditForm @@ -248,5 +251,7 @@ class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'secrets.delete_secret' cls = Secret + queryset = Secret.objects.select_related('role', 'device') filter = filters.SecretFilter + table = tables.SecretTable default_return_url = 'secrets:secret_list' diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 6906be267..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,6 +115,7 @@ 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 @@ -122,5 +125,7 @@ class TenantBulkEditView(PermissionRequiredMixin, BulkEditView): 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/views.py b/netbox/utilities/views.py index e5e10731f..a1d001380 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -461,6 +461,7 @@ 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 @@ -470,9 +471,10 @@ class BulkEditView(View): """ cls = None parent_cls = None + queryset = None filter = None - form = None table = None + form = None template_name = 'utilities/obj_bulk_edit.html' default_return_url = 'home' @@ -539,7 +541,9 @@ class BulkEditView(View): initial_data['pk'] = pk_list form = self.form(self.cls, initial=initial_data) - table = self.table(self.cls.objects.filter(pk__in=pk_list), orderable=False) + # 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) @@ -605,6 +609,7 @@ 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 @@ -614,6 +619,7 @@ class BulkDeleteView(View): """ cls = None parent_cls = None + queryset = None filter = None table = None form = None @@ -665,7 +671,9 @@ class BulkDeleteView(View): else: form = form_cls(initial={'pk': pk_list, 'return_url': return_url}) - table = self.table(self.cls.objects.filter(pk__in=pk_list), orderable=False) + # 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) From b2d3f3ff22d943d6d31d845020e3f7f1ff06357e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Jul 2017 10:01:59 -0400 Subject: [PATCH 24/38] Tweaked page title --- netbox/templates/_base.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index f7280f95e..221aa2f7d 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -3,7 +3,7 @@ - NetBox - {% block title %}Home{% endblock %} + {% block title %}Home{% endblock %} - NetBox From bb2f86463e3c80543e349bf5f6cacaa243dc59ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Jul 2017 10:17:09 -0400 Subject: [PATCH 25/38] Upgraded jQuery to v3.2.1 --- .../js/{jquery-3.2.0.min.js => jquery-3.2.1.min.js} | 8 ++++---- netbox/templates/_base.html | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) rename netbox/project-static/js/{jquery-3.2.0.min.js => jquery-3.2.1.min.js} (50%) 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(" - + From f6a8d32880f211b878f18ec68f4e76dc9be5685f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Jul 2017 14:42:56 -0400 Subject: [PATCH 26/38] Initial work on NAPALM integration --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/api/views.py | 54 +++++++++++++++++++ netbox/dcim/forms.py | 2 +- .../migrations/0041_napalm_integration.py | 40 ++++++++++++++ netbox/dcim/models.py | 9 +++- 5 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 netbox/dcim/migrations/0041_napalm_integration.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 39381fc9a..09da3ced7 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -422,7 +422,7 @@ 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): diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 8c888e60f..64733de5d 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -7,6 +7,7 @@ from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet from django.conf import settings +from django.http import Http404, HttpResponseForbidden from django.shortcuts import get_object_or_404 from dcim.models import ( @@ -224,6 +225,59 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): write_serializer_class = serializers.WritableDeviceSerializer filter_class = filters.DeviceFilter + @detail_route(url_path='napalm/(?Pget_[a-z_]+)') + def napalm(self, request, pk, method): + """ + Execute a NAPALM method on a Device + """ + device = get_object_or_404(Device, pk=pk) + if not device.primary_ip: + 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 + )) + + # Check that NAPALM is installed and verify the configured driver + try: + 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 + )) + + # Raise a 404 for invalid NAPALM methods + if not hasattr(driver, method): + raise Http404() + + # Verify user permission + if not request.user.has_perm('dcim.napalm_read'): + return HttpResponseForbidden() + + # Connect to the device and execute the given method + # TODO: Improve error handling + ip_address = str(device.primary_ip.address.ip) + d = driver( + hostname=ip_address, + username=settings.NETBOX_USERNAME, + password=settings.NETBOX_PASSWORD + ) + try: + d.open() + response = getattr(d, method)() + except Exception as e: + raise ServiceUnavailable("Error connecting to the device: {}".format(e)) + + return Response(response) + + @detail_route(url_path='lldp-neighbors') def lldp_neighbors(self, request, pk): """ diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 740a9c9a6..440c12623 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -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'] # 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 f1506a924..8dd11e663 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -738,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'] @@ -809,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__() From 12472a2612d8d6b0b290224f153131e2d1659bc9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Jul 2017 16:07:28 -0400 Subject: [PATCH 27/38] Live device status PoC --- netbox/dcim/api/views.py | 26 +++++--- netbox/dcim/urls.py | 1 + netbox/dcim/views.py | 21 ++++++ netbox/templates/dcim/device_status.html | 67 ++++++++++++++++++++ netbox/templates/dcim/inc/device_header.html | 1 + 5 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 netbox/templates/dcim/device_status.html diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 64733de5d..8275aa888 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,7 +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 Http404, HttpResponseForbidden +from django.http import HttpResponseBadRequest, HttpResponseForbidden from django.shortcuts import get_object_or_404 from dcim.models import ( @@ -225,8 +226,8 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): write_serializer_class = serializers.WritableDeviceSerializer filter_class = filters.DeviceFilter - @detail_route(url_path='napalm/(?Pget_[a-z_]+)') - def napalm(self, request, pk, method): + @detail_route(url_path='napalm') + def napalm(self, request, pk): """ Execute a NAPALM method on a Device """ @@ -253,16 +254,21 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): device.platform, device.platform.napalm_driver )) - # Raise a 404 for invalid NAPALM methods - if not hasattr(driver, method): - raise Http404() - # Verify user permission if not request.user.has_perm('dcim.napalm_read'): return HttpResponseForbidden() - # Connect to the device and execute the given method + # 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, @@ -271,10 +277,12 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): ) try: d.open() - response = getattr(d, method)() + 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/urls.py b/netbox/dcim/urls.py index 53031ebbe..6adbc4dae 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -122,6 +122,7 @@ 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+)/add-secret/$', secret_add, name='device_addsecret'), url(r'^devices/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='service_assign'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 9af1a320c..221916c9f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -921,6 +921,27 @@ class DeviceInventoryView(View): }) +class DeviceStatusView(View): + + def get(self, request, pk): + + device = get_object_or_404(Device, pk=pk) + method = request.GET.get('method', 'get_facts') + + interfaces = Interface.objects.order_naturally( + device.device_type.interface_ordering + ).filter( + device=device + ).select_related( + 'connected_as_a', 'connected_as_b' + ) + + return render(request, 'dcim/device_status.html', { + 'device': device, + 'interfaces': interfaces, + }) + + class DeviceLLDPNeighborsView(View): def get(self, request, pk): diff --git a/netbox/templates/dcim/device_status.html b/netbox/templates/dcim/device_status.html new file mode 100644 index 000000000..d5931b550 --- /dev/null +++ b/netbox/templates/dcim/device_status.html @@ -0,0 +1,67 @@ +{% extends '_base.html' %} + +{% block title %}{{ device }} - NAPALM{% endblock %} + +{% block content %} + {% include 'dcim/inc/device_header.html' with active_tab='status' %} +
+
+
+
Device Facts
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Hostname
FQDN
Vendor
Model
Serial Number
OS Version
Uptime
+
+
+
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/dcim/inc/device_header.html b/netbox/templates/dcim/inc/device_header.html index 6861abf4a..8a807873a 100644 --- a/netbox/templates/dcim/inc/device_header.html +++ b/netbox/templates/dcim/inc/device_header.html @@ -45,6 +45,7 @@
- + {% block javascript %}{% endblock %} diff --git a/netbox/templates/dcim/device_status.html b/netbox/templates/dcim/device_status.html index d5931b550..537d1034b 100644 --- a/netbox/templates/dcim/device_status.html +++ b/netbox/templates/dcim/device_status.html @@ -1,8 +1,10 @@ {% extends '_base.html' %} +{% load staticfiles %} {% block title %}{{ device }} - NAPALM{% endblock %} {% block content %} + {% include 'inc/ajax_loader.html' %} {% include 'dcim/inc/device_header.html' with active_tab='status' %}
@@ -47,7 +49,7 @@ +{% endblock %} diff --git a/netbox/templates/dcim/device_status.html b/netbox/templates/dcim/device_status.html index 5e5dab168..4d3c9ba78 100644 --- a/netbox/templates/dcim/device_status.html +++ b/netbox/templates/dcim/device_status.html @@ -1,7 +1,7 @@ {% extends '_base.html' %} {% load staticfiles %} -{% block title %}{{ device }} - NAPALM{% endblock %} +{% block title %}{{ device }} - Status{% endblock %} {% block content %} {% include 'inc/ajax_loader.html' %} diff --git a/netbox/templates/dcim/inc/device_header.html b/netbox/templates/dcim/inc/device_header.html index 8a807873a..b9eb7fb19 100644 --- a/netbox/templates/dcim/inc/device_header.html +++ b/netbox/templates/dcim/inc/device_header.html @@ -45,8 +45,9 @@ From e85cc0d856f5bf825d22c3fcf9d0c4b8f7f7ef31 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Jul 2017 13:21:38 -0400 Subject: [PATCH 32/38] Removed legacy LLDP neighbors API endpoint --- netbox/dcim/api/views.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 8275aa888..d32c63bfa 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -286,29 +286,6 @@ class DeviceViewSet(WritableSerializerMixin, CustomFieldModelViewSet): return Response(response) - @detail_route(url_path='lldp-neighbors') - def lldp_neighbors(self, request, pk): - """ - Retrieve live LLDP neighbors of a device - """ - device = get_object_or_404(Device, pk=pk) - if not device.primary_ip: - raise ServiceUnavailable("No IP configured for this device.") - - 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 - 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.") - - return Response(lldp_neighbors) - - # # Device components # From a45bfaf3dac501527d46536a8b9493e39a8166b9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Jul 2017 13:29:11 -0400 Subject: [PATCH 33/38] Hide/disable NAPALM tabs as appropriate --- netbox/templates/dcim/inc/device_header.html | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/netbox/templates/dcim/inc/device_header.html b/netbox/templates/dcim/inc/device_header.html index b9eb7fb19..37c5e715a 100644 --- a/netbox/templates/dcim/inc/device_header.html +++ b/netbox/templates/dcim/inc/device_header.html @@ -45,9 +45,15 @@ From d73ea54e0883e210f59b5f243624156d145a0202 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Jul 2017 13:55:20 -0400 Subject: [PATCH 34/38] Fixed table cell alignment for IP addresses --- netbox/templates/dcim/inc/interface.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 45f71305f..75d0f027d 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -118,7 +118,7 @@ {% if selectable and perms.dcim.change_interface or perms.dcim.delete_interface %} {% endif %} - + {{ ip }} {% if ip.description %} From 05aaafc1cf3865437b46d8a4b7f2d27f90478b99 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 24 Jul 2017 13:26:31 -0400 Subject: [PATCH 35/38] Added docs for using the NetBox shell --- docs/shell/intro.md | 194 ++++++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 2 + 2 files changed, 196 insertions(+) create mode 100644 docs/shell/intro.md 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: From 091cf390d28ea6d1fbb59372f8ca5b34a1d84ef0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 24 Jul 2017 14:22:07 -0400 Subject: [PATCH 36/38] Import constants from each app --- netbox/extras/management/commands/nbshell.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py index 48448c16f..918b97ef6 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -37,9 +37,11 @@ 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) @@ -50,6 +52,15 @@ class Command(BaseCommand): 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, From 4047c1a4e4a4c167f1768c8c4bc099892ceb0831 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 24 Jul 2017 14:34:01 -0400 Subject: [PATCH 37/38] lsmodules() should only return native models --- netbox/extras/management/commands/nbshell.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py index 918b97ef6..9762f0cbd 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -46,7 +46,7 @@ class Command(BaseCommand): 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: From 336cdcddc55c8cc7d5635b8aaa7e23a15fb356c1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 24 Jul 2017 14:51:00 -0400 Subject: [PATCH 38/38] PEP8 fix --- netbox/extras/management/commands/nbshell.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py index 9762f0cbd..a50b1384d 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -60,7 +60,6 @@ class Command(BaseCommand): except KeyError: pass - # Load convenience commands namespace.update({ 'lsmodels': self._lsmodels,