From cd263484c351bdd09fe36b56ff05e820eb03fa03 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 14 Jun 2017 14:34:14 -0400 Subject: [PATCH 01/48] 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 93a325d98dc..e418d169dd4 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -433,12 +433,12 @@ def _has_primary_ip(self, queryset, name, value): 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 Meta: 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 Meta: 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 6411c6bffbe..dbfe95519dc 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -816,7 +816,7 @@ def __str__(self): return self.name -class InterfaceManager(models.Manager): +class InterfaceQuerySet(models.QuerySet): def order_naturally(self, method=IFACE_ORDERING_POSITION): """ @@ -841,13 +841,12 @@ def order_naturally(self, method=IFACE_ORDERING_POSITION): 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/48] 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 e6e6327b623..b3f0b5187b5 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ ) -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/48] 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 f4493719f96..8a618fcb13d 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 89ee0facc83..d3ed9addd81 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -97,7 +97,7 @@ class Meta: 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 @@ def to_csv(self): 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/48] 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 8a618fcb13d..e02e384ed0b 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -6,8 +6,8 @@ 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 11c19b7eebf..a4532edb460 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -11,8 +11,8 @@ 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 e3fe96c4c63..66d563b2c4f 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -13,8 +13,8 @@ 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 00000000000..6ad44c146e2 --- /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 d3ed9addd81..bc1e8fe760b 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -47,6 +47,23 @@ (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 @@ 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 @@ def to_csv(self): 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 767bd2cec29..bfdab9319e8 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 e6dd489df51..44c5ec5fffd 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -82,6 +82,12 @@

{{ ipaddress }}

{{ 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 668f495ebc7..78406a3f242 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 64dc223535a..5a625e03cc1 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/48] 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 6ad44c146e2..d91c3daa983 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 bc1e8fe760b..dd1de8622df 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -50,7 +50,7 @@ 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_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/48] 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 00000000000..816e28e4efa --- /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 44018ae1cea..1acd3f4a0b9 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -10,14 +10,7 @@ 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 00000000000..01e146e3ea9 --- /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 dbfe95519dc..6891e191166 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -24,204 +24,10 @@ 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 00000000000..86da9089574 --- /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 ade251c9465..8ee0fa3a3e7 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -15,62 +15,7 @@ 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 00000000000..3beb188236a --- /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 dd1de8622df..57ad939ede4 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -17,79 +17,10 @@ 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/48] 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 c9a5ecdc905..626bc9e7a2d 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -368,10 +368,11 @@ class Meta(BaseTable.Meta): 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 e6b77cb596b..c257129a725 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -573,15 +573,10 @@ def get(self, request, pk): 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 @@ def get(self, request, pk): 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 @@ def get(self, request, pk): '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 @@ def get(self, request, pk): 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 @@ def get(self, request, pk): '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 a6e5d1dbeed..a4672f6d062 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 365ba2057f8..aac7a0622f4 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 @@

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

{% 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 58f5fa7de78..8216e291ddd 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 86e48071016..352574128aa 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 ce4ac6967ef..4665246c749 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/48] 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 89f7a598f6c..817ff47dece 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -3,7 +3,7 @@ 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 01e146e3ea9..f2c04791071 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -66,6 +66,12 @@ 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_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_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 @@ 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 e418d169dd4..66913f182e3 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -11,8 +11,9 @@ 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 @@ def filter_device(self, queryset, name, value): 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 e05ffec502a..6b0bfec1d55 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -24,7 +24,7 @@ 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 @@ def __init__(self, device_a, *args, **kwargs): 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 00000000000..61cdb3996cf --- /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 6891e191166..b6d345b9864 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -661,6 +661,13 @@ def order_naturally(self, method=IFACE_ORDERING_POSITION): '_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 @@ def __str__(self): 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 @@ def clean(self): }) # 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 @@ def clean(self): 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 352574128aa..25d9f6f8a5d 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/48] 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 | 213 ++++++++++++++++++++++------------- 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, 216 insertions(+), 120 deletions(-) diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index d140805318a..685fa8f9ef6 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -43,6 +43,7 @@ def graphs(self, request, pk=None): 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 6e9e1f44338..8a1b01a89d3 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 @@ def search(self, queryset, name, value): 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 @@ def search(self, queryset, name, value): 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 116aaa77cae..8c888e60fdd 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 @@ def perform_create(self, serializer): 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 66913f182e3..cdb5519b782 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -3,6 +3,7 @@ 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 @@ ) +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 @@ def search(self, queryset, name, value): 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 @@ def search(self, queryset, name, value): 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 @@ def search(self, queryset, name, value): ) +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 @@ def search(self, queryset, name, value): 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,39 +317,43 @@ class Meta: 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', - ) - site_id = django_filters.ModelMultipleChoiceFilter( - name='site', - queryset=Site.objects.all(), - label='Site (ID)', + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + name='device_type__manufacturer', + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', ) - site = django_filters.ModelMultipleChoiceFilter( - name='site__slug', - queryset=Site.objects.all(), + manufacturer = django_filters.ModelMultipleChoiceFilter( + name='device_type__manufacturer__slug', + queryset=Manufacturer.objects.all(), to_field_name='slug', - label='Site name (slug)', - ) - rack_group_id = django_filters.ModelMultipleChoiceFilter( - name='rack__group', - queryset=RackGroup.objects.all(), - label='Rack group (ID)', + label='Manufacturer (slug)', ) - rack_id = NullableModelMultipleChoiceFilter( - name='rack', - queryset=Rack.objects.all(), - label='Rack (ID)', + device_type_id = django_filters.ModelMultipleChoiceFilter( + queryset=DeviceType.objects.all(), + label='Device type (ID)', ) role_id = django_filters.ModelMultipleChoiceFilter( - name='device_role', + name='device_role_id', queryset=DeviceRole.objects.all(), label='Role (ID)', ) @@ -333,7 +364,6 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Role (slug)', ) tenant_id = NullableModelMultipleChoiceFilter( - name='tenant', queryset=Tenant.objects.all(), label='Tenant (ID)', ) @@ -343,21 +373,35 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Tenant (slug)', ) - device_type_id = django_filters.ModelMultipleChoiceFilter( - name='device_type', - queryset=DeviceType.objects.all(), - label='Device type (ID)', + platform_id = NullableModelMultipleChoiceFilter( + queryset=Platform.objects.all(), + label='Platform (ID)', ) - manufacturer_id = django_filters.ModelMultipleChoiceFilter( - name='device_type__manufacturer', - queryset=Manufacturer.objects.all(), - label='Manufacturer (ID)', + platform = NullableModelMultipleChoiceFilter( + name='platform', + queryset=Platform.objects.all(), + to_field_name='slug', + label='Platform (slug)', ) - manufacturer = django_filters.ModelMultipleChoiceFilter( - name='device_type__manufacturer__slug', - queryset=Manufacturer.objects.all(), + 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='Manufacturer (slug)', + label='Site name (slug)', + ) + rack_group_id = django_filters.ModelMultipleChoiceFilter( + name='rack__group', + queryset=RackGroup.objects.all(), + label='Rack group (ID)', + ) + rack_id = NullableModelMultipleChoiceFilter( + name='rack', + queryset=Rack.objects.all(), + label='Rack (ID)', ) model = django_filters.ModelMultipleChoiceFilter( name='device_type__slug', @@ -365,16 +409,12 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): 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 @@ def _has_primary_ip(self, queryset, name, value): 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 Meta: 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 Meta: 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 87c1996a165..74d26dca1b5 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 a4532edb460..045ca1df49e 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 @@ def search(self, queryset, name, value): 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 @@ def search(self, queryset, name, value): 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 @@ def filter_mask_length(self, queryset, name, value): 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 @@ def search(self, queryset, name, value): 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 edc165aa07b..52a77b87c6b 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 49cc03c176e..eb40e8770cd 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -9,6 +9,13 @@ 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 e5105f33888..3c930bf7310 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -3,8 +3,8 @@ 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 @@ 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 4ded4f0c482..630e936e4b6 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -9,6 +9,13 @@ 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/48] 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 8ca6cab35ef..1561b8dc9dd 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -7,8 +7,8 @@ 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 cdb5519b782..e9d6290458e 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 6b0bfec1d55..b23e4a23e31 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 @@ def __init__(self, *args, **kwargs): 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 @@ def __init__(self, *args, **kwargs): 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 00000000000..4cc7e96161a --- /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 b6d345b9864..4f76de1e685 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 25d9f6f8a5d..b4c07584837 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/48] 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 1561b8dc9dd..7d48913080c 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 e9d6290458e..2669e7d8c37 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 b23e4a23e31..337cea86e0c 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 00000000000..c7d49fe2ca9 --- /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 4f76de1e685..d8a69e4d34a 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 cc3dd361bb1..32b15670c35 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 6aa77d1c2a8..8bc3149b113 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/48] #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 e02e384ed0b..5a7d9635277 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 Meta: ] +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 74d26dca1b5..0bb6411f8ff 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 57ad939ede4..8b23f3f42f3 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 @@ def get_utilization(self): """ 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 @@ def get_status_class(self): 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 @@ def get_utilization(self): 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/48] 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 2669e7d8c37..e3579085a00 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 d8a69e4d34a..f1506a92460 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/48] Closes #1246: Added ability to auto-create the next available IP address within a prefix --- netbox/ipam/api/views.py | 65 ++++++++++++++++++++++++++--------- netbox/ipam/models.py | 2 +- netbox/ipam/tests/test_api.py | 29 ++++++++++++++++ 3 files changed, 78 insertions(+), 18 deletions(-) diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 0bb6411f8ff..87511d5c561 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 @@ def available_ips(self, request, pk=None): """ 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) - - # 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) + else: + try: + limit = int(request.query_params.get('limit', settings.PAGINATE_COUNT)) + except ValueError: + limit = settings.PAGINATE_COUNT + if settings.MAX_PAGE_SIZE: + limit = min(limit, settings.MAX_PAGE_SIZE) + + # Calculate available IPs within the prefix + ip_list = list(prefix.get_available_ips())[:limit] + serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={ + 'request': request, + 'prefix': prefix.prefix, + 'vrf': prefix.vrf, + }) + + return Response(serializer.data) # diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 8b23f3f42f3..add959862d0 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -331,7 +331,7 @@ 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()]) + 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 0b6814b4ad8..1a40b95a5f9 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -367,6 +367,35 @@ def test_delete_prefix(self): 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/48] 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 f2e6d0d00b6..cdab3427a37 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -6,6 +6,7 @@ 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 Meta: # Circuit types # -class CircuitTypeSerializer(serializers.ModelSerializer): +class CircuitTypeSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = CircuitType @@ -110,7 +111,7 @@ class Meta: ] -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 7d48913080c..d0a8d4a4366 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -13,7 +13,7 @@ ) 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 Meta: fields = ['id', 'name', 'slug', 'parent'] -class WritableRegionSerializer(serializers.ModelSerializer): +class WritableRegionSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Region @@ -98,7 +98,7 @@ class Meta: fields = ['id', 'url', 'name', 'slug'] -class WritableRackGroupSerializer(serializers.ModelSerializer): +class WritableRackGroupSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = RackGroup @@ -109,7 +109,7 @@ class Meta: # Rack roles # -class RackRoleSerializer(serializers.ModelSerializer): +class RackRoleSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = RackRole @@ -174,6 +174,9 @@ def validate(self, data): validator.set_context(self) validator(data) + # Enforce model validation + super(WritableRackSerializer, self).validate(data) + return data @@ -211,7 +214,7 @@ class Meta: 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 Meta: # Manufacturers # -class ManufacturerSerializer(serializers.ModelSerializer): +class ManufacturerSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Manufacturer @@ -287,7 +290,7 @@ class Meta: fields = ['id', 'device_type', 'name'] -class WritableConsolePortTemplateSerializer(serializers.ModelSerializer): +class WritableConsolePortTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = ConsolePortTemplate @@ -306,7 +309,7 @@ class Meta: fields = ['id', 'device_type', 'name'] -class WritableConsoleServerPortTemplateSerializer(serializers.ModelSerializer): +class WritableConsoleServerPortTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = ConsoleServerPortTemplate @@ -325,7 +328,7 @@ class Meta: fields = ['id', 'device_type', 'name'] -class WritablePowerPortTemplateSerializer(serializers.ModelSerializer): +class WritablePowerPortTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = PowerPortTemplate @@ -344,7 +347,7 @@ class Meta: fields = ['id', 'device_type', 'name'] -class WritablePowerOutletTemplateSerializer(serializers.ModelSerializer): +class WritablePowerOutletTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = PowerOutletTemplate @@ -364,7 +367,7 @@ class Meta: 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 Meta: fields = ['id', 'device_type', 'name'] -class WritableDeviceBayTemplateSerializer(serializers.ModelSerializer): +class WritableDeviceBayTemplateSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = DeviceBayTemplate @@ -394,7 +397,7 @@ class Meta: # Device roles # -class DeviceRoleSerializer(serializers.ModelSerializer): +class DeviceRoleSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = DeviceRole @@ -413,7 +416,7 @@ class Meta: # Platforms # -class PlatformSerializer(serializers.ModelSerializer): +class PlatformSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Platform @@ -496,6 +499,9 @@ def validate(self, data): validator.set_context(self) validator(data) + # Enforce model validation + super(WritableDeviceSerializer, self).validate(data) + return data @@ -512,7 +518,7 @@ class Meta: read_only_fields = ['connected_console'] -class WritableConsoleServerPortSerializer(serializers.ModelSerializer): +class WritableConsoleServerPortSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = ConsoleServerPort @@ -532,7 +538,7 @@ class Meta: 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 Meta: read_only_fields = ['connected_port'] -class WritablePowerOutletSerializer(serializers.ModelSerializer): +class WritablePowerOutletSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = PowerOutlet @@ -572,7 +578,7 @@ class Meta: 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 Meta: ] -class WritableInterfaceSerializer(serializers.ModelSerializer): +class WritableInterfaceSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Interface @@ -652,7 +658,7 @@ class Meta: fields = ['id', 'device', 'name', 'installed_device'] -class WritableDeviceBaySerializer(serializers.ModelSerializer): +class WritableDeviceBaySerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = DeviceBay @@ -675,7 +681,7 @@ class Meta: ] -class WritableInventoryItemSerializer(serializers.ModelSerializer): +class WritableInventoryItemSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = InventoryItem @@ -707,7 +713,7 @@ class Meta: 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 5a1878b7781..52f127a7d6b 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -111,6 +111,16 @@ def _save_custom_fields(self, instance, custom_fields): 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 c8b3ff6f762..39ce63524c0 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -10,7 +10,7 @@ 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 @@ def get_parent(self, obj): 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 @@ def validate(self, data): "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 5a7d9635277..1374d355275 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -11,7 +11,7 @@ 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 Meta: # Roles # -class RoleSerializer(serializers.ModelSerializer): +class RoleSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = Role @@ -64,7 +64,7 @@ class Meta: # RIRs # -class RIRSerializer(serializers.ModelSerializer): +class RIRSerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: model = RIR @@ -142,6 +142,9 @@ def validate(self, data): validator.set_context(self) validator(data) + # Enforce model validation + super(WritableVLANGroupSerializer, self).validate(data) + return data @@ -188,6 +191,9 @@ def validate(self, data): validator.set_context(self) validator(data) + # Enforce model validation + super(WritableVLANSerializer, self).validate(data) + return data @@ -297,6 +303,7 @@ class Meta: 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 3c7132d3765..ff2eb1dfa55 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -5,13 +5,14 @@ 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 @@ def validate(self, data): 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 712d524c58c..ef5b15a169c 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -4,13 +4,14 @@ 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 6fcfc694946..5774584a69f 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -98,6 +98,17 @@ def to_internal_value(self, data): 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/48] #1269: Reworked interface connection serialization --- netbox/dcim/api/serializers.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d0a8d4a4366..f023a8cbeac 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_is_connected(self, obj): + return bool(obj.connection) + def get_connection(self, obj): + data = OrderedDict(( + ('interface', None), + ('status', None), + )) if obj.connection: - return NestedInterfaceConnectionSerializer(obj.connection, context=self.context).data - return None - - def get_connected_interface(self, obj): - if obj.connected_interface: - return PeerInterfaceSerializer(obj.connected_interface, context=self.context).data - return None + 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/48] Closes #1269: Added circuit termination to interface serializer --- netbox/dcim/api/serializers.py | 50 ++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 11 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f023a8cbeac..39381fc9aea 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -5,6 +5,7 @@ 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 Meta: 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) + """ + Return True if the interface has a connected interface or circuit termination. + """ + if obj.connection: + return True + try: + circuit_termination = obj.circuit_termination + return True + except CircuitTermination.DoesNotExist: + pass + return False - def get_connection(self, obj): - data = OrderedDict(( - ('interface', None), - ('status', None), - )) + def get_interface_connection(self, obj): if obj.connection: - data['interface'] = PeerInterfaceSerializer(obj.connected_interface, context=self.context).data - data['status'] = obj.connection.connection_status - return data + 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/48] #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 b298f4a9813..c1ed007e28d 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 337cea86e0c..65f73c45f97 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/48] 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 c1ed007e28d..d9954e55b4f 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -244,7 +244,7 @@ def __init__(self, *args, **kwargs): # 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 65f73c45f97..3b596b5aecd 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -630,7 +630,7 @@ def __init__(self, *args, **kwargs): 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 7eb9a9599fd..f290335d253 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -217,7 +217,7 @@ def __init__(self, *args, **kwargs): # 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 @@ def __init__(self, *args, **kwargs): # 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 9950abfc280..9690508411b 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -102,7 +102,7 @@ def __init__(self, *args, **kwargs): # 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/48] 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 3b596b5aecd..db9adffa347 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1694,8 +1694,7 @@ def clean_interface_b(self): 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 16383d6f739..9f3f4b8e624 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 bb42b731508..0fa402d5282 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/48] Removed SearchTables; created DetailTables for models where needed --- netbox/circuits/tables.py | 29 +++------- netbox/circuits/views.py | 2 +- netbox/dcim/tables.py | 86 ++++++++++-------------------- netbox/dcim/views.py | 8 +-- netbox/ipam/tables.py | 106 +++++++++---------------------------- 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, 100 insertions(+), 228 deletions(-) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index d09c5a7b269..58775b378e9 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -3,7 +3,7 @@ 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 @@ 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 eda37340d7a..cb9e956691a 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 626bc9e7a2d..30fe83c9fc7 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -3,7 +3,7 @@ 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,27 +210,20 @@ 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', 'devices', 'get_utilization' - ) + fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height') -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 RackDetailTable(RackTable): + devices = tables.Column(accessor=Accessor('device_count')) + get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') - class Meta(SearchTable.Meta): - model = Rack - fields = ('name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height') + class Meta(RackTable.Meta): + fields = ( + 'pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'get_utilization' + ) class RackImportTable(BaseTable): @@ -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,30 +421,20 @@ class DeviceTable(BaseTable): 'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type', text=lambda record: record.device_type.full_name ) - primary_ip = tables.TemplateColumn( - orderable=False, verbose_name='IP Address', template_code=DEVICE_PRIMARY_IP - ) class Meta(BaseTable.Meta): model = Device - fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip') + fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type') -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 DeviceDetailTable(DeviceTable): + primary_ip = tables.TemplateColumn( + orderable=False, verbose_name='IP Address', template_code=DEVICE_PRIMARY_IP ) - class Meta(SearchTable.Meta): + class Meta(DeviceTable.Meta): model = Device - fields = ('name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type') + fields = ('pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip') class DeviceImportTable(BaseTable): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c257129a725..37ed696785b 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 b648eb9c9b7..8753d5f945b 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -3,7 +3,7 @@ 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 Meta(BaseTable.Meta): 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,22 +187,19 @@ class Meta(BaseTable.Meta): 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', 'child_count', 'get_utilization', 'date_added', 'description') + fields = ('pk', 'prefix', 'rir', 'date_added', 'description') -class AggregateSearchTable(SearchTable): - prefix = tables.LinkColumn(verbose_name='Aggregate') - date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added') +class AggregateDetailTable(AggregateTable): + child_count = tables.Column(verbose_name='Prefixes') + get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') - class Meta(SearchTable.Meta): - model = Aggregate - fields = ('prefix', 'rir', 'date_added', 'description') + class Meta(AggregateTable.Meta): + fields = ('pk', 'prefix', 'rir', 'child_count', 'get_utilization', 'date_added', 'description') # @@ -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 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 PrefixDetailTable(PrefixTable): + get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') - 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', 'prefixes', 'tenant', 'status', 'role', 'description') + fields = ('pk', 'vid', 'site', 'group', 'name', '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 VLANDetailTable(VLANTable): + prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes') - class Meta(SearchTable.Meta): - model = VLAN - fields = ('vid', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description') + class Meta(VLANTable.Meta): + fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description') diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 74432d1802d..74f7ed9c276 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 @@ def get(self, request, pk): ).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 @@ def get(self, request, pk): ).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 @@ def get(self, request, pk): ).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 @@ def get(self, request, pk): ).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 @@ def get(self, request, pk): ).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 @@ def get(self, request, pk): '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 6f642063bdc..d5224b46243 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -11,20 +11,20 @@ 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 @@ ('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 @@ def get(self, request): # 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 980c093b796..30424b0bb2f 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -2,7 +2,7 @@ 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 9941e269ab8..4ef774fb68a 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -2,7 +2,7 @@ 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 1dd8969a1e9..579280b647b 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -16,21 +16,10 @@ def __init__(self, *args, **kwargs): 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/48] 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 cb9e956691a..f34abba2883 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 30fe83c9fc7..7c13be4f08a 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -5,9 +5,9 @@ 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 Meta(BaseTable.Meta): 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 37ed696785b..d07bb1b9d07 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 @@ def get_return_url(self, request, obj): 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 74f7ed9c276..a669cb42825 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 ac4226358c6..85d2bd9ee6b 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -4,7 +4,6 @@ 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 2b03cda2ecd..00000000000 --- 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 2e6321a094a..00000000000 --- 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 69109828a19..00000000000 --- 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 ebeb0b781f4..00000000000 --- 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 7b25abb4d2b..00000000000 --- 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 494d35fb687..00000000000 --- 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 d1a8dac0e60..00000000000 --- 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 34523bd82ca..00000000000 --- 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 b5c09e48f3f..00000000000 --- 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 7dc0f6d1afb..00000000000 --- 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 b1b915b0e83..00000000000 --- 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 4829bc3cc9d..00000000000 --- 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 7d57ca39d27..00000000000 --- 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 6c64b09dd45..00000000000 --- 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 25b08ad10b0..00000000000 --- 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 6f3100cba12..00000000000 --- 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 00000000000..5ec26a9c827 --- /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 3e3bbc187a2..e2c501aba81 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 @@

{% block title %}{% endblock %}

{{ 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 d151ff5ca1c..6906be26715 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 055f9fbbac0..e5e10731f59 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 @@ def post(self, request, **kwargs): 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 @@ def post(self, request, **kwargs): 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 @@ def post(self, request, **kwargs): '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/48] 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 f34abba2883..345e3379df7 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 7c13be4f08a..427f0bb4294 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 Meta(BaseTable.Meta): model = ConsolePortTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class ConsoleServerPortTemplateTable(BaseTable): @@ -325,7 +315,6 @@ class Meta(BaseTable.Meta): model = ConsoleServerPortTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class PowerPortTemplateTable(BaseTable): @@ -335,7 +324,6 @@ class Meta(BaseTable.Meta): model = PowerPortTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class PowerOutletTemplateTable(BaseTable): @@ -345,7 +333,6 @@ class Meta(BaseTable.Meta): model = PowerOutletTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False class InterfaceTemplateTable(BaseTable): @@ -356,7 +343,6 @@ class Meta(BaseTable.Meta): model = InterfaceTemplate fields = ('pk', 'name', 'mgmt_only', 'form_factor') empty_text = "None" - show_header = False class DeviceBayTemplateTable(BaseTable): @@ -366,7 +352,6 @@ class Meta(BaseTable.Meta): model = DeviceBayTemplate fields = ('pk', 'name') empty_text = "None" - show_header = False # diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d07bb1b9d07..9af1a320ccd 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 @@ def get(self, request, pk): # 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 @@ def _save_obj(self, obj_form): 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 8753d5f945b..65ab5b2e407 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 a669cb42825..05f16aa35c2 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 85d2bd9ee6b..71cf42c1357 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 @@ def post(self, request): 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 6906be26715..e176075cba2 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 e5e10731f59..a1d0013800a 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 @@ def post(self, request, **kwargs): 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 @@ def post(self, request, **kwargs): 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/48] 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 f7280f95e25..221aa2f7d48 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 0655834938403827f25ade746c1e6d7daa4f860b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Jul 2017 10:11:04 -0400 Subject: [PATCH 25/48] Post-release version bump --- 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 af84e323b8b..c413002ab67 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ ) -VERSION = '2.0.10' +VERSION = '2.0.11-dev' # Import required configuration parameters ALLOWED_HOSTS = DATABASE = SECRET_KEY = None From bb2f86463e3c80543e349bf5f6cacaa243dc59ed Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 14 Jul 2017 10:17:09 -0400 Subject: [PATCH 26/48] 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 2ec0d1da096..644d35e274f 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 27/48] 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 39381fc9aea..09da3ced762 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 8c888e60fdd..64733de5d64 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -7,6 +7,7 @@ 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 740a9c9a6d2..440c1262393 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 00000000000..73ca8f3ee7d --- /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 f1506a92460..8dd11e66361 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 28/48] 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 64733de5d64..8275aa88897 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.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 @@ def napalm(self, request, pk, method): 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 @@ def napalm(self, request, pk, method): ) 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 53031ebbe8d..6adbc4dae93 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -122,6 +122,7 @@ 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 9af1a320ccd..221916c9f91 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -921,6 +921,27 @@ def get(self, request, pk): }) +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 00000000000..d5931b55025 --- /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 6861abf4ab5..8a807873a82 100644 --- a/netbox/templates/dcim/inc/device_header.html +++ b/netbox/templates/dcim/inc/device_header.html @@ -45,6 +45,7 @@

{{ device }}

- + {% block javascript %}{% endblock %} diff --git a/netbox/templates/dcim/device_status.html b/netbox/templates/dcim/device_status.html index d5931b55025..537d1034b60 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 5e5dab1686a..4d3c9ba7827 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 8a807873a82..b9eb7fb198d 100644 --- a/netbox/templates/dcim/inc/device_header.html +++ b/netbox/templates/dcim/inc/device_header.html @@ -45,8 +45,9 @@

{{ device }}

From e85cc0d856f5bf825d22c3fcf9d0c4b8f7f7ef31 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Jul 2017 13:21:38 -0400 Subject: [PATCH 33/48] 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 8275aa88897..d32c63bfa68 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -286,29 +286,6 @@ def napalm(self, request, pk): 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 34/48] 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 b9eb7fb198d..37c5e715a3a 100644 --- a/netbox/templates/dcim/inc/device_header.html +++ b/netbox/templates/dcim/inc/device_header.html @@ -45,9 +45,15 @@

{{ device }}

From d73ea54e0883e210f59b5f243624156d145a0202 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 17 Jul 2017 13:55:20 -0400 Subject: [PATCH 35/48] 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 45f71305fee..75d0f027dc5 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 106627da04c17788fb058a41b62b502707e8c4eb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 18 Jul 2017 10:39:09 -0400 Subject: [PATCH 36/48] Fixes #1358: Correct VRF example values in IP/prefix import forms --- netbox/templates/utilities/obj_import.html | 2 +- netbox/utilities/templatetags/helpers.py | 18 +++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/netbox/templates/utilities/obj_import.html b/netbox/templates/utilities/obj_import.html index 7c331a2eff2..90cb81a61df 100644 --- a/netbox/templates/utilities/obj_import.html +++ b/netbox/templates/utilities/obj_import.html @@ -44,7 +44,7 @@

CSV Format

{{ field.help_text|default:field.label }} {% if field.choices %} -
Choices: {{ field.choices|example_choices }} +
Choices: {{ field|example_choices }} {% elif field|widget_type == 'dateinput' %}
Format: YYYY-MM-DD {% elif field|widget_type == 'checkboxinput' %} diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 42e8a277fa4..b7a40d01856 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -63,19 +63,23 @@ def bettertitle(value): @register.filter() -def example_choices(value, arg=3): +def example_choices(field, arg=3): """ Returns a number (default: 3) of example choices for a ChoiceFiled (useful for CSV import forms). """ - choices = [] - for id, label in value: - if len(choices) == arg: - choices.append('etc.') + examples = [] + if hasattr(field, 'queryset'): + choices = [(obj.pk, getattr(obj, field.to_field_name)) for obj in field.queryset[:arg+1]] + else: + choices = field.choices + for id, label in choices: + if len(examples) == arg: + examples.append('etc.') break if not id: continue - choices.append(label) - return ', '.join(choices) or 'None' + examples.append(label) + return ', '.join(examples) or 'None' # From 5885b833cdf27e3ad4866ccdfdbfed0fee40ef76 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 19 Jul 2017 11:03:13 -0400 Subject: [PATCH 37/48] Fixes #1362: Raise validation error when attempting to create an API key that's too short --- netbox/users/views.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/netbox/users/views.py b/netbox/users/views.py index 88b6ebd3231..5b12ad32546 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -244,6 +244,7 @@ def post(self, request, pk=None): token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) form = TokenForm(request.POST, instance=token) else: + token = Token() form = TokenForm(request.POST) if form.is_valid(): @@ -259,6 +260,13 @@ def post(self, request, pk=None): else: return redirect('user:token_list') + return render(request, 'utilities/obj_edit.html', { + 'obj': token, + 'obj_type': token._meta.verbose_name, + 'form': form, + 'return_url': reverse('user:token_list'), + }) + class TokenDeleteView(LoginRequiredMixin, View): From 05aaafc1cf3865437b46d8a4b7f2d27f90478b99 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 24 Jul 2017 13:26:31 -0400 Subject: [PATCH 38/48] 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 00000000000..df92cb7cdd5 --- /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 8b77b289d80..a8be998e439 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 39/48] 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 48448c16faa..918b97ef6c2 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -37,9 +37,11 @@ def _lsmodels(self): 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 @@ def get_namespace(self): 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 40/48] 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 918b97ef6c2..9762f0cbd56 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -46,7 +46,7 @@ def get_namespace(self): 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 41/48] 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 9762f0cbd56..a50b1384df9 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -60,7 +60,6 @@ def get_namespace(self): except KeyError: pass - # Load convenience commands namespace.update({ 'lsmodels': self._lsmodels, From 0991f94d0604f9f6fe48a2420ccf675606856f05 Mon Sep 17 00:00:00 2001 From: vanderaaj Date: Tue, 25 Jul 2017 08:40:51 -0500 Subject: [PATCH 42/48] How to migrate from Py2 to Py3 (#1355) * How to migrate from Py2 to Py3 The commands done to migrate Ubuntu from Py2 to Py3. * Update Migrating-to-Python3 --- docs/installation/Migrating-to-Python3 | 27 ++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 docs/installation/Migrating-to-Python3 diff --git a/docs/installation/Migrating-to-Python3 b/docs/installation/Migrating-to-Python3 new file mode 100644 index 00000000000..09fe3657bb9 --- /dev/null +++ b/docs/installation/Migrating-to-Python3 @@ -0,0 +1,27 @@ +# Migration +Remove Python 2 packages +```no-highlight +# apt-get remove --purge -y python-dev python-pip +``` + +Install Python 3 packages +```no-highlight +# apt-get install -y python3 python3-dev python3-pip +``` + +Install Python Packages +```no-highlight +# cd /opt/netbox +# pip3 install -r requirements.txt +``` + +Gunicorn Update +``` +# pip uninstall gunicorn +# pip3 install gunicorn +``` + +Re-install LDAP Module (Optional if using LDAP for auth) +``` +sudo pip3 install django-auth-ldap +``` From f9b6ddc230e457f68b609e0ab9df09c4c0534a28 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Jul 2017 09:44:15 -0400 Subject: [PATCH 43/48] Added "Migrating to Python3" to the docs index --- ...{Migrating-to-Python3 => migrating-to-python3.md} | 12 +++++++++--- mkdocs.yml | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) rename docs/installation/{Migrating-to-Python3 => migrating-to-python3.md} (81%) diff --git a/docs/installation/Migrating-to-Python3 b/docs/installation/migrating-to-python3.md similarity index 81% rename from docs/installation/Migrating-to-Python3 rename to docs/installation/migrating-to-python3.md index 09fe3657bb9..e9901825228 100644 --- a/docs/installation/Migrating-to-Python3 +++ b/docs/installation/migrating-to-python3.md @@ -1,27 +1,33 @@ # Migration + Remove Python 2 packages + ```no-highlight # apt-get remove --purge -y python-dev python-pip ``` Install Python 3 packages + ```no-highlight # apt-get install -y python3 python3-dev python3-pip ``` Install Python Packages + ```no-highlight # cd /opt/netbox # pip3 install -r requirements.txt ``` Gunicorn Update -``` + +```no-highlight # pip uninstall gunicorn # pip3 install gunicorn ``` -Re-install LDAP Module (Optional if using LDAP for auth) -``` +Re-install LDAP Module (optional if using LDAP for auth) + +```no-highlight sudo pip3 install django-auth-ldap ``` diff --git a/mkdocs.yml b/mkdocs.yml index a8be998e439..f204749d597 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -8,6 +8,7 @@ pages: - 'Web Server': 'installation/web-server.md' - 'LDAP (Optional)': 'installation/ldap.md' - 'Upgrading': 'installation/upgrading.md' + - 'Migrating to Python3': 'installation/migrating-to-python3.md' - 'Configuration': - 'Mandatory Settings': 'configuration/mandatory-settings.md' - 'Optional Settings': 'configuration/optional-settings.md' From 9e26198afe61c6d31a2b94114c250533a8a8e090 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Jul 2017 10:09:44 -0400 Subject: [PATCH 44/48] Added note about NAPALM integration --- docs/installation/netbox.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/installation/netbox.md b/docs/installation/netbox.md index c7c2eb8ed7e..c6c4926266a 100644 --- a/docs/installation/netbox.md +++ b/docs/installation/netbox.md @@ -32,6 +32,11 @@ Python 2: # yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel ``` +!!! info + As of v2.1.0, NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to retrieve live data from devices and return it to a requester via its REST API. + + Installation of NAPALM is optional. To enable it, simply install the "napalm" package using your distribution's package manager. + You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub. ## Option A: Download a Release From e364659c6e85ba78653f9be372776daa8dc891fc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Jul 2017 10:17:28 -0400 Subject: [PATCH 45/48] Tweaked NAPALM integration instructions --- docs/installation/netbox.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/installation/netbox.md b/docs/installation/netbox.md index c6c4926266a..4befbeefcee 100644 --- a/docs/installation/netbox.md +++ b/docs/installation/netbox.md @@ -32,11 +32,6 @@ Python 2: # yum install -y gcc python2 python-devel python-pip libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel ``` -!!! info - As of v2.1.0, NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to retrieve live data from devices and return it to a requester via its REST API. - - Installation of NAPALM is optional. To enable it, simply install the "napalm" package using your distribution's package manager. - You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub. ## Option A: Download a Release @@ -102,6 +97,14 @@ Python 2: # pip install -r requirements.txt ``` +### NAPALM Automation + +As of v2.1.0, NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3: + +```no-highlight +# pip install napalm +``` + # Configuration Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`. From 1770c8568940cd801d0d2b6c7948f778043fec51 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Jul 2017 10:56:23 -0400 Subject: [PATCH 46/48] Fixes #1371: Extend DeviceSerializer.parent_device to include standard fields --- netbox/dcim/api/serializers.py | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 09da3ced762..50bf756e3ee 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -473,14 +473,10 @@ def get_parent_device(self, obj): device_bay = obj.parent_bay except DeviceBay.DoesNotExist: return None - return { - 'id': device_bay.device.pk, - 'name': device_bay.device.name, - 'device_bay': { - 'id': device_bay.pk, - 'name': device_bay.name, - } - } + context = {'request': self.context['request']} + data = NestedDeviceSerializer(instance=device_bay.device, context=context).data + data['device_bay'] = NestedDeviceBaySerializer(instance=device_bay, context=context).data + return data class WritableDeviceSerializer(CustomFieldModelSerializer): @@ -690,6 +686,14 @@ class Meta: fields = ['id', 'device', 'name', 'installed_device'] +class NestedDeviceBaySerializer(serializers.ModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') + + class Meta: + model = DeviceBay + fields = ['id', 'url', 'name'] + + class WritableDeviceBaySerializer(ModelValidationMixin, serializers.ModelSerializer): class Meta: From 7476194bd149a82aa3fbc3bc66990c1f118a55f7 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Jul 2017 10:58:28 -0400 Subject: [PATCH 47/48] PEP8 fix --- netbox/utilities/templatetags/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index b7a40d01856..7cb2753e77c 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -69,7 +69,7 @@ def example_choices(field, arg=3): """ examples = [] if hasattr(field, 'queryset'): - choices = [(obj.pk, getattr(obj, field.to_field_name)) for obj in field.queryset[:arg+1]] + choices = [(obj.pk, getattr(obj, field.to_field_name)) for obj in field.queryset[:arg + 1]] else: choices = field.choices for id, label in choices: From c7e9d90321b1566b0ab191adfddcc95673b54671 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 25 Jul 2017 11:19:33 -0400 Subject: [PATCH 48/48] Release v2.1.0 --- 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 b3f0b5187b5..28d98acf152 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -13,7 +13,7 @@ ) -VERSION = '2.1.0-dev' +VERSION = '2.1.0' # Import required configuration parameters ALLOWED_HOSTS = DATABASE = SECRET_KEY = None