From 8e1535f7ecba14aa9259b0ad62f0eabeccc12d82 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 12 Oct 2021 10:46:41 -0400 Subject: [PATCH 01/68] Add RF channel fields to Interface --- netbox/dcim/api/serializers.py | 10 +- netbox/dcim/choices.py | 131 ++++++++++++++++++++++ netbox/dcim/constants.py | 1 + netbox/dcim/filtersets.py | 5 +- netbox/dcim/forms/bulk_edit.py | 6 +- netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/filtersets.py | 11 ++ netbox/dcim/forms/models.py | 5 +- netbox/dcim/forms/object_create.py | 15 ++- netbox/dcim/migrations/0136_wireless.py | 21 ++++ netbox/dcim/models/device_components.py | 18 +++ netbox/dcim/tables/devices.py | 4 +- netbox/templates/dcim/interface.html | 10 ++ netbox/templates/dcim/interface_edit.html | 10 ++ 14 files changed, 236 insertions(+), 13 deletions(-) create mode 100644 netbox/dcim/migrations/0136_wireless.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d6e44c281..edd73b87e 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -632,6 +632,8 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co parent = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) + rf_channel = ChoiceField(choices=WirelessChannelChoices) + rf_channel_width = ChoiceField(choices=WirelessChannelWidthChoices) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), @@ -646,10 +648,10 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co model = Interface fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'wwn', 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', - 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', - '_occupied', + 'wwn', 'mgmt_only', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'untagged_vlan', + 'tagged_vlans', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', 'count_ipaddresses', '_occupied', ] def validate(self, data): diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index acea294f8..9a78a74f9 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1135,6 +1135,137 @@ class CableLengthUnitChoices(ChoiceSet): ) +# +# Wireless +# + +class WirelessChannelChoices(ChoiceSet): + CHANNEL_AUTO = 'auto' + + # 2.4 GHz + CHANNEL_24G_1 = '2.4g-1' + CHANNEL_24G_2 = '2.4g-2' + CHANNEL_24G_3 = '2.4g-3' + CHANNEL_24G_4 = '2.4g-4' + CHANNEL_24G_5 = '2.4g-5' + CHANNEL_24G_6 = '2.4g-6' + CHANNEL_24G_7 = '2.4g-7' + CHANNEL_24G_8 = '2.4g-8' + CHANNEL_24G_9 = '2.4g-9' + CHANNEL_24G_10 = '2.4g-10' + CHANNEL_24G_11 = '2.4g-11' + CHANNEL_24G_12 = '2.4g-12' + CHANNEL_24G_13 = '2.4g-13' + + # 5 GHz + CHANNEL_5G_32 = '5g-32' + CHANNEL_5G_34 = '5g-34' + CHANNEL_5G_36 = '5g-36' + CHANNEL_5G_38 = '5g-38' + CHANNEL_5G_40 = '5g-40' + CHANNEL_5G_42 = '5g-42' + CHANNEL_5G_44 = '5g-44' + CHANNEL_5G_46 = '5g-46' + CHANNEL_5G_48 = '5g-48' + CHANNEL_5G_50 = '5g-50' + CHANNEL_5G_52 = '5g-52' + CHANNEL_5G_54 = '5g-54' + CHANNEL_5G_56 = '5g-56' + CHANNEL_5G_58 = '5g-58' + CHANNEL_5G_60 = '5g-60' + CHANNEL_5G_62 = '5g-62' + CHANNEL_5G_64 = '5g-64' + CHANNEL_5G_100 = '5g-100' + CHANNEL_5G_102 = '5g-102' + CHANNEL_5G_104 = '5g-104' + CHANNEL_5G_106 = '5g-106' + CHANNEL_5G_108 = '5g-108' + CHANNEL_5G_110 = '5g-110' + CHANNEL_5G_112 = '5g-112' + CHANNEL_5G_114 = '5g-114' + CHANNEL_5G_116 = '5g-116' + CHANNEL_5G_118 = '5g-118' + CHANNEL_5G_120 = '5g-120' + CHANNEL_5G_122 = '5g-122' + CHANNEL_5G_124 = '5g-124' + CHANNEL_5G_126 = '5g-126' + CHANNEL_5G_128 = '5g-128' + + CHOICES = ( + (CHANNEL_AUTO, 'Auto'), + ( + '2.4 GHz (802.11b/g/n/ax)', + ( + (CHANNEL_24G_1, '1 (2412 MHz)'), + (CHANNEL_24G_2, '2 (2417 MHz)'), + (CHANNEL_24G_3, '3 (2422 MHz)'), + (CHANNEL_24G_4, '4 (2427 MHz)'), + (CHANNEL_24G_5, '5 (2432 MHz)'), + (CHANNEL_24G_6, '6 (2437 MHz)'), + (CHANNEL_24G_7, '7 (2442 MHz)'), + (CHANNEL_24G_8, '8 (2447 MHz)'), + (CHANNEL_24G_9, '9 (2452 MHz)'), + (CHANNEL_24G_10, '10 (2457 MHz)'), + (CHANNEL_24G_11, '11 (2462 MHz)'), + (CHANNEL_24G_12, '12 (2467 MHz)'), + (CHANNEL_24G_13, '13 (2472 MHz)'), + ) + ), + ( + '5 GHz (802.11a/n/ac/ax)', + ( + (CHANNEL_5G_32, '32 (5160 MHz)'), + (CHANNEL_5G_34, '34 (5170 MHz)'), + (CHANNEL_5G_36, '36 (5180 MHz)'), + (CHANNEL_5G_38, '38 (5190 MHz)'), + (CHANNEL_5G_40, '40 (5200 MHz)'), + (CHANNEL_5G_42, '42 (5210 MHz)'), + (CHANNEL_5G_44, '44 (5220 MHz)'), + (CHANNEL_5G_46, '46 (5230 MHz)'), + (CHANNEL_5G_48, '48 (5240 MHz)'), + (CHANNEL_5G_50, '50 (5250 MHz)'), + (CHANNEL_5G_52, '52 (5260 MHz)'), + (CHANNEL_5G_54, '54 (5270 MHz)'), + (CHANNEL_5G_56, '56 (5280 MHz)'), + (CHANNEL_5G_58, '58 (5290 MHz)'), + (CHANNEL_5G_60, '60 (5300 MHz)'), + (CHANNEL_5G_62, '62 (5310 MHz)'), + (CHANNEL_5G_64, '64 (5320 MHz)'), + (CHANNEL_5G_100, '100 (5500 MHz)'), + (CHANNEL_5G_102, '102 (5510 MHz)'), + (CHANNEL_5G_104, '104 (5520 MHz)'), + (CHANNEL_5G_106, '106 (5530 MHz)'), + (CHANNEL_5G_108, '108 (5540 MHz)'), + (CHANNEL_5G_110, '110 (5550 MHz)'), + (CHANNEL_5G_112, '112 (5560 MHz)'), + (CHANNEL_5G_114, '114 (5570 MHz)'), + (CHANNEL_5G_116, '116 (5580 MHz)'), + (CHANNEL_5G_118, '118 (5590 MHz)'), + (CHANNEL_5G_120, '120 (5600 MHz)'), + (CHANNEL_5G_122, '122 (5610 MHz)'), + (CHANNEL_5G_124, '124 (5620 MHz)'), + (CHANNEL_5G_126, '126 (5630 MHz)'), + (CHANNEL_5G_128, '128 (5640 MHz)'), + ) + ), + ) + + +class WirelessChannelWidthChoices(ChoiceSet): + + CHANNEL_WIDTH_20 = 20 + CHANNEL_WIDTH_40 = 40 + CHANNEL_WIDTH_80 = 80 + CHANNEL_WIDTH_160 = 160 + + CHOICES = ( + (CHANNEL_WIDTH_20, '20 MHz'), + (CHANNEL_WIDTH_40, '40 MHz'), + (CHANNEL_WIDTH_80, '80 MHz'), + (CHANNEL_WIDTH_160, '160 MHz'), + ) + + # # PowerFeeds # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 2a4d368f4..0d64b357b 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -42,6 +42,7 @@ WIRELESS_IFACE_TYPES = [ InterfaceTypeChoices.TYPE_80211N, InterfaceTypeChoices.TYPE_80211AC, InterfaceTypeChoices.TYPE_80211AD, + InterfaceTypeChoices.TYPE_80211AX, ] NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 7f029097e..0c756957a 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -990,7 +990,10 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT class Meta: model = Interface - fields = ['id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] + fields = [ + 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_channel', 'rf_channel_width', + 'description', + ] def filter_device(self, queryset, name, value): try: diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index fd87d7304..67a482a26 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -926,7 +926,7 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ 'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', - 'mode', + 'mode', 'rf_channel', 'rf_channel_width', ]), BootstrapMixin, AddRemoveTagsForm, @@ -977,8 +977,8 @@ class InterfaceBulkEditForm( class Meta: nullable_fields = [ - 'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'untagged_vlan', - 'tagged_vlans', + 'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', + 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', ] def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index ff9ab6fff..a2685c8e0 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -584,7 +584,7 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): model = Interface fields = ( 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'wwn', - 'mtu', 'mgmt_only', 'description', 'mode', + 'mtu', 'mgmt_only', 'description', 'mode', 'rf_channel', 'rf_channel_width', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 4f4e10e96..605139c1b 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -963,6 +963,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'], + ['rf_channel', 'rf_channel_width'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], ] type = forms.MultipleChoiceField( @@ -990,6 +991,16 @@ class InterfaceFilterForm(DeviceComponentFilterForm): required=False, label='WWN' ) + rf_channel = forms.MultipleChoiceField( + choices=WirelessChannelChoices, + required=False, + widget=StaticSelectMultiple() + ) + rf_channel_width = forms.MultipleChoiceField( + choices=WirelessChannelWidthChoices, + required=False, + widget=StaticSelectMultiple() + ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index a8c2991a4..435fab309 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1098,12 +1098,15 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): model = Interface fields = [ 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', - 'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', + 'mark_connected', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', + 'tags', ] widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), 'mode': StaticSelect(), + 'rf_channel': StaticSelect(), + 'rf_channel_width': StaticSelect(), } labels = { 'mode': '802.1Q Mode', diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 7577ad355..db28412e6 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -465,7 +465,19 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): mode = forms.ChoiceField( choices=add_blank_choice(InterfaceModeChoices), required=False, + widget=StaticSelect() + ) + rf_channel = forms.ChoiceField( + choices=add_blank_choice(WirelessChannelChoices), + required=False, widget=StaticSelect(), + label='Wireless channel' + ) + rf_channel_width = forms.ChoiceField( + choices=add_blank_choice(WirelessChannelWidthChoices), + required=False, + widget=StaticSelect(), + label='Channel width' ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), @@ -477,7 +489,8 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): ) field_order = ( 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' + 'description', 'mgmt_only', 'mark_connected', 'rf_channel', 'rf_channel_width', 'mode' 'untagged_vlan', + 'tagged_vlans', 'tags' ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/migrations/0136_wireless.py b/netbox/dcim/migrations/0136_wireless.py new file mode 100644 index 000000000..429a72694 --- /dev/null +++ b/netbox/dcim/migrations/0136_wireless.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0135_location_tenant'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='rf_channel', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='interface', + name='rf_channel_width', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 386776b41..4e0d65f86 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -517,6 +517,18 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): verbose_name='WWN', help_text='64-bit World Wide Name' ) + rf_channel = models.CharField( + max_length=50, + choices=WirelessChannelChoices, + blank=True, + verbose_name='Wireless channel' + ) + rf_channel_width = models.PositiveSmallIntegerField( + choices=WirelessChannelWidthChoices, + blank=True, + null=True, + verbose_name='Channel width' + ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, @@ -603,6 +615,12 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): if self.pk and self.lag_id == self.pk: raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) + # RF channel attributes may be set only for wireless interfaces + if self.rf_channel and self.type not in WIRELESS_IFACE_TYPES: + raise ValidationError({'rf_channel': "Channel may be set only on wireless interfaces."}) + if self.rf_channel_width and self.type not in WIRELESS_IFACE_TYPES: + raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."}) + # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: raise ValidationError({ diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index c2b4b907b..1eae62a05 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -493,8 +493,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable model = Interface fields = ( 'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', - 'untagged_vlan', 'tagged_vlans', + 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', + 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index f9a9b0425..3283aac4f 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -39,6 +39,16 @@ Type {{ object.get_type_display }} + {% if object.is_wireless %} + + Channel + {{ object.get_rf_channel_display|placeholder }} + + + Channel Width + {{ object.get_rf_channel_width_display|placeholder }} + + {% endif %} Enabled diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 041eab73a..e91c74d31 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -29,6 +29,16 @@ {% render_field form.mark_connected %} + {% if form.instance.is_wireless %} +
+
+
Wireless
+
+ {% render_field form.rf_channel %} + {% render_field form.rf_channel_width %} +
+ {% endif %} +
802.1Q Switching
From 8b80b0c3df451a894c1286f7afdbb5cd940b8f61 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 12 Oct 2021 12:27:12 -0400 Subject: [PATCH 02/68] Introduce the wireless app and SSID model --- netbox/netbox/api/views.py | 1 + netbox/netbox/graphql/schema.py | 2 + netbox/netbox/navigation_menu.py | 14 +++++++ netbox/netbox/settings.py | 1 + netbox/netbox/urls.py | 2 + netbox/templates/wireless/ssid.html | 46 ++++++++++++++++++++++ netbox/wireless/__init__.py | 0 netbox/wireless/api/__init__.py | 0 netbox/wireless/api/nested_serializers.py | 16 ++++++++ netbox/wireless/api/serializers.py | 21 ++++++++++ netbox/wireless/api/urls.py | 12 ++++++ netbox/wireless/api/views.py | 24 +++++++++++ netbox/wireless/apps.py | 5 +++ netbox/wireless/filtersets.py | 31 +++++++++++++++ netbox/wireless/forms/__init__.py | 4 ++ netbox/wireless/forms/bulk_edit.py | 29 ++++++++++++++ netbox/wireless/forms/bulk_import.py | 20 ++++++++++ netbox/wireless/forms/filtersets.py | 19 +++++++++ netbox/wireless/forms/models.py | 32 +++++++++++++++ netbox/wireless/graphql/__init__.py | 0 netbox/wireless/graphql/schema.py | 9 +++++ netbox/wireless/graphql/types.py | 14 +++++++ netbox/wireless/migrations/0001_initial.py | 36 +++++++++++++++++ netbox/wireless/migrations/__init__.py | 0 netbox/wireless/models.py | 40 +++++++++++++++++++ netbox/wireless/tables.py | 24 +++++++++++ netbox/wireless/urls.py | 22 +++++++++++ netbox/wireless/views.py | 46 ++++++++++++++++++++++ 28 files changed, 470 insertions(+) create mode 100644 netbox/templates/wireless/ssid.html create mode 100644 netbox/wireless/__init__.py create mode 100644 netbox/wireless/api/__init__.py create mode 100644 netbox/wireless/api/nested_serializers.py create mode 100644 netbox/wireless/api/serializers.py create mode 100644 netbox/wireless/api/urls.py create mode 100644 netbox/wireless/api/views.py create mode 100644 netbox/wireless/apps.py create mode 100644 netbox/wireless/filtersets.py create mode 100644 netbox/wireless/forms/__init__.py create mode 100644 netbox/wireless/forms/bulk_edit.py create mode 100644 netbox/wireless/forms/bulk_import.py create mode 100644 netbox/wireless/forms/filtersets.py create mode 100644 netbox/wireless/forms/models.py create mode 100644 netbox/wireless/graphql/__init__.py create mode 100644 netbox/wireless/graphql/schema.py create mode 100644 netbox/wireless/graphql/types.py create mode 100644 netbox/wireless/migrations/0001_initial.py create mode 100644 netbox/wireless/migrations/__init__.py create mode 100644 netbox/wireless/models.py create mode 100644 netbox/wireless/tables.py create mode 100644 netbox/wireless/urls.py create mode 100644 netbox/wireless/views.py diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 74000e978..7ad64aeae 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -308,6 +308,7 @@ class APIRootView(APIView): ('tenancy', reverse('tenancy-api:api-root', request=request, format=format)), ('users', reverse('users-api:api-root', request=request, format=format)), ('virtualization', reverse('virtualization-api:api-root', request=request, format=format)), + ('wireless', reverse('wireless-api:api-root', request=request, format=format)), ))) diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py index bb752b8c4..812c1656d 100644 --- a/netbox/netbox/graphql/schema.py +++ b/netbox/netbox/graphql/schema.py @@ -7,6 +7,7 @@ from ipam.graphql.schema import IPAMQuery from tenancy.graphql.schema import TenancyQuery from users.graphql.schema import UsersQuery from virtualization.graphql.schema import VirtualizationQuery +from wireless.graphql.schema import WirelessQuery class Query( @@ -17,6 +18,7 @@ class Query( TenancyQuery, UsersQuery, VirtualizationQuery, + WirelessQuery, graphene.ObjectType ): pass diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index a3978f16e..0a78f35ab 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -188,6 +188,19 @@ CONNECTIONS_MENU = Menu( ), ) +WIRELESS_MENU = Menu( + label='Wireless', + icon_class='mdi mdi-wifi', + groups=( + MenuGroup( + label='Wireless', + items=( + get_model_item('wireless', 'ssid', 'SSIDs'), + ), + ), + ), +) + IPAM_MENU = Menu( label='IPAM', icon_class='mdi mdi-counter', @@ -343,6 +356,7 @@ MENUS = [ ORGANIZATION_MENU, DEVICES_MENU, CONNECTIONS_MENU, + WIRELESS_MENU, IPAM_MENU, VIRTUALIZATION_MENU, CIRCUITS_MENU, diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 3df9a855a..e41c77d1d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -326,6 +326,7 @@ INSTALLED_APPS = [ 'users', 'utilities', 'virtualization', + 'wireless', 'django_rq', # Must come after extras to allow overriding management commands 'drf_yasg', ] diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 3d4c60c93..4e0a2e2c6 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -48,6 +48,7 @@ _patterns = [ path('tenancy/', include('tenancy.urls')), path('user/', include('users.urls')), path('virtualization/', include('virtualization.urls')), + path('wireless/', include('wireless.urls')), # API path('api/', APIRootView.as_view(), name='api-root'), @@ -58,6 +59,7 @@ _patterns = [ path('api/tenancy/', include('tenancy.api.urls')), path('api/users/', include('users.api.urls')), path('api/virtualization/', include('virtualization.api.urls')), + path('api/wireless/', include('wireless.api.urls')), path('api/status/', StatusView.as_view(), name='api-status'), path('api/docs/', schema_view.with_ui('swagger', cache_timeout=86400), name='api_docs'), path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=86400), name='api_redocs'), diff --git a/netbox/templates/wireless/ssid.html b/netbox/templates/wireless/ssid.html new file mode 100644 index 000000000..5425149aa --- /dev/null +++ b/netbox/templates/wireless/ssid.html @@ -0,0 +1,46 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
SSID
+
+ + + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|placeholder }}
VLAN + {% if object.vlan %} + {{ object.vlan }} + {% else %} + None + {% endif %} +
+
+
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %} + {% plugin_left_page object %} +
+
+ {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/wireless/__init__.py b/netbox/wireless/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/wireless/api/__init__.py b/netbox/wireless/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/wireless/api/nested_serializers.py b/netbox/wireless/api/nested_serializers.py new file mode 100644 index 000000000..50454a641 --- /dev/null +++ b/netbox/wireless/api/nested_serializers.py @@ -0,0 +1,16 @@ +from rest_framework import serializers + +from netbox.api import WritableNestedSerializer +from wireless.models import * + +__all__ = ( + 'NestedSSIDSerializer', +) + + +class NestedSSIDSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail') + + class Meta: + model = SSID + fields = ['id', 'url', 'display', 'name'] diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py new file mode 100644 index 000000000..c129e5c96 --- /dev/null +++ b/netbox/wireless/api/serializers.py @@ -0,0 +1,21 @@ +from rest_framework import serializers + +from dcim.api.serializers import NestedInterfaceSerializer +from ipam.api.serializers import NestedVLANSerializer +from netbox.api.serializers import PrimaryModelSerializer +from wireless.models import * + +__all__ = ( + 'SSIDSerializer', +) + + +class SSIDSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail') + vlan = NestedVLANSerializer(required=False, allow_null=True) + + class Meta: + model = SSID + fields = [ + 'id', 'url', 'display', 'name', 'description', 'vlan', + ] diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py new file mode 100644 index 000000000..f6936708c --- /dev/null +++ b/netbox/wireless/api/urls.py @@ -0,0 +1,12 @@ +from netbox.api import OrderedDefaultRouter +from . import views + + +router = OrderedDefaultRouter() +router.APIRootView = views.WirelessRootView + +# SSIDs +router.register('ssids', views.SSIDViewSet) + +app_name = 'wireless-api' +urlpatterns = router.urls diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py new file mode 100644 index 000000000..97827eb7e --- /dev/null +++ b/netbox/wireless/api/views.py @@ -0,0 +1,24 @@ +from rest_framework.routers import APIRootView + +from extras.api.views import CustomFieldModelViewSet +from wireless import filtersets +from wireless.models import * +from . import serializers + + +class WirelessRootView(APIRootView): + """ + Wireless API root view + """ + def get_view_name(self): + return 'Wireless' + + +# +# Providers +# + +class SSIDViewSet(CustomFieldModelViewSet): + queryset = SSID.objects.prefetch_related('tags') + serializer_class = serializers.SSIDSerializer + filterset_class = filtersets.SSIDFilterSet diff --git a/netbox/wireless/apps.py b/netbox/wireless/apps.py new file mode 100644 index 000000000..1f6deff22 --- /dev/null +++ b/netbox/wireless/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class WirelessConfig(AppConfig): + name = 'wireless' diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py new file mode 100644 index 000000000..232bc74ff --- /dev/null +++ b/netbox/wireless/filtersets.py @@ -0,0 +1,31 @@ +import django_filters +from django.db.models import Q + +from extras.filters import TagFilter +from netbox.filtersets import PrimaryModelFilterSet +from .models import * + +__all__ = ( + 'SSIDFilterSet', +) + + +class SSIDFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + tag = TagFilter() + + class Meta: + model = SSID + fields = ['id', 'name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + return queryset.filter(qs_filter) diff --git a/netbox/wireless/forms/__init__.py b/netbox/wireless/forms/__init__.py new file mode 100644 index 000000000..62c2ec2d9 --- /dev/null +++ b/netbox/wireless/forms/__init__.py @@ -0,0 +1,4 @@ +from .models import * +from .filtersets import * +from .bulk_edit import * +from .bulk_import import * diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py new file mode 100644 index 000000000..ed9fb650b --- /dev/null +++ b/netbox/wireless/forms/bulk_edit.py @@ -0,0 +1,29 @@ +from django import forms + +from dcim.models import * +from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm +from ipam.models import VLAN +from utilities.forms import BootstrapMixin, DynamicModelChoiceField + +__all__ = ( + 'SSIDBulkEditForm', +) + + +class SSIDBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerFeed.objects.all(), + widget=forms.MultipleHiddenInput + ) + vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + ) + description = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = [ + 'vlan', 'description', + ] diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py new file mode 100644 index 000000000..0cf997fd3 --- /dev/null +++ b/netbox/wireless/forms/bulk_import.py @@ -0,0 +1,20 @@ +from extras.forms import CustomFieldModelCSVForm +from ipam.models import VLAN +from utilities.forms import CSVModelChoiceField +from wireless.models import SSID + +__all__ = ( + 'SSIDCSVForm', +) + + +class SSIDCSVForm(CustomFieldModelCSVForm): + vlan = CSVModelChoiceField( + queryset=VLAN.objects.all(), + to_field_name='name', + help_text='Bridged VLAN' + ) + + class Meta: + model = SSID + fields = ('name', 'description', 'vlan') diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py new file mode 100644 index 000000000..733b807f7 --- /dev/null +++ b/netbox/wireless/forms/filtersets.py @@ -0,0 +1,19 @@ +from django import forms +from django.utils.translation import gettext as _ + +from dcim.models import * +from extras.forms import CustomFieldModelFilterForm +from utilities.forms import BootstrapMixin, TagFilterField + + +class SSIDFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = PowerFeed + field_groups = [ + ['q', 'tag'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + tag = TagFilterField(model) diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py new file mode 100644 index 000000000..ea6d51223 --- /dev/null +++ b/netbox/wireless/forms/models.py @@ -0,0 +1,32 @@ +from dcim.constants import * +from dcim.models import * +from extras.forms import CustomFieldModelForm +from extras.models import Tag +from ipam.models import VLAN +from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from wireless.models import SSID + +__all__ = ( + 'SSIDForm', +) + + +class SSIDForm(BootstrapMixin, CustomFieldModelForm): + vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = SSID + fields = [ + 'name', 'description', 'vlan', 'tags', + ] + fieldsets = ( + ('SSID', ('name', 'description', 'tags')), + ('VLAN', ('vlan',)), + ) diff --git a/netbox/wireless/graphql/__init__.py b/netbox/wireless/graphql/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/wireless/graphql/schema.py b/netbox/wireless/graphql/schema.py new file mode 100644 index 000000000..d0beec7d9 --- /dev/null +++ b/netbox/wireless/graphql/schema.py @@ -0,0 +1,9 @@ +import graphene + +from netbox.graphql.fields import ObjectField, ObjectListField +from .types import * + + +class WirelessQuery(graphene.ObjectType): + ssid = ObjectField(SSIDType) + ssid_list = ObjectListField(SSIDType) diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py new file mode 100644 index 000000000..66e73429d --- /dev/null +++ b/netbox/wireless/graphql/types.py @@ -0,0 +1,14 @@ +from wireless import filtersets, models +from netbox.graphql.types import ObjectType + +__all__ = ( + 'SSIDType', +) + + +class SSIDType(ObjectType): + + class Meta: + model = models.SSID + fields = '__all__' + filterset_class = filtersets.SSIDFilterSet diff --git a/netbox/wireless/migrations/0001_initial.py b/netbox/wireless/migrations/0001_initial.py new file mode 100644 index 000000000..b0011dad9 --- /dev/null +++ b/netbox/wireless/migrations/0001_initial.py @@ -0,0 +1,36 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dcim', '0136_wireless'), + ('extras', '0062_clear_secrets_changelog'), + ('ipam', '0050_iprange'), + ] + + operations = [ + migrations.CreateModel( + name='SSID', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=32)), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlan')), + ], + options={ + 'verbose_name': 'SSID', + 'verbose_name_plural': 'SSIDs', + 'ordering': ('name', 'pk'), + }, + ), + ] diff --git a/netbox/wireless/migrations/__init__.py b/netbox/wireless/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py new file mode 100644 index 000000000..5bb964345 --- /dev/null +++ b/netbox/wireless/models.py @@ -0,0 +1,40 @@ +from django.db import models + +from extras.utils import extras_features +from netbox.models import PrimaryModel +from utilities.querysets import RestrictedQuerySet + +__all__ = ( + 'SSID', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class SSID(PrimaryModel): + """ + A service set identifier belonging to a wireless network. + """ + name = models.CharField( + max_length=32 + ) + vlan = models.ForeignKey( + to='ipam.VLAN', + on_delete=models.PROTECT, + blank=True, + null=True, + verbose_name='VLAN' + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('name', 'pk') + verbose_name = 'SSID' + verbose_name_plural = 'SSIDs' + + def __str__(self): + return self.name diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py new file mode 100644 index 000000000..846296bb4 --- /dev/null +++ b/netbox/wireless/tables.py @@ -0,0 +1,24 @@ +import django_tables2 as tables + +from .models import SSID +from utilities.tables import BaseTable, TagColumn, ToggleColumn + +__all__ = ( + 'SSIDTable', +) + + +class SSIDTable(BaseTable): + pk = ToggleColumn() + id = tables.Column( + linkify=True, + verbose_name='ID' + ) + tags = TagColumn( + url_name='dcim:cable_list' + ) + + class Meta(BaseTable.Meta): + model = SSID + fields = ('pk', 'id', 'name', 'description', 'vlan') + default_columns = ('pk', 'name', 'description', 'vlan') diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py new file mode 100644 index 000000000..57e0eab9b --- /dev/null +++ b/netbox/wireless/urls.py @@ -0,0 +1,22 @@ +from django.urls import path + +from extras.views import ObjectChangeLogView, ObjectJournalView +from . import views +from .models import * + +app_name = 'wireless' +urlpatterns = ( + + # SSIDs + path('ssids/', views.SSIDListView.as_view(), name='ssid_list'), + path('ssids/add/', views.SSIDEditView.as_view(), name='ssid_add'), + path('ssids/import/', views.SSIDBulkImportView.as_view(), name='ssid_import'), + path('ssids/edit/', views.SSIDBulkEditView.as_view(), name='ssid_bulk_edit'), + path('ssids/delete/', views.SSIDBulkDeleteView.as_view(), name='ssid_bulk_delete'), + path('ssids//', views.SSIDView.as_view(), name='ssid'), + path('ssids//edit/', views.SSIDEditView.as_view(), name='ssid_edit'), + path('ssids//delete/', views.SSIDDeleteView.as_view(), name='ssid_delete'), + path('ssids//changelog/', ObjectChangeLogView.as_view(), name='ssid_changelog', kwargs={'model': SSID}), + path('ssids//journal/', ObjectJournalView.as_view(), name='ssid_journal', kwargs={'model': SSID}), + +) diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py new file mode 100644 index 000000000..b0d1f5156 --- /dev/null +++ b/netbox/wireless/views.py @@ -0,0 +1,46 @@ +from netbox.views import generic +from . import filtersets, forms, tables +from .models import * + + +# +# SSIDs +# + +class SSIDListView(generic.ObjectListView): + queryset = SSID.objects.all() + filterset = filtersets.SSIDFilterSet + filterset_form = forms.SSIDFilterForm + table = tables.SSIDTable + + +class SSIDView(generic.ObjectView): + queryset = SSID.objects.prefetch_related('power_panel', 'rack') + + +class SSIDEditView(generic.ObjectEditView): + queryset = SSID.objects.all() + model_form = forms.SSIDForm + + +class SSIDDeleteView(generic.ObjectDeleteView): + queryset = SSID.objects.all() + + +class SSIDBulkImportView(generic.BulkImportView): + queryset = SSID.objects.all() + model_form = forms.SSIDCSVForm + table = tables.SSIDTable + + +class SSIDBulkEditView(generic.BulkEditView): + queryset = SSID.objects.prefetch_related('power_panel', 'rack') + filterset = filtersets.SSIDFilterSet + table = tables.SSIDTable + form = forms.SSIDBulkEditForm + + +class SSIDBulkDeleteView(generic.BulkDeleteView): + queryset = SSID.objects.prefetch_related('power_panel', 'rack') + filterset = filtersets.SSIDFilterSet + table = tables.SSIDTable From 38f6d22d2d7ac412efa9b50ef4f598d17726e0a9 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 12 Oct 2021 13:48:06 -0400 Subject: [PATCH 03/68] Enable attachment of wireless interfaces to SSIDs --- netbox/dcim/forms/models.py | 10 ++++++-- netbox/dcim/migrations/0136_wireless.py | 6 +++++ netbox/dcim/models/device_components.py | 6 +++++ netbox/templates/dcim/interface.html | 27 ++++++++++++++++++++++ netbox/templates/dcim/interface_edit.html | 1 + netbox/wireless/migrations/0001_initial.py | 1 - netbox/wireless/models.py | 10 +++++++- netbox/wireless/tables.py | 7 +++--- netbox/wireless/views.py | 6 ++--- 9 files changed, 63 insertions(+), 11 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 435fab309..2a6dc1f6f 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -16,6 +16,7 @@ from utilities.forms import ( SlugField, StaticSelect, ) from virtualization.models import Cluster, ClusterGroup +from wireless.models import SSID from .common import InterfaceCommonForm __all__ = ( @@ -1068,6 +1069,11 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): 'type': 'lag', } ) + ssids = DynamicModelMultipleChoiceField( + queryset=SSID.objects.all(), + required=False, + label='SSIDs' + ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, @@ -1098,8 +1104,8 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): model = Interface fields = [ 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', - 'mark_connected', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', - 'tags', + 'mark_connected', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'ssids', 'untagged_vlan', + 'tagged_vlans', 'tags', ] widgets = { 'device': forms.HiddenInput(), diff --git a/netbox/dcim/migrations/0136_wireless.py b/netbox/dcim/migrations/0136_wireless.py index 429a72694..0a1d15365 100644 --- a/netbox/dcim/migrations/0136_wireless.py +++ b/netbox/dcim/migrations/0136_wireless.py @@ -4,6 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ + ('wireless', '__first__'), ('dcim', '0135_location_tenant'), ] @@ -18,4 +19,9 @@ class Migration(migrations.Migration): name='rf_channel_width', field=models.PositiveSmallIntegerField(blank=True, null=True), ), + migrations.AddField( + model_name='interface', + name='ssids', + field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.SSID'), + ), ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 4e0d65f86..5e3e7e6db 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -529,6 +529,12 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): null=True, verbose_name='Channel width' ) + ssids = models.ManyToManyField( + to='wireless.SSID', + related_name='interfaces', + blank=True, + verbose_name='SSIDs' + ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 3283aac4f..bc4fc23e2 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -258,6 +258,33 @@
{% endif %} + {% if object.is_wireless %} +
+
SSIDs
+
+ + + + + + + + {% for ssid in object.ssids.all %} + + + + {% empty %} + + + + {% endfor %} + +
Name
+ {{ ssid.name }} +
None
+
+
+ {% endif %} {% if object.is_lag %}
LAG Members
diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index e91c74d31..9fc752432 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -36,6 +36,7 @@
{% render_field form.rf_channel %} {% render_field form.rf_channel_width %} + {% render_field form.ssids %} {% endif %} diff --git a/netbox/wireless/migrations/0001_initial.py b/netbox/wireless/migrations/0001_initial.py index b0011dad9..78d1dfc73 100644 --- a/netbox/wireless/migrations/0001_initial.py +++ b/netbox/wireless/migrations/0001_initial.py @@ -9,7 +9,6 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('dcim', '0136_wireless'), ('extras', '0062_clear_secrets_changelog'), ('ipam', '0050_iprange'), ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 5bb964345..2bdcecd79 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -1,7 +1,8 @@ from django.db import models +from django.urls import reverse from extras.utils import extras_features -from netbox.models import PrimaryModel +from netbox.models import BigIDModel, PrimaryModel from utilities.querysets import RestrictedQuerySet __all__ = ( @@ -9,6 +10,10 @@ __all__ = ( ) +# +# SSIDs +# + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class SSID(PrimaryModel): """ @@ -38,3 +43,6 @@ class SSID(PrimaryModel): def __str__(self): return self.name + + def get_absolute_url(self): + return reverse('wireless:ssid', args=[self.pk]) diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 846296bb4..9d3705549 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -10,9 +10,8 @@ __all__ = ( class SSIDTable(BaseTable): pk = ToggleColumn() - id = tables.Column( - linkify=True, - verbose_name='ID' + name = tables.Column( + linkify=True ) tags = TagColumn( url_name='dcim:cable_list' @@ -20,5 +19,5 @@ class SSIDTable(BaseTable): class Meta(BaseTable.Meta): model = SSID - fields = ('pk', 'id', 'name', 'description', 'vlan') + fields = ('pk', 'name', 'description', 'vlan') default_columns = ('pk', 'name', 'description', 'vlan') diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index b0d1f5156..b741330b7 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -15,7 +15,7 @@ class SSIDListView(generic.ObjectListView): class SSIDView(generic.ObjectView): - queryset = SSID.objects.prefetch_related('power_panel', 'rack') + queryset = SSID.objects.all() class SSIDEditView(generic.ObjectEditView): @@ -34,13 +34,13 @@ class SSIDBulkImportView(generic.BulkImportView): class SSIDBulkEditView(generic.BulkEditView): - queryset = SSID.objects.prefetch_related('power_panel', 'rack') + queryset = SSID.objects.all() filterset = filtersets.SSIDFilterSet table = tables.SSIDTable form = forms.SSIDBulkEditForm class SSIDBulkDeleteView(generic.BulkDeleteView): - queryset = SSID.objects.prefetch_related('power_panel', 'rack') + queryset = SSID.objects.all() filterset = filtersets.SSIDFilterSet table = tables.SSIDTable From 5271680483709114760dc0695e3d1dbbb6f45186 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 12 Oct 2021 17:02:53 -0400 Subject: [PATCH 04/68] Rename SSID model to WirelessLAN --- netbox/dcim/forms/models.py | 10 ++-- netbox/dcim/migrations/0136_wireless.py | 6 +-- netbox/dcim/models/device_components.py | 6 +-- netbox/netbox/navigation_menu.py | 2 +- netbox/templates/dcim/interface.html | 8 +-- netbox/templates/dcim/interface_edit.html | 2 +- .../wireless/{ssid.html => wirelesslan.html} | 6 +-- netbox/wireless/api/nested_serializers.py | 8 +-- netbox/wireless/api/serializers.py | 9 ++-- netbox/wireless/api/urls.py | 3 +- netbox/wireless/api/views.py | 8 +-- netbox/wireless/constants.py | 1 + netbox/wireless/filtersets.py | 10 ++-- netbox/wireless/forms/bulk_edit.py | 4 +- netbox/wireless/forms/bulk_import.py | 10 ++-- netbox/wireless/forms/filtersets.py | 6 +-- netbox/wireless/forms/models.py | 14 +++-- netbox/wireless/graphql/schema.py | 4 +- netbox/wireless/graphql/types.py | 8 +-- netbox/wireless/migrations/0001_initial.py | 11 ++-- netbox/wireless/models.py | 19 +++---- netbox/wireless/tables.py | 16 +++--- netbox/wireless/urls.py | 22 ++++---- netbox/wireless/views.py | 52 +++++++++---------- 24 files changed, 119 insertions(+), 126 deletions(-) rename netbox/templates/wireless/{ssid.html => wirelesslan.html} (90%) create mode 100644 netbox/wireless/constants.py diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 2a6dc1f6f..cd697e9f3 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -16,7 +16,7 @@ from utilities.forms import ( SlugField, StaticSelect, ) from virtualization.models import Cluster, ClusterGroup -from wireless.models import SSID +from wireless.models import WirelessLAN from .common import InterfaceCommonForm __all__ = ( @@ -1069,10 +1069,10 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): 'type': 'lag', } ) - ssids = DynamicModelMultipleChoiceField( - queryset=SSID.objects.all(), + wireless_lans = DynamicModelMultipleChoiceField( + queryset=WirelessLAN.objects.all(), required=False, - label='SSIDs' + label='Wireless LANs' ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), @@ -1104,7 +1104,7 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): model = Interface fields = [ 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', - 'mark_connected', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'ssids', 'untagged_vlan', + 'mark_connected', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { diff --git a/netbox/dcim/migrations/0136_wireless.py b/netbox/dcim/migrations/0136_wireless.py index 0a1d15365..108e63802 100644 --- a/netbox/dcim/migrations/0136_wireless.py +++ b/netbox/dcim/migrations/0136_wireless.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('wireless', '__first__'), + ('wireless', '0001_initial'), ('dcim', '0135_location_tenant'), ] @@ -21,7 +21,7 @@ class Migration(migrations.Migration): ), migrations.AddField( model_name='interface', - name='ssids', - field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.SSID'), + name='wireless_lans', + field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.WirelessLAN'), ), ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 5e3e7e6db..60eb4c368 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -529,11 +529,11 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): null=True, verbose_name='Channel width' ) - ssids = models.ManyToManyField( - to='wireless.SSID', + wireless_lans = models.ManyToManyField( + to='wireless.WirelessLAN', related_name='interfaces', blank=True, - verbose_name='SSIDs' + verbose_name='Wireless LANs' ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 0a78f35ab..073189d31 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -195,7 +195,7 @@ WIRELESS_MENU = Menu( MenuGroup( label='Wireless', items=( - get_model_item('wireless', 'ssid', 'SSIDs'), + get_model_item('wireless', 'wirelesslan', 'Wireless LANs'), ), ), ), diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index bc4fc23e2..33eaa95db 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -260,19 +260,19 @@ {% endif %} {% if object.is_wireless %}
-
SSIDs
+
Wireless LANs
- + - {% for ssid in object.ssids.all %} + {% for wlan in object.wlans.all %} {% empty %} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 9fc752432..51834f4e2 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -36,7 +36,7 @@ {% render_field form.rf_channel %} {% render_field form.rf_channel_width %} - {% render_field form.ssids %} + {% render_field form.wireless_lans %} {% endif %} diff --git a/netbox/templates/wireless/ssid.html b/netbox/templates/wireless/wirelesslan.html similarity index 90% rename from netbox/templates/wireless/ssid.html rename to netbox/templates/wireless/wirelesslan.html index 5425149aa..98bde8688 100644 --- a/netbox/templates/wireless/ssid.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -6,12 +6,12 @@
-
SSID
+
Wireless LAN
NameSSID
- {{ ssid.name }} + {{ wlan.ssid }}
- - + + diff --git a/netbox/wireless/api/nested_serializers.py b/netbox/wireless/api/nested_serializers.py index 50454a641..e290653a2 100644 --- a/netbox/wireless/api/nested_serializers.py +++ b/netbox/wireless/api/nested_serializers.py @@ -4,13 +4,13 @@ from netbox.api import WritableNestedSerializer from wireless.models import * __all__ = ( - 'NestedSSIDSerializer', + 'NestedWirelessLANSerializer', ) -class NestedSSIDSerializer(WritableNestedSerializer): +class NestedWirelessLANSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail') class Meta: - model = SSID - fields = ['id', 'url', 'display', 'name'] + model = WirelessLAN + fields = ['id', 'url', 'display', 'ssid'] diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index c129e5c96..08642259f 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -1,21 +1,20 @@ from rest_framework import serializers -from dcim.api.serializers import NestedInterfaceSerializer from ipam.api.serializers import NestedVLANSerializer from netbox.api.serializers import PrimaryModelSerializer from wireless.models import * __all__ = ( - 'SSIDSerializer', + 'WirelessLANSerializer', ) -class SSIDSerializer(PrimaryModelSerializer): +class WirelessLANSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail') vlan = NestedVLANSerializer(required=False, allow_null=True) class Meta: - model = SSID + model = WirelessLAN fields = [ - 'id', 'url', 'display', 'name', 'description', 'vlan', + 'id', 'url', 'display', 'ssid', 'description', 'vlan', ] diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py index f6936708c..638f31bbf 100644 --- a/netbox/wireless/api/urls.py +++ b/netbox/wireless/api/urls.py @@ -5,8 +5,7 @@ from . import views router = OrderedDefaultRouter() router.APIRootView = views.WirelessRootView -# SSIDs -router.register('ssids', views.SSIDViewSet) +router.register('wireless-lans', views.WirelessLANViewSet) app_name = 'wireless-api' urlpatterns = router.urls diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py index 97827eb7e..a09b2e23d 100644 --- a/netbox/wireless/api/views.py +++ b/netbox/wireless/api/views.py @@ -18,7 +18,7 @@ class WirelessRootView(APIRootView): # Providers # -class SSIDViewSet(CustomFieldModelViewSet): - queryset = SSID.objects.prefetch_related('tags') - serializer_class = serializers.SSIDSerializer - filterset_class = filtersets.SSIDFilterSet +class WirelessLANViewSet(CustomFieldModelViewSet): + queryset = WirelessLAN.objects.prefetch_related('tags') + serializer_class = serializers.WirelessLANSerializer + filterset_class = filtersets.WirelessLANFilterSet diff --git a/netbox/wireless/constants.py b/netbox/wireless/constants.py new file mode 100644 index 000000000..188c4abd9 --- /dev/null +++ b/netbox/wireless/constants.py @@ -0,0 +1 @@ +SSID_MAX_LENGTH = 32 # Per IEEE 802.11-2007 diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 232bc74ff..c148354a0 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -6,11 +6,11 @@ from netbox.filtersets import PrimaryModelFilterSet from .models import * __all__ = ( - 'SSIDFilterSet', + 'WirelessLANFilterSet', ) -class SSIDFilterSet(PrimaryModelFilterSet): +class WirelessLANFilterSet(PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -18,14 +18,14 @@ class SSIDFilterSet(PrimaryModelFilterSet): tag = TagFilter() class Meta: - model = SSID - fields = ['id', 'name'] + model = WirelessLAN + fields = ['id', 'ssid'] def search(self, queryset, name, value): if not value.strip(): return queryset qs_filter = ( - Q(name__icontains=value) | + Q(ssid__icontains=value) | Q(description__icontains=value) ) return queryset.filter(qs_filter) diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index ed9fb650b..c11a16239 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -6,11 +6,11 @@ from ipam.models import VLAN from utilities.forms import BootstrapMixin, DynamicModelChoiceField __all__ = ( - 'SSIDBulkEditForm', + 'WirelessLANBulkEditForm', ) -class SSIDBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=PowerFeed.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 0cf997fd3..5dc07f91a 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -1,14 +1,14 @@ from extras.forms import CustomFieldModelCSVForm from ipam.models import VLAN from utilities.forms import CSVModelChoiceField -from wireless.models import SSID +from wireless.models import WirelessLAN __all__ = ( - 'SSIDCSVForm', + 'WirelessLANCSVForm', ) -class SSIDCSVForm(CustomFieldModelCSVForm): +class WirelessLANCSVForm(CustomFieldModelCSVForm): vlan = CSVModelChoiceField( queryset=VLAN.objects.all(), to_field_name='name', @@ -16,5 +16,5 @@ class SSIDCSVForm(CustomFieldModelCSVForm): ) class Meta: - model = SSID - fields = ('name', 'description', 'vlan') + model = WirelessLAN + fields = ('ssid', 'description', 'vlan') diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 733b807f7..99e38918e 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -1,13 +1,13 @@ from django import forms from django.utils.translation import gettext as _ -from dcim.models import * from extras.forms import CustomFieldModelFilterForm from utilities.forms import BootstrapMixin, TagFilterField +from .models import WirelessLAN -class SSIDFilterForm(BootstrapMixin, CustomFieldModelFilterForm): - model = PowerFeed +class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = WirelessLAN field_groups = [ ['q', 'tag'], ] diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index ea6d51223..95b43c7d3 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -1,17 +1,15 @@ -from dcim.constants import * -from dcim.models import * from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import VLAN from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField -from wireless.models import SSID +from wireless.models import WirelessLAN __all__ = ( - 'SSIDForm', + 'WirelessLANForm', ) -class SSIDForm(BootstrapMixin, CustomFieldModelForm): +class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False @@ -22,11 +20,11 @@ class SSIDForm(BootstrapMixin, CustomFieldModelForm): ) class Meta: - model = SSID + model = WirelessLAN fields = [ - 'name', 'description', 'vlan', 'tags', + 'ssid', 'description', 'vlan', 'tags', ] fieldsets = ( - ('SSID', ('name', 'description', 'tags')), + ('Wireless LAN', ('ssid', 'description', 'tags')), ('VLAN', ('vlan',)), ) diff --git a/netbox/wireless/graphql/schema.py b/netbox/wireless/graphql/schema.py index d0beec7d9..8297f4545 100644 --- a/netbox/wireless/graphql/schema.py +++ b/netbox/wireless/graphql/schema.py @@ -5,5 +5,5 @@ from .types import * class WirelessQuery(graphene.ObjectType): - ssid = ObjectField(SSIDType) - ssid_list = ObjectListField(SSIDType) + wirelesslan = ObjectField(WirelessLANType) + wirelesslan_list = ObjectListField(WirelessLANType) diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index 66e73429d..4cdb75ebe 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/graphql/types.py @@ -2,13 +2,13 @@ from wireless import filtersets, models from netbox.graphql.types import ObjectType __all__ = ( - 'SSIDType', + 'WirelessLANType', ) -class SSIDType(ObjectType): +class WirelessLANType(ObjectType): class Meta: - model = models.SSID + model = models.WirelessLAN fields = '__all__' - filterset_class = filtersets.SSIDFilterSet + filterset_class = filtersets.WirelessLANFilterSet diff --git a/netbox/wireless/migrations/0001_initial.py b/netbox/wireless/migrations/0001_initial.py index 78d1dfc73..c93a17190 100644 --- a/netbox/wireless/migrations/0001_initial.py +++ b/netbox/wireless/migrations/0001_initial.py @@ -9,27 +9,26 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('extras', '0062_clear_secrets_changelog'), ('ipam', '0050_iprange'), + ('extras', '0062_clear_secrets_changelog'), ] operations = [ migrations.CreateModel( - name='SSID', + name='WirelessLAN', fields=[ ('created', models.DateField(auto_now_add=True, null=True)), ('last_updated', models.DateTimeField(auto_now=True, null=True)), ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=32)), + ('ssid', models.CharField(max_length=32)), ('description', models.CharField(blank=True, max_length=200)), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), ('vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlan')), ], options={ - 'verbose_name': 'SSID', - 'verbose_name_plural': 'SSIDs', - 'ordering': ('name', 'pk'), + 'verbose_name': 'Wireless LAN', + 'ordering': ('ssid', 'pk'), }, ), ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 2bdcecd79..363631ef5 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -1,25 +1,23 @@ +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse +from dcim.constants import WIRELESS_IFACE_TYPES from extras.utils import extras_features from netbox.models import BigIDModel, PrimaryModel from utilities.querysets import RestrictedQuerySet __all__ = ( - 'SSID', + 'WirelessLAN', ) -# -# SSIDs -# - @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class SSID(PrimaryModel): +class WirelessLAN(PrimaryModel): """ A service set identifier belonging to a wireless network. """ - name = models.CharField( + ssid = models.CharField( max_length=32 ) vlan = models.ForeignKey( @@ -37,12 +35,11 @@ class SSID(PrimaryModel): objects = RestrictedQuerySet.as_manager() class Meta: - ordering = ('name', 'pk') - verbose_name = 'SSID' - verbose_name_plural = 'SSIDs' + ordering = ('ssid', 'pk') + verbose_name = 'Wireless LAN' def __str__(self): - return self.name + return self.ssid def get_absolute_url(self): return reverse('wireless:ssid', args=[self.pk]) diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 9d3705549..133353f57 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,23 +1,23 @@ import django_tables2 as tables -from .models import SSID +from .models import WirelessLAN from utilities.tables import BaseTable, TagColumn, ToggleColumn __all__ = ( - 'SSIDTable', + 'WirelessLANTable', ) -class SSIDTable(BaseTable): +class WirelessLANTable(BaseTable): pk = ToggleColumn() - name = tables.Column( + ssid = tables.Column( linkify=True ) tags = TagColumn( - url_name='dcim:cable_list' + url_name='wireless:wirelesslan_list' ) class Meta(BaseTable.Meta): - model = SSID - fields = ('pk', 'name', 'description', 'vlan') - default_columns = ('pk', 'name', 'description', 'vlan') + model = WirelessLAN + fields = ('pk', 'ssid', 'description', 'vlan') + default_columns = ('pk', 'ssid', 'description', 'vlan') diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py index 57e0eab9b..c30ca472c 100644 --- a/netbox/wireless/urls.py +++ b/netbox/wireless/urls.py @@ -7,16 +7,16 @@ from .models import * app_name = 'wireless' urlpatterns = ( - # SSIDs - path('ssids/', views.SSIDListView.as_view(), name='ssid_list'), - path('ssids/add/', views.SSIDEditView.as_view(), name='ssid_add'), - path('ssids/import/', views.SSIDBulkImportView.as_view(), name='ssid_import'), - path('ssids/edit/', views.SSIDBulkEditView.as_view(), name='ssid_bulk_edit'), - path('ssids/delete/', views.SSIDBulkDeleteView.as_view(), name='ssid_bulk_delete'), - path('ssids//', views.SSIDView.as_view(), name='ssid'), - path('ssids//edit/', views.SSIDEditView.as_view(), name='ssid_edit'), - path('ssids//delete/', views.SSIDDeleteView.as_view(), name='ssid_delete'), - path('ssids//changelog/', ObjectChangeLogView.as_view(), name='ssid_changelog', kwargs={'model': SSID}), - path('ssids//journal/', ObjectJournalView.as_view(), name='ssid_journal', kwargs={'model': SSID}), + # Wireless LANs + path('wireless-lans/', views.WirelessLANListView.as_view(), name='wirelesslan_list'), + path('wireless-lans/add/', views.WirelessLANEditView.as_view(), name='wirelesslan_add'), + path('wireless-lans/import/', views.WirelessLANBulkImportView.as_view(), name='wirelesslan_import'), + path('wireless-lans/edit/', views.WirelessLANBulkEditView.as_view(), name='wirelesslan_bulk_edit'), + path('wireless-lans/delete/', views.WirelessLANBulkDeleteView.as_view(), name='wirelesslan_bulk_delete'), + path('wireless-lans//', views.WirelessLANView.as_view(), name='wirelesslan'), + path('wireless-lans//edit/', views.WirelessLANEditView.as_view(), name='wirelesslan_edit'), + path('wireless-lans//delete/', views.WirelessLANDeleteView.as_view(), name='wirelesslan_delete'), + path('wireless-lans//changelog/', ObjectChangeLogView.as_view(), name='wirelesslan_changelog', kwargs={'model': WirelessLAN}), + path('wireless-lans//journal/', ObjectJournalView.as_view(), name='wirelesslan_journal', kwargs={'model': WirelessLAN}), ) diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index b741330b7..6e1c0b1b7 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -4,43 +4,43 @@ from .models import * # -# SSIDs +# Wireless LANs # -class SSIDListView(generic.ObjectListView): - queryset = SSID.objects.all() - filterset = filtersets.SSIDFilterSet - filterset_form = forms.SSIDFilterForm - table = tables.SSIDTable +class WirelessLANListView(generic.ObjectListView): + queryset = WirelessLAN.objects.all() + filterset = filtersets.WirelessLANFilterSet + filterset_form = forms.WirelessLANFilterForm + table = tables.WirelessLANTable -class SSIDView(generic.ObjectView): - queryset = SSID.objects.all() +class WirelessLANView(generic.ObjectView): + queryset = WirelessLAN.objects.all() -class SSIDEditView(generic.ObjectEditView): - queryset = SSID.objects.all() - model_form = forms.SSIDForm +class WirelessLANEditView(generic.ObjectEditView): + queryset = WirelessLAN.objects.all() + model_form = forms.WirelessLANForm -class SSIDDeleteView(generic.ObjectDeleteView): - queryset = SSID.objects.all() +class WirelessLANDeleteView(generic.ObjectDeleteView): + queryset = WirelessLAN.objects.all() -class SSIDBulkImportView(generic.BulkImportView): - queryset = SSID.objects.all() - model_form = forms.SSIDCSVForm - table = tables.SSIDTable +class WirelessLANBulkImportView(generic.BulkImportView): + queryset = WirelessLAN.objects.all() + model_form = forms.WirelessLANCSVForm + table = tables.WirelessLANTable -class SSIDBulkEditView(generic.BulkEditView): - queryset = SSID.objects.all() - filterset = filtersets.SSIDFilterSet - table = tables.SSIDTable - form = forms.SSIDBulkEditForm +class WirelessLANBulkEditView(generic.BulkEditView): + queryset = WirelessLAN.objects.all() + filterset = filtersets.WirelessLANFilterSet + table = tables.WirelessLANTable + form = forms.WirelessLANBulkEditForm -class SSIDBulkDeleteView(generic.BulkDeleteView): - queryset = SSID.objects.all() - filterset = filtersets.SSIDFilterSet - table = tables.SSIDTable +class WirelessLANBulkDeleteView(generic.BulkDeleteView): + queryset = WirelessLAN.objects.all() + filterset = filtersets.WirelessLANFilterSet + table = tables.WirelessLANTable From 90e9f344944ef192b4b3ebc56a895d93e2995440 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 09:46:17 -0400 Subject: [PATCH 05/68] Add WirelessLink model --- netbox/dcim/migrations/0136_wireless.py | 8 +- netbox/dcim/migrations/0137_wireless.py | 19 +++++ netbox/netbox/navigation_menu.py | 1 + .../wireless/inc/wirelesslink_interface.html | 20 +++++ netbox/templates/wireless/wirelesslan.html | 2 +- netbox/templates/wireless/wirelesslink.html | 48 +++++++++++ netbox/wireless/api/nested_serializers.py | 11 ++- netbox/wireless/api/serializers.py | 17 +++- netbox/wireless/api/urls.py | 1 + netbox/wireless/api/views.py | 12 +-- netbox/wireless/filtersets.py | 22 +++++ netbox/wireless/forms/bulk_edit.py | 27 +++++- netbox/wireless/forms/bulk_import.py | 17 +++- netbox/wireless/forms/filtersets.py | 28 ++++++- netbox/wireless/forms/models.py | 29 ++++++- netbox/wireless/migrations/0001_initial.py | 34 -------- netbox/wireless/migrations/0001_wireless.py | 57 +++++++++++++ netbox/wireless/models.py | 83 ++++++++++++++++++- netbox/wireless/tables.py | 25 +++++- netbox/wireless/urls.py | 12 +++ netbox/wireless/views.py | 43 ++++++++++ 21 files changed, 458 insertions(+), 58 deletions(-) create mode 100644 netbox/dcim/migrations/0137_wireless.py create mode 100644 netbox/templates/wireless/inc/wirelesslink_interface.html create mode 100644 netbox/templates/wireless/wirelesslink.html delete mode 100644 netbox/wireless/migrations/0001_initial.py create mode 100644 netbox/wireless/migrations/0001_wireless.py diff --git a/netbox/dcim/migrations/0136_wireless.py b/netbox/dcim/migrations/0136_wireless.py index 108e63802..3b33f7d3f 100644 --- a/netbox/dcim/migrations/0136_wireless.py +++ b/netbox/dcim/migrations/0136_wireless.py @@ -1,10 +1,11 @@ +# Generated by Django 3.2.8 on 2021-10-13 13:44 + from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('wireless', '0001_initial'), ('dcim', '0135_location_tenant'), ] @@ -19,9 +20,4 @@ class Migration(migrations.Migration): name='rf_channel_width', field=models.PositiveSmallIntegerField(blank=True, null=True), ), - migrations.AddField( - model_name='interface', - name='wireless_lans', - field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.WirelessLAN'), - ), ] diff --git a/netbox/dcim/migrations/0137_wireless.py b/netbox/dcim/migrations/0137_wireless.py new file mode 100644 index 000000000..9108735a1 --- /dev/null +++ b/netbox/dcim/migrations/0137_wireless.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2021-10-13 13:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0136_wireless'), + ('wireless', '0001_wireless'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='wireless_lans', + field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.WirelessLAN'), + ), + ] diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 073189d31..b3e11f6ce 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -196,6 +196,7 @@ WIRELESS_MENU = Menu( label='Wireless', items=( get_model_item('wireless', 'wirelesslan', 'Wireless LANs'), + get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']), ), ), ), diff --git a/netbox/templates/wireless/inc/wirelesslink_interface.html b/netbox/templates/wireless/inc/wirelesslink_interface.html new file mode 100644 index 000000000..9c4669ad1 --- /dev/null +++ b/netbox/templates/wireless/inc/wirelesslink_interface.html @@ -0,0 +1,20 @@ +
Name{{ object.name }}SSID{{ object.ssid }}
Description
+ + + + + + + + + + + + +
Device + {{ interface.device }} +
Interface + {{ interface }} +
Type + {{ interface.get_type_display }} +
diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index 98bde8688..f8fabf558 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -30,7 +30,7 @@
- {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:site_list' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='wireless:wirelesslan_list' %} {% plugin_left_page object %}
diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html new file mode 100644 index 000000000..6196adae4 --- /dev/null +++ b/netbox/templates/wireless/wirelesslink.html @@ -0,0 +1,48 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+
Interface A
+
+ {% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_a %} +
+
+
+
Link Properties
+
+ + + + + + + + + +
SSID{{ object.ssid|placeholder }}
Description{{ object.description|placeholder }}
+
+
+ {% plugin_left_page object %} +
+
+
+
Interface B
+
+ {% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_b %} +
+
+ {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='wireless:wirelesslink_list' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/wireless/api/nested_serializers.py b/netbox/wireless/api/nested_serializers.py index e290653a2..5a8cf6671 100644 --- a/netbox/wireless/api/nested_serializers.py +++ b/netbox/wireless/api/nested_serializers.py @@ -5,12 +5,21 @@ from wireless.models import * __all__ = ( 'NestedWirelessLANSerializer', + 'NestedWirelessLinkSerializer', ) class NestedWirelessLANSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail') + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') class Meta: model = WirelessLAN fields = ['id', 'url', 'display', 'ssid'] + + +class NestedWirelessLinkSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslink-detail') + + class Meta: + model = WirelessLink + fields = ['id', 'url', 'display', 'ssid'] diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index 08642259f..90515f53e 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -1,16 +1,19 @@ from rest_framework import serializers +from dcim.api.serializers import NestedInterfaceSerializer from ipam.api.serializers import NestedVLANSerializer from netbox.api.serializers import PrimaryModelSerializer from wireless.models import * +from .nested_serializers import * __all__ = ( 'WirelessLANSerializer', + 'WirelessLinkSerializer', ) class WirelessLANSerializer(PrimaryModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='wireless-api:ssid-detail') + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') vlan = NestedVLANSerializer(required=False, allow_null=True) class Meta: @@ -18,3 +21,15 @@ class WirelessLANSerializer(PrimaryModelSerializer): fields = [ 'id', 'url', 'display', 'ssid', 'description', 'vlan', ] + + +class WirelessLinkSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslink-detail') + interface_a = NestedInterfaceSerializer() + interface_b = NestedInterfaceSerializer() + + class Meta: + model = WirelessLAN + fields = [ + 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'description', + ] diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py index 638f31bbf..431bb05f8 100644 --- a/netbox/wireless/api/urls.py +++ b/netbox/wireless/api/urls.py @@ -6,6 +6,7 @@ router = OrderedDefaultRouter() router.APIRootView = views.WirelessRootView router.register('wireless-lans', views.WirelessLANViewSet) +router.register('wireless-links', views.WirelessLinkViewSet) app_name = 'wireless-api' urlpatterns = router.urls diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py index a09b2e23d..aa361a7f7 100644 --- a/netbox/wireless/api/views.py +++ b/netbox/wireless/api/views.py @@ -14,11 +14,13 @@ class WirelessRootView(APIRootView): return 'Wireless' -# -# Providers -# - class WirelessLANViewSet(CustomFieldModelViewSet): - queryset = WirelessLAN.objects.prefetch_related('tags') + queryset = WirelessLAN.objects.prefetch_related('vlan', 'tags') serializer_class = serializers.WirelessLANSerializer filterset_class = filtersets.WirelessLANFilterSet + + +class WirelessLinkViewSet(CustomFieldModelViewSet): + queryset = WirelessLink.objects.prefetch_related('interface_a', 'interface_b', 'tags') + serializer_class = serializers.WirelessLinkSerializer + filterset_class = filtersets.WirelessLinkFilterSet diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index c148354a0..7341ada9d 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -7,6 +7,7 @@ from .models import * __all__ = ( 'WirelessLANFilterSet', + 'WirelessLinkFilterSet', ) @@ -29,3 +30,24 @@ class WirelessLANFilterSet(PrimaryModelFilterSet): Q(description__icontains=value) ) return queryset.filter(qs_filter) + + +class WirelessLinkFilterSet(PrimaryModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + tag = TagFilter() + + class Meta: + model = WirelessLink + fields = ['id', 'ssid'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(ssid__icontains=value) | + Q(description__icontains=value) + ) + return queryset.filter(qs_filter) diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index c11a16239..65666ccb1 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -4,9 +4,11 @@ from dcim.models import * from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from ipam.models import VLAN from utilities.forms import BootstrapMixin, DynamicModelChoiceField +from wireless.constants import SSID_MAX_LENGTH __all__ = ( 'WirelessLANBulkEditForm', + 'WirelessLinkBulkEditForm', ) @@ -19,11 +21,30 @@ class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode queryset=VLAN.objects.all(), required=False, ) + ssid = forms.CharField( + max_length=SSID_MAX_LENGTH, + required=False + ) description = forms.CharField( required=False ) class Meta: - nullable_fields = [ - 'vlan', 'description', - ] + nullable_fields = ['vlan', 'ssid', 'description'] + + +class WirelessLinkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerFeed.objects.all(), + widget=forms.MultipleHiddenInput + ) + ssid = forms.CharField( + max_length=SSID_MAX_LENGTH, + required=False + ) + description = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = ['ssid', 'description'] diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 5dc07f91a..caf322dc1 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -1,10 +1,12 @@ +from dcim.models import Interface from extras.forms import CustomFieldModelCSVForm from ipam.models import VLAN from utilities.forms import CSVModelChoiceField -from wireless.models import WirelessLAN +from wireless.models import * __all__ = ( 'WirelessLANCSVForm', + 'WirelessLinkCSVForm', ) @@ -18,3 +20,16 @@ class WirelessLANCSVForm(CustomFieldModelCSVForm): class Meta: model = WirelessLAN fields = ('ssid', 'description', 'vlan') + + +class WirelessLinkCSVForm(CustomFieldModelCSVForm): + interface_a = CSVModelChoiceField( + queryset=Interface.objects.all() + ) + interface_b = CSVModelChoiceField( + queryset=Interface.objects.all() + ) + + class Meta: + model = WirelessLink + fields = ('interface_a', 'interface_b', 'ssid', 'description') diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 99e38918e..fa1912099 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -3,7 +3,12 @@ from django.utils.translation import gettext as _ from extras.forms import CustomFieldModelFilterForm from utilities.forms import BootstrapMixin, TagFilterField -from .models import WirelessLAN +from wireless.models import * + +__all__ = ( + 'WirelessLANFilterForm', + 'WirelessLinkFilterForm', +) class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): @@ -16,4 +21,25 @@ class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + ssid = forms.CharField( + required=False, + label='SSID' + ) + tag = TagFilterField(model) + + +class WirelessLinkFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = WirelessLink + field_groups = [ + ['q', 'tag'], + ] + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + ssid = forms.CharField( + required=False, + label='SSID' + ) tag = TagFilterField(model) diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 95b43c7d3..08e864340 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -1,11 +1,13 @@ +from dcim.models import Interface from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import VLAN from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField -from wireless.models import WirelessLAN +from wireless.models import * __all__ = ( 'WirelessLANForm', + 'WirelessLinkForm', ) @@ -28,3 +30,28 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): ('Wireless LAN', ('ssid', 'description', 'tags')), ('VLAN', ('vlan',)), ) + + +class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): + interface_a = DynamicModelChoiceField( + queryset=Interface.objects.all(), + query_params={ + 'kind': 'wireless' + } + ) + interface_b = DynamicModelChoiceField( + queryset=Interface.objects.all(), + query_params={ + 'kind': 'wireless' + } + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = WirelessLink + fields = [ + 'interface_a', 'interface_b', 'ssid', 'description', 'tags', + ] diff --git a/netbox/wireless/migrations/0001_initial.py b/netbox/wireless/migrations/0001_initial.py deleted file mode 100644 index c93a17190..000000000 --- a/netbox/wireless/migrations/0001_initial.py +++ /dev/null @@ -1,34 +0,0 @@ -import django.core.serializers.json -from django.db import migrations, models -import django.db.models.deletion -import taggit.managers - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('ipam', '0050_iprange'), - ('extras', '0062_clear_secrets_changelog'), - ] - - operations = [ - migrations.CreateModel( - name='WirelessLAN', - fields=[ - ('created', models.DateField(auto_now_add=True, null=True)), - ('last_updated', models.DateTimeField(auto_now=True, null=True)), - ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('ssid', models.CharField(max_length=32)), - ('description', models.CharField(blank=True, max_length=200)), - ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), - ('vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlan')), - ], - options={ - 'verbose_name': 'Wireless LAN', - 'ordering': ('ssid', 'pk'), - }, - ), - ] diff --git a/netbox/wireless/migrations/0001_wireless.py b/netbox/wireless/migrations/0001_wireless.py new file mode 100644 index 000000000..2fb07e5fd --- /dev/null +++ b/netbox/wireless/migrations/0001_wireless.py @@ -0,0 +1,57 @@ +# Generated by Django 3.2.8 on 2021-10-13 13:44 + +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dcim', '0136_wireless'), + ('extras', '0062_clear_secrets_changelog'), + ('ipam', '0050_iprange'), + ] + + operations = [ + migrations.CreateModel( + name='WirelessLAN', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('ssid', models.CharField(max_length=32)), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlan')), + ], + options={ + 'verbose_name': 'Wireless LAN', + 'ordering': ('ssid', 'pk'), + }, + ), + migrations.CreateModel( + name='WirelessLink', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('ssid', models.CharField(blank=True, max_length=32)), + ('description', models.CharField(blank=True, max_length=200)), + ('_interface_a_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')), + ('_interface_b_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')), + ('interface_a', models.ForeignKey(limit_choices_to={'type__in': ['ieee802.11a', 'ieee802.11g', 'ieee802.11n', 'ieee802.11ac', 'ieee802.11ad', 'ieee802.11ax']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface')), + ('interface_b', models.ForeignKey(limit_choices_to={'type__in': ['ieee802.11a', 'ieee802.11g', 'ieee802.11n', 'ieee802.11ac', 'ieee802.11ad', 'ieee802.11ax']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ['pk'], + 'unique_together': {('interface_a', 'interface_b')}, + }, + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 363631ef5..41f3dbf6d 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -6,19 +6,22 @@ from dcim.constants import WIRELESS_IFACE_TYPES from extras.utils import extras_features from netbox.models import BigIDModel, PrimaryModel from utilities.querysets import RestrictedQuerySet +from .constants import SSID_MAX_LENGTH __all__ = ( 'WirelessLAN', + 'WirelessLink', ) @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class WirelessLAN(PrimaryModel): """ - A service set identifier belonging to a wireless network. + A wireless network formed among an arbitrary number of access point and clients. """ ssid = models.CharField( - max_length=32 + max_length=SSID_MAX_LENGTH, + verbose_name='SSID' ) vlan = models.ForeignKey( to='ipam.VLAN', @@ -42,4 +45,78 @@ class WirelessLAN(PrimaryModel): return self.ssid def get_absolute_url(self): - return reverse('wireless:ssid', args=[self.pk]) + return reverse('wireless:wirelesslan', args=[self.pk]) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class WirelessLink(PrimaryModel): + """ + A point-to-point connection between two wireless Interfaces. + """ + interface_a = models.ForeignKey( + to='dcim.Interface', + limit_choices_to={'type__in': WIRELESS_IFACE_TYPES}, + on_delete=models.PROTECT, + related_name='+' + ) + interface_b = models.ForeignKey( + to='dcim.Interface', + limit_choices_to={'type__in': WIRELESS_IFACE_TYPES}, + on_delete=models.PROTECT, + related_name='+' + ) + ssid = models.CharField( + max_length=SSID_MAX_LENGTH, + blank=True, + verbose_name='SSID' + ) + description = models.CharField( + max_length=200, + blank=True + ) + + # Cache the associated device for the A and B interfaces. This enables filtering of WirelessLinks by their + # associated Devices. + _interface_a_device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True + ) + _interface_b_device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='+', + blank=True, + null=True + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ['pk'] + unique_together = ('interface_a', 'interface_b') + + def get_absolute_url(self): + return reverse('wireless:wirelesslink', args=[self.pk]) + + def clean(self): + + # Validate interface types + if self.interface_a.type not in WIRELESS_IFACE_TYPES: + raise ValidationError({ + 'interface_a': f"{self.interface_a.get_type_display()} is not a wireless interface." + }) + if self.interface_b.type not in WIRELESS_IFACE_TYPES: + raise ValidationError({ + 'interface_a': f"{self.interface_b.get_type_display()} is not a wireless interface." + }) + + def save(self, *args, **kwargs): + + # Store the parent Device for the A and B interfaces + self._interface_a_device = self.interface_a.device + self._interface_b_device = self.interface_b.device + + super().save(*args, **kwargs) diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 133353f57..31c9e56a8 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,10 +1,11 @@ import django_tables2 as tables -from .models import WirelessLAN +from .models import * from utilities.tables import BaseTable, TagColumn, ToggleColumn __all__ = ( 'WirelessLANTable', + 'WirelessLinkTable', ) @@ -21,3 +22,25 @@ class WirelessLANTable(BaseTable): model = WirelessLAN fields = ('pk', 'ssid', 'description', 'vlan') default_columns = ('pk', 'ssid', 'description', 'vlan') + + +class WirelessLinkTable(BaseTable): + pk = ToggleColumn() + id = tables.Column( + linkify=True, + verbose_name='ID' + ) + interface_a = tables.Column( + linkify=True + ) + interface_b = tables.Column( + linkify=True + ) + tags = TagColumn( + url_name='wireless:wirelesslink_list' + ) + + class Meta(BaseTable.Meta): + model = WirelessLink + fields = ('pk', 'id', 'interface_a', 'interface_b', 'ssid', 'description') + default_columns = ('pk', 'id', 'interface_a', 'interface_b', 'ssid', 'description') diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py index c30ca472c..21d704e6a 100644 --- a/netbox/wireless/urls.py +++ b/netbox/wireless/urls.py @@ -19,4 +19,16 @@ urlpatterns = ( path('wireless-lans//changelog/', ObjectChangeLogView.as_view(), name='wirelesslan_changelog', kwargs={'model': WirelessLAN}), path('wireless-lans//journal/', ObjectJournalView.as_view(), name='wirelesslan_journal', kwargs={'model': WirelessLAN}), + # Wireless links + path('wireless-links/', views.WirelessLinkListView.as_view(), name='wirelesslink_list'), + path('wireless-links/add/', views.WirelessLinkEditView.as_view(), name='wirelesslink_add'), + path('wireless-links/import/', views.WirelessLinkBulkImportView.as_view(), name='wirelesslink_import'), + path('wireless-links/edit/', views.WirelessLinkBulkEditView.as_view(), name='wirelesslink_bulk_edit'), + path('wireless-links/delete/', views.WirelessLinkBulkDeleteView.as_view(), name='wirelesslink_bulk_delete'), + path('wireless-links//', views.WirelessLinkView.as_view(), name='wirelesslink'), + path('wireless-links//edit/', views.WirelessLinkEditView.as_view(), name='wirelesslink_edit'), + path('wireless-links//delete/', views.WirelessLinkDeleteView.as_view(), name='wirelesslink_delete'), + path('wireless-links//changelog/', ObjectChangeLogView.as_view(), name='wirelesslink_changelog', kwargs={'model': WirelessLink}), + path('wireless-links//journal/', ObjectJournalView.as_view(), name='wirelesslink_journal', kwargs={'model': WirelessLink}), + ) diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index 6e1c0b1b7..041ffbd42 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -44,3 +44,46 @@ class WirelessLANBulkDeleteView(generic.BulkDeleteView): queryset = WirelessLAN.objects.all() filterset = filtersets.WirelessLANFilterSet table = tables.WirelessLANTable + + +# +# Wireless Links +# + +class WirelessLinkListView(generic.ObjectListView): + queryset = WirelessLink.objects.all() + filterset = filtersets.WirelessLinkFilterSet + filterset_form = forms.WirelessLinkFilterForm + table = tables.WirelessLinkTable + + +class WirelessLinkView(generic.ObjectView): + queryset = WirelessLink.objects.all() + + +class WirelessLinkEditView(generic.ObjectEditView): + queryset = WirelessLink.objects.all() + model_form = forms.WirelessLinkForm + + +class WirelessLinkDeleteView(generic.ObjectDeleteView): + queryset = WirelessLink.objects.all() + + +class WirelessLinkBulkImportView(generic.BulkImportView): + queryset = WirelessLink.objects.all() + model_form = forms.WirelessLinkCSVForm + table = tables.WirelessLinkTable + + +class WirelessLinkBulkEditView(generic.BulkEditView): + queryset = WirelessLink.objects.all() + filterset = filtersets.WirelessLinkFilterSet + table = tables.WirelessLinkTable + form = forms.WirelessLinkBulkEditForm + + +class WirelessLinkBulkDeleteView(generic.BulkDeleteView): + queryset = WirelessLink.objects.all() + filterset = filtersets.WirelessLinkFilterSet + table = tables.WirelessLinkTable From 445e16f6682e250f855c2cc5185e8dce4f146e51 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 11:48:15 -0400 Subject: [PATCH 06/68] Reference WirelessLink on both attached Interfaces --- .../0138_interface_wireless_link.py | 20 +++++++ netbox/dcim/models/device_components.py | 19 ++++-- netbox/dcim/tables/template_code.py | 2 +- netbox/templates/dcim/interface.html | 19 +++++- netbox/wireless/apps.py | 3 + netbox/wireless/forms/models.py | 6 +- netbox/wireless/signals.py | 58 +++++++++++++++++++ 7 files changed, 116 insertions(+), 11 deletions(-) create mode 100644 netbox/dcim/migrations/0138_interface_wireless_link.py create mode 100644 netbox/wireless/signals.py diff --git a/netbox/dcim/migrations/0138_interface_wireless_link.py b/netbox/dcim/migrations/0138_interface_wireless_link.py new file mode 100644 index 000000000..42b7a1042 --- /dev/null +++ b/netbox/dcim/migrations/0138_interface_wireless_link.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.8 on 2021-10-13 15:29 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0001_wireless'), + ('dcim', '0137_wireless'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='wireless_link', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wireless.wirelesslink'), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 60eb4c368..dc8246990 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -529,6 +529,13 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): null=True, verbose_name='Channel width' ) + wireless_link = models.ForeignKey( + to='wireless.WirelessLink', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) wireless_lans = models.ManyToManyField( to='wireless.WirelessLAN', related_name='interfaces', @@ -568,14 +575,14 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): def clean(self): super().clean() - # Virtual interfaces cannot be connected - if not self.is_connectable and self.cable: + # Virtual Interfaces cannot have a Cable attached + if self.is_virtual and self.cable: raise ValidationError({ 'type': f"{self.get_type_display()} interfaces cannot have a cable attached." }) - # Non-connectable interfaces cannot be marked as connected - if not self.is_connectable and self.mark_connected: + # Virtual Interfaces cannot be marked as connected + if self.is_virtual and self.mark_connected: raise ValidationError({ 'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected." }) @@ -635,8 +642,8 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): }) @property - def is_connectable(self): - return self.type not in NONCONNECTABLE_IFACE_TYPES + def is_wired(self): + return not self.is_virtual and not self.is_wireless @property def is_virtual(self): diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 2f359e1b9..9e2dc519b 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -203,7 +203,7 @@ INTERFACE_BUTTONS = """ {% endif %} -{% elif record.is_connectable and perms.dcim.add_cable %} +{% elif record.is_wired and perms.dcim.add_cable %} {% if not record.mark_connected %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 33eaa95db..bf24a89ef 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -117,7 +117,7 @@ {% plugin_left_page object %}
- {% if object.is_connectable %} + {% if not object.is_virtual %}
Connection @@ -221,10 +221,19 @@ + {% elif object.wireless_link %} + + + + + +
Wireless Link + {{ object.wireless_link }} +
{% else %}
Not Connected - {% if perms.dcim.add_cable %} + {% if object.is_wired and perms.dcim.add_cable %} + {% elif object.is_wireless and perms.wireless.add_wirelesslink %} + {% endif %}
{% endif %} diff --git a/netbox/wireless/apps.py b/netbox/wireless/apps.py index 1f6deff22..59e47aba5 100644 --- a/netbox/wireless/apps.py +++ b/netbox/wireless/apps.py @@ -3,3 +3,6 @@ from django.apps import AppConfig class WirelessConfig(AppConfig): name = 'wireless' + + def ready(self): + import wireless.signals diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 08e864340..c494fb5a2 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -37,13 +37,15 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): queryset=Interface.objects.all(), query_params={ 'kind': 'wireless' - } + }, + label='Interface A' ) interface_b = DynamicModelChoiceField( queryset=Interface.objects.all(), query_params={ 'kind': 'wireless' - } + }, + label='Interface B' ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), diff --git a/netbox/wireless/signals.py b/netbox/wireless/signals.py new file mode 100644 index 000000000..a42566a00 --- /dev/null +++ b/netbox/wireless/signals.py @@ -0,0 +1,58 @@ +import logging + +from django.db.models.signals import post_save, post_delete +from django.dispatch import receiver + +from dcim.models import Interface +from .models import WirelessLink + + +# +# Wireless links +# + +@receiver(post_save, sender=WirelessLink) +def update_connected_interfaces(instance, raw=False, **kwargs): + """ + When a WirelessLink is saved, save a reference to it on each connected interface. + """ + print('update_connected_interfaces') + logger = logging.getLogger('netbox.wireless.wirelesslink') + if raw: + logger.debug(f"Skipping endpoint updates for imported wireless link {instance}") + return + + if instance.interface_a.wireless_link != instance: + logger.debug(f"Updating interface A for wireless link {instance}") + instance.interface_a.wireless_link = instance + # instance.interface_a._cable_peer = instance.interface_b # TODO: Rename _cable_peer field + instance.interface_a.save() + if instance.interface_b.cable != instance: + logger.debug(f"Updating interface B for wireless link {instance}") + instance.interface_b.wireless_link = instance + # instance.interface_b._cable_peer = instance.interface_a + instance.interface_b.save() + + +@receiver(post_delete, sender=WirelessLink) +def nullify_connected_interfaces(instance, **kwargs): + """ + When a WirelessLink is deleted, update its two connected Interfaces + """ + print('nullify_connected_interfaces') + logger = logging.getLogger('netbox.wireless.wirelesslink') + + if instance.interface_a is not None: + logger.debug(f"Nullifying interface A for wireless link {instance}") + Interface.objects.filter(pk=instance.interface_a.pk).update( + wireless_link=None, + _cable_peer_type=None, + _cable_peer_id=None + ) + if instance.interface_b is not None: + logger.debug(f"Nullifying interface B for wireless link {instance}") + Interface.objects.filter(pk=instance.interface_b.pk).update( + wireless_link=None, + _cable_peer_type=None, + _cable_peer_id=None + ) From 138af27bf7c18d6833233ff84029eb8ef6a186bf Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 13:28:14 -0400 Subject: [PATCH 07/68] Record wireless links as part of cable path --- netbox/dcim/models/cables.py | 10 ++++----- netbox/dcim/models/device_components.py | 11 ++++++++++ netbox/dcim/signals.py | 28 +------------------------ netbox/dcim/utils.py | 27 ++++++++++++++++++++++++ netbox/wireless/signals.py | 21 +++++++++++++------ 5 files changed, 59 insertions(+), 38 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index c3f8cac3f..6c61c0712 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -379,7 +379,7 @@ class CablePath(BigIDModel): """ from circuits.models import CircuitTermination - if origin is None or origin.cable is None: + if origin is None or origin.link is None: return None destination = None @@ -389,12 +389,12 @@ class CablePath(BigIDModel): is_split = False node = origin - while node.cable is not None: - if node.cable.status != CableStatusChoices.STATUS_CONNECTED: + while node.link is not None: + if hasattr(node.link, 'status') and node.link.status != CableStatusChoices.STATUS_CONNECTED: is_active = False - # Follow the cable to its far-end termination - path.append(object_to_path_node(node.cable)) + # Follow the link to its far-end termination + path.append(object_to_path_node(node.link)) peer_termination = node.get_cable_peer() # Follow a FrontPort to its corresponding RearPort diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index dc8246990..38e902f22 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -157,6 +157,13 @@ class CableTermination(models.Model): def parent_object(self): raise NotImplementedError("CableTermination models must implement parent_object()") + @property + def link(self): + """ + Generic wrapper for a Cable, WirelessLink, or some other relation to a connected termination. + """ + return self.cable + class PathEndpoint(models.Model): """ @@ -657,6 +664,10 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): def is_lag(self): return self.type == InterfaceTypeChoices.TYPE_LAG + @property + def link(self): + return self.cable or self.wireless_link + # # Pass-through ports diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 9fc68ee70..942bf04e4 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -2,37 +2,11 @@ import logging from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_save, post_delete, pre_delete -from django.db import transaction from django.dispatch import receiver from .choices import CableStatusChoices from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis - - -def create_cablepath(node): - """ - Create CablePaths for all paths originating from the specified node. - """ - cp = CablePath.from_origin(node) - if cp: - try: - cp.save() - except Exception as e: - print(node, node.pk) - raise e - - -def rebuild_paths(obj): - """ - Rebuild all CablePaths which traverse the specified node - """ - cable_paths = CablePath.objects.filter(path__contains=obj) - - with transaction.atomic(): - for cp in cable_paths: - cp.delete() - if cp.origin: - create_cablepath(cp.origin) +from .utils import create_cablepath, rebuild_paths # diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index 91c5c7c77..ec3a44603 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.models import ContentType +from django.db import transaction def compile_path_node(ct_id, object_id): @@ -26,3 +27,29 @@ def path_node_to_object(repr): ct_id, object_id = decompile_path_node(repr) ct = ContentType.objects.get_for_id(ct_id) return ct.model_class().objects.get(pk=object_id) + + +def create_cablepath(node): + """ + Create CablePaths for all paths originating from the specified node. + """ + from dcim.models import CablePath + + cp = CablePath.from_origin(node) + if cp: + cp.save() + + +def rebuild_paths(obj): + """ + Rebuild all CablePaths which traverse the specified node + """ + from dcim.models import CablePath + + cable_paths = CablePath.objects.filter(path__contains=obj) + + with transaction.atomic(): + for cp in cable_paths: + cp.delete() + if cp.origin: + create_cablepath(cp.origin) diff --git a/netbox/wireless/signals.py b/netbox/wireless/signals.py index a42566a00..b8dc8a186 100644 --- a/netbox/wireless/signals.py +++ b/netbox/wireless/signals.py @@ -3,7 +3,8 @@ import logging from django.db.models.signals import post_save, post_delete from django.dispatch import receiver -from dcim.models import Interface +from dcim.models import CablePath, Interface +from dcim.utils import create_cablepath from .models import WirelessLink @@ -12,11 +13,10 @@ from .models import WirelessLink # @receiver(post_save, sender=WirelessLink) -def update_connected_interfaces(instance, raw=False, **kwargs): +def update_connected_interfaces(instance, created, raw=False, **kwargs): """ When a WirelessLink is saved, save a reference to it on each connected interface. """ - print('update_connected_interfaces') logger = logging.getLogger('netbox.wireless.wirelesslink') if raw: logger.debug(f"Skipping endpoint updates for imported wireless link {instance}") @@ -25,21 +25,25 @@ def update_connected_interfaces(instance, raw=False, **kwargs): if instance.interface_a.wireless_link != instance: logger.debug(f"Updating interface A for wireless link {instance}") instance.interface_a.wireless_link = instance - # instance.interface_a._cable_peer = instance.interface_b # TODO: Rename _cable_peer field + instance.interface_a._cable_peer = instance.interface_b # TODO: Rename _cable_peer field instance.interface_a.save() if instance.interface_b.cable != instance: logger.debug(f"Updating interface B for wireless link {instance}") instance.interface_b.wireless_link = instance - # instance.interface_b._cable_peer = instance.interface_a + instance.interface_b._cable_peer = instance.interface_a instance.interface_b.save() + # Create/update cable paths + if created: + for interface in (instance.interface_a, instance.interface_b): + create_cablepath(interface) + @receiver(post_delete, sender=WirelessLink) def nullify_connected_interfaces(instance, **kwargs): """ When a WirelessLink is deleted, update its two connected Interfaces """ - print('nullify_connected_interfaces') logger = logging.getLogger('netbox.wireless.wirelesslink') if instance.interface_a is not None: @@ -56,3 +60,8 @@ def nullify_connected_interfaces(instance, **kwargs): _cable_peer_type=None, _cable_peer_id=None ) + + # Delete and retrace any dependent cable paths + for cablepath in CablePath.objects.filter(path__contains=instance): + print(f'Deleting cable path {cablepath.pk}') + cablepath.delete() From 1c73bd5079876cec632d8a7eafa71e01e701a369 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 13:39:14 -0400 Subject: [PATCH 08/68] Resolve test errors --- netbox/dcim/api/serializers.py | 4 ++-- netbox/dcim/graphql/types.py | 3 +++ netbox/wireless/api/serializers.py | 2 +- netbox/wireless/graphql/types.py | 9 +++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index edd73b87e..28d3a143b 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -632,8 +632,8 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co parent = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) - rf_channel = ChoiceField(choices=WirelessChannelChoices) - rf_channel_width = ChoiceField(choices=WirelessChannelWidthChoices) + rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False) + rf_channel_width = ChoiceField(choices=WirelessChannelWidthChoices, required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index be10556be..55f1ba150 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -206,6 +206,9 @@ class InterfaceType(IPAddressesMixin, ComponentObjectType): def resolve_mode(self, info): return self.mode or None + def resolve_rf_channel(self, info): + return self.rf_channel or None + class InterfaceTemplateType(ComponentTemplateObjectType): diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index 90515f53e..9337d6864 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -29,7 +29,7 @@ class WirelessLinkSerializer(PrimaryModelSerializer): interface_b = NestedInterfaceSerializer() class Meta: - model = WirelessLAN + model = WirelessLink fields = [ 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'description', ] diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index 4cdb75ebe..0afd8e69a 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/graphql/types.py @@ -3,6 +3,7 @@ from netbox.graphql.types import ObjectType __all__ = ( 'WirelessLANType', + 'WirelessLinkType', ) @@ -12,3 +13,11 @@ class WirelessLANType(ObjectType): model = models.WirelessLAN fields = '__all__' filterset_class = filtersets.WirelessLANFilterSet + + +class WirelessLinkType(ObjectType): + + class Meta: + model = models.WirelessLink + fields = '__all__' + filterset_class = filtersets.WirelessLinkFilterSet From ac2cd552b9641023e7ddf615fcb461e24fe42ea4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 14:04:53 -0400 Subject: [PATCH 09/68] Rename cable_peer fields to link_peer --- netbox/circuits/api/serializers.py | 6 +- .../migrations/0003_rename_cable_peer.py | 23 +++++ netbox/circuits/models.py | 4 +- netbox/dcim/api/serializers.py | 54 +++++------ netbox/dcim/api/views.py | 12 +-- .../dcim/migrations/0139_rename_cable_peer.py | 93 +++++++++++++++++++ netbox/dcim/models/__init__.py | 2 +- netbox/dcim/models/cables.py | 2 +- netbox/dcim/models/device_components.py | 52 +++++------ netbox/dcim/models/power.py | 4 +- netbox/dcim/models/racks.py | 8 +- netbox/dcim/signals.py | 8 +- netbox/dcim/tables/devices.py | 51 +++++----- netbox/dcim/tables/power.py | 4 +- netbox/dcim/tables/template_code.py | 2 +- netbox/dcim/tests/test_models.py | 8 +- .../circuits/inc/circuit_termination.html | 2 +- netbox/wireless/models.py | 3 + netbox/wireless/signals.py | 12 +-- 19 files changed, 236 insertions(+), 114 deletions(-) create mode 100644 netbox/circuits/migrations/0003_rename_cable_peer.py create mode 100644 netbox/dcim/migrations/0139_rename_cable_peer.py diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index ac6285610..e00b3dfc8 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -3,7 +3,7 @@ from rest_framework import serializers from circuits.choices import CircuitStatusChoices from circuits.models import * from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer -from dcim.api.serializers import CableTerminationSerializer +from dcim.api.serializers import LinkTerminationSerializer from netbox.api import ChoiceField from netbox.api.serializers import ( OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer @@ -90,7 +90,7 @@ class CircuitSerializer(PrimaryModelSerializer): ] -class CircuitTerminationSerializer(ValidatedModelSerializer, CableTerminationSerializer): +class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') circuit = NestedCircuitSerializer() site = NestedSiteSerializer(required=False, allow_null=True) @@ -101,6 +101,6 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, CableTerminationSer model = CircuitTermination fields = [ 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', - 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', + 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', '_occupied', ] diff --git a/netbox/circuits/migrations/0003_rename_cable_peer.py b/netbox/circuits/migrations/0003_rename_cable_peer.py new file mode 100644 index 000000000..475a84d0f --- /dev/null +++ b/netbox/circuits/migrations/0003_rename_cable_peer.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.8 on 2021-10-13 17:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0002_squashed_0029'), + ] + + operations = [ + migrations.RenameField( + model_name='circuittermination', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='circuittermination', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index bc7dcc219..8420db563 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -4,7 +4,7 @@ from django.db import models from django.urls import reverse from dcim.fields import ASNField -from dcim.models import CableTermination, PathEndpoint +from dcim.models import LinkTermination, PathEndpoint from extras.models import ObjectChange from extras.utils import extras_features from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel @@ -246,7 +246,7 @@ class Circuit(PrimaryModel): @extras_features('webhooks') -class CircuitTermination(ChangeLoggedModel, CableTermination): +class CircuitTermination(ChangeLoggedModel, LinkTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 28d3a143b..9187901f0 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -22,25 +22,25 @@ from virtualization.api.nested_serializers import NestedClusterSerializer from .nested_serializers import * -class CableTerminationSerializer(serializers.ModelSerializer): - cable_peer_type = serializers.SerializerMethodField(read_only=True) - cable_peer = serializers.SerializerMethodField(read_only=True) +class LinkTerminationSerializer(serializers.ModelSerializer): + link_peer_type = serializers.SerializerMethodField(read_only=True) + link_peer = serializers.SerializerMethodField(read_only=True) _occupied = serializers.SerializerMethodField(read_only=True) - def get_cable_peer_type(self, obj): - if obj._cable_peer is not None: - return f'{obj._cable_peer._meta.app_label}.{obj._cable_peer._meta.model_name}' + def get_link_peer_type(self, obj): + if obj._link_peer is not None: + return f'{obj._link_peer._meta.app_label}.{obj._link_peer._meta.model_name}' return None @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_cable_peer(self, obj): + def get_link_peer(self, obj): """ - Return the appropriate serializer for the cable termination model. + Return the appropriate serializer for the link termination model. """ - if obj._cable_peer is not None: - serializer = get_serializer_for_model(obj._cable_peer, prefix='Nested') + if obj._link_peer is not None: + serializer = get_serializer_for_model(obj._link_peer, prefix='Nested') context = {'request': self.context['request']} - return serializer(obj._cable_peer, context=context).data + return serializer(obj._link_peer, context=context).data return None @swagger_serializer_method(serializer_or_field=serializers.BooleanField) @@ -529,7 +529,7 @@ class DeviceNAPALMSerializer(serializers.Serializer): # Device components # -class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -548,12 +548,12 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerial model = ConsoleServerPort fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', - 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -572,12 +572,12 @@ class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer, model = ConsolePort fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', - 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -601,12 +601,12 @@ class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer, model = PowerOutlet fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', - 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -620,12 +620,12 @@ class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co model = PowerPort fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', - 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices) @@ -649,7 +649,7 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'untagged_vlan', - 'tagged_vlans', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', + 'tagged_vlans', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', ] @@ -668,7 +668,7 @@ class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co return super().validate(data) -class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): +class RearPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -678,7 +678,7 @@ class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): model = RearPort fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'positions', 'description', - 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created', + 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -694,7 +694,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name', 'label'] -class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): +class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -705,7 +705,7 @@ class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): model = FrontPort fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', - 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', + 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -881,7 +881,7 @@ class PowerPanelSerializer(PrimaryModelSerializer): fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count'] -class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') power_panel = NestedPowerPanelSerializer() rack = NestedRackSerializer( @@ -911,7 +911,7 @@ class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, Co model = PowerFeed fields = [ 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', - 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', + 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 2b9d9734c..43ced046c 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -513,7 +513,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): # class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): - queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') + queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') serializer_class = serializers.ConsolePortSerializer filterset_class = filtersets.ConsolePortFilterSet brief_prefetch_fields = ['device'] @@ -521,7 +521,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): queryset = ConsoleServerPort.objects.prefetch_related( - 'device', '_path__destination', 'cable', '_cable_peer', 'tags' + 'device', '_path__destination', 'cable', '_link_peer', 'tags' ) serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filtersets.ConsoleServerPortFilterSet @@ -529,14 +529,14 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): class PowerPortViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') + queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') serializer_class = serializers.PowerPortSerializer filterset_class = filtersets.PowerPortFilterSet brief_prefetch_fields = ['device'] class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') + queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') serializer_class = serializers.PowerOutletSerializer filterset_class = filtersets.PowerOutletFilterSet brief_prefetch_fields = ['device'] @@ -544,7 +544,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', 'parent', 'lag', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags' + 'device', 'parent', 'lag', '_path__destination', 'cable', '_link_peer', 'ip_addresses', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet @@ -625,7 +625,7 @@ class PowerPanelViewSet(ModelViewSet): class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet): queryset = PowerFeed.objects.prefetch_related( - 'power_panel', 'rack', '_path__destination', 'cable', '_cable_peer', 'tags' + 'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags' ) serializer_class = serializers.PowerFeedSerializer filterset_class = filtersets.PowerFeedFilterSet diff --git a/netbox/dcim/migrations/0139_rename_cable_peer.py b/netbox/dcim/migrations/0139_rename_cable_peer.py new file mode 100644 index 000000000..62a4bacdd --- /dev/null +++ b/netbox/dcim/migrations/0139_rename_cable_peer.py @@ -0,0 +1,93 @@ +# Generated by Django 3.2.8 on 2021-10-13 17:47 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0138_interface_wireless_link'), + ] + + operations = [ + migrations.RenameField( + model_name='consoleport', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='consoleport', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='consoleserverport', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='consoleserverport', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='frontport', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='frontport', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='interface', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='interface', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='powerfeed', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='powerfeed', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='poweroutlet', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='poweroutlet', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='powerport', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='powerport', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + migrations.RenameField( + model_name='rearport', + old_name='_cable_peer_id', + new_name='_link_peer_id', + ), + migrations.RenameField( + model_name='rearport', + old_name='_cable_peer_type', + new_name='_link_peer_type', + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 0375a9fb4..58a3e1de5 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -10,7 +10,7 @@ __all__ = ( 'BaseInterface', 'Cable', 'CablePath', - 'CableTermination', + 'LinkTermination', 'ConsolePort', 'ConsolePortTemplate', 'ConsoleServerPort', diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 6c61c0712..fb3e71543 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -395,7 +395,7 @@ class CablePath(BigIDModel): # Follow the link to its far-end termination path.append(object_to_path_node(node.link)) - peer_termination = node.get_cable_peer() + peer_termination = node.get_link_peer() # Follow a FrontPort to its corresponding RearPort if isinstance(peer_termination, FrontPort): diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 38e902f22..f8649b419 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -22,7 +22,7 @@ from utilities.query_functions import CollateAsChar __all__ = ( 'BaseInterface', - 'CableTermination', + 'LinkTermination', 'ConsolePort', 'ConsoleServerPort', 'DeviceBay', @@ -87,14 +87,14 @@ class ComponentModel(PrimaryModel): return self.device -class CableTermination(models.Model): +class LinkTermination(models.Model): """ - An abstract model inherited by all models to which a Cable can terminate (certain device components, PowerFeed, and - CircuitTermination instances). The `cable` field indicates the Cable instance which is terminated to this instance. + An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples + include most device components, CircuitTerminations, and PowerFeeds. The `cable` and `wireless_link` fields + reference the attached Cable or WirelessLink instance, respectively. - `_cable_peer` is a GenericForeignKey used to cache the far-end CableTermination on the local instance; this is a - shortcut to referencing `cable.termination_b`, for example. `_cable_peer` is set or cleared by the receivers in - dcim.signals when a Cable instance is created or deleted, respectively. + `_link_peer` is a GenericForeignKey used to cache the far-end LinkTermination on the local instance; this is a + shortcut to referencing `instance.link.termination_b`, for example. """ cable = models.ForeignKey( to='dcim.Cable', @@ -103,20 +103,20 @@ class CableTermination(models.Model): blank=True, null=True ) - _cable_peer_type = models.ForeignKey( + _link_peer_type = models.ForeignKey( to=ContentType, on_delete=models.SET_NULL, related_name='+', blank=True, null=True ) - _cable_peer_id = models.PositiveIntegerField( + _link_peer_id = models.PositiveIntegerField( blank=True, null=True ) - _cable_peer = GenericForeignKey( - ct_field='_cable_peer_type', - fk_field='_cable_peer_id' + _link_peer = GenericForeignKey( + ct_field='_link_peer_type', + fk_field='_link_peer_id' ) mark_connected = models.BooleanField( default=False, @@ -146,8 +146,8 @@ class CableTermination(models.Model): "mark_connected": "Cannot mark as connected with a cable attached." }) - def get_cable_peer(self): - return self._cable_peer + def get_link_peer(self): + return self._link_peer @property def _occupied(self): @@ -226,7 +226,7 @@ class PathEndpoint(models.Model): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ConsolePort(ComponentModel, CableTermination, PathEndpoint): +class ConsolePort(ComponentModel, LinkTermination, PathEndpoint): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -258,7 +258,7 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint): +class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ @@ -290,7 +290,7 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerPort(ComponentModel, CableTermination, PathEndpoint): +class PowerPort(ComponentModel, LinkTermination, PathEndpoint): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ @@ -340,8 +340,8 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint): poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet) outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True) utilization = PowerPort.objects.filter( - _cable_peer_type=poweroutlet_ct, - _cable_peer_id__in=outlet_ids + _link_peer_type=poweroutlet_ct, + _link_peer_id__in=outlet_ids ).aggregate( maximum_draw_total=Sum('maximum_draw'), allocated_draw_total=Sum('allocated_draw'), @@ -354,12 +354,12 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint): } # Calculate per-leg aggregates for three-phase feeds - if getattr(self._cable_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE: + if getattr(self._link_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE: for leg, leg_name in PowerOutletFeedLegChoices: outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True) utilization = PowerPort.objects.filter( - _cable_peer_type=poweroutlet_ct, - _cable_peer_id__in=outlet_ids + _link_peer_type=poweroutlet_ct, + _link_peer_id__in=outlet_ids ).aggregate( maximum_draw_total=Sum('maximum_draw'), allocated_draw_total=Sum('allocated_draw'), @@ -387,7 +387,7 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerOutlet(ComponentModel, CableTermination, PathEndpoint): +class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ @@ -482,7 +482,7 @@ class BaseInterface(models.Model): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): +class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. """ @@ -674,7 +674,7 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class FrontPort(ComponentModel, CableTermination): +class FrontPort(ComponentModel, LinkTermination): """ A pass-through port on the front of a Device. """ @@ -728,7 +728,7 @@ class FrontPort(ComponentModel, CableTermination): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class RearPort(ComponentModel, CableTermination): +class RearPort(ComponentModel, LinkTermination): """ A pass-through port on the rear of a Device. """ diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 0e9520b36..6d6a04cea 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -10,7 +10,7 @@ from extras.utils import extras_features from netbox.models import PrimaryModel from utilities.querysets import RestrictedQuerySet from utilities.validators import ExclusionValidator -from .device_components import CableTermination, PathEndpoint +from .device_components import LinkTermination, PathEndpoint __all__ = ( 'PowerFeed', @@ -67,7 +67,7 @@ class PowerPanel(PrimaryModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerFeed(PrimaryModel, PathEndpoint, CableTermination): +class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination): """ An electrical circuit delivered from a PowerPanel. """ diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index c287d7d6c..94e7bf53a 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -422,13 +422,13 @@ class Rack(PrimaryModel): return 0 pf_powerports = PowerPort.objects.filter( - _cable_peer_type=ContentType.objects.get_for_model(PowerFeed), - _cable_peer_id__in=powerfeeds.values_list('id', flat=True) + _link_peer_type=ContentType.objects.get_for_model(PowerFeed), + _link_peer_id__in=powerfeeds.values_list('id', flat=True) ) poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports) allocated_draw_total = PowerPort.objects.filter( - _cable_peer_type=ContentType.objects.get_for_model(PowerOutlet), - _cable_peer_id__in=poweroutlets.values_list('id', flat=True) + _link_peer_type=ContentType.objects.get_for_model(PowerOutlet), + _link_peer_id__in=poweroutlets.values_list('id', flat=True) ).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0 return int(allocated_draw_total / available_power_total * 100) diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 942bf04e4..616546525 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -83,12 +83,12 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs): if instance.termination_a.cable != instance: logger.debug(f"Updating termination A for cable {instance}") instance.termination_a.cable = instance - instance.termination_a._cable_peer = instance.termination_b + instance.termination_a._link_peer = instance.termination_b instance.termination_a.save() if instance.termination_b.cable != instance: logger.debug(f"Updating termination B for cable {instance}") instance.termination_b.cable = instance - instance.termination_b._cable_peer = instance.termination_a + instance.termination_b._link_peer = instance.termination_a instance.termination_b.save() # Create/update cable paths @@ -119,11 +119,11 @@ def nullify_connected_endpoints(instance, **kwargs): if instance.termination_a is not None: logger.debug(f"Nullifying termination A for cable {instance}") model = instance.termination_a._meta.model - model.objects.filter(pk=instance.termination_a.pk).update(_cable_peer_type=None, _cable_peer_id=None) + model.objects.filter(pk=instance.termination_a.pk).update(_link_peer_type=None, _link_peer_id=None) if instance.termination_b is not None: logger.debug(f"Nullifying termination B for cable {instance}") model = instance.termination_b._meta.model - model.objects.filter(pk=instance.termination_b.pk).update(_cable_peer_type=None, _cable_peer_id=None) + model.objects.filter(pk=instance.termination_b.pk).update(_link_peer_type=None, _link_peer_id=None) # Delete and retrace any dependent cable paths for cablepath in CablePath.objects.filter(path__contains=instance): diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 1eae62a05..a375a77cc 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -12,7 +12,7 @@ from utilities.tables import ( MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, ) from .template_code import ( - CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS, + LINKTERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS, FRONTPORT_BUTTONS, INTERFACE_BUTTONS, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, POWEROUTLET_BUTTONS, POWERPORT_BUTTONS, REARPORT_BUTTONS, ) @@ -258,11 +258,11 @@ class CableTerminationTable(BaseTable): orderable=False, verbose_name='Cable Color' ) - cable_peer = TemplateColumn( - accessor='_cable_peer', - template_code=CABLETERMINATION, + link_peer = TemplateColumn( + accessor='_link_peer', + template_code=LINKTERMINATION, orderable=False, - verbose_name='Cable Peer' + verbose_name='Link Peer' ) mark_connected = BooleanColumn() @@ -270,7 +270,7 @@ class CableTerminationTable(BaseTable): class PathEndpointTable(CableTerminationTable): connection = TemplateColumn( accessor='_path.last_node', - template_code=CABLETERMINATION, + template_code=LINKTERMINATION, verbose_name='Connection', orderable=False ) @@ -291,7 +291,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable): model = ConsolePort fields = ( 'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'cable_peer', 'connection', 'tags', + 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -312,7 +312,7 @@ class DeviceConsolePortTable(ConsolePortTable): model = ConsolePort fields = ( 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'cable_peer', 'connection', 'tags', 'actions' + 'link_peer', 'connection', 'tags', 'actions' ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { @@ -335,7 +335,7 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): model = ConsoleServerPort fields = ( 'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'cable_peer', 'connection', 'tags', + 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -357,7 +357,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): model = ConsoleServerPort fields = ( 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'cable_peer', 'connection', 'tags', 'actions', + 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { @@ -380,7 +380,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable): model = PowerPort fields = ( 'pk', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw', - 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', + 'cable', 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') @@ -402,7 +402,7 @@ class DevicePowerPortTable(PowerPortTable): model = PowerPort fields = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'connection', 'tags', 'actions', + 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', @@ -431,7 +431,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable): model = PowerOutlet fields = ( 'pk', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'connection', 'tags', + 'cable_color', 'link_peer', 'connection', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -452,7 +452,7 @@ class DevicePowerOutletTable(PowerOutletTable): model = PowerOutlet fields = ( 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'connection', 'tags', 'actions', + 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', @@ -485,6 +485,9 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable } ) mgmt_only = BooleanColumn() + wireless_link = tables.Column( + linkify=True + ) tags = TagColumn( url_name='dcim:interface_list' ) @@ -493,8 +496,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable model = Interface fields = ( 'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', - 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', + 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -525,8 +528,8 @@ class DeviceInterfaceTable(InterfaceTable): model = Interface fields = ( 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', - 'untagged_vlan', 'tagged_vlans', 'actions', + 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'link_peer', 'connection', 'tags', + 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', ) order_by = ('name',) default_columns = ( @@ -562,7 +565,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable): model = FrontPort fields = ( 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', - 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'tags', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', ) default_columns = ( 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', @@ -586,10 +589,10 @@ class DeviceFrontPortTable(FrontPortTable): model = FrontPort fields = ( 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'tags', 'actions', + 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer', + 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer', 'actions', ) row_attrs = { @@ -613,7 +616,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable): model = RearPort fields = ( 'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', - 'cable_color', 'cable_peer', 'tags', + 'cable_color', 'link_peer', 'tags', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description') @@ -635,10 +638,10 @@ class DeviceRearPortTable(RearPortTable): model = RearPort fields = ( 'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', - 'cable_peer', 'tags', 'actions', + 'link_peer', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions', + 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions', ) row_attrs = { 'class': get_cabletermination_row_class diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index b8e032e7f..956282911 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -71,10 +71,10 @@ class PowerFeedTable(CableTerminationTable): model = PowerFeed fields = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', - 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power', + 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power', 'comments', 'tags', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', - 'cable_peer', + 'link_peer', ) diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 9e2dc519b..7e78cb97d 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -1,4 +1,4 @@ -CABLETERMINATION = """ +LINKTERMINATION = """ {% if value %} {% if value.parent_object %} {{ value.parent_object }} diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index ae280365e..1042057de 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -494,9 +494,9 @@ class CableTestCase(TestCase): interface1 = Interface.objects.get(pk=self.interface1.pk) interface2 = Interface.objects.get(pk=self.interface2.pk) self.assertEqual(self.cable.termination_a, interface1) - self.assertEqual(interface1._cable_peer, interface2) + self.assertEqual(interface1._link_peer, interface2) self.assertEqual(self.cable.termination_b, interface2) - self.assertEqual(interface2._cable_peer, interface1) + self.assertEqual(interface2._link_peer, interface1) def test_cable_deletion(self): """ @@ -508,10 +508,10 @@ class CableTestCase(TestCase): self.assertNotEqual(str(self.cable), '#None') interface1 = Interface.objects.get(pk=self.interface1.pk) self.assertIsNone(interface1.cable) - self.assertIsNone(interface1._cable_peer) + self.assertIsNone(interface1._link_peer) interface2 = Interface.objects.get(pk=self.interface2.pk) self.assertIsNone(interface2.cable) - self.assertIsNone(interface2._cable_peer) + self.assertIsNone(interface2._link_peer) def test_cabletermination_deletion(self): """ diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index e2fe6af29..5c224f7c0 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -45,7 +45,7 @@ Marked as connected {% elif termination.cable %} {{ termination.cable }} - {% with peer=termination.get_cable_peer %} + {% with peer=termination.get_link_peer %} to {% if peer.device %} {{ peer.device }}
diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 41f3dbf6d..f8c947385 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -98,6 +98,9 @@ class WirelessLink(PrimaryModel): ordering = ['pk'] unique_together = ('interface_a', 'interface_b') + def __str__(self): + return f'#{self.pk}' + def get_absolute_url(self): return reverse('wireless:wirelesslink', args=[self.pk]) diff --git a/netbox/wireless/signals.py b/netbox/wireless/signals.py index b8dc8a186..935e11677 100644 --- a/netbox/wireless/signals.py +++ b/netbox/wireless/signals.py @@ -25,12 +25,12 @@ def update_connected_interfaces(instance, created, raw=False, **kwargs): if instance.interface_a.wireless_link != instance: logger.debug(f"Updating interface A for wireless link {instance}") instance.interface_a.wireless_link = instance - instance.interface_a._cable_peer = instance.interface_b # TODO: Rename _cable_peer field + instance.interface_a._link_peer = instance.interface_b instance.interface_a.save() if instance.interface_b.cable != instance: logger.debug(f"Updating interface B for wireless link {instance}") instance.interface_b.wireless_link = instance - instance.interface_b._cable_peer = instance.interface_a + instance.interface_b._link_peer = instance.interface_a instance.interface_b.save() # Create/update cable paths @@ -50,15 +50,15 @@ def nullify_connected_interfaces(instance, **kwargs): logger.debug(f"Nullifying interface A for wireless link {instance}") Interface.objects.filter(pk=instance.interface_a.pk).update( wireless_link=None, - _cable_peer_type=None, - _cable_peer_id=None + _link_peer_type=None, + _link_peer_id=None ) if instance.interface_b is not None: logger.debug(f"Nullifying interface B for wireless link {instance}") Interface.objects.filter(pk=instance.interface_b.pk).update( wireless_link=None, - _cable_peer_type=None, - _cable_peer_id=None + _link_peer_type=None, + _link_peer_id=None ) # Delete and retrace any dependent cable paths From ec0560a2c547c6f480ab67b025fa979a939e5f3c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 14:16:10 -0400 Subject: [PATCH 10/68] Fix trace_paths command for wireless links --- netbox/dcim/management/commands/trace_paths.py | 6 +++++- netbox/dcim/tables/template_code.py | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py index fd5f9cfab..d0cd64486 100644 --- a/netbox/dcim/management/commands/trace_paths.py +++ b/netbox/dcim/management/commands/trace_paths.py @@ -1,6 +1,7 @@ from django.core.management.base import BaseCommand from django.core.management.color import no_style from django.db import connection +from django.db.models import Q from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort from dcim.signals import create_cablepath @@ -67,7 +68,10 @@ class Command(BaseCommand): # Retrace paths for model in ENDPOINT_MODELS: - origins = model.objects.filter(cable__isnull=False) + params = Q(cable__isnull=False) + if hasattr(model, 'wireless_link'): + params |= Q(wireless_link__isnull=False) + origins = model.objects.filter(params) if not options['force']: origins = origins.filter(_path__isnull=True) origins_count = origins.count() diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 7e78cb97d..a5a4d9979 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -195,8 +195,10 @@ INTERFACE_BUTTONS = """ {% endif %} -{% if record.cable %} +{% if record.link %} +{% endif %} +{% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} {% if perms.dcim.delete_cable %} From 95ed07a95ec0fe2f71441311cb3e6bb9047ef17a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 14:31:30 -0400 Subject: [PATCH 11/68] Add status field to WirelessLink --- netbox/dcim/api/serializers.py | 2 +- netbox/dcim/choices.py | 4 ++-- netbox/dcim/filtersets.py | 2 +- netbox/dcim/forms/bulk_edit.py | 2 +- netbox/dcim/forms/bulk_import.py | 2 +- netbox/dcim/forms/filtersets.py | 2 +- netbox/dcim/models/cables.py | 8 ++++---- netbox/dcim/signals.py | 4 ++-- netbox/dcim/tests/test_cablepaths.py | 6 +++--- netbox/dcim/tests/test_filtersets.py | 16 ++++++++-------- netbox/dcim/tests/test_views.py | 4 ++-- netbox/templates/wireless/wirelesslink.html | 4 ++++ netbox/wireless/api/serializers.py | 5 ++++- netbox/wireless/filtersets.py | 4 ++++ netbox/wireless/forms/bulk_edit.py | 11 ++++++++--- netbox/wireless/forms/bulk_import.py | 7 ++++++- netbox/wireless/forms/filtersets.py | 8 +++++++- netbox/wireless/forms/models.py | 7 +++++-- netbox/wireless/migrations/0001_wireless.py | 1 + netbox/wireless/models.py | 11 +++++++++++ netbox/wireless/tables.py | 7 ++++--- 21 files changed, 80 insertions(+), 37 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9187901f0..03d14a160 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -757,7 +757,7 @@ class CableSerializer(PrimaryModelSerializer): ) termination_a = serializers.SerializerMethodField(read_only=True) termination_b = serializers.SerializerMethodField(read_only=True) - status = ChoiceField(choices=CableStatusChoices, required=False) + status = ChoiceField(choices=LinkStatusChoices, required=False) length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) class Meta: diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 9a78a74f9..d58d5466d 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1030,7 +1030,7 @@ class PortTypeChoices(ChoiceSet): # -# Cables +# Cables/links # class CableTypeChoices(ChoiceSet): @@ -1094,7 +1094,7 @@ class CableTypeChoices(ChoiceSet): ) -class CableStatusChoices(ChoiceSet): +class LinkStatusChoices(ChoiceSet): STATUS_CONNECTED = 'connected' STATUS_PLANNED = 'planned' diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 0c756957a..f778fd083 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1205,7 +1205,7 @@ class CableFilterSet(PrimaryModelFilterSet): choices=CableTypeChoices ) status = django_filters.MultipleChoiceFilter( - choices=CableStatusChoices + choices=LinkStatusChoices ) color = django_filters.MultipleChoiceFilter( choices=ColorChoices diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 67a482a26..f710e7d8c 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -453,7 +453,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE widget=StaticSelect() ) status = forms.ChoiceField( - choices=add_blank_choice(CableStatusChoices), + choices=add_blank_choice(LinkStatusChoices), required=False, widget=StaticSelect(), initial='' diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index a2685c8e0..95b4dd136 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -807,7 +807,7 @@ class CableCSVForm(CustomFieldModelCSVForm): # Cable attributes status = CSVChoiceField( - choices=CableStatusChoices, + choices=LinkStatusChoices, required=False, help_text='Connection status' ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 605139c1b..169b93028 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -732,7 +732,7 @@ class CableFilterForm(BootstrapMixin, CustomFieldModelFilterForm): ) status = forms.ChoiceField( required=False, - choices=add_blank_choice(CableStatusChoices), + choices=add_blank_choice(LinkStatusChoices), widget=StaticSelect() ) color = ColorField( diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index fb3e71543..129617746 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -64,8 +64,8 @@ class Cable(PrimaryModel): ) status = models.CharField( max_length=50, - choices=CableStatusChoices, - default=CableStatusChoices.STATUS_CONNECTED + choices=LinkStatusChoices, + default=LinkStatusChoices.STATUS_CONNECTED ) label = models.CharField( max_length=100, @@ -285,7 +285,7 @@ class Cable(PrimaryModel): self._pk = self.pk def get_status_class(self): - return CableStatusChoices.CSS_CLASSES.get(self.status) + return LinkStatusChoices.CSS_CLASSES.get(self.status) def get_compatible_types(self): """ @@ -390,7 +390,7 @@ class CablePath(BigIDModel): node = origin while node.link is not None: - if hasattr(node.link, 'status') and node.link.status != CableStatusChoices.STATUS_CONNECTED: + if hasattr(node.link, 'status') and node.link.status != LinkStatusChoices.STATUS_CONNECTED: is_active = False # Follow the link to its far-end termination diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 616546525..79e9c6687 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -4,7 +4,7 @@ from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_save, post_delete, pre_delete from django.dispatch import receiver -from .choices import CableStatusChoices +from .choices import LinkStatusChoices from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis from .utils import create_cablepath, rebuild_paths @@ -102,7 +102,7 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs): # We currently don't support modifying either termination of an existing Cable. (This # may change in the future.) However, we do need to capture status changes and update # any CablePaths accordingly. - if instance.status != CableStatusChoices.STATUS_CONNECTED: + if instance.status != LinkStatusChoices.STATUS_CONNECTED: CablePath.objects.filter(path__contains=instance).update(is_active=False) else: rebuild_paths(instance) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index c0fc89f83..6849df012 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -2,7 +2,7 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from circuits.models import * -from dcim.choices import CableStatusChoices +from dcim.choices import LinkStatusChoices from dcim.models import * from dcim.utils import object_to_path_node @@ -1142,7 +1142,7 @@ class CablePathTestCase(TestCase): self.assertEqual(CablePath.objects.count(), 2) # Change cable 2's status to "planned" - cable2.status = CableStatusChoices.STATUS_PLANNED + cable2.status = LinkStatusChoices.STATUS_PLANNED cable2.save() self.assertPathExists( origin=interface1, @@ -1160,7 +1160,7 @@ class CablePathTestCase(TestCase): # Change cable 2's status to "connected" cable2 = Cable.objects.get(pk=cable2.pk) - cable2.status = CableStatusChoices.STATUS_CONNECTED + cable2.status = LinkStatusChoices.STATUS_CONNECTED cable2.save() self.assertPathExists( origin=interface1, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index fb94bde08..0c1ffd54b 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2855,12 +2855,12 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1') # Cables - Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, status=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save() - Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() + Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save() + Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save() def test_label(self): @@ -2880,9 +2880,9 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_status(self): - params = {'status': [CableStatusChoices.STATUS_CONNECTED]} + params = {'status': [LinkStatusChoices.STATUS_CONNECTED]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'status': [CableStatusChoices.STATUS_PLANNED]} + params = {'status': [LinkStatusChoices.STATUS_PLANNED]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) def test_color(self): diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 545a56f81..c0af2d438 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1813,7 +1813,7 @@ class CableTestCase( 'termination_b_type': interface_ct.pk, 'termination_b_id': interfaces[3].pk, 'type': CableTypeChoices.TYPE_CAT6, - 'status': CableStatusChoices.STATUS_PLANNED, + 'status': LinkStatusChoices.STATUS_PLANNED, 'label': 'Label', 'color': 'c0c0c0', 'length': 100, @@ -1830,7 +1830,7 @@ class CableTestCase( cls.bulk_edit_data = { 'type': CableTypeChoices.TYPE_CAT5E, - 'status': CableStatusChoices.STATUS_CONNECTED, + 'status': LinkStatusChoices.STATUS_CONNECTED, 'label': 'New label', 'color': '00ff00', 'length': 50, diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html index 6196adae4..45ec6b0c9 100644 --- a/netbox/templates/wireless/wirelesslink.html +++ b/netbox/templates/wireless/wirelesslink.html @@ -15,6 +15,10 @@
Link Properties
+ + + + diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index 9337d6864..5a7330129 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -1,7 +1,9 @@ from rest_framework import serializers +from dcim.choices import LinkStatusChoices from dcim.api.serializers import NestedInterfaceSerializer from ipam.api.serializers import NestedVLANSerializer +from netbox.api import ChoiceField from netbox.api.serializers import PrimaryModelSerializer from wireless.models import * from .nested_serializers import * @@ -25,11 +27,12 @@ class WirelessLANSerializer(PrimaryModelSerializer): class WirelessLinkSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslink-detail') + status = ChoiceField(choices=LinkStatusChoices, required=False) interface_a = NestedInterfaceSerializer() interface_b = NestedInterfaceSerializer() class Meta: model = WirelessLink fields = [ - 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'description', + 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'description', ] diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 7341ada9d..f765172dd 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -1,6 +1,7 @@ import django_filters from django.db.models import Q +from dcim.choices import LinkStatusChoices from extras.filters import TagFilter from netbox.filtersets import PrimaryModelFilterSet from .models import * @@ -37,6 +38,9 @@ class WirelessLinkFilterSet(PrimaryModelFilterSet): method='search', label='Search', ) + status = django_filters.MultipleChoiceFilter( + choices=LinkStatusChoices + ) tag = TagFilter() class Meta: diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 65666ccb1..72af81f56 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -1,10 +1,11 @@ from django import forms -from dcim.models import * +from dcim.choices import LinkStatusChoices from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from ipam.models import VLAN from utilities.forms import BootstrapMixin, DynamicModelChoiceField from wireless.constants import SSID_MAX_LENGTH +from wireless.models import * __all__ = ( 'WirelessLANBulkEditForm', @@ -14,7 +15,7 @@ __all__ = ( class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( - queryset=PowerFeed.objects.all(), + queryset=WirelessLAN.objects.all(), widget=forms.MultipleHiddenInput ) vlan = DynamicModelChoiceField( @@ -35,13 +36,17 @@ class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode class WirelessLinkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( - queryset=PowerFeed.objects.all(), + queryset=WirelessLink.objects.all(), widget=forms.MultipleHiddenInput ) ssid = forms.CharField( max_length=SSID_MAX_LENGTH, required=False ) + status = forms.ChoiceField( + choices=LinkStatusChoices, + required=False + ) description = forms.CharField( required=False ) diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index caf322dc1..763305c38 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -1,7 +1,8 @@ +from dcim.choices import LinkStatusChoices from dcim.models import Interface from extras.forms import CustomFieldModelCSVForm from ipam.models import VLAN -from utilities.forms import CSVModelChoiceField +from utilities.forms import CSVChoiceField, CSVModelChoiceField from wireless.models import * __all__ = ( @@ -23,6 +24,10 @@ class WirelessLANCSVForm(CustomFieldModelCSVForm): class WirelessLinkCSVForm(CustomFieldModelCSVForm): + status = CSVChoiceField( + choices=LinkStatusChoices, + help_text='Connection status' + ) interface_a = CSVModelChoiceField( queryset=Interface.objects.all() ) diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index fa1912099..6da3cd716 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -1,8 +1,9 @@ from django import forms from django.utils.translation import gettext as _ +from dcim.choices import LinkStatusChoices from extras.forms import CustomFieldModelFilterForm -from utilities.forms import BootstrapMixin, TagFilterField +from utilities.forms import add_blank_choice, BootstrapMixin, StaticSelect, TagFilterField from wireless.models import * __all__ = ( @@ -42,4 +43,9 @@ class WirelessLinkFilterForm(BootstrapMixin, CustomFieldModelFilterForm): required=False, label='SSID' ) + status = forms.ChoiceField( + required=False, + choices=add_blank_choice(LinkStatusChoices), + widget=StaticSelect() + ) tag = TagFilterField(model) diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index c494fb5a2..174eb5983 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -2,7 +2,7 @@ from dcim.models import Interface from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import VLAN -from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect from wireless.models import * __all__ = ( @@ -55,5 +55,8 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = WirelessLink fields = [ - 'interface_a', 'interface_b', 'ssid', 'description', 'tags', + 'interface_a', 'interface_b', 'status', 'ssid', 'description', 'tags', ] + widgets = { + 'status': StaticSelect, + } diff --git a/netbox/wireless/migrations/0001_wireless.py b/netbox/wireless/migrations/0001_wireless.py index 2fb07e5fd..8eb042d7d 100644 --- a/netbox/wireless/migrations/0001_wireless.py +++ b/netbox/wireless/migrations/0001_wireless.py @@ -42,6 +42,7 @@ class Migration(migrations.Migration): ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('ssid', models.CharField(blank=True, max_length=32)), + ('status', models.CharField(default='connected', max_length=50)), ('description', models.CharField(blank=True, max_length=200)), ('_interface_a_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')), ('_interface_b_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='dcim.device')), diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index f8c947385..d02358f1c 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -2,6 +2,7 @@ from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse +from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES from extras.utils import extras_features from netbox.models import BigIDModel, PrimaryModel @@ -70,6 +71,11 @@ class WirelessLink(PrimaryModel): blank=True, verbose_name='SSID' ) + status = models.CharField( + max_length=50, + choices=LinkStatusChoices, + default=LinkStatusChoices.STATUS_CONNECTED + ) description = models.CharField( max_length=200, blank=True @@ -94,6 +100,8 @@ class WirelessLink(PrimaryModel): objects = RestrictedQuerySet.as_manager() + clone_fields = ('ssid', 'status') + class Meta: ordering = ['pk'] unique_together = ('interface_a', 'interface_b') @@ -104,6 +112,9 @@ class WirelessLink(PrimaryModel): def get_absolute_url(self): return reverse('wireless:wirelesslink', args=[self.pk]) + def get_status_class(self): + return LinkStatusChoices.CSS_CLASSES.get(self.status) + def clean(self): # Validate interface types diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 31c9e56a8..9b0ef7291 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from .models import * -from utilities.tables import BaseTable, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ChoiceFieldColumn, TagColumn, ToggleColumn __all__ = ( 'WirelessLANTable', @@ -30,6 +30,7 @@ class WirelessLinkTable(BaseTable): linkify=True, verbose_name='ID' ) + status = ChoiceFieldColumn() interface_a = tables.Column( linkify=True ) @@ -42,5 +43,5 @@ class WirelessLinkTable(BaseTable): class Meta(BaseTable.Meta): model = WirelessLink - fields = ('pk', 'id', 'interface_a', 'interface_b', 'ssid', 'description') - default_columns = ('pk', 'id', 'interface_a', 'interface_b', 'ssid', 'description') + fields = ('pk', 'id', 'status', 'interface_a', 'interface_b', 'ssid', 'description') + default_columns = ('pk', 'id', 'status', 'interface_a', 'interface_b', 'ssid', 'description') From 43f2d4a331253ababf5a2f7e3dcb809cfa98200d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 15:00:35 -0400 Subject: [PATCH 12/68] Add SVG trace support for WirelessLinks --- netbox/dcim/svg.py | 81 ++++++++++++++---- netbox/project-static/dist/cable_trace.css | Bin 1032 -> 1133 bytes netbox/project-static/styles/cable-trace.scss | 7 +- 3 files changed, 72 insertions(+), 16 deletions(-) diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py index 5601bc591..b7f1576ee 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -398,6 +398,39 @@ class CableTraceSVG: return group + def _draw_wirelesslink(self, url, labels): + """ + Draw a line with labels representing a WirelessLink. + + :param url: Hyperlink URL + :param labels: Iterable of text labels + """ + group = Group(class_='connector') + + # Draw the wireless link + start = (OFFSET + self.center, self.cursor) + height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 + end = (start[0], start[1] + height) + line = Line(start=start, end=end, class_='wireless-link') + group.add(line) + + self.cursor += PADDING * 2 + + # Add link + link = Hyperlink(href=f'{self.base_url}{url}', target='_blank') + + # Add text label(s) + for i, label in enumerate(labels): + self.cursor += LINE_HEIGHT + text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2) + text = Text(label, insert=text_coords, class_='bold' if not i else []) + link.add(text) + + group.add(link) + self.cursor += PADDING * 2 + + return group + def _draw_attachment(self): """ Return an SVG group containing a line element and "Attachment" label. @@ -418,6 +451,9 @@ class CableTraceSVG: """ Return an SVG document representing a cable trace. """ + from dcim.models import Cable + from wireless.models import WirelessLink + traced_path = self.origin.trace() # Prep elements list @@ -452,24 +488,39 @@ class CableTraceSVG: ) terminations.append(termination) - # Connector (either a Cable or attachment to a ProviderNetwork) + # Connector (a Cable or WirelessLink) if connector is not None: # Cable - cable_labels = [ - f'Cable {connector}', - connector.get_status_display() - ] - if connector.type: - cable_labels.append(connector.get_type_display()) - if connector.length and connector.length_unit: - cable_labels.append(f'{connector.length} {connector.get_length_unit_display()}') - cable = self._draw_cable( - color=connector.color or '000000', - url=connector.get_absolute_url(), - labels=cable_labels - ) - connectors.append(cable) + if type(connector) is Cable: + connector_labels = [ + f'Cable {connector}', + connector.get_status_display() + ] + if connector.type: + connector_labels.append(connector.get_type_display()) + if connector.length and connector.length_unit: + connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}') + cable = self._draw_cable( + color=connector.color or '000000', + url=connector.get_absolute_url(), + labels=connector_labels + ) + connectors.append(cable) + + # WirelessLink + elif type(connector) is WirelessLink: + connector_labels = [ + f'Wireless link {connector}', + connector.get_status_display() + ] + if connector.ssid: + connector_labels.append(connector.ssid) + wirelesslink = self._draw_wirelesslink( + url=connector.get_absolute_url(), + labels=connector_labels + ) + connectors.append(wirelesslink) # Far end termination termination = self._draw_box( diff --git a/netbox/project-static/dist/cable_trace.css b/netbox/project-static/dist/cable_trace.css index 633ccd57232933af139eeee3562e2af22b52336f..50622f1284d056b81de5e643b27c1fa1d8487cdc 100644 GIT binary patch delta 69 zcmeC+c+0Wj05flSW>IQRYH_h{PG(;A4DZ)5ghs^tOzh>96K delta 18 ZcmaFM(ZR9d0P|!`7AH0x(}IdxE&x781 Date: Wed, 13 Oct 2021 16:40:12 -0400 Subject: [PATCH 13/68] Add WirelessLANGroup model --- netbox/netbox/navigation_menu.py | 3 +- netbox/templates/wireless/wirelesslan.html | 10 +++ .../templates/wireless/wirelesslangroup.html | 72 ++++++++++++++++++ netbox/wireless/api/nested_serializers.py | 11 +++ netbox/wireless/api/serializers.py | 15 +++- netbox/wireless/api/urls.py | 1 + netbox/wireless/api/views.py | 12 +++ netbox/wireless/filtersets.py | 18 ++++- netbox/wireless/forms/bulk_edit.py | 25 ++++++- netbox/wireless/forms/bulk_import.py | 25 ++++++- netbox/wireless/forms/filtersets.py | 30 +++++++- netbox/wireless/forms/models.py | 27 ++++++- netbox/wireless/graphql/schema.py | 6 ++ netbox/wireless/graphql/types.py | 9 +++ netbox/wireless/migrations/0001_wireless.py | 25 ++++++- netbox/wireless/models.py | 49 ++++++++++++- netbox/wireless/tables.py | 23 +++++- netbox/wireless/urls.py | 11 +++ netbox/wireless/views.py | 73 +++++++++++++++++++ 19 files changed, 429 insertions(+), 16 deletions(-) create mode 100644 netbox/templates/wireless/wirelesslangroup.html diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index b3e11f6ce..7f64a2df8 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -168,6 +168,7 @@ CONNECTIONS_MENU = Menu( label='Connections', items=( get_model_item('dcim', 'cable', 'Cables', actions=['import']), + get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']), MenuItem( link='dcim:interface_connections_list', link_text='Interface Connections', @@ -196,7 +197,7 @@ WIRELESS_MENU = Menu( label='Wireless', items=( get_model_item('wireless', 'wirelesslan', 'Wireless LANs'), - get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']), + get_model_item('wireless', 'wirelesslangroup', 'Wireless LAN Groups'), ), ), ), diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index f8fabf558..f2133cd54 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -13,6 +13,16 @@ + + + + diff --git a/netbox/templates/wireless/wirelesslangroup.html b/netbox/templates/wireless/wirelesslangroup.html new file mode 100644 index 000000000..170f72eff --- /dev/null +++ b/netbox/templates/wireless/wirelesslangroup.html @@ -0,0 +1,72 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + {{ block.super }} + {% for group in object.get_ancestors %} + + {% endfor %} +{% endblock %} + +{% block content %} +
+
+
+
Wireless LAN Group
+
+
Status{{ object.get_status_display }}
SSID {{ object.ssid|placeholder }}SSID {{ object.ssid }}
Group + {% if object.group %} + {{ object.group }} + {% else %} + None + {% endif %} +
Description {{ object.description|placeholder }}
+ + + + + + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|placeholder }}
Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
Wireless LANs + {{ wirelesslans_table.rows|length }} +
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
+ +
+
+
+
Wireless LANs
+
+ {% include 'inc/table.html' with table=wirelesslans_table %} +
+ {% if perms.wireless.add_wirelesslan %} + + {% endif %} +
+ {% include 'inc/paginator.html' with paginator=wirelesslans_table.paginator page=wirelesslans_table.page %} + {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/wireless/api/nested_serializers.py b/netbox/wireless/api/nested_serializers.py index 5a8cf6671..e9a840bfc 100644 --- a/netbox/wireless/api/nested_serializers.py +++ b/netbox/wireless/api/nested_serializers.py @@ -5,10 +5,21 @@ from wireless.models import * __all__ = ( 'NestedWirelessLANSerializer', + 'NestedWirelessLANGroupSerializer', 'NestedWirelessLinkSerializer', ) +class NestedWirelessLANGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslangroup-detail') + wirelesslan_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = WirelessLANGroup + fields = ['id', 'url', 'display', 'name', 'slug', 'wirelesslan_count', '_depth'] + + class NestedWirelessLANSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index 5a7330129..24395b77c 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -4,7 +4,7 @@ from dcim.choices import LinkStatusChoices from dcim.api.serializers import NestedInterfaceSerializer from ipam.api.serializers import NestedVLANSerializer from netbox.api import ChoiceField -from netbox.api.serializers import PrimaryModelSerializer +from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer from wireless.models import * from .nested_serializers import * @@ -14,6 +14,19 @@ __all__ = ( ) +class WirelessLANGroupSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslangroup-detail') + parent = NestedWirelessLANGroupSerializer(required=False, allow_null=True) + wirelesslan_count = serializers.IntegerField(read_only=True) + + class Meta: + model = WirelessLANGroup + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', + 'wirelesslan_count', '_depth', + ] + + class WirelessLANSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') vlan = NestedVLANSerializer(required=False, allow_null=True) diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py index 431bb05f8..54f764db6 100644 --- a/netbox/wireless/api/urls.py +++ b/netbox/wireless/api/urls.py @@ -5,6 +5,7 @@ from . import views router = OrderedDefaultRouter() router.APIRootView = views.WirelessRootView +router.register('wireless-lan-groupss', views.WirelessLANGroupViewSet) router.register('wireless-lans', views.WirelessLANViewSet) router.register('wireless-links', views.WirelessLinkViewSet) diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py index aa361a7f7..734f6940f 100644 --- a/netbox/wireless/api/views.py +++ b/netbox/wireless/api/views.py @@ -14,6 +14,18 @@ class WirelessRootView(APIRootView): return 'Wireless' +class WirelessLANGroupViewSet(CustomFieldModelViewSet): + queryset = WirelessLANGroup.objects.add_related_count( + WirelessLANGroup.objects.all(), + WirelessLAN, + 'group', + 'wirelesslan_count', + cumulative=True + ) + serializer_class = serializers.WirelessLANGroupSerializer + filterset_class = filtersets.WirelessLANGroupFilterSet + + class WirelessLANViewSet(CustomFieldModelViewSet): queryset = WirelessLAN.objects.prefetch_related('vlan', 'tags') serializer_class = serializers.WirelessLANSerializer diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index f765172dd..ac503e474 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -3,15 +3,31 @@ from django.db.models import Q from dcim.choices import LinkStatusChoices from extras.filters import TagFilter -from netbox.filtersets import PrimaryModelFilterSet +from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet from .models import * __all__ = ( 'WirelessLANFilterSet', + 'WirelessLANGroupFilterSet', 'WirelessLinkFilterSet', ) +class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=WirelessLANGroup.objects.all() + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=WirelessLANGroup.objects.all(), + to_field_name='slug' + ) + + class Meta: + model = WirelessLANGroup + fields = ['id', 'name', 'slug', 'description'] + + class WirelessLANFilterSet(PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 72af81f56..c0d5a925e 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -9,15 +9,38 @@ from wireless.models import * __all__ = ( 'WirelessLANBulkEditForm', + 'WirelessLANGroupBulkEditForm', 'WirelessLinkBulkEditForm', ) +class WirelessLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=WirelessLANGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=WirelessLAN.objects.all(), widget=forms.MultipleHiddenInput ) + group = DynamicModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False + ) vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, @@ -31,7 +54,7 @@ class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode ) class Meta: - nullable_fields = ['vlan', 'ssid', 'description'] + nullable_fields = ['ssid', 'group', 'vlan', 'description'] class WirelessLinkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 763305c38..6b22728f6 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -2,16 +2,37 @@ from dcim.choices import LinkStatusChoices from dcim.models import Interface from extras.forms import CustomFieldModelCSVForm from ipam.models import VLAN -from utilities.forms import CSVChoiceField, CSVModelChoiceField +from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField from wireless.models import * __all__ = ( 'WirelessLANCSVForm', + 'WirelessLANGroupCSVForm', 'WirelessLinkCSVForm', ) +class WirelessLANGroupCSVForm(CustomFieldModelCSVForm): + parent = CSVModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Parent group' + ) + slug = SlugField() + + class Meta: + model = WirelessLANGroup + fields = ('name', 'slug', 'parent', 'description') + + class WirelessLANCSVForm(CustomFieldModelCSVForm): + group = CSVModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned group' + ) vlan = CSVModelChoiceField( queryset=VLAN.objects.all(), to_field_name='name', @@ -20,7 +41,7 @@ class WirelessLANCSVForm(CustomFieldModelCSVForm): class Meta: model = WirelessLAN - fields = ('ssid', 'description', 'vlan') + fields = ('ssid', 'group', 'description', 'vlan') class WirelessLinkCSVForm(CustomFieldModelCSVForm): diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 6da3cd716..13aae99a5 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -3,19 +3,38 @@ from django.utils.translation import gettext as _ from dcim.choices import LinkStatusChoices from extras.forms import CustomFieldModelFilterForm -from utilities.forms import add_blank_choice, BootstrapMixin, StaticSelect, TagFilterField +from utilities.forms import ( + add_blank_choice, BootstrapMixin, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField, +) from wireless.models import * __all__ = ( 'WirelessLANFilterForm', + 'WirelessLANGroupFilterForm', 'WirelessLinkFilterForm', ) +class WirelessLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): + model = WirelessLANGroup + q = forms.CharField( + required=False, + widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), + label=_('Search') + ) + parent_id = DynamicModelMultipleChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + label=_('Parent group'), + fetch_trigger='open' + ) + + class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = WirelessLAN field_groups = [ - ['q', 'tag'], + ('q', 'tag'), + ('group_id',), ] q = forms.CharField( required=False, @@ -26,6 +45,13 @@ class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): required=False, label='SSID' ) + group_id = DynamicModelMultipleChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + null_option='None', + label=_('Group'), + fetch_trigger='open' + ) tag = TagFilterField(model) diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 174eb5983..ca20b6ea8 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -2,16 +2,37 @@ from dcim.models import Interface from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import VLAN -from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect +from utilities.forms import ( + BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, StaticSelect, +) from wireless.models import * __all__ = ( 'WirelessLANForm', + 'WirelessLANGroupForm', 'WirelessLinkForm', ) +class WirelessLANGroupForm(BootstrapMixin, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False + ) + slug = SlugField() + + class Meta: + model = WirelessLANGroup + fields = [ + 'parent', 'name', 'slug', 'description', + ] + + class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): + group = DynamicModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False + ) vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False @@ -24,10 +45,10 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = WirelessLAN fields = [ - 'ssid', 'description', 'vlan', 'tags', + 'ssid', 'group', 'description', 'vlan', 'tags', ] fieldsets = ( - ('Wireless LAN', ('ssid', 'description', 'tags')), + ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), ('VLAN', ('vlan',)), ) diff --git a/netbox/wireless/graphql/schema.py b/netbox/wireless/graphql/schema.py index 8297f4545..05fc57c4d 100644 --- a/netbox/wireless/graphql/schema.py +++ b/netbox/wireless/graphql/schema.py @@ -7,3 +7,9 @@ from .types import * class WirelessQuery(graphene.ObjectType): wirelesslan = ObjectField(WirelessLANType) wirelesslan_list = ObjectListField(WirelessLANType) + + wirelesslangroup = ObjectField(WirelessLANGroupType) + wirelesslangroup_list = ObjectListField(WirelessLANGroupType) + + wirelesslink = ObjectField(WirelessLinkType) + wirelesslink_list = ObjectListField(WirelessLinkType) diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index 0afd8e69a..4697cc44b 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/graphql/types.py @@ -3,10 +3,19 @@ from netbox.graphql.types import ObjectType __all__ = ( 'WirelessLANType', + 'WirelessLANGroupType', 'WirelessLinkType', ) +class WirelessLANGroupType(ObjectType): + + class Meta: + model = models.WirelessLANGroup + fields = '__all__' + filterset_class = filtersets.WirelessLANGroupFilterSet + + class WirelessLANType(ObjectType): class Meta: diff --git a/netbox/wireless/migrations/0001_wireless.py b/netbox/wireless/migrations/0001_wireless.py index 8eb042d7d..068f4d64a 100644 --- a/netbox/wireless/migrations/0001_wireless.py +++ b/netbox/wireless/migrations/0001_wireless.py @@ -1,8 +1,7 @@ -# Generated by Django 3.2.8 on 2021-10-13 13:44 - import django.core.serializers.json from django.db import migrations, models import django.db.models.deletion +import mptt.fields import taggit.managers @@ -17,6 +16,27 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='WirelessLANGroup', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='wireless.wirelesslangroup')), + ], + options={ + 'ordering': ('name', 'pk'), + 'unique_together': {('parent', 'name')}, + }, + ), migrations.CreateModel( name='WirelessLAN', fields=[ @@ -25,6 +45,7 @@ class Migration(migrations.Migration): ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), ('id', models.BigAutoField(primary_key=True, serialize=False)), ('ssid', models.CharField(max_length=32)), + ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wireless_lans', to='wireless.wirelesslangroup')), ('description', models.CharField(blank=True, max_length=200)), ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), ('vlan', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipam.vlan')), diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index d02358f1c..12c4e55aa 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -1,20 +1,58 @@ from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse +from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES from extras.utils import extras_features -from netbox.models import BigIDModel, PrimaryModel +from netbox.models import BigIDModel, NestedGroupModel, PrimaryModel from utilities.querysets import RestrictedQuerySet from .constants import SSID_MAX_LENGTH __all__ = ( 'WirelessLAN', + 'WirelessLANGroup', 'WirelessLink', ) +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class WirelessLANGroup(NestedGroupModel): + """ + A nested grouping of WirelessLANs + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + class Meta: + ordering = ('name', 'pk') + unique_together = ( + ('parent', 'name') + ) + + def get_absolute_url(self): + return reverse('wireless:wirelesslangroup', args=[self.pk]) + + @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class WirelessLAN(PrimaryModel): """ @@ -24,6 +62,13 @@ class WirelessLAN(PrimaryModel): max_length=SSID_MAX_LENGTH, verbose_name='SSID' ) + group = models.ForeignKey( + to='wireless.WirelessLANGroup', + on_delete=models.SET_NULL, + related_name='wireless_lans', + blank=True, + null=True + ) vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.PROTECT, @@ -100,7 +145,7 @@ class WirelessLink(PrimaryModel): objects = RestrictedQuerySet.as_manager() - clone_fields = ('ssid', 'status') + clone_fields = ('ssid', 'group', 'status') class Meta: ordering = ['pk'] diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 9b0ef7291..58d77b56f 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,14 +1,35 @@ import django_tables2 as tables +from utilities.tables import ( + BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, +) from .models import * -from utilities.tables import BaseTable, ChoiceFieldColumn, TagColumn, ToggleColumn __all__ = ( 'WirelessLANTable', + 'WirelessLANGroupTable', 'WirelessLinkTable', ) +class WirelessLANGroupTable(BaseTable): + pk = ToggleColumn() + name = MPTTColumn( + linkify=True + ) + wirelesslan_count = LinkedCountColumn( + viewname='wireless:wirelesslan_list', + url_params={'group_id': 'pk'}, + verbose_name='Wireless LANs' + ) + actions = ButtonsColumn(WirelessLANGroup) + + class Meta(BaseTable.Meta): + model = WirelessLANGroup + fields = ('pk', 'name', 'wirelesslan_count', 'description', 'slug', 'actions') + default_columns = ('pk', 'name', 'wirelesslan_count', 'description', 'actions') + + class WirelessLANTable(BaseTable): pk = ToggleColumn() ssid = tables.Column( diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py index 21d704e6a..684f55ad5 100644 --- a/netbox/wireless/urls.py +++ b/netbox/wireless/urls.py @@ -7,6 +7,17 @@ from .models import * app_name = 'wireless' urlpatterns = ( + # Wireless LAN groups + path('wireless-lan-groups/', views.WirelessLANGroupListView.as_view(), name='wirelesslangroup_list'), + path('wireless-lan-groups/add/', views.WirelessLANGroupEditView.as_view(), name='wirelesslangroup_add'), + path('wireless-lan-groups/import/', views.WirelessLANGroupBulkImportView.as_view(), name='wirelesslangroup_import'), + path('wireless-lan-groups/edit/', views.WirelessLANGroupBulkEditView.as_view(), name='wirelesslangroup_bulk_edit'), + path('wireless-lan-groups/delete/', views.WirelessLANGroupBulkDeleteView.as_view(), name='wirelesslangroup_bulk_delete'), + path('wireless-lan-groups//', views.WirelessLANGroupView.as_view(), name='wirelesslangroup'), + path('wireless-lan-groups//edit/', views.WirelessLANGroupEditView.as_view(), name='wirelesslangroup_edit'), + path('wireless-lan-groups//delete/', views.WirelessLANGroupDeleteView.as_view(), name='wirelesslangroup_delete'), + path('wireless-lan-groups//changelog/', ObjectChangeLogView.as_view(), name='wirelesslangroup_changelog', kwargs={'model': WirelessLANGroup}), + # Wireless LANs path('wireless-lans/', views.WirelessLANListView.as_view(), name='wirelesslan_list'), path('wireless-lans/add/', views.WirelessLANEditView.as_view(), name='wirelesslan_add'), diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index 041ffbd42..6405d46df 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -1,8 +1,81 @@ from netbox.views import generic +from utilities.tables import paginate_table from . import filtersets, forms, tables from .models import * +# +# Wireless LAN groups +# + +class WirelessLANGroupListView(generic.ObjectListView): + queryset = WirelessLANGroup.objects.add_related_count( + WirelessLANGroup.objects.all(), + WirelessLAN, + 'group', + 'wirelesslan_count', + cumulative=True + ) + filterset = filtersets.WirelessLANGroupFilterSet + filterset_form = forms.WirelessLANGroupFilterForm + table = tables.WirelessLANGroupTable + + +class WirelessLANGroupView(generic.ObjectView): + queryset = WirelessLANGroup.objects.all() + + def get_extra_context(self, request, instance): + wirelesslans = WirelessLAN.objects.restrict(request.user, 'view').filter( + group=instance + ) + wirelesslans_table = tables.WirelessLANTable(wirelesslans, exclude=('group',)) + paginate_table(wirelesslans_table, request) + + return { + 'wirelesslans_table': wirelesslans_table, + } + + +class WirelessLANGroupEditView(generic.ObjectEditView): + queryset = WirelessLANGroup.objects.all() + model_form = forms.WirelessLANGroupForm + + +class WirelessLANGroupDeleteView(generic.ObjectDeleteView): + queryset = WirelessLANGroup.objects.all() + + +class WirelessLANGroupBulkImportView(generic.BulkImportView): + queryset = WirelessLANGroup.objects.all() + model_form = forms.WirelessLANGroupCSVForm + table = tables.WirelessLANGroupTable + + +class WirelessLANGroupBulkEditView(generic.BulkEditView): + queryset = WirelessLANGroup.objects.add_related_count( + WirelessLANGroup.objects.all(), + WirelessLAN, + 'group', + 'wirelesslan_count', + cumulative=True + ) + filterset = filtersets.WirelessLANGroupFilterSet + table = tables.WirelessLANGroupTable + form = forms.WirelessLANGroupBulkEditForm + + +class WirelessLANGroupBulkDeleteView(generic.BulkDeleteView): + queryset = WirelessLANGroup.objects.add_related_count( + WirelessLANGroup.objects.all(), + WirelessLAN, + 'group', + 'wirelesslan_count', + cumulative=True + ) + filterset = filtersets.WirelessLANGroupFilterSet + table = tables.WirelessLANGroupTable + + # # Wireless LANs # From 438b4b47581f2b47a19243f3e848aabe52830b38 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 20:16:36 -0400 Subject: [PATCH 14/68] Add rf_role to Interface --- netbox/dcim/api/serializers.py | 3 ++- netbox/dcim/choices.py | 10 ++++++++ netbox/dcim/filtersets.py | 4 ++-- netbox/dcim/forms/bulk_edit.py | 2 +- netbox/dcim/forms/bulk_import.py | 7 +++++- netbox/dcim/forms/filtersets.py | 14 ++++++++--- netbox/dcim/forms/models.py | 5 ++-- netbox/dcim/forms/object_create.py | 10 ++++++-- netbox/dcim/graphql/types.py | 3 +++ netbox/dcim/migrations/0136_wireless.py | 7 ++++-- netbox/dcim/models/device_components.py | 12 ++++++++-- netbox/dcim/tables/devices.py | 9 +++---- netbox/templates/dcim/interface.html | 29 +++++++++++++++-------- netbox/templates/dcim/interface_edit.html | 1 + 14 files changed, 86 insertions(+), 30 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 03d14a160..af1b6eeb0 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -632,6 +632,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con parent = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) + rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True) rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False) rf_channel_width = ChoiceField(choices=WirelessChannelWidthChoices, required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) @@ -648,7 +649,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con model = Interface fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'wwn', 'mgmt_only', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'untagged_vlan', + 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index d58d5466d..b0ebf7cf4 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1139,6 +1139,16 @@ class CableLengthUnitChoices(ChoiceSet): # Wireless # +class WirelessRoleChoices(ChoiceSet): + ROLE_AP = 'ap' + ROLE_STATION = 'station' + + CHOICES = ( + (ROLE_AP, 'Access point'), + (ROLE_STATION, 'Station'), + ) + + class WirelessChannelChoices(ChoiceSet): CHANNEL_AUTO = 'auto' diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index f778fd083..ab4336dbf 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -991,8 +991,8 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT class Meta: model = Interface fields = [ - 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_channel', 'rf_channel_width', - 'description', + 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel', + 'rf_channel_width', 'description', ] def filter_device(self, queryset, name, value): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index f710e7d8c..382a0570e 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -926,7 +926,7 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ 'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', - 'mode', 'rf_channel', 'rf_channel_width', + 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', ]), BootstrapMixin, AddRemoveTagsForm, diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 95b4dd136..675e850ed 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -579,12 +579,17 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): required=False, help_text='IEEE 802.1Q operational mode (for L2 interfaces)' ) + rf_role = CSVChoiceField( + choices=WirelessRoleChoices, + required=False, + help_text='Wireless role (AP/station)' + ) class Meta: model = Interface fields = ( 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'wwn', - 'mtu', 'mgmt_only', 'description', 'mode', 'rf_channel', 'rf_channel_width', + 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 169b93028..a6f25ae00 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -963,7 +963,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): field_groups = [ ['q', 'tag'], ['name', 'label', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'], - ['rf_channel', 'rf_channel_width'], + ['rf_role', 'rf_channel', 'rf_channel_width'], ['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'], ] type = forms.MultipleChoiceField( @@ -991,15 +991,23 @@ class InterfaceFilterForm(DeviceComponentFilterForm): required=False, label='WWN' ) + rf_role = forms.MultipleChoiceField( + choices=WirelessRoleChoices, + required=False, + widget=StaticSelectMultiple(), + label='Wireless role' + ) rf_channel = forms.MultipleChoiceField( choices=WirelessChannelChoices, required=False, - widget=StaticSelectMultiple() + widget=StaticSelectMultiple(), + label='Wireless channel' ) rf_channel_width = forms.MultipleChoiceField( choices=WirelessChannelWidthChoices, required=False, - widget=StaticSelectMultiple() + widget=StaticSelectMultiple(), + label='Channel width' ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index cd697e9f3..4cfa40fe2 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1104,13 +1104,14 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): model = Interface fields = [ 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', - 'mark_connected', 'description', 'mode', 'rf_channel', 'rf_channel_width', 'wireless_lans', 'untagged_vlan', - 'tagged_vlans', 'tags', + 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', 'wireless_lans', + 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), 'mode': StaticSelect(), + 'rf_role': StaticSelect(), 'rf_channel': StaticSelect(), 'rf_channel_width': StaticSelect(), } diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index db28412e6..3998dcbc1 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -467,6 +467,12 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): required=False, widget=StaticSelect() ) + rf_role = forms.ChoiceField( + choices=add_blank_choice(WirelessRoleChoices), + required=False, + widget=StaticSelect(), + label='Wireless role' + ) rf_channel = forms.ChoiceField( choices=add_blank_choice(WirelessChannelChoices), required=False, @@ -489,8 +495,8 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): ) field_order = ( 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'description', 'mgmt_only', 'mark_connected', 'rf_channel', 'rf_channel_width', 'mode' 'untagged_vlan', - 'tagged_vlans', 'tags' + 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_width', 'mode', + 'untagged_vlan', 'tagged_vlans', 'tags' ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 55f1ba150..3d489973c 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -206,6 +206,9 @@ class InterfaceType(IPAddressesMixin, ComponentObjectType): def resolve_mode(self, info): return self.mode or None + def resolve_rf_role(self, info): + return self.rf_role or None + def resolve_rf_channel(self, info): return self.rf_channel or None diff --git a/netbox/dcim/migrations/0136_wireless.py b/netbox/dcim/migrations/0136_wireless.py index 3b33f7d3f..7a3ee9673 100644 --- a/netbox/dcim/migrations/0136_wireless.py +++ b/netbox/dcim/migrations/0136_wireless.py @@ -1,5 +1,3 @@ -# Generated by Django 3.2.8 on 2021-10-13 13:44 - from django.db import migrations, models @@ -10,6 +8,11 @@ class Migration(migrations.Migration): ] operations = [ + migrations.AddField( + model_name='interface', + name='rf_role', + field=models.CharField(blank=True, max_length=30), + ), migrations.AddField( model_name='interface', name='rf_channel', diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index f8649b419..74724baf8 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -524,6 +524,12 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): verbose_name='WWN', help_text='64-bit World Wide Name' ) + rf_role = models.CharField( + max_length=30, + choices=WirelessRoleChoices, + blank=True, + verbose_name='Wireless role' + ) rf_channel = models.CharField( max_length=50, choices=WirelessChannelChoices, @@ -636,9 +642,11 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) # RF channel attributes may be set only for wireless interfaces - if self.rf_channel and self.type not in WIRELESS_IFACE_TYPES: + if self.rf_role and not self.is_wireless: + raise ValidationError({'rf_role': "Wireless role may be set only on wireless interfaces."}) + if self.rf_channel and not self.is_wireless: raise ValidationError({'rf_channel': "Channel may be set only on wireless interfaces."}) - if self.rf_channel_width and self.type not in WIRELESS_IFACE_TYPES: + if self.rf_channel_width and not self.is_wireless: raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."}) # Validate untagged VLAN diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index a375a77cc..7f2ff1c71 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -496,8 +496,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable model = Interface fields = ( 'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', - 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', + 'wireless_link', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -528,8 +528,9 @@ class DeviceInterfaceTable(InterfaceTable): model = Interface fields = ( 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'link_peer', 'connection', 'tags', - 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', + 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', + 'wireless_link', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'actions', ) order_by = ('name',) default_columns = ( diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index bf24a89ef..90c9497ef 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -39,16 +39,6 @@ Type {{ object.get_type_display }} - {% if object.is_wireless %} - - Channel - {{ object.get_rf_channel_display|placeholder }} - - - Channel Width - {{ object.get_rf_channel_width_display|placeholder }} - - {% endif %} Enabled @@ -274,6 +264,25 @@ {% endif %} {% if object.is_wireless %} +
+
Wireless
+
+ + + + + + + + + + + + + +
Role{{ object.get_rf_role_display|placeholder }}
Channel{{ object.get_rf_channel_display|placeholder }}
Channel Width{{ object.get_rf_channel_width_display|placeholder }}
+
+
Wireless LANs
diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 51834f4e2..cb8d51828 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -34,6 +34,7 @@
Wireless
+ {% render_field form.rf_role %} {% render_field form.rf_channel %} {% render_field form.rf_channel_width %} {% render_field form.wireless_lans %} From 4c475c1b33018be900393a74082d381eb23d0429 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 13 Oct 2021 20:56:14 -0400 Subject: [PATCH 15/68] Extend wireless channel choices --- netbox/dcim/choices.py | 108 +++++++++++++++++++++++++++++------------ 1 file changed, 76 insertions(+), 32 deletions(-) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index b0ebf7cf4..18537812c 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1200,6 +1200,28 @@ class WirelessChannelChoices(ChoiceSet): CHANNEL_5G_124 = '5g-124' CHANNEL_5G_126 = '5g-126' CHANNEL_5G_128 = '5g-128' + CHANNEL_5G_132 = '5g-132' + CHANNEL_5G_134 = '5g-134' + CHANNEL_5G_136 = '5g-136' + CHANNEL_5G_138 = '5g-138' + CHANNEL_5G_140 = '5g-140' + CHANNEL_5G_142 = '5g-142' + CHANNEL_5G_144 = '5g-144' + CHANNEL_5G_149 = '5g-149' + CHANNEL_5G_151 = '5g-151' + CHANNEL_5G_153 = '5g-153' + CHANNEL_5G_155 = '5g-155' + CHANNEL_5G_157 = '5g-157' + CHANNEL_5G_159 = '5g-159' + CHANNEL_5G_161 = '5g-161' + CHANNEL_5G_163 = '5g-163' + CHANNEL_5G_165 = '5g-165' + CHANNEL_5G_167 = '5g-167' + CHANNEL_5G_169 = '5g-169' + CHANNEL_5G_171 = '5g-171' + CHANNEL_5G_173 = '5g-173' + CHANNEL_5G_175 = '5g-175' + CHANNEL_5G_177 = '5g-177' CHOICES = ( (CHANNEL_AUTO, 'Auto'), @@ -1224,38 +1246,60 @@ class WirelessChannelChoices(ChoiceSet): ( '5 GHz (802.11a/n/ac/ax)', ( - (CHANNEL_5G_32, '32 (5160 MHz)'), - (CHANNEL_5G_34, '34 (5170 MHz)'), - (CHANNEL_5G_36, '36 (5180 MHz)'), - (CHANNEL_5G_38, '38 (5190 MHz)'), - (CHANNEL_5G_40, '40 (5200 MHz)'), - (CHANNEL_5G_42, '42 (5210 MHz)'), - (CHANNEL_5G_44, '44 (5220 MHz)'), - (CHANNEL_5G_46, '46 (5230 MHz)'), - (CHANNEL_5G_48, '48 (5240 MHz)'), - (CHANNEL_5G_50, '50 (5250 MHz)'), - (CHANNEL_5G_52, '52 (5260 MHz)'), - (CHANNEL_5G_54, '54 (5270 MHz)'), - (CHANNEL_5G_56, '56 (5280 MHz)'), - (CHANNEL_5G_58, '58 (5290 MHz)'), - (CHANNEL_5G_60, '60 (5300 MHz)'), - (CHANNEL_5G_62, '62 (5310 MHz)'), - (CHANNEL_5G_64, '64 (5320 MHz)'), - (CHANNEL_5G_100, '100 (5500 MHz)'), - (CHANNEL_5G_102, '102 (5510 MHz)'), - (CHANNEL_5G_104, '104 (5520 MHz)'), - (CHANNEL_5G_106, '106 (5530 MHz)'), - (CHANNEL_5G_108, '108 (5540 MHz)'), - (CHANNEL_5G_110, '110 (5550 MHz)'), - (CHANNEL_5G_112, '112 (5560 MHz)'), - (CHANNEL_5G_114, '114 (5570 MHz)'), - (CHANNEL_5G_116, '116 (5580 MHz)'), - (CHANNEL_5G_118, '118 (5590 MHz)'), - (CHANNEL_5G_120, '120 (5600 MHz)'), - (CHANNEL_5G_122, '122 (5610 MHz)'), - (CHANNEL_5G_124, '124 (5620 MHz)'), - (CHANNEL_5G_126, '126 (5630 MHz)'), - (CHANNEL_5G_128, '128 (5640 MHz)'), + (CHANNEL_5G_32, '32 (5160/20 MHz)'), + (CHANNEL_5G_34, '34 (5170/40 MHz)'), + (CHANNEL_5G_36, '36 (5180/20 MHz)'), + (CHANNEL_5G_38, '38 (5190/40 MHz)'), + (CHANNEL_5G_40, '40 (5200/20 MHz)'), + (CHANNEL_5G_42, '42 (5210/80 MHz)'), + (CHANNEL_5G_44, '44 (5220/20 MHz)'), + (CHANNEL_5G_46, '46 (5230/40 MHz)'), + (CHANNEL_5G_48, '48 (5240/20 MHz)'), + (CHANNEL_5G_50, '50 (5250/160 MHz)'), + (CHANNEL_5G_52, '52 (5260/20 MHz)'), + (CHANNEL_5G_54, '54 (5270/40 MHz)'), + (CHANNEL_5G_56, '56 (5280/20 MHz)'), + (CHANNEL_5G_58, '58 (5290/80 MHz)'), + (CHANNEL_5G_60, '60 (5300/20 MHz)'), + (CHANNEL_5G_62, '62 (5310/40 MHz)'), + (CHANNEL_5G_64, '64 (5320/20 MHz)'), + (CHANNEL_5G_100, '100 (5500/20 MHz)'), + (CHANNEL_5G_102, '102 (5510/40 MHz)'), + (CHANNEL_5G_104, '104 (5520/20 MHz)'), + (CHANNEL_5G_106, '106 (5530/80 MHz)'), + (CHANNEL_5G_108, '108 (5540/20 MHz)'), + (CHANNEL_5G_110, '110 (5550/40 MHz)'), + (CHANNEL_5G_112, '112 (5560/20 MHz)'), + (CHANNEL_5G_114, '114 (5570/160 MHz)'), + (CHANNEL_5G_116, '116 (5580/20 MHz)'), + (CHANNEL_5G_118, '118 (5590/40 MHz)'), + (CHANNEL_5G_120, '120 (5600/20 MHz)'), + (CHANNEL_5G_122, '122 (5610/80 MHz)'), + (CHANNEL_5G_124, '124 (5620/20 MHz)'), + (CHANNEL_5G_126, '126 (5630/40 MHz)'), + (CHANNEL_5G_128, '128 (5640/20 MHz)'), + (CHANNEL_5G_132, '132 (5660/20 MHz)'), + (CHANNEL_5G_134, '134 (5670/40 MHz)'), + (CHANNEL_5G_136, '136 (5680/20 MHz)'), + (CHANNEL_5G_138, '138 (5690/80 MHz)'), + (CHANNEL_5G_140, '140 (5700/20 MHz)'), + (CHANNEL_5G_142, '142 (5710/40 MHz)'), + (CHANNEL_5G_144, '144 (5720/20 MHz)'), + (CHANNEL_5G_149, '149 (5745/20 MHz)'), + (CHANNEL_5G_151, '151 (5755/40 MHz)'), + (CHANNEL_5G_153, '153 (5765/20 MHz)'), + (CHANNEL_5G_155, '155 (5775/80 MHz)'), + (CHANNEL_5G_157, '157 (5785/20 MHz)'), + (CHANNEL_5G_159, '159 (5795/40 MHz)'), + (CHANNEL_5G_161, '161 (5805/20 MHz)'), + (CHANNEL_5G_163, '163 (5815/160 MHz)'), + (CHANNEL_5G_165, '165 (5825/20 MHz)'), + (CHANNEL_5G_167, '167 (5835/40 MHz)'), + (CHANNEL_5G_169, '169 (5845/20 MHz)'), + (CHANNEL_5G_171, '171 (5855/80 MHz)'), + (CHANNEL_5G_173, '173 (5865/20 MHz)'), + (CHANNEL_5G_175, '175 (5875/40 MHz)'), + (CHANNEL_5G_177, '177 (5885/20 MHz)'), ) ), ) From bdf359470ea749f46a98ffb7f163a1b04996e0f5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Oct 2021 09:48:12 -0400 Subject: [PATCH 16/68] Include WirelessLAN attached interfaces --- netbox/templates/wireless/wirelesslan.html | 11 +++++++-- netbox/wireless/models.py | 2 +- netbox/wireless/tables.py | 26 ++++++++++++++++++++-- netbox/wireless/views.py | 17 +++++++++++++- 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index f2133cd54..cfe13ca45 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -49,8 +49,15 @@
-
- {% plugin_full_width_page object %} +
+
+
Attached Interfaces
+
+ {% include 'inc/table.html' with table=interfaces_table %} +
+ {% include 'inc/paginator.html' with paginator=interfaces_table.paginator page=interfaces_table.page %} + {% plugin_full_width_page object %} +
{% endblock %} diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 12c4e55aa..b0cacde15 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -145,7 +145,7 @@ class WirelessLink(PrimaryModel): objects = RestrictedQuerySet.as_manager() - clone_fields = ('ssid', 'group', 'status') + clone_fields = ('ssid', 'status') class Meta: ordering = ['pk'] diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 58d77b56f..671e948d2 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -1,5 +1,6 @@ import django_tables2 as tables +from dcim.models import Interface from utilities.tables import ( BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, ) @@ -35,14 +36,35 @@ class WirelessLANTable(BaseTable): ssid = tables.Column( linkify=True ) + group = tables.Column( + linkify=True + ) + interface_count = tables.Column( + verbose_name='Interfaces' + ) tags = TagColumn( url_name='wireless:wirelesslan_list' ) class Meta(BaseTable.Meta): model = WirelessLAN - fields = ('pk', 'ssid', 'description', 'vlan') - default_columns = ('pk', 'ssid', 'description', 'vlan') + fields = ('pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'tags') + default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'interface_count') + + +class WirelessLANInterfacesTable(BaseTable): + pk = ToggleColumn() + device = tables.Column( + linkify=True + ) + name = tables.Column( + linkify=True + ) + + class Meta(BaseTable.Meta): + model = Interface + fields = ('pk', 'device', 'name', 'rf_role', 'rf_channel') + default_columns = ('pk', 'device', 'name', 'rf_role', 'rf_channel') class WirelessLinkTable(BaseTable): diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index 6405d46df..a9238df33 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -1,5 +1,7 @@ +from dcim.models import Interface from netbox.views import generic from utilities.tables import paginate_table +from utilities.utils import count_related from . import filtersets, forms, tables from .models import * @@ -81,7 +83,9 @@ class WirelessLANGroupBulkDeleteView(generic.BulkDeleteView): # class WirelessLANListView(generic.ObjectListView): - queryset = WirelessLAN.objects.all() + queryset = WirelessLAN.objects.annotate( + interface_count=count_related(Interface, 'wireless_lans') + ) filterset = filtersets.WirelessLANFilterSet filterset_form = forms.WirelessLANFilterForm table = tables.WirelessLANTable @@ -90,6 +94,17 @@ class WirelessLANListView(generic.ObjectListView): class WirelessLANView(generic.ObjectView): queryset = WirelessLAN.objects.all() + def get_extra_context(self, request, instance): + attached_interfaces = Interface.objects.restrict(request.user, 'view').filter( + wireless_lans=instance + ) + interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces) + paginate_table(interfaces_table, request) + + return { + 'interfaces_table': interfaces_table, + } + class WirelessLANEditView(generic.ObjectEditView): queryset = WirelessLAN.objects.all() From fb9da87abb8462c811391bf5bba530394f9b9f25 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Oct 2021 10:02:05 -0400 Subject: [PATCH 17/68] Add devices to WirelessLinkForm --- netbox/dcim/models/device_components.py | 4 ++++ netbox/wireless/forms/models.py | 20 ++++++++++++++++---- netbox/wireless/tables.py | 14 ++++++++++++-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 74724baf8..39c618f4d 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -656,6 +656,10 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): "device, or it must be global".format(self.untagged_vlan) }) + @property + def _occupied(self): + return super()._occupied or bool(self.wireless_link_id) + @property def is_wired(self): return not self.is_virtual and not self.is_wireless diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index ca20b6ea8..a3454c79a 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -1,4 +1,4 @@ -from dcim.models import Interface +from dcim.models import Device, Interface from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import VLAN @@ -54,18 +54,30 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): + device_a = DynamicModelChoiceField( + queryset=Device.objects.all(), + label='Device A' + ) interface_a = DynamicModelChoiceField( queryset=Interface.objects.all(), query_params={ - 'kind': 'wireless' + 'kind': 'wireless', + 'device_id': '$device_a', }, + disabled_indicator='_occupied', label='Interface A' ) + device_b = DynamicModelChoiceField( + queryset=Device.objects.all(), + label='Device B' + ) interface_b = DynamicModelChoiceField( queryset=Interface.objects.all(), query_params={ - 'kind': 'wireless' + 'kind': 'wireless', + 'device_id': '$device_b', }, + disabled_indicator='_occupied', label='Interface B' ) tags = DynamicModelMultipleChoiceField( @@ -76,7 +88,7 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = WirelessLink fields = [ - 'interface_a', 'interface_b', 'status', 'ssid', 'description', 'tags', + 'device_a', 'interface_a', 'device_b', 'interface_b', 'status', 'ssid', 'description', 'tags', ] widgets = { 'status': StaticSelect, diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 671e948d2..486fa2a71 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -74,9 +74,17 @@ class WirelessLinkTable(BaseTable): verbose_name='ID' ) status = ChoiceFieldColumn() + device_a = tables.Column( + accessor=tables.A('interface_a__device'), + linkify=True + ) interface_a = tables.Column( linkify=True ) + device_b = tables.Column( + accessor=tables.A('interface_b__device'), + linkify=True + ) interface_b = tables.Column( linkify=True ) @@ -86,5 +94,7 @@ class WirelessLinkTable(BaseTable): class Meta(BaseTable.Meta): model = WirelessLink - fields = ('pk', 'id', 'status', 'interface_a', 'interface_b', 'ssid', 'description') - default_columns = ('pk', 'id', 'status', 'interface_a', 'interface_b', 'ssid', 'description') + fields = ('pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description') + default_columns = ( + 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description', + ) From 909b83c537cb08782802af17759f83c26f8237c5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Oct 2021 10:06:46 -0400 Subject: [PATCH 18/68] Include interface RF attributes on wireless link view --- .../wireless/inc/wirelesslink_interface.html | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/netbox/templates/wireless/inc/wirelesslink_interface.html b/netbox/templates/wireless/inc/wirelesslink_interface.html index 9c4669ad1..82f7cfd8d 100644 --- a/netbox/templates/wireless/inc/wirelesslink_interface.html +++ b/netbox/templates/wireless/inc/wirelesslink_interface.html @@ -1,3 +1,5 @@ +{% load helpers %} + @@ -17,4 +19,16 @@ {{ interface.get_type_display }} + + + + + + + +
Device
Role + {{ interface.get_rf_role_display|placeholder }} +
Channel + {{ interface.get_rf_channel_display|placeholder }} +
From 64dad7dbd282b8576e9211b5ae3e7ba4d78e6d06 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 14 Oct 2021 15:11:03 -0400 Subject: [PATCH 19/68] Optimize migrations --- .../migrations/0003_rename_cable_peer.py | 2 -- ...able_peer.py => 0136_rename_cable_peer.py} | 4 +-- netbox/dcim/migrations/0136_wireless.py | 26 ------------------- netbox/dcim/migrations/0137_wireless.py | 25 +++++++++++++++--- .../0138_interface_wireless_link.py | 20 -------------- netbox/wireless/migrations/0001_wireless.py | 2 +- 6 files changed, 24 insertions(+), 55 deletions(-) rename netbox/dcim/migrations/{0139_rename_cable_peer.py => 0136_rename_cable_peer.py} (96%) delete mode 100644 netbox/dcim/migrations/0136_wireless.py delete mode 100644 netbox/dcim/migrations/0138_interface_wireless_link.py diff --git a/netbox/circuits/migrations/0003_rename_cable_peer.py b/netbox/circuits/migrations/0003_rename_cable_peer.py index 475a84d0f..63dc1006e 100644 --- a/netbox/circuits/migrations/0003_rename_cable_peer.py +++ b/netbox/circuits/migrations/0003_rename_cable_peer.py @@ -1,5 +1,3 @@ -# Generated by Django 3.2.8 on 2021-10-13 17:47 - from django.db import migrations diff --git a/netbox/dcim/migrations/0139_rename_cable_peer.py b/netbox/dcim/migrations/0136_rename_cable_peer.py similarity index 96% rename from netbox/dcim/migrations/0139_rename_cable_peer.py rename to netbox/dcim/migrations/0136_rename_cable_peer.py index 62a4bacdd..6958458e8 100644 --- a/netbox/dcim/migrations/0139_rename_cable_peer.py +++ b/netbox/dcim/migrations/0136_rename_cable_peer.py @@ -1,12 +1,10 @@ -# Generated by Django 3.2.8 on 2021-10-13 17:47 - from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('dcim', '0138_interface_wireless_link'), + ('dcim', '0135_location_tenant'), ] operations = [ diff --git a/netbox/dcim/migrations/0136_wireless.py b/netbox/dcim/migrations/0136_wireless.py deleted file mode 100644 index 7a3ee9673..000000000 --- a/netbox/dcim/migrations/0136_wireless.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0135_location_tenant'), - ] - - operations = [ - migrations.AddField( - model_name='interface', - name='rf_role', - field=models.CharField(blank=True, max_length=30), - ), - migrations.AddField( - model_name='interface', - name='rf_channel', - field=models.CharField(blank=True, max_length=50), - ), - migrations.AddField( - model_name='interface', - name='rf_channel_width', - field=models.PositiveSmallIntegerField(blank=True, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0137_wireless.py b/netbox/dcim/migrations/0137_wireless.py index 9108735a1..788157c23 100644 --- a/netbox/dcim/migrations/0137_wireless.py +++ b/netbox/dcim/migrations/0137_wireless.py @@ -1,19 +1,38 @@ -# Generated by Django 3.2.8 on 2021-10-13 13:44 - from django.db import migrations, models +import django.db.models.deletion class Migration(migrations.Migration): dependencies = [ - ('dcim', '0136_wireless'), + ('dcim', '0136_rename_cable_peer'), ('wireless', '0001_wireless'), ] operations = [ + migrations.AddField( + model_name='interface', + name='rf_role', + field=models.CharField(blank=True, max_length=30), + ), + migrations.AddField( + model_name='interface', + name='rf_channel', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='interface', + name='rf_channel_width', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), migrations.AddField( model_name='interface', name='wireless_lans', field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.WirelessLAN'), ), + migrations.AddField( + model_name='interface', + name='wireless_link', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wireless.wirelesslink'), + ), ] diff --git a/netbox/dcim/migrations/0138_interface_wireless_link.py b/netbox/dcim/migrations/0138_interface_wireless_link.py deleted file mode 100644 index 42b7a1042..000000000 --- a/netbox/dcim/migrations/0138_interface_wireless_link.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 3.2.8 on 2021-10-13 15:29 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('wireless', '0001_wireless'), - ('dcim', '0137_wireless'), - ] - - operations = [ - migrations.AddField( - model_name='interface', - name='wireless_link', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wireless.wirelesslink'), - ), - ] diff --git a/netbox/wireless/migrations/0001_wireless.py b/netbox/wireless/migrations/0001_wireless.py index 068f4d64a..9adc8757b 100644 --- a/netbox/wireless/migrations/0001_wireless.py +++ b/netbox/wireless/migrations/0001_wireless.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('dcim', '0136_wireless'), + ('dcim', '0136_rename_cable_peer'), ('extras', '0062_clear_secrets_changelog'), ('ipam', '0050_iprange'), ] From 01d3c062f210bcb59bc126a19dfb41a3ca421348 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 15 Oct 2021 10:00:03 -0400 Subject: [PATCH 20/68] Move wireless field choices to wireless app --- netbox/dcim/api/serializers.py | 1 + netbox/dcim/choices.py | 185 ------------------------ netbox/dcim/forms/bulk_import.py | 1 + netbox/dcim/forms/filtersets.py | 1 + netbox/dcim/forms/object_create.py | 1 + netbox/dcim/models/device_components.py | 1 + netbox/wireless/choices.py | 182 +++++++++++++++++++++++ 7 files changed, 187 insertions(+), 185 deletions(-) create mode 100644 netbox/wireless/choices.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f50733163..d7857d5e8 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -19,6 +19,7 @@ from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedClusterSerializer +from wireless.choices import * from .nested_serializers import * diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index ae713d687..9f87dded0 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1154,191 +1154,6 @@ class CableLengthUnitChoices(ChoiceSet): ) -# -# Wireless -# - -class WirelessRoleChoices(ChoiceSet): - ROLE_AP = 'ap' - ROLE_STATION = 'station' - - CHOICES = ( - (ROLE_AP, 'Access point'), - (ROLE_STATION, 'Station'), - ) - - -class WirelessChannelChoices(ChoiceSet): - CHANNEL_AUTO = 'auto' - - # 2.4 GHz - CHANNEL_24G_1 = '2.4g-1' - CHANNEL_24G_2 = '2.4g-2' - CHANNEL_24G_3 = '2.4g-3' - CHANNEL_24G_4 = '2.4g-4' - CHANNEL_24G_5 = '2.4g-5' - CHANNEL_24G_6 = '2.4g-6' - CHANNEL_24G_7 = '2.4g-7' - CHANNEL_24G_8 = '2.4g-8' - CHANNEL_24G_9 = '2.4g-9' - CHANNEL_24G_10 = '2.4g-10' - CHANNEL_24G_11 = '2.4g-11' - CHANNEL_24G_12 = '2.4g-12' - CHANNEL_24G_13 = '2.4g-13' - - # 5 GHz - CHANNEL_5G_32 = '5g-32' - CHANNEL_5G_34 = '5g-34' - CHANNEL_5G_36 = '5g-36' - CHANNEL_5G_38 = '5g-38' - CHANNEL_5G_40 = '5g-40' - CHANNEL_5G_42 = '5g-42' - CHANNEL_5G_44 = '5g-44' - CHANNEL_5G_46 = '5g-46' - CHANNEL_5G_48 = '5g-48' - CHANNEL_5G_50 = '5g-50' - CHANNEL_5G_52 = '5g-52' - CHANNEL_5G_54 = '5g-54' - CHANNEL_5G_56 = '5g-56' - CHANNEL_5G_58 = '5g-58' - CHANNEL_5G_60 = '5g-60' - CHANNEL_5G_62 = '5g-62' - CHANNEL_5G_64 = '5g-64' - CHANNEL_5G_100 = '5g-100' - CHANNEL_5G_102 = '5g-102' - CHANNEL_5G_104 = '5g-104' - CHANNEL_5G_106 = '5g-106' - CHANNEL_5G_108 = '5g-108' - CHANNEL_5G_110 = '5g-110' - CHANNEL_5G_112 = '5g-112' - CHANNEL_5G_114 = '5g-114' - CHANNEL_5G_116 = '5g-116' - CHANNEL_5G_118 = '5g-118' - CHANNEL_5G_120 = '5g-120' - CHANNEL_5G_122 = '5g-122' - CHANNEL_5G_124 = '5g-124' - CHANNEL_5G_126 = '5g-126' - CHANNEL_5G_128 = '5g-128' - CHANNEL_5G_132 = '5g-132' - CHANNEL_5G_134 = '5g-134' - CHANNEL_5G_136 = '5g-136' - CHANNEL_5G_138 = '5g-138' - CHANNEL_5G_140 = '5g-140' - CHANNEL_5G_142 = '5g-142' - CHANNEL_5G_144 = '5g-144' - CHANNEL_5G_149 = '5g-149' - CHANNEL_5G_151 = '5g-151' - CHANNEL_5G_153 = '5g-153' - CHANNEL_5G_155 = '5g-155' - CHANNEL_5G_157 = '5g-157' - CHANNEL_5G_159 = '5g-159' - CHANNEL_5G_161 = '5g-161' - CHANNEL_5G_163 = '5g-163' - CHANNEL_5G_165 = '5g-165' - CHANNEL_5G_167 = '5g-167' - CHANNEL_5G_169 = '5g-169' - CHANNEL_5G_171 = '5g-171' - CHANNEL_5G_173 = '5g-173' - CHANNEL_5G_175 = '5g-175' - CHANNEL_5G_177 = '5g-177' - - CHOICES = ( - (CHANNEL_AUTO, 'Auto'), - ( - '2.4 GHz (802.11b/g/n/ax)', - ( - (CHANNEL_24G_1, '1 (2412 MHz)'), - (CHANNEL_24G_2, '2 (2417 MHz)'), - (CHANNEL_24G_3, '3 (2422 MHz)'), - (CHANNEL_24G_4, '4 (2427 MHz)'), - (CHANNEL_24G_5, '5 (2432 MHz)'), - (CHANNEL_24G_6, '6 (2437 MHz)'), - (CHANNEL_24G_7, '7 (2442 MHz)'), - (CHANNEL_24G_8, '8 (2447 MHz)'), - (CHANNEL_24G_9, '9 (2452 MHz)'), - (CHANNEL_24G_10, '10 (2457 MHz)'), - (CHANNEL_24G_11, '11 (2462 MHz)'), - (CHANNEL_24G_12, '12 (2467 MHz)'), - (CHANNEL_24G_13, '13 (2472 MHz)'), - ) - ), - ( - '5 GHz (802.11a/n/ac/ax)', - ( - (CHANNEL_5G_32, '32 (5160/20 MHz)'), - (CHANNEL_5G_34, '34 (5170/40 MHz)'), - (CHANNEL_5G_36, '36 (5180/20 MHz)'), - (CHANNEL_5G_38, '38 (5190/40 MHz)'), - (CHANNEL_5G_40, '40 (5200/20 MHz)'), - (CHANNEL_5G_42, '42 (5210/80 MHz)'), - (CHANNEL_5G_44, '44 (5220/20 MHz)'), - (CHANNEL_5G_46, '46 (5230/40 MHz)'), - (CHANNEL_5G_48, '48 (5240/20 MHz)'), - (CHANNEL_5G_50, '50 (5250/160 MHz)'), - (CHANNEL_5G_52, '52 (5260/20 MHz)'), - (CHANNEL_5G_54, '54 (5270/40 MHz)'), - (CHANNEL_5G_56, '56 (5280/20 MHz)'), - (CHANNEL_5G_58, '58 (5290/80 MHz)'), - (CHANNEL_5G_60, '60 (5300/20 MHz)'), - (CHANNEL_5G_62, '62 (5310/40 MHz)'), - (CHANNEL_5G_64, '64 (5320/20 MHz)'), - (CHANNEL_5G_100, '100 (5500/20 MHz)'), - (CHANNEL_5G_102, '102 (5510/40 MHz)'), - (CHANNEL_5G_104, '104 (5520/20 MHz)'), - (CHANNEL_5G_106, '106 (5530/80 MHz)'), - (CHANNEL_5G_108, '108 (5540/20 MHz)'), - (CHANNEL_5G_110, '110 (5550/40 MHz)'), - (CHANNEL_5G_112, '112 (5560/20 MHz)'), - (CHANNEL_5G_114, '114 (5570/160 MHz)'), - (CHANNEL_5G_116, '116 (5580/20 MHz)'), - (CHANNEL_5G_118, '118 (5590/40 MHz)'), - (CHANNEL_5G_120, '120 (5600/20 MHz)'), - (CHANNEL_5G_122, '122 (5610/80 MHz)'), - (CHANNEL_5G_124, '124 (5620/20 MHz)'), - (CHANNEL_5G_126, '126 (5630/40 MHz)'), - (CHANNEL_5G_128, '128 (5640/20 MHz)'), - (CHANNEL_5G_132, '132 (5660/20 MHz)'), - (CHANNEL_5G_134, '134 (5670/40 MHz)'), - (CHANNEL_5G_136, '136 (5680/20 MHz)'), - (CHANNEL_5G_138, '138 (5690/80 MHz)'), - (CHANNEL_5G_140, '140 (5700/20 MHz)'), - (CHANNEL_5G_142, '142 (5710/40 MHz)'), - (CHANNEL_5G_144, '144 (5720/20 MHz)'), - (CHANNEL_5G_149, '149 (5745/20 MHz)'), - (CHANNEL_5G_151, '151 (5755/40 MHz)'), - (CHANNEL_5G_153, '153 (5765/20 MHz)'), - (CHANNEL_5G_155, '155 (5775/80 MHz)'), - (CHANNEL_5G_157, '157 (5785/20 MHz)'), - (CHANNEL_5G_159, '159 (5795/40 MHz)'), - (CHANNEL_5G_161, '161 (5805/20 MHz)'), - (CHANNEL_5G_163, '163 (5815/160 MHz)'), - (CHANNEL_5G_165, '165 (5825/20 MHz)'), - (CHANNEL_5G_167, '167 (5835/40 MHz)'), - (CHANNEL_5G_169, '169 (5845/20 MHz)'), - (CHANNEL_5G_171, '171 (5855/80 MHz)'), - (CHANNEL_5G_173, '173 (5865/20 MHz)'), - (CHANNEL_5G_175, '175 (5875/40 MHz)'), - (CHANNEL_5G_177, '177 (5885/20 MHz)'), - ) - ), - ) - - -class WirelessChannelWidthChoices(ChoiceSet): - - CHANNEL_WIDTH_20 = 20 - CHANNEL_WIDTH_40 = 40 - CHANNEL_WIDTH_80 = 80 - CHANNEL_WIDTH_160 = 160 - - CHOICES = ( - (CHANNEL_WIDTH_20, '20 MHz'), - (CHANNEL_WIDTH_40, '40 MHz'), - (CHANNEL_WIDTH_80, '80 MHz'), - (CHANNEL_WIDTH_160, '160 MHz'), - ) - - # # PowerFeeds # diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 4deda9df6..5ca009dee 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -11,6 +11,7 @@ from extras.forms import CustomFieldModelCSVForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField from virtualization.models import Cluster +from wireless.choices import WirelessRoleChoices __all__ = ( 'CableCSVForm', diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 6334cbff6..50954e534 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -12,6 +12,7 @@ from utilities.forms import ( APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) +from wireless.choices import * __all__ = ( 'CableFilterForm', diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 3998dcbc1..ff8a19d47 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -10,6 +10,7 @@ from utilities.forms import ( add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, StaticSelect, ) +from wireless.choices import * from .common import InterfaceCommonForm __all__ = ( diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 39c618f4d..381a2dcf6 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -18,6 +18,7 @@ from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface from utilities.querysets import RestrictedQuerySet from utilities.query_functions import CollateAsChar +from wireless.choices import * __all__ = ( diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py new file mode 100644 index 000000000..f33bf75a1 --- /dev/null +++ b/netbox/wireless/choices.py @@ -0,0 +1,182 @@ +from utilities.choices import ChoiceSet + + +class WirelessRoleChoices(ChoiceSet): + ROLE_AP = 'ap' + ROLE_STATION = 'station' + + CHOICES = ( + (ROLE_AP, 'Access point'), + (ROLE_STATION, 'Station'), + ) + + +class WirelessChannelChoices(ChoiceSet): + CHANNEL_AUTO = 'auto' + + # 2.4 GHz + CHANNEL_24G_1 = '2.4g-1' + CHANNEL_24G_2 = '2.4g-2' + CHANNEL_24G_3 = '2.4g-3' + CHANNEL_24G_4 = '2.4g-4' + CHANNEL_24G_5 = '2.4g-5' + CHANNEL_24G_6 = '2.4g-6' + CHANNEL_24G_7 = '2.4g-7' + CHANNEL_24G_8 = '2.4g-8' + CHANNEL_24G_9 = '2.4g-9' + CHANNEL_24G_10 = '2.4g-10' + CHANNEL_24G_11 = '2.4g-11' + CHANNEL_24G_12 = '2.4g-12' + CHANNEL_24G_13 = '2.4g-13' + + # 5 GHz + CHANNEL_5G_32 = '5g-32' + CHANNEL_5G_34 = '5g-34' + CHANNEL_5G_36 = '5g-36' + CHANNEL_5G_38 = '5g-38' + CHANNEL_5G_40 = '5g-40' + CHANNEL_5G_42 = '5g-42' + CHANNEL_5G_44 = '5g-44' + CHANNEL_5G_46 = '5g-46' + CHANNEL_5G_48 = '5g-48' + CHANNEL_5G_50 = '5g-50' + CHANNEL_5G_52 = '5g-52' + CHANNEL_5G_54 = '5g-54' + CHANNEL_5G_56 = '5g-56' + CHANNEL_5G_58 = '5g-58' + CHANNEL_5G_60 = '5g-60' + CHANNEL_5G_62 = '5g-62' + CHANNEL_5G_64 = '5g-64' + CHANNEL_5G_100 = '5g-100' + CHANNEL_5G_102 = '5g-102' + CHANNEL_5G_104 = '5g-104' + CHANNEL_5G_106 = '5g-106' + CHANNEL_5G_108 = '5g-108' + CHANNEL_5G_110 = '5g-110' + CHANNEL_5G_112 = '5g-112' + CHANNEL_5G_114 = '5g-114' + CHANNEL_5G_116 = '5g-116' + CHANNEL_5G_118 = '5g-118' + CHANNEL_5G_120 = '5g-120' + CHANNEL_5G_122 = '5g-122' + CHANNEL_5G_124 = '5g-124' + CHANNEL_5G_126 = '5g-126' + CHANNEL_5G_128 = '5g-128' + CHANNEL_5G_132 = '5g-132' + CHANNEL_5G_134 = '5g-134' + CHANNEL_5G_136 = '5g-136' + CHANNEL_5G_138 = '5g-138' + CHANNEL_5G_140 = '5g-140' + CHANNEL_5G_142 = '5g-142' + CHANNEL_5G_144 = '5g-144' + CHANNEL_5G_149 = '5g-149' + CHANNEL_5G_151 = '5g-151' + CHANNEL_5G_153 = '5g-153' + CHANNEL_5G_155 = '5g-155' + CHANNEL_5G_157 = '5g-157' + CHANNEL_5G_159 = '5g-159' + CHANNEL_5G_161 = '5g-161' + CHANNEL_5G_163 = '5g-163' + CHANNEL_5G_165 = '5g-165' + CHANNEL_5G_167 = '5g-167' + CHANNEL_5G_169 = '5g-169' + CHANNEL_5G_171 = '5g-171' + CHANNEL_5G_173 = '5g-173' + CHANNEL_5G_175 = '5g-175' + CHANNEL_5G_177 = '5g-177' + + CHOICES = ( + (CHANNEL_AUTO, 'Auto'), + ( + '2.4 GHz (802.11b/g/n/ax)', + ( + (CHANNEL_24G_1, '1 (2412 MHz)'), + (CHANNEL_24G_2, '2 (2417 MHz)'), + (CHANNEL_24G_3, '3 (2422 MHz)'), + (CHANNEL_24G_4, '4 (2427 MHz)'), + (CHANNEL_24G_5, '5 (2432 MHz)'), + (CHANNEL_24G_6, '6 (2437 MHz)'), + (CHANNEL_24G_7, '7 (2442 MHz)'), + (CHANNEL_24G_8, '8 (2447 MHz)'), + (CHANNEL_24G_9, '9 (2452 MHz)'), + (CHANNEL_24G_10, '10 (2457 MHz)'), + (CHANNEL_24G_11, '11 (2462 MHz)'), + (CHANNEL_24G_12, '12 (2467 MHz)'), + (CHANNEL_24G_13, '13 (2472 MHz)'), + ) + ), + ( + '5 GHz (802.11a/n/ac/ax)', + ( + (CHANNEL_5G_32, '32 (5160/20 MHz)'), + (CHANNEL_5G_34, '34 (5170/40 MHz)'), + (CHANNEL_5G_36, '36 (5180/20 MHz)'), + (CHANNEL_5G_38, '38 (5190/40 MHz)'), + (CHANNEL_5G_40, '40 (5200/20 MHz)'), + (CHANNEL_5G_42, '42 (5210/80 MHz)'), + (CHANNEL_5G_44, '44 (5220/20 MHz)'), + (CHANNEL_5G_46, '46 (5230/40 MHz)'), + (CHANNEL_5G_48, '48 (5240/20 MHz)'), + (CHANNEL_5G_50, '50 (5250/160 MHz)'), + (CHANNEL_5G_52, '52 (5260/20 MHz)'), + (CHANNEL_5G_54, '54 (5270/40 MHz)'), + (CHANNEL_5G_56, '56 (5280/20 MHz)'), + (CHANNEL_5G_58, '58 (5290/80 MHz)'), + (CHANNEL_5G_60, '60 (5300/20 MHz)'), + (CHANNEL_5G_62, '62 (5310/40 MHz)'), + (CHANNEL_5G_64, '64 (5320/20 MHz)'), + (CHANNEL_5G_100, '100 (5500/20 MHz)'), + (CHANNEL_5G_102, '102 (5510/40 MHz)'), + (CHANNEL_5G_104, '104 (5520/20 MHz)'), + (CHANNEL_5G_106, '106 (5530/80 MHz)'), + (CHANNEL_5G_108, '108 (5540/20 MHz)'), + (CHANNEL_5G_110, '110 (5550/40 MHz)'), + (CHANNEL_5G_112, '112 (5560/20 MHz)'), + (CHANNEL_5G_114, '114 (5570/160 MHz)'), + (CHANNEL_5G_116, '116 (5580/20 MHz)'), + (CHANNEL_5G_118, '118 (5590/40 MHz)'), + (CHANNEL_5G_120, '120 (5600/20 MHz)'), + (CHANNEL_5G_122, '122 (5610/80 MHz)'), + (CHANNEL_5G_124, '124 (5620/20 MHz)'), + (CHANNEL_5G_126, '126 (5630/40 MHz)'), + (CHANNEL_5G_128, '128 (5640/20 MHz)'), + (CHANNEL_5G_132, '132 (5660/20 MHz)'), + (CHANNEL_5G_134, '134 (5670/40 MHz)'), + (CHANNEL_5G_136, '136 (5680/20 MHz)'), + (CHANNEL_5G_138, '138 (5690/80 MHz)'), + (CHANNEL_5G_140, '140 (5700/20 MHz)'), + (CHANNEL_5G_142, '142 (5710/40 MHz)'), + (CHANNEL_5G_144, '144 (5720/20 MHz)'), + (CHANNEL_5G_149, '149 (5745/20 MHz)'), + (CHANNEL_5G_151, '151 (5755/40 MHz)'), + (CHANNEL_5G_153, '153 (5765/20 MHz)'), + (CHANNEL_5G_155, '155 (5775/80 MHz)'), + (CHANNEL_5G_157, '157 (5785/20 MHz)'), + (CHANNEL_5G_159, '159 (5795/40 MHz)'), + (CHANNEL_5G_161, '161 (5805/20 MHz)'), + (CHANNEL_5G_163, '163 (5815/160 MHz)'), + (CHANNEL_5G_165, '165 (5825/20 MHz)'), + (CHANNEL_5G_167, '167 (5835/40 MHz)'), + (CHANNEL_5G_169, '169 (5845/20 MHz)'), + (CHANNEL_5G_171, '171 (5855/80 MHz)'), + (CHANNEL_5G_173, '173 (5865/20 MHz)'), + (CHANNEL_5G_175, '175 (5875/40 MHz)'), + (CHANNEL_5G_177, '177 (5885/20 MHz)'), + ) + ), + ) + + +class WirelessChannelWidthChoices(ChoiceSet): + + CHANNEL_WIDTH_20 = 20 + CHANNEL_WIDTH_40 = 40 + CHANNEL_WIDTH_80 = 80 + CHANNEL_WIDTH_160 = 160 + + CHOICES = ( + (CHANNEL_WIDTH_20, '20 MHz'), + (CHANNEL_WIDTH_40, '40 MHz'), + (CHANNEL_WIDTH_80, '80 MHz'), + (CHANNEL_WIDTH_160, '160 MHz'), + ) From b7317bfe2911f792ead028db80d5797ba18ac166 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 15 Oct 2021 10:06:49 -0400 Subject: [PATCH 21/68] Remove choices from rf_channel_width --- netbox/dcim/api/serializers.py | 1 - netbox/dcim/forms/filtersets.py | 6 ++---- netbox/dcim/forms/models.py | 1 - netbox/dcim/forms/object_create.py | 4 +--- netbox/dcim/models/device_components.py | 3 +-- netbox/templates/dcim/interface.html | 2 +- netbox/wireless/choices.py | 15 --------------- 7 files changed, 5 insertions(+), 27 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d7857d5e8..d2a9125c0 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -637,7 +637,6 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True) rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False) - rf_channel_width = ChoiceField(choices=WirelessChannelWidthChoices, required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 50954e534..fb9449d41 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1014,11 +1014,9 @@ class InterfaceFilterForm(DeviceComponentFilterForm): widget=StaticSelectMultiple(), label='Wireless channel' ) - rf_channel_width = forms.MultipleChoiceField( - choices=WirelessChannelWidthChoices, + rf_channel_width = forms.IntegerField( required=False, - widget=StaticSelectMultiple(), - label='Channel width' + label='Channel width (kHz)' ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 35b90291e..675319f11 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1117,7 +1117,6 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): 'mode': StaticSelect(), 'rf_role': StaticSelect(), 'rf_channel': StaticSelect(), - 'rf_channel_width': StaticSelect(), } labels = { 'mode': '802.1Q Mode', diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index ff8a19d47..f924fb5d9 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -480,10 +480,8 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): widget=StaticSelect(), label='Wireless channel' ) - rf_channel_width = forms.ChoiceField( - choices=add_blank_choice(WirelessChannelWidthChoices), + rf_channel_width = forms.IntegerField( required=False, - widget=StaticSelect(), label='Channel width' ) untagged_vlan = DynamicModelChoiceField( diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 381a2dcf6..bb18e0c26 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -538,10 +538,9 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): verbose_name='Wireless channel' ) rf_channel_width = models.PositiveSmallIntegerField( - choices=WirelessChannelWidthChoices, blank=True, null=True, - verbose_name='Channel width' + verbose_name='Channel width (kHz)' ) wireless_link = models.ForeignKey( to='wireless.WirelessLink', diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 90c9497ef..427ea8352 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -278,7 +278,7 @@ Channel Width - {{ object.get_rf_channel_width_display|placeholder }} + {{ object.rf_channel_width|placeholder }}
diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py index f33bf75a1..1369ee340 100644 --- a/netbox/wireless/choices.py +++ b/netbox/wireless/choices.py @@ -165,18 +165,3 @@ class WirelessChannelChoices(ChoiceSet): ) ), ) - - -class WirelessChannelWidthChoices(ChoiceSet): - - CHANNEL_WIDTH_20 = 20 - CHANNEL_WIDTH_40 = 40 - CHANNEL_WIDTH_80 = 80 - CHANNEL_WIDTH_160 = 160 - - CHOICES = ( - (CHANNEL_WIDTH_20, '20 MHz'), - (CHANNEL_WIDTH_40, '40 MHz'), - (CHANNEL_WIDTH_80, '80 MHz'), - (CHANNEL_WIDTH_160, '160 MHz'), - ) From 075f4907ef7e3da89c1e3f8aed418432857da9d3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 15 Oct 2021 11:35:10 -0400 Subject: [PATCH 22/68] Store channel frequency & width as independent values --- netbox/dcim/api/serializers.py | 8 +- netbox/dcim/forms/bulk_edit.py | 4 +- netbox/dcim/forms/bulk_import.py | 3 +- netbox/dcim/forms/filtersets.py | 6 +- netbox/dcim/forms/models.py | 6 +- netbox/dcim/forms/object_create.py | 12 +- netbox/dcim/migrations/0138_wireless.py | 7 +- netbox/dcim/models/device_components.py | 40 +++++- netbox/dcim/tables/devices.py | 5 +- netbox/templates/dcim/interface.html | 18 ++- netbox/templates/dcim/interface_edit.html | 1 + .../wireless/inc/wirelesslink_interface.html | 20 +++ netbox/utilities/templatetags/helpers.py | 14 ++ netbox/wireless/choices.py | 136 +++++++++--------- netbox/wireless/forms/models.py | 10 +- netbox/wireless/utils.py | 27 ++++ 16 files changed, 223 insertions(+), 94 deletions(-) create mode 100644 netbox/wireless/utils.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d2a9125c0..4eeb717d7 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -651,10 +651,10 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con model = Interface fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', 'untagged_vlan', - 'tagged_vlans', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', 'count_ipaddresses', '_occupied', + 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'link_peer', + 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', ] def validate(self, data): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 9c6a85885..e8e60a4f9 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -936,7 +936,7 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ 'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', - 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', + 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', ]), BootstrapMixin, AddRemoveTagsForm, @@ -988,7 +988,7 @@ class InterfaceBulkEditForm( class Meta: nullable_fields = [ 'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', - 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', + 'rf_channel_frequency', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', ] def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 5ca009dee..4eb860836 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -595,7 +595,8 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): model = Interface fields = ( 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'wwn', - 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', + 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index fb9449d41..e28714914 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1014,9 +1014,13 @@ class InterfaceFilterForm(DeviceComponentFilterForm): widget=StaticSelectMultiple(), label='Wireless channel' ) + rf_channel_frequency = forms.IntegerField( + required=False, + label='Channel frequency (MHz)' + ) rf_channel_width = forms.IntegerField( required=False, - label='Channel width (kHz)' + label='Channel width (MHz)' ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 675319f11..603767518 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1108,8 +1108,8 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): model = Interface fields = [ 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', - 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_width', 'wireless_lans', - 'untagged_vlan', 'tagged_vlans', 'tags', + 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -1123,6 +1123,8 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): } help_texts = { 'mode': INTERFACE_MODE_HELP_TEXT, + 'rf_channel_frequency': "Populated by selected channel (if set)", + 'rf_channel_width': "Populated by selected channel (if set)", } def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index f924fb5d9..547fe7e68 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -480,9 +480,13 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): widget=StaticSelect(), label='Wireless channel' ) - rf_channel_width = forms.IntegerField( + rf_channel_frequency = forms.DecimalField( required=False, - label='Channel width' + label='Channel frequency (MHz)' + ) + rf_channel_width = forms.DecimalField( + required=False, + label='Channel width (MHz)' ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), @@ -494,8 +498,8 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): ) field_order = ( 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_width', 'mode', - 'untagged_vlan', 'tagged_vlans', 'tags' + 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/migrations/0138_wireless.py b/netbox/dcim/migrations/0138_wireless.py index faebcf268..bbdb28283 100644 --- a/netbox/dcim/migrations/0138_wireless.py +++ b/netbox/dcim/migrations/0138_wireless.py @@ -20,10 +20,15 @@ class Migration(migrations.Migration): name='rf_channel', field=models.CharField(blank=True, max_length=50), ), + migrations.AddField( + model_name='interface', + name='rf_channel_frequency', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True), + ), migrations.AddField( model_name='interface', name='rf_channel_width', - field=models.PositiveSmallIntegerField(blank=True, null=True), + field=models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True), ), migrations.AddField( model_name='interface', diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index bb18e0c26..c2a37fcae 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -19,6 +19,7 @@ from utilities.ordering import naturalize_interface from utilities.querysets import RestrictedQuerySet from utilities.query_functions import CollateAsChar from wireless.choices import * +from wireless.utils import get_channel_attr __all__ = ( @@ -537,10 +538,19 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): blank=True, verbose_name='Wireless channel' ) - rf_channel_width = models.PositiveSmallIntegerField( + rf_channel_frequency = models.DecimalField( + max_digits=7, + decimal_places=2, blank=True, null=True, - verbose_name='Channel width (kHz)' + verbose_name='Channel frequency (MHz)' + ) + rf_channel_width = models.DecimalField( + max_digits=7, + decimal_places=3, + blank=True, + null=True, + verbose_name='Channel width (MHz)' ) wireless_link = models.ForeignKey( to='wireless.WirelessLink', @@ -641,13 +651,33 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): if self.pk and self.lag_id == self.pk: raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) - # RF channel attributes may be set only for wireless interfaces + # RF role & channel may only be set for wireless interfaces if self.rf_role and not self.is_wireless: raise ValidationError({'rf_role': "Wireless role may be set only on wireless interfaces."}) if self.rf_channel and not self.is_wireless: raise ValidationError({'rf_channel': "Channel may be set only on wireless interfaces."}) - if self.rf_channel_width and not self.is_wireless: - raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."}) + + # Validate channel frequency against interface type and selected channel (if any) + if self.rf_channel_frequency: + if not self.is_wireless: + raise ValidationError({ + 'rf_channel_frequency': "Channel frequency may be set only on wireless interfaces.", + }) + if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'): + raise ValidationError({ + 'rf_channel_frequency': "Cannot specify custom frequency with channel selected.", + }) + elif self.rf_channel: + self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency') + + # Validate channel width against interface type and selected channel (if any) + if self.rf_channel_width: + if not self.is_wireless: + raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."}) + if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'): + raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."}) + elif self.rf_channel: + self.rf_channel_width = get_channel_attr(self.rf_channel, 'width') # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index b0ef9807e..3b0ec349e 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -496,8 +496,9 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable model = Interface fields = ( 'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', - 'wireless_link', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'description', 'mark_connected', + 'cable', 'cable_color', 'wireless_link', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', + 'tagged_vlans', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 427ea8352..6e01dee98 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -276,9 +276,25 @@ Channel {{ object.get_rf_channel_display|placeholder }} + + Channel Frequency + + {% if object.rf_channel_frequency %} + {{ object.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} + + Channel Width - {{ object.rf_channel_width|placeholder }} + + {% if object.rf_channel_width %} + {{ object.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} + diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index cb8d51828..de7d21269 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -36,6 +36,7 @@ {% render_field form.rf_role %} {% render_field form.rf_channel %} + {% render_field form.rf_channel_frequency %} {% render_field form.rf_channel_width %} {% render_field form.wireless_lans %} diff --git a/netbox/templates/wireless/inc/wirelesslink_interface.html b/netbox/templates/wireless/inc/wirelesslink_interface.html index 82f7cfd8d..e33047539 100644 --- a/netbox/templates/wireless/inc/wirelesslink_interface.html +++ b/netbox/templates/wireless/inc/wirelesslink_interface.html @@ -31,4 +31,24 @@ {{ interface.get_rf_channel_display|placeholder }} + + Channel Frequency + + {% if interface.rf_channel_frequency %} + {{ interface.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} + + + + Channel Width + + {% if interface.rf_channel_width %} + {{ interface.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} + + diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index a900d59e2..668596c8e 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,4 +1,5 @@ import datetime +import decimal import json import re from typing import Dict, Any @@ -146,6 +147,19 @@ def humanize_megabytes(mb): return f'{mb} MB' +@register.filter() +def simplify_decimal(value): + """ + Return the simplest expression of a decimal value. Examples: + 1.00 => '1' + 1.20 => '1.2' + 1.23 => '1.23' + """ + if type(value) is not decimal.Decimal: + return value + return str(value).rstrip('0.') + + @register.filter() def tzoffset(value): """ diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py index 1369ee340..8a710b532 100644 --- a/netbox/wireless/choices.py +++ b/netbox/wireless/choices.py @@ -12,81 +12,79 @@ class WirelessRoleChoices(ChoiceSet): class WirelessChannelChoices(ChoiceSet): - CHANNEL_AUTO = 'auto' # 2.4 GHz - CHANNEL_24G_1 = '2.4g-1' - CHANNEL_24G_2 = '2.4g-2' - CHANNEL_24G_3 = '2.4g-3' - CHANNEL_24G_4 = '2.4g-4' - CHANNEL_24G_5 = '2.4g-5' - CHANNEL_24G_6 = '2.4g-6' - CHANNEL_24G_7 = '2.4g-7' - CHANNEL_24G_8 = '2.4g-8' - CHANNEL_24G_9 = '2.4g-9' - CHANNEL_24G_10 = '2.4g-10' - CHANNEL_24G_11 = '2.4g-11' - CHANNEL_24G_12 = '2.4g-12' - CHANNEL_24G_13 = '2.4g-13' + CHANNEL_24G_1 = '2.4g-1-2412-22' + CHANNEL_24G_2 = '2.4g-2-2417-22' + CHANNEL_24G_3 = '2.4g-3-2422-22' + CHANNEL_24G_4 = '2.4g-4-2427-22' + CHANNEL_24G_5 = '2.4g-5-2432-22' + CHANNEL_24G_6 = '2.4g-6-2437-22' + CHANNEL_24G_7 = '2.4g-7-2442-22' + CHANNEL_24G_8 = '2.4g-8-2447-22' + CHANNEL_24G_9 = '2.4g-9-2452-22' + CHANNEL_24G_10 = '2.4g-10-2457-22' + CHANNEL_24G_11 = '2.4g-11-2462-22' + CHANNEL_24G_12 = '2.4g-12-2467-22' + CHANNEL_24G_13 = '2.4g-13-2472-22' # 5 GHz - CHANNEL_5G_32 = '5g-32' - CHANNEL_5G_34 = '5g-34' - CHANNEL_5G_36 = '5g-36' - CHANNEL_5G_38 = '5g-38' - CHANNEL_5G_40 = '5g-40' - CHANNEL_5G_42 = '5g-42' - CHANNEL_5G_44 = '5g-44' - CHANNEL_5G_46 = '5g-46' - CHANNEL_5G_48 = '5g-48' - CHANNEL_5G_50 = '5g-50' - CHANNEL_5G_52 = '5g-52' - CHANNEL_5G_54 = '5g-54' - CHANNEL_5G_56 = '5g-56' - CHANNEL_5G_58 = '5g-58' - CHANNEL_5G_60 = '5g-60' - CHANNEL_5G_62 = '5g-62' - CHANNEL_5G_64 = '5g-64' - CHANNEL_5G_100 = '5g-100' - CHANNEL_5G_102 = '5g-102' - CHANNEL_5G_104 = '5g-104' - CHANNEL_5G_106 = '5g-106' - CHANNEL_5G_108 = '5g-108' - CHANNEL_5G_110 = '5g-110' - CHANNEL_5G_112 = '5g-112' - CHANNEL_5G_114 = '5g-114' - CHANNEL_5G_116 = '5g-116' - CHANNEL_5G_118 = '5g-118' - CHANNEL_5G_120 = '5g-120' - CHANNEL_5G_122 = '5g-122' - CHANNEL_5G_124 = '5g-124' - CHANNEL_5G_126 = '5g-126' - CHANNEL_5G_128 = '5g-128' - CHANNEL_5G_132 = '5g-132' - CHANNEL_5G_134 = '5g-134' - CHANNEL_5G_136 = '5g-136' - CHANNEL_5G_138 = '5g-138' - CHANNEL_5G_140 = '5g-140' - CHANNEL_5G_142 = '5g-142' - CHANNEL_5G_144 = '5g-144' - CHANNEL_5G_149 = '5g-149' - CHANNEL_5G_151 = '5g-151' - CHANNEL_5G_153 = '5g-153' - CHANNEL_5G_155 = '5g-155' - CHANNEL_5G_157 = '5g-157' - CHANNEL_5G_159 = '5g-159' - CHANNEL_5G_161 = '5g-161' - CHANNEL_5G_163 = '5g-163' - CHANNEL_5G_165 = '5g-165' - CHANNEL_5G_167 = '5g-167' - CHANNEL_5G_169 = '5g-169' - CHANNEL_5G_171 = '5g-171' - CHANNEL_5G_173 = '5g-173' - CHANNEL_5G_175 = '5g-175' - CHANNEL_5G_177 = '5g-177' + CHANNEL_5G_32 = '5g-32-5160-20' + CHANNEL_5G_34 = '5g-34-5170-40' + CHANNEL_5G_36 = '5g-36-5180-20' + CHANNEL_5G_38 = '5g-38-5190-40' + CHANNEL_5G_40 = '5g-40-5200-20' + CHANNEL_5G_42 = '5g-42-5210-80' + CHANNEL_5G_44 = '5g-44-5220-20' + CHANNEL_5G_46 = '5g-46-5230-40' + CHANNEL_5G_48 = '5g-48-5240-20' + CHANNEL_5G_50 = '5g-50-5250-160' + CHANNEL_5G_52 = '5g-52-5260-20' + CHANNEL_5G_54 = '5g-54-5270-40' + CHANNEL_5G_56 = '5g-56-5280-20' + CHANNEL_5G_58 = '5g-58-5290-80' + CHANNEL_5G_60 = '5g-60-5300-20' + CHANNEL_5G_62 = '5g-62-5310-40' + CHANNEL_5G_64 = '5g-64-5320-20' + CHANNEL_5G_100 = '5g-100-5500-20' + CHANNEL_5G_102 = '5g-102-5510-40' + CHANNEL_5G_104 = '5g-104-5520-20' + CHANNEL_5G_106 = '5g-106-5530-80' + CHANNEL_5G_108 = '5g-108-5540-20' + CHANNEL_5G_110 = '5g-110-5550-40' + CHANNEL_5G_112 = '5g-112-5560-20' + CHANNEL_5G_114 = '5g-114-5570-160' + CHANNEL_5G_116 = '5g-116-5580-20' + CHANNEL_5G_118 = '5g-118-5590-40' + CHANNEL_5G_120 = '5g-120-5600-20' + CHANNEL_5G_122 = '5g-122-5610-80' + CHANNEL_5G_124 = '5g-124-5620-20' + CHANNEL_5G_126 = '5g-126-5630-40' + CHANNEL_5G_128 = '5g-128-5640-20' + CHANNEL_5G_132 = '5g-132-5660-20' + CHANNEL_5G_134 = '5g-134-5670-40' + CHANNEL_5G_136 = '5g-136-5680-20' + CHANNEL_5G_138 = '5g-138-5690-80' + CHANNEL_5G_140 = '5g-140-5700-20' + CHANNEL_5G_142 = '5g-142-5710-40' + CHANNEL_5G_144 = '5g-144-5720-20' + CHANNEL_5G_149 = '5g-149-5745-20' + CHANNEL_5G_151 = '5g-151-5755-40' + CHANNEL_5G_153 = '5g-153-5765-20' + CHANNEL_5G_155 = '5g-155-5775-80' + CHANNEL_5G_157 = '5g-157-5785-20' + CHANNEL_5G_159 = '5g-159-5795-40' + CHANNEL_5G_161 = '5g-161-5805-20' + CHANNEL_5G_163 = '5g-163-5815-160' + CHANNEL_5G_165 = '5g-165-5825-20' + CHANNEL_5G_167 = '5g-167-5835-40' + CHANNEL_5G_169 = '5g-169-5845-20' + CHANNEL_5G_171 = '5g-171-5855-80' + CHANNEL_5G_173 = '5g-173-5865-20' + CHANNEL_5G_175 = '5g-175-5875-40' + CHANNEL_5G_177 = '5g-177-5885-20' CHOICES = ( - (CHANNEL_AUTO, 'Auto'), ( '2.4 GHz (802.11b/g/n/ax)', ( diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index a3454c79a..9a7b78b31 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -56,7 +56,10 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): device_a = DynamicModelChoiceField( queryset=Device.objects.all(), - label='Device A' + label='Device A', + initial_params={ + 'interfaces': '$interface_a' + } ) interface_a = DynamicModelChoiceField( queryset=Interface.objects.all(), @@ -69,7 +72,10 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): ) device_b = DynamicModelChoiceField( queryset=Device.objects.all(), - label='Device B' + label='Device B', + initial_params={ + 'interfaces': '$interface_b' + } ) interface_b = DynamicModelChoiceField( queryset=Interface.objects.all(), diff --git a/netbox/wireless/utils.py b/netbox/wireless/utils.py new file mode 100644 index 000000000..d98d6a853 --- /dev/null +++ b/netbox/wireless/utils.py @@ -0,0 +1,27 @@ +from decimal import Decimal + +from .choices import WirelessChannelChoices + +__all__ = ( + 'get_channel_attr', +) + + +def get_channel_attr(channel, attr): + """ + Return the specified attribute of a given WirelessChannelChoices value. + """ + if channel not in WirelessChannelChoices.values(): + raise ValueError(f"Invalid channel value: {channel}") + + channel_values = channel.split('-') + attrs = { + 'band': channel_values[0], + 'id': int(channel_values[1]), + 'frequency': Decimal(channel_values[2]), + 'width': Decimal(channel_values[3]), + } + if attr not in attrs: + raise ValueError(f"Invalid channel attribute: {attr}") + + return attrs[attr] From 717fd760df62dd9ad1d994c8fc1893169109988a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 15 Oct 2021 12:24:07 -0400 Subject: [PATCH 23/68] #3979: UI cleanup --- netbox/dcim/forms/models.py | 12 +- netbox/dcim/tables/devices.py | 4 +- netbox/dcim/tables/template_code.py | 10 ++ netbox/templates/dcim/interface.html | 128 ++++++++++++++++------ netbox/templates/dcim/interface_edit.html | 1 + netbox/utilities/templatetags/helpers.py | 2 +- netbox/wireless/filtersets.py | 3 + 7 files changed, 124 insertions(+), 36 deletions(-) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 603767518..9ce0b54aa 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -16,7 +16,7 @@ from utilities.forms import ( SlugField, StaticSelect, ) from virtualization.models import Cluster, ClusterGroup -from wireless.models import WirelessLAN +from wireless.models import WirelessLAN, WirelessLANGroup from .common import InterfaceCommonForm __all__ = ( @@ -1073,10 +1073,18 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): 'type': 'lag', } ) + wireless_lan_group = DynamicModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + label='Wireless LAN group' + ) wireless_lans = DynamicModelMultipleChoiceField( queryset=WirelessLAN.objects.all(), required=False, - label='Wireless LANs' + label='Wireless LANs', + query_params={ + 'group_id': '$wireless_lan_group', + } ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 3b0ec349e..3b92efd76 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -505,8 +505,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable class DeviceInterfaceTable(InterfaceTable): name = tables.TemplateColumn( - template_code=' {{ value }}', order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index a5a4d9979..a948baffd 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -205,6 +205,12 @@ INTERFACE_BUTTONS = """ {% endif %} +{% elif record.wireless_link %} + {% if perms.wireless.delete_wirelesslink %} + + + + {% endif %} {% elif record.is_wired and perms.dcim.add_cable %} @@ -223,6 +229,10 @@ INTERFACE_BUTTONS = """ {% else %} {% endif %} +{% elif record.is_wireless and perms.wireless.add_wirelesslink %} + + + {% endif %} """ diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 6e01dee98..e9230fbf9 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -217,8 +217,29 @@ Wireless Link {{ object.wireless_link }} + + + + {% with peer_interface=object.connected_endpoint %} + + Device + + {{ peer_interface.device }} + + + + Name + + {{ peer_interface }} + + + + Type + {{ peer_interface.get_type_display }} + + {% endwith %} {% else %}
@@ -267,36 +288,73 @@
Wireless
- - - - - - - - - - - - - - - - - -
Role{{ object.get_rf_role_display|placeholder }}
Channel{{ object.get_rf_channel_display|placeholder }}
Channel Frequency - {% if object.rf_channel_frequency %} - {{ object.rf_channel_frequency|simplify_decimal }} MHz - {% else %} - - {% endif %} -
Channel Width - {% if object.rf_channel_width %} - {{ object.rf_channel_width|simplify_decimal }} MHz - {% else %} - - {% endif %} -
+ {% with peer=object.connected_endpoint %} + + + + + + {% if peer %} + + {% endif %} + + + + + + {% if peer %} + + {% endif %} + + + + + {% if peer %} + + {{ peer.get_rf_channel_display|placeholder }} + + {% endif %} + + + + + {% if peer %} + + {% if peer.rf_channel_frequency %} + {{ peer.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} + + {% endif %} + + + + + {% if peer %} + + {% if peer.rf_channel_width %} + {{ peer.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} + + {% endif %} + +
LocalPeer
Role{{ object.get_rf_role_display|placeholder }}{{ peer.get_rf_role_display|placeholder }}
Channel{{ object.get_rf_channel_display|placeholder }}
Channel Frequency + {% if object.rf_channel_frequency %} + {{ object.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} +
Channel Width + {% if object.rf_channel_width %} + {{ object.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} +
+ {% endwith %}
@@ -305,12 +363,20 @@ + - {% for wlan in object.wlans.all %} + {% for wlan in object.wireless_lans.all %} + diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index de7d21269..aec88d25a 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -38,6 +38,7 @@ {% render_field form.rf_channel %} {% render_field form.rf_channel_frequency %} {% render_field form.rf_channel_width %} + {% render_field form.wireless_lan_group %} {% render_field form.wireless_lans %} {% endif %} diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 668596c8e..3318fe1e7 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -157,7 +157,7 @@ def simplify_decimal(value): """ if type(value) is not decimal.Decimal: return value - return str(value).rstrip('0.') + return str(value).rstrip('0').rstrip('.') @register.filter() diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index ac503e474..a5d9b7d75 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -33,6 +33,9 @@ class WirelessLANFilterSet(PrimaryModelFilterSet): method='search', label='Search', ) + group_id = django_filters.ModelMultipleChoiceFilter( + queryset=WirelessLANGroup.objects.all() + ) tag = TagFilter() class Meta: From 0c72c20d2aa1b3db087eb63613fc087528a2095b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 18 Oct 2021 10:05:43 -0400 Subject: [PATCH 24/68] Add WirelessLANs column to interfaces table --- netbox/dcim/tables/devices.py | 19 ++++++++++--------- netbox/dcim/tables/template_code.py | 6 ++++++ 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 3b92efd76..343667d46 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -11,11 +11,7 @@ from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, ) -from .template_code import ( - LINKTERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS, - FRONTPORT_BUTTONS, INTERFACE_BUTTONS, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, POWEROUTLET_BUTTONS, - POWERPORT_BUTTONS, REARPORT_BUTTONS, -) +from .template_code import * __all__ = ( 'BaseInterfaceTable', @@ -488,6 +484,11 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable wireless_link = tables.Column( linkify=True ) + wireless_lans = TemplateColumn( + template_code=INTERFACE_WIRELESS_LANS, + orderable=False, + verbose_name='Wireless LANs' + ) tags = TagColumn( url_name='dcim:interface_list' ) @@ -497,8 +498,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable fields = ( 'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'description', 'mark_connected', - 'cable', 'cable_color', 'wireless_link', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', - 'tagged_vlans', + 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', + 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -530,8 +531,8 @@ class DeviceInterfaceTable(InterfaceTable): fields = ( 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', - 'wireless_link', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', - 'actions', + 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', + 'tagged_vlans', 'actions', ) order_by = ('name',) default_columns = ( diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index a948baffd..aab15b5ef 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -64,6 +64,12 @@ INTERFACE_TAGGED_VLANS = """ {% endif %} """ +INTERFACE_WIRELESS_LANS = """ +{% for wlan in record.wireless_lans.all %} + {{ wlan }}
+{% endfor %} +""" + POWERFEED_CABLE = """ {{ value }} From a66501250e10f09641e5b0bef927db10014323aa Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 20 Oct 2021 10:58:15 -0400 Subject: [PATCH 25/68] Add wireless authentication attributes --- .../wireless/inc/authentication_attrs.html | 21 ++++++++++ netbox/templates/wireless/wirelesslan.html | 3 +- netbox/templates/wireless/wirelesslink.html | 7 +++- netbox/wireless/api/serializers.py | 10 ++++- netbox/wireless/choices.py | 26 ++++++++++++ netbox/wireless/constants.py | 1 + netbox/wireless/filtersets.py | 17 +++++++- netbox/wireless/forms/bulk_edit.py | 27 +++++++++++- netbox/wireless/forms/bulk_import.py | 25 ++++++++++- netbox/wireless/forms/filtersets.py | 27 ++++++++++++ netbox/wireless/forms/models.py | 19 +++++++-- .../wireless/migrations/0002_wireless_auth.py | 41 +++++++++++++++++++ netbox/wireless/models.py | 34 +++++++++++++-- netbox/wireless/tables.py | 15 +++++-- 14 files changed, 252 insertions(+), 21 deletions(-) create mode 100644 netbox/templates/wireless/inc/authentication_attrs.html create mode 100644 netbox/wireless/migrations/0002_wireless_auth.py diff --git a/netbox/templates/wireless/inc/authentication_attrs.html b/netbox/templates/wireless/inc/authentication_attrs.html new file mode 100644 index 000000000..ed4c7546c --- /dev/null +++ b/netbox/templates/wireless/inc/authentication_attrs.html @@ -0,0 +1,21 @@ +{% load helpers %} + +
+
Authentication
+
+
Group SSID
+ {% if wlan.group %} + {{ wlan.group }} + {% else %} + — + {% endif %} + {{ wlan.ssid }}
+ + + + + + + + + + + + +
Type{{ object.get_auth_type_display|placeholder }}
Cipher{{ object.get_auth_cipher_display|placeholder }}
PSK{{ object.auth_psk|placeholder }}
+
+
diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index cfe13ca45..5c6784de4 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -40,10 +40,11 @@ - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='wireless:wirelesslan_list' %} + {% include 'wireless/inc/authentication_attrs.html' %} {% plugin_left_page object %}
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='wireless:wirelesslan_list' %} {% include 'inc/custom_fields_panel.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html index 45ec6b0c9..afdeff357 100644 --- a/netbox/templates/wireless/wirelesslink.html +++ b/netbox/templates/wireless/wirelesslink.html @@ -17,7 +17,9 @@ - + @@ -30,6 +32,7 @@
Status{{ object.get_status_display }} + {{ object.get_status_display }} +
SSID
+ {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='wireless:wirelesslink_list' %} {% plugin_left_page object %}
@@ -39,8 +42,8 @@ {% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_b %}
+ {% include 'wireless/inc/authentication_attrs.html' %} {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='wireless:wirelesslink_list' %} {% plugin_right_page object %} diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index 24395b77c..e9be35618 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -5,6 +5,7 @@ from dcim.api.serializers import NestedInterfaceSerializer from ipam.api.serializers import NestedVLANSerializer from netbox.api import ChoiceField from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer +from wireless.choices import * from wireless.models import * from .nested_serializers import * @@ -30,11 +31,13 @@ class WirelessLANGroupSerializer(NestedGroupModelSerializer): class WirelessLANSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') vlan = NestedVLANSerializer(required=False, allow_null=True) + auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) + auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True) class Meta: model = WirelessLAN fields = [ - 'id', 'url', 'display', 'ssid', 'description', 'vlan', + 'id', 'url', 'display', 'ssid', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', ] @@ -43,9 +46,12 @@ class WirelessLinkSerializer(PrimaryModelSerializer): status = ChoiceField(choices=LinkStatusChoices, required=False) interface_a = NestedInterfaceSerializer() interface_b = NestedInterfaceSerializer() + auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) + auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True) class Meta: model = WirelessLink fields = [ - 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'description', + 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'description', 'auth_type', + 'auth_cipher', 'auth_psk', ] diff --git a/netbox/wireless/choices.py b/netbox/wireless/choices.py index 8a710b532..c8e7fd09f 100644 --- a/netbox/wireless/choices.py +++ b/netbox/wireless/choices.py @@ -163,3 +163,29 @@ class WirelessChannelChoices(ChoiceSet): ) ), ) + + +class WirelessAuthTypeChoices(ChoiceSet): + TYPE_OPEN = 'open' + TYPE_WEP = 'wep' + TYPE_WPA_PERSONAL = 'wpa-personal' + TYPE_WPA_ENTERPRISE = 'wpa-enterprise' + + CHOICES = ( + (TYPE_OPEN, 'Open'), + (TYPE_WEP, 'WEP'), + (TYPE_WPA_PERSONAL, 'WPA Personal (PSK)'), + (TYPE_WPA_ENTERPRISE, 'WPA Enterprise'), + ) + + +class WirelessAuthCipherChoices(ChoiceSet): + CIPHER_AUTO = 'auto' + CIPHER_TKIP = 'tkip' + CIPHER_AES = 'aes' + + CHOICES = ( + (CIPHER_AUTO, 'Auto'), + (CIPHER_TKIP, 'TKIP'), + (CIPHER_AES, 'AES'), + ) diff --git a/netbox/wireless/constants.py b/netbox/wireless/constants.py index 188c4abd9..63de2b136 100644 --- a/netbox/wireless/constants.py +++ b/netbox/wireless/constants.py @@ -1 +1,2 @@ SSID_MAX_LENGTH = 32 # Per IEEE 802.11-2007 +PSK_MAX_LENGTH = 64 diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index a5d9b7d75..cc67c1fc3 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -4,6 +4,7 @@ from django.db.models import Q from dcim.choices import LinkStatusChoices from extras.filters import TagFilter from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from .choices import * from .models import * __all__ = ( @@ -36,11 +37,17 @@ class WirelessLANFilterSet(PrimaryModelFilterSet): group_id = django_filters.ModelMultipleChoiceFilter( queryset=WirelessLANGroup.objects.all() ) + auth_type = django_filters.MultipleChoiceFilter( + choices=WirelessAuthTypeChoices + ) + auth_cipher = django_filters.MultipleChoiceFilter( + choices=WirelessAuthCipherChoices + ) tag = TagFilter() class Meta: model = WirelessLAN - fields = ['id', 'ssid'] + fields = ['id', 'ssid', 'auth_psk'] def search(self, queryset, name, value): if not value.strip(): @@ -60,11 +67,17 @@ class WirelessLinkFilterSet(PrimaryModelFilterSet): status = django_filters.MultipleChoiceFilter( choices=LinkStatusChoices ) + auth_type = django_filters.MultipleChoiceFilter( + choices=WirelessAuthTypeChoices + ) + auth_cipher = django_filters.MultipleChoiceFilter( + choices=WirelessAuthCipherChoices + ) tag = TagFilter() class Meta: model = WirelessLink - fields = ['id', 'ssid'] + fields = ['id', 'ssid', 'auth_psk'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index c0d5a925e..1da98026c 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -4,6 +4,7 @@ from dcim.choices import LinkStatusChoices from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from ipam.models import VLAN from utilities.forms import BootstrapMixin, DynamicModelChoiceField +from wireless.choices import * from wireless.constants import SSID_MAX_LENGTH from wireless.models import * @@ -52,9 +53,20 @@ class WirelessLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode description = forms.CharField( required=False ) + auth_type = forms.ChoiceField( + choices=WirelessAuthTypeChoices, + required=False + ) + auth_cipher = forms.ChoiceField( + choices=WirelessAuthCipherChoices, + required=False + ) + auth_psk = forms.CharField( + required=False + ) class Meta: - nullable_fields = ['ssid', 'group', 'vlan', 'description'] + nullable_fields = ['ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk'] class WirelessLinkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): @@ -73,6 +85,17 @@ class WirelessLinkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMod description = forms.CharField( required=False ) + auth_type = forms.ChoiceField( + choices=WirelessAuthTypeChoices, + required=False + ) + auth_cipher = forms.ChoiceField( + choices=WirelessAuthCipherChoices, + required=False + ) + auth_psk = forms.CharField( + required=False + ) class Meta: - nullable_fields = ['ssid', 'description'] + nullable_fields = ['ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk'] diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 6b22728f6..e9e9afed6 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -3,6 +3,7 @@ from dcim.models import Interface from extras.forms import CustomFieldModelCSVForm from ipam.models import VLAN from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField +from wireless.choices import * from wireless.models import * __all__ = ( @@ -38,10 +39,20 @@ class WirelessLANCSVForm(CustomFieldModelCSVForm): to_field_name='name', help_text='Bridged VLAN' ) + auth_type = CSVChoiceField( + choices=WirelessAuthTypeChoices, + required=False, + help_text='Authentication type' + ) + auth_cipher = CSVChoiceField( + choices=WirelessAuthCipherChoices, + required=False, + help_text='Authentication cipher' + ) class Meta: model = WirelessLAN - fields = ('ssid', 'group', 'description', 'vlan') + fields = ('ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk') class WirelessLinkCSVForm(CustomFieldModelCSVForm): @@ -55,7 +66,17 @@ class WirelessLinkCSVForm(CustomFieldModelCSVForm): interface_b = CSVModelChoiceField( queryset=Interface.objects.all() ) + auth_type = CSVChoiceField( + choices=WirelessAuthTypeChoices, + required=False, + help_text='Authentication type' + ) + auth_cipher = CSVChoiceField( + choices=WirelessAuthCipherChoices, + required=False, + help_text='Authentication cipher' + ) class Meta: model = WirelessLink - fields = ('interface_a', 'interface_b', 'ssid', 'description') + fields = ('interface_a', 'interface_b', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk') diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 13aae99a5..483d74a7c 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -6,6 +6,7 @@ from extras.forms import CustomFieldModelFilterForm from utilities.forms import ( add_blank_choice, BootstrapMixin, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField, ) +from wireless.choices import * from wireless.models import * __all__ = ( @@ -52,6 +53,19 @@ class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Group'), fetch_trigger='open' ) + auth_type = forms.ChoiceField( + required=False, + choices=add_blank_choice(WirelessAuthTypeChoices), + widget=StaticSelect() + ) + auth_cipher = forms.ChoiceField( + required=False, + choices=add_blank_choice(WirelessAuthCipherChoices), + widget=StaticSelect() + ) + auth_psk = forms.CharField( + required=False + ) tag = TagFilterField(model) @@ -74,4 +88,17 @@ class WirelessLinkFilterForm(BootstrapMixin, CustomFieldModelFilterForm): choices=add_blank_choice(LinkStatusChoices), widget=StaticSelect() ) + auth_type = forms.ChoiceField( + required=False, + choices=add_blank_choice(WirelessAuthTypeChoices), + widget=StaticSelect() + ) + auth_cipher = forms.ChoiceField( + required=False, + choices=add_blank_choice(WirelessAuthCipherChoices), + widget=StaticSelect() + ) + auth_psk = forms.CharField( + required=False + ) tag = TagFilterField(model) diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 9a7b78b31..aa453ba64 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -35,7 +35,8 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): ) vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + label='VLAN' ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -45,12 +46,17 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = WirelessLAN fields = [ - 'ssid', 'group', 'description', 'vlan', 'tags', + 'ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', ] fieldsets = ( ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), ('VLAN', ('vlan',)), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) + widgets = { + 'auth_type': StaticSelect, + 'auth_cipher': StaticSelect, + } class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): @@ -94,8 +100,15 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = WirelessLink fields = [ - 'device_a', 'interface_a', 'device_b', 'interface_b', 'status', 'ssid', 'description', 'tags', + 'device_a', 'interface_a', 'device_b', 'interface_b', 'status', 'ssid', 'description', 'auth_type', + 'auth_cipher', 'auth_psk', 'tags', ] + fieldsets = ( + ('Link', ('device_a', 'interface_a', 'device_b', 'interface_b', 'status', 'ssid', 'description', 'tags')), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), + ) widgets = { 'status': StaticSelect, + 'auth_type': StaticSelect, + 'auth_cipher': StaticSelect, } diff --git a/netbox/wireless/migrations/0002_wireless_auth.py b/netbox/wireless/migrations/0002_wireless_auth.py new file mode 100644 index 000000000..9ca4e351c --- /dev/null +++ b/netbox/wireless/migrations/0002_wireless_auth.py @@ -0,0 +1,41 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0001_wireless'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslan', + name='auth_cipher', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='wirelesslan', + name='auth_psk', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='wirelesslan', + name='auth_type', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='wirelesslink', + name='auth_cipher', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='wirelesslink', + name='auth_psk', + field=models.CharField(blank=True, max_length=64), + ), + migrations.AddField( + model_name='wirelesslink', + name='auth_type', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index b0cacde15..43818279e 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -8,7 +8,8 @@ from dcim.constants import WIRELESS_IFACE_TYPES from extras.utils import extras_features from netbox.models import BigIDModel, NestedGroupModel, PrimaryModel from utilities.querysets import RestrictedQuerySet -from .constants import SSID_MAX_LENGTH +from .choices import * +from .constants import * __all__ = ( 'WirelessLAN', @@ -17,6 +18,30 @@ __all__ = ( ) +class WirelessAuthenticationBase(models.Model): + """ + Abstract model for attaching attributes related to wireless authentication. + """ + auth_type = models.CharField( + max_length=50, + choices=WirelessAuthTypeChoices, + blank=True + ) + auth_cipher = models.CharField( + max_length=50, + choices=WirelessAuthCipherChoices, + blank=True + ) + auth_psk = models.CharField( + max_length=PSK_MAX_LENGTH, + blank=True, + verbose_name='Pre-shared key' + ) + + class Meta: + abstract = True + + @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class WirelessLANGroup(NestedGroupModel): """ @@ -49,12 +74,15 @@ class WirelessLANGroup(NestedGroupModel): ('parent', 'name') ) + def __str__(self): + return self.name + def get_absolute_url(self): return reverse('wireless:wirelesslangroup', args=[self.pk]) @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class WirelessLAN(PrimaryModel): +class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): """ A wireless network formed among an arbitrary number of access point and clients. """ @@ -95,7 +123,7 @@ class WirelessLAN(PrimaryModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class WirelessLink(PrimaryModel): +class WirelessLink(WirelessAuthenticationBase, PrimaryModel): """ A point-to-point connection between two wireless Interfaces. """ diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py index 486fa2a71..ec8f3ddd2 100644 --- a/netbox/wireless/tables.py +++ b/netbox/wireless/tables.py @@ -48,8 +48,11 @@ class WirelessLANTable(BaseTable): class Meta(BaseTable.Meta): model = WirelessLAN - fields = ('pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'tags') - default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'interface_count') + fields = ( + 'pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', 'auth_psk', + 'tags', + ) + default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') class WirelessLANInterfacesTable(BaseTable): @@ -94,7 +97,11 @@ class WirelessLinkTable(BaseTable): class Meta(BaseTable.Meta): model = WirelessLink - fields = ('pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description') - default_columns = ( + fields = ( 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description', + 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + ) + default_columns = ( + 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'auth_type', + 'description', ) From 4a7159389ee7cb5713e5749cc92bd0f133206ffc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 20 Oct 2021 11:22:56 -0400 Subject: [PATCH 26/68] Add wireless documentation --- docs/core-functionality/wireless.md | 8 ++++++++ docs/models/dcim/interface.md | 11 +++++++++++ docs/models/wireless/wirelesslan.md | 11 +++++++++++ docs/models/wireless/wirelesslangroup.md | 3 +++ docs/models/wireless/wirelesslink.md | 9 +++++++++ mkdocs.yml | 1 + 6 files changed, 43 insertions(+) create mode 100644 docs/core-functionality/wireless.md create mode 100644 docs/models/wireless/wirelesslan.md create mode 100644 docs/models/wireless/wirelesslangroup.md create mode 100644 docs/models/wireless/wirelesslink.md diff --git a/docs/core-functionality/wireless.md b/docs/core-functionality/wireless.md new file mode 100644 index 000000000..57133f756 --- /dev/null +++ b/docs/core-functionality/wireless.md @@ -0,0 +1,8 @@ +# Wireless Networks + +{!models/wireless/wirelesslan.md!} +{!models/wireless/wirelesslangroup.md!} + +--- + +{!models/wireless/wirelesslink.md!} diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index bd9975a72..585674de1 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -11,6 +11,17 @@ Interfaces may be physical or virtual in nature, but only physical interfaces ma Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically. +### Wireless Interfaces + +Wireless interfaces may additionally track the following attributes: + +* **Role** - AP or station +* **Channel** - One of several standard wireless channels +* **Channel Frequency** - The transmit frequency +* **Channel Width** - Channel bandwidth + +If a predefined channel is selected, the frequency and width attributes will be assigned automatically. If no channel is selected, these attributes may be defined manually. + ### IP Address Assignment IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) diff --git a/docs/models/wireless/wirelesslan.md b/docs/models/wireless/wirelesslan.md new file mode 100644 index 000000000..80a3a40b0 --- /dev/null +++ b/docs/models/wireless/wirelesslan.md @@ -0,0 +1,11 @@ +# Wireless LANs + +A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups. + +An interface may be attached to multiple wireless LANs, provided they are all operating on the same channel. Only wireless interfaces may be attached to wireless LANs. + +Each wireless LAN may have authentication attributes associated with it, including: + +* Authentication type +* Cipher +* Pre-shared key diff --git a/docs/models/wireless/wirelesslangroup.md b/docs/models/wireless/wirelesslangroup.md new file mode 100644 index 000000000..e477abd0b --- /dev/null +++ b/docs/models/wireless/wirelesslangroup.md @@ -0,0 +1,3 @@ +# Wireless LAN Groups + +Wireless LAN groups can be used to organize and classify wireless LANs. These groups are hierarchical: groups can be nested within parent groups. However, each wireless LAN may assigned only to one group. diff --git a/docs/models/wireless/wirelesslink.md b/docs/models/wireless/wirelesslink.md new file mode 100644 index 000000000..85cdbd6d9 --- /dev/null +++ b/docs/models/wireless/wirelesslink.md @@ -0,0 +1,9 @@ +# Wireless Links + +A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model. + +Each wireless link may have authentication attributes associated with it, including: + +* Authentication type +* Cipher +* Pre-shared key diff --git a/mkdocs.yml b/mkdocs.yml index 7244c36d6..ac394d704 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -60,6 +60,7 @@ nav: - Virtualization: 'core-functionality/virtualization.md' - Service Mapping: 'core-functionality/services.md' - Circuits: 'core-functionality/circuits.md' + - Wireless: 'core-functionality/wireless.md' - Power Tracking: 'core-functionality/power.md' - Tenancy: 'core-functionality/tenancy.md' - Customization: From 6a4becfb4602675429fc75a1c511c35d7415ae68 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 20 Oct 2021 13:34:39 -0400 Subject: [PATCH 27/68] Add tests for wireless --- netbox/dcim/filtersets.py | 9 +- netbox/dcim/tests/test_filtersets.py | 27 +++- netbox/wireless/api/serializers.py | 6 +- netbox/wireless/filtersets.py | 17 +- netbox/wireless/forms/bulk_import.py | 1 + netbox/wireless/forms/models.py | 2 + netbox/wireless/graphql/schema.py | 12 +- netbox/wireless/graphql/types.py | 18 ++- netbox/wireless/signals.py | 1 - netbox/wireless/tests/__init__.py | 0 netbox/wireless/tests/test_api.py | 141 ++++++++++++++++ netbox/wireless/tests/test_filtersets.py | 194 +++++++++++++++++++++++ netbox/wireless/tests/test_views.py | 120 ++++++++++++++ 13 files changed, 529 insertions(+), 19 deletions(-) create mode 100644 netbox/wireless/tests/__init__.py create mode 100644 netbox/wireless/tests/test_api.py create mode 100644 netbox/wireless/tests/test_filtersets.py create mode 100644 netbox/wireless/tests/test_views.py diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index aa525e8e1..f6d6ed8dc 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -14,6 +14,7 @@ from utilities.filters import ( TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster +from wireless.choices import WirelessRoleChoices, WirelessChannelChoices from .choices import * from .constants import * from .models import * @@ -987,12 +988,18 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT choices=InterfaceTypeChoices, null_value=None ) + rf_role = django_filters.MultipleChoiceFilter( + choices=WirelessRoleChoices + ) + rf_channel = django_filters.MultipleChoiceFilter( + choices=WirelessChannelChoices + ) class Meta: model = Interface fields = [ 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel', - 'rf_channel_width', 'description', + 'rf_channel_frequency', 'rf_channel_width', 'description', ] def filter_device(self, queryset, name, value): diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 8d8be324e..62bdaed82 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -9,6 +9,7 @@ from tenancy.models import Tenant, TenantGroup from utilities.choices import ColorChoices from utilities.testing import ChangeLoggedFilterSetTests from virtualization.models import Cluster, ClusterType +from wireless.choices import WirelessChannelChoices, WirelessRoleChoices class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): @@ -2063,6 +2064,8 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False), + Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22), + Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, rf_channel_width=20), ) Interface.objects.bulk_create(interfaces) @@ -2083,11 +2086,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_enabled(self): params = {'enabled': 'true'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) params = {'enabled': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2099,7 +2102,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'mgmt_only': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'mgmt_only': 'false'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_mode(self): params = {'mode': InterfaceModeChoices.MODE_ACCESS} @@ -2176,7 +2179,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'cabled': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'cabled': 'false'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_kind(self): params = {'kind': 'physical'} @@ -2192,6 +2195,22 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_rf_role(self): + params = {'rf_role': [WirelessRoleChoices.ROLE_AP, WirelessRoleChoices.ROLE_STATION]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_rf_channel(self): + params = {'rf_channel': [WirelessChannelChoices.CHANNEL_24G_1, WirelessChannelChoices.CHANNEL_5G_32]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_rf_channel_frequency(self): + params = {'rf_channel_frequency': [2412, 5160]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_rf_channel_width(self): + params = {'rf_channel_width': [22, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = FrontPort.objects.all() diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index e9be35618..12986dcaf 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -10,6 +10,7 @@ from wireless.models import * from .nested_serializers import * __all__ = ( + 'WirelessLANGroupSerializer', 'WirelessLANSerializer', 'WirelessLinkSerializer', ) @@ -17,7 +18,7 @@ __all__ = ( class WirelessLANGroupSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslangroup-detail') - parent = NestedWirelessLANGroupSerializer(required=False, allow_null=True) + parent = NestedWirelessLANGroupSerializer(required=False, allow_null=True, default=None) wirelesslan_count = serializers.IntegerField(read_only=True) class Meta: @@ -30,6 +31,7 @@ class WirelessLANGroupSerializer(NestedGroupModelSerializer): class WirelessLANSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') + group = NestedWirelessLANGroupSerializer(required=False, allow_null=True) vlan = NestedVLANSerializer(required=False, allow_null=True) auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True) @@ -37,7 +39,7 @@ class WirelessLANSerializer(PrimaryModelSerializer): class Meta: model = WirelessLAN fields = [ - 'id', 'url', 'display', 'ssid', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', + 'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', ] diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index cc67c1fc3..cffdcf046 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -3,7 +3,9 @@ from django.db.models import Q from dcim.choices import LinkStatusChoices from extras.filters import TagFilter +from ipam.models import VLAN from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from utilities.filters import TreeNodeMultipleChoiceFilter from .choices import * from .models import * @@ -34,8 +36,19 @@ class WirelessLANFilterSet(PrimaryModelFilterSet): method='search', label='Search', ) - group_id = django_filters.ModelMultipleChoiceFilter( - queryset=WirelessLANGroup.objects.all() + group_id = TreeNodeMultipleChoiceFilter( + queryset=WirelessLANGroup.objects.all(), + field_name='group', + lookup_expr='in' + ) + group = TreeNodeMultipleChoiceFilter( + queryset=WirelessLANGroup.objects.all(), + field_name='group', + lookup_expr='in', + to_field_name='slug' + ) + vlan_id = django_filters.ModelMultipleChoiceFilter( + queryset=VLAN.objects.all() ) auth_type = django_filters.MultipleChoiceFilter( choices=WirelessAuthTypeChoices diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index e9e9afed6..aa79e1fc7 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -36,6 +36,7 @@ class WirelessLANCSVForm(CustomFieldModelCSVForm): ) vlan = CSVModelChoiceField( queryset=VLAN.objects.all(), + required=False, to_field_name='name', help_text='Bridged VLAN' ) diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index aa453ba64..26bcd2260 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -62,6 +62,7 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): device_a = DynamicModelChoiceField( queryset=Device.objects.all(), + required=False, label='Device A', initial_params={ 'interfaces': '$interface_a' @@ -78,6 +79,7 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): ) device_b = DynamicModelChoiceField( queryset=Device.objects.all(), + required=False, label='Device B', initial_params={ 'interfaces': '$interface_b' diff --git a/netbox/wireless/graphql/schema.py b/netbox/wireless/graphql/schema.py index 05fc57c4d..cd8fd9f52 100644 --- a/netbox/wireless/graphql/schema.py +++ b/netbox/wireless/graphql/schema.py @@ -5,11 +5,11 @@ from .types import * class WirelessQuery(graphene.ObjectType): - wirelesslan = ObjectField(WirelessLANType) - wirelesslan_list = ObjectListField(WirelessLANType) + wireless_lan = ObjectField(WirelessLANType) + wireless_lan_list = ObjectListField(WirelessLANType) - wirelesslangroup = ObjectField(WirelessLANGroupType) - wirelesslangroup_list = ObjectListField(WirelessLANGroupType) + wireless_lan_group = ObjectField(WirelessLANGroupType) + wireless_lan_group_list = ObjectListField(WirelessLANGroupType) - wirelesslink = ObjectField(WirelessLinkType) - wirelesslink_list = ObjectListField(WirelessLinkType) + wireless_link = ObjectField(WirelessLinkType) + wireless_link_list = ObjectListField(WirelessLinkType) diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index 4697cc44b..be0b2f7aa 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/graphql/types.py @@ -1,5 +1,5 @@ from wireless import filtersets, models -from netbox.graphql.types import ObjectType +from netbox.graphql.types import ObjectType, PrimaryObjectType __all__ = ( 'WirelessLANType', @@ -16,17 +16,29 @@ class WirelessLANGroupType(ObjectType): filterset_class = filtersets.WirelessLANGroupFilterSet -class WirelessLANType(ObjectType): +class WirelessLANType(PrimaryObjectType): class Meta: model = models.WirelessLAN fields = '__all__' filterset_class = filtersets.WirelessLANFilterSet + def resolve_auth_type(self, info): + return self.auth_type or None -class WirelessLinkType(ObjectType): + def resolve_auth_cipher(self, info): + return self.auth_cipher or None + + +class WirelessLinkType(PrimaryObjectType): class Meta: model = models.WirelessLink fields = '__all__' filterset_class = filtersets.WirelessLinkFilterSet + + def resolve_auth_type(self, info): + return self.auth_type or None + + def resolve_auth_cipher(self, info): + return self.auth_cipher or None diff --git a/netbox/wireless/signals.py b/netbox/wireless/signals.py index 935e11677..3b4831a8d 100644 --- a/netbox/wireless/signals.py +++ b/netbox/wireless/signals.py @@ -63,5 +63,4 @@ def nullify_connected_interfaces(instance, **kwargs): # Delete and retrace any dependent cable paths for cablepath in CablePath.objects.filter(path__contains=instance): - print(f'Deleting cable path {cablepath.pk}') cablepath.delete() diff --git a/netbox/wireless/tests/__init__.py b/netbox/wireless/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py new file mode 100644 index 000000000..917b7b320 --- /dev/null +++ b/netbox/wireless/tests/test_api.py @@ -0,0 +1,141 @@ +from django.urls import reverse + +from wireless.choices import * +from wireless.models import * +from dcim.choices import InterfaceTypeChoices +from dcim.models import Interface +from utilities.testing import APITestCase, APIViewTestCases, create_test_device + + +class AppTest(APITestCase): + + def test_root(self): + url = reverse('wireless-api:api-root') + response = self.client.get('{}?format=api'.format(url), **self.header) + + self.assertEqual(response.status_code, 200) + + +class WirelessLANGroupTest(APIViewTestCases.APIViewTestCase): + model = WirelessLANGroup + brief_fields = ['_depth', 'display', 'id', 'name', 'slug', 'url', 'wirelesslan_count'] + create_data = [ + { + 'name': 'Wireless LAN Group 4', + 'slug': 'wireless-lan-group-4', + }, + { + 'name': 'Wireless LAN Group 5', + 'slug': 'wireless-lan-group-5', + }, + { + 'name': 'Wireless LAN Group 6', + 'slug': 'wireless-lan-group-6', + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + WirelessLANGroup.objects.create(name='Wireless LAN Group 1', slug='wireless-lan-group-1') + WirelessLANGroup.objects.create(name='Wireless LAN Group 2', slug='wireless-lan-group-2') + WirelessLANGroup.objects.create(name='Wireless LAN Group 3', slug='wireless-lan-group-3') + + +class WirelessLANTest(APIViewTestCases.APIViewTestCase): + model = WirelessLAN + brief_fields = ['display', 'id', 'ssid', 'url'] + + @classmethod + def setUpTestData(cls): + + groups = ( + WirelessLANGroup(name='Group 1', slug='group-1'), + WirelessLANGroup(name='Group 2', slug='group-2'), + WirelessLANGroup(name='Group 3', slug='group-3'), + ) + for group in groups: + group.save() + + wireless_lans = ( + WirelessLAN(ssid='WLAN1'), + WirelessLAN(ssid='WLAN2'), + WirelessLAN(ssid='WLAN3'), + ) + WirelessLAN.objects.bulk_create(wireless_lans) + + cls.create_data = [ + { + 'ssid': 'WLAN4', + 'group': groups[0].pk, + 'auth_type': WirelessAuthTypeChoices.TYPE_OPEN, + }, + { + 'ssid': 'WLAN5', + 'group': groups[1].pk, + 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, + }, + { + 'ssid': 'WLAN6', + 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_ENTERPRISE, + }, + ] + + cls.bulk_update_data = { + 'group': groups[2].pk, + 'description': 'New description', + 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, + 'auth_cipher': WirelessAuthCipherChoices.CIPHER_AES, + 'auth_psk': 'abc123def456', + } + + +class WirelessLinkTest(APIViewTestCases.APIViewTestCase): + model = WirelessLink + brief_fields = ['display', 'id', 'ssid', 'url'] + bulk_update_data = { + 'status': 'planned', + } + + @classmethod + def setUpTestData(cls): + device = create_test_device('test-device') + interfaces = [ + Interface( + device=device, + name=f'radio{i}', + type=InterfaceTypeChoices.TYPE_80211AC, + rf_channel=WirelessChannelChoices.CHANNEL_5G_32, + rf_channel_frequency=5160, + rf_channel_width=20 + ) for i in range(12) + ] + Interface.objects.bulk_create(interfaces) + + wireless_links = ( + WirelessLink(ssid='LINK1', interface_a=interfaces[0], interface_b=interfaces[1]), + WirelessLink(ssid='LINK2', interface_a=interfaces[2], interface_b=interfaces[3]), + WirelessLink(ssid='LINK3', interface_a=interfaces[4], interface_b=interfaces[5]), + ) + WirelessLink.objects.bulk_create(wireless_links) + + cls.create_data = [ + { + 'interface_a': interfaces[6].pk, + 'interface_b': interfaces[7].pk, + 'ssid': 'LINK4', + }, + { + 'interface_a': interfaces[8].pk, + 'interface_b': interfaces[9].pk, + 'ssid': 'LINK5', + }, + { + 'interface_a': interfaces[10].pk, + 'interface_b': interfaces[11].pk, + 'ssid': 'LINK6', + }, + ] diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py new file mode 100644 index 000000000..50f89c4d6 --- /dev/null +++ b/netbox/wireless/tests/test_filtersets.py @@ -0,0 +1,194 @@ +from django.test import TestCase + +from dcim.choices import InterfaceTypeChoices, LinkStatusChoices +from dcim.models import Interface +from ipam.models import VLAN +from wireless.choices import * +from wireless.filtersets import * +from wireless.models import * +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device + + +class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = WirelessLANGroup.objects.all() + filterset = WirelessLANGroupFilterSet + + @classmethod + def setUpTestData(cls): + + groups = ( + WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1', description='A'), + WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2', description='B'), + WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3', description='C'), + ) + for group in groups: + group.save() + + child_groups = ( + WirelessLANGroup(name='Wireless LAN Group 1A', slug='wireless-lan-group-1a', parent=groups[0]), + WirelessLANGroup(name='Wireless LAN Group 1B', slug='wireless-lan-group-1b', parent=groups[0]), + WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=groups[1]), + WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=groups[1]), + WirelessLANGroup(name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=groups[2]), + WirelessLANGroup(name='Wireless LAN Group 3B', slug='wireless-lan-group-3b', parent=groups[2]), + ) + for group in child_groups: + group.save() + + def test_name(self): + params = {'name': ['Wireless LAN Group 1', 'Wireless LAN Group 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['wireless-lan-group-1', 'wireless-lan-group-2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_description(self): + params = {'description': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_parent(self): + parent_groups = WirelessLANGroup.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + +class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = WirelessLAN.objects.all() + filterset = WirelessLANFilterSet + + @classmethod + def setUpTestData(cls): + + groups = ( + WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), + WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), + WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'), + ) + for group in groups: + group.save() + + vlans = ( + VLAN(name='VLAN1', vid=1), + VLAN(name='VLAN2', vid=2), + VLAN(name='VLAN3', vid=3), + ) + VLAN.objects.bulk_create(vlans) + + wireless_lans = ( + WirelessLAN(ssid='WLAN1', group=groups[0], vlan=vlans[0], auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_psk='PSK1'), + WirelessLAN(ssid='WLAN2', group=groups[1], vlan=vlans[1], auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_psk='PSK2'), + WirelessLAN(ssid='WLAN3', group=groups[2], vlan=vlans[2], auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, auth_psk='PSK3'), + ) + WirelessLAN.objects.bulk_create(wireless_lans) + + def test_ssid(self): + params = {'ssid': ['WLAN1', 'WLAN2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_group(self): + groups = WirelessLANGroup.objects.all()[:2] + params = {'group_id': [groups[0].pk, groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group': [groups[0].slug, groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_vlan(self): + vlans = VLAN.objects.all()[:2] + params = {'vlan_id': [vlans[0].pk, vlans[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_type(self): + params = {'auth_type': [WirelessAuthTypeChoices.TYPE_OPEN, WirelessAuthTypeChoices.TYPE_WEP]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_cipher(self): + params = {'auth_cipher': [WirelessAuthCipherChoices.CIPHER_AUTO, WirelessAuthCipherChoices.CIPHER_TKIP]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_psk(self): + params = {'auth_psk': ['PSK1', 'PSK2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = WirelessLink.objects.all() + filterset = WirelessLinkFilterSet + + @classmethod + def setUpTestData(cls): + + devices = ( + create_test_device('device1'), + create_test_device('device2'), + create_test_device('device3'), + create_test_device('device4'), + ) + + interfaces = ( + Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[1], name='Interface 3', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[1], name='Interface 4', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[2], name='Interface 5', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[2], name='Interface 6', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC), + Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC), + ) + Interface.objects.bulk_create(interfaces) + + # Wireless links + WirelessLink( + interface_a=interfaces[0], + interface_b=interfaces[2], + ssid='LINK1', + status=LinkStatusChoices.STATUS_CONNECTED, + auth_type=WirelessAuthTypeChoices.TYPE_OPEN, + auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, + auth_psk='PSK1' + ).save() + WirelessLink( + interface_a=interfaces[1], + interface_b=interfaces[3], + ssid='LINK2', + status=LinkStatusChoices.STATUS_PLANNED, + auth_type=WirelessAuthTypeChoices.TYPE_WEP, + auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, + auth_psk='PSK2' + ).save() + WirelessLink( + interface_a=interfaces[4], + interface_b=interfaces[6], + ssid='LINK3', + status=LinkStatusChoices.STATUS_DECOMMISSIONING, + auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, + auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, + auth_psk='PSK3' + ).save() + WirelessLink( + interface_a=interfaces[5], + interface_b=interfaces[7], + ssid='LINK4' + ).save() + + def test_ssid(self): + params = {'ssid': ['LINK1', 'LINK2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_status(self): + params = {'status': [LinkStatusChoices.STATUS_PLANNED, LinkStatusChoices.STATUS_DECOMMISSIONING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_type(self): + params = {'auth_type': [WirelessAuthTypeChoices.TYPE_OPEN, WirelessAuthTypeChoices.TYPE_WEP]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_cipher(self): + params = {'auth_cipher': [WirelessAuthCipherChoices.CIPHER_AUTO, WirelessAuthCipherChoices.CIPHER_TKIP]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_auth_psk(self): + params = {'auth_psk': ['PSK1', 'PSK2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py new file mode 100644 index 000000000..d4422e7e3 --- /dev/null +++ b/netbox/wireless/tests/test_views.py @@ -0,0 +1,120 @@ +from wireless.choices import * +from wireless.models import * +from dcim.choices import InterfaceTypeChoices, LinkStatusChoices +from dcim.models import Interface +from utilities.testing import ViewTestCases, create_tags, create_test_device + + +class WirelessLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = WirelessLANGroup + + @classmethod + def setUpTestData(cls): + + groups = ( + WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), + WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), + WirelessLANGroup(name='Wireless LAN Group 3', slug='wireless-lan-group-3'), + ) + for group in groups: + group.save() + + cls.form_data = { + 'name': 'Wireless LAN Group X', + 'slug': 'wireless-lan-group-x', + 'parent': groups[2].pk, + 'description': 'A new wireless LAN group', + } + + cls.csv_data = ( + "name,slug,description", + "Wireles sLAN Group 4,wireless-lan-group-4,Fourth wireless LAN group", + "Wireless LAN Group 5,wireless-lan-group-5,Fifth wireless LAN group", + "Wireless LAN Group 6,wireless-lan-group-6,Sixth wireless LAN group", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = WirelessLAN + + @classmethod + def setUpTestData(cls): + + groups = ( + WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), + WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), + ) + for group in groups: + group.save() + + WirelessLAN.objects.bulk_create([ + WirelessLAN(group=groups[0], ssid='WLAN1'), + WirelessLAN(group=groups[0], ssid='WLAN2'), + WirelessLAN(group=groups[0], ssid='WLAN3'), + ]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'ssid': 'WLAN2', + 'group': groups[1].pk, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "group,ssid", + "Wireless LAN Group 2,WLAN4", + "Wireless LAN Group 2,WLAN5", + "Wireless LAN Group 2,WLAN6", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = WirelessLink + + @classmethod + def setUpTestData(cls): + device = create_test_device('test-device') + interfaces = [ + Interface( + device=device, + name=f'radio{i}', + type=InterfaceTypeChoices.TYPE_80211AC, + rf_channel=WirelessChannelChoices.CHANNEL_5G_32, + rf_channel_frequency=5160, + rf_channel_width=20 + ) for i in range(12) + ] + Interface.objects.bulk_create(interfaces) + + WirelessLink(interface_a=interfaces[0], interface_b=interfaces[1], ssid='LINK1').save() + WirelessLink(interface_a=interfaces[2], interface_b=interfaces[3], ssid='LINK2').save() + WirelessLink(interface_a=interfaces[4], interface_b=interfaces[5], ssid='LINK3').save() + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'interface_a': interfaces[6].pk, + 'interface_b': interfaces[7].pk, + 'status': LinkStatusChoices.STATUS_PLANNED, + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "interface_a,interface_b,status", + f"{interfaces[6].pk},{interfaces[7].pk},connected", + f"{interfaces[8].pk},{interfaces[9].pk},connected", + f"{interfaces[10].pk},{interfaces[11].pk},connected", + ) + + cls.bulk_edit_data = { + 'status': LinkStatusChoices.STATUS_PLANNED, + } From cfb3897047ff6fc38586466f56468eb9bb3ccba4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 10:51:02 -0400 Subject: [PATCH 28/68] Add tags to organizational & nested group models --- docs/development/models.md | 4 +- docs/models/extras/tag.md | 3 -- netbox/circuits/api/serializers.py | 8 ++- netbox/circuits/api/views.py | 2 +- netbox/circuits/forms/bulk_edit.py | 2 +- netbox/circuits/forms/models.py | 6 ++- .../migrations/0003_extend_tag_support.py | 20 ++++++++ netbox/circuits/models.py | 2 +- netbox/circuits/tables.py | 5 +- netbox/circuits/tests/test_views.py | 3 ++ netbox/dcim/api/serializers.py | 33 ++++++------ netbox/dcim/api/views.py | 14 +++--- netbox/dcim/forms/bulk_edit.py | 14 +++--- netbox/dcim/forms/models.py | 44 +++++++++++++--- .../migrations/0138_extend_tag_support.py | 50 +++++++++++++++++++ netbox/dcim/models/devices.py | 6 +-- netbox/dcim/models/racks.py | 2 +- netbox/dcim/models/sites.py | 6 +-- netbox/dcim/tables/devices.py | 12 ++++- netbox/dcim/tables/devicetypes.py | 6 ++- netbox/dcim/tables/racks.py | 5 +- netbox/dcim/tables/sites.py | 17 +++++-- netbox/dcim/tests/test_views.py | 21 ++++++++ netbox/ipam/api/serializers.py | 17 +++---- netbox/ipam/api/views.py | 6 +-- netbox/ipam/forms/bulk_edit.py | 6 +-- netbox/ipam/forms/models.py | 20 ++++++-- .../migrations/0051_extend_tag_support.py | 30 +++++++++++ netbox/ipam/models/ip.py | 4 +- netbox/ipam/models/vlans.py | 2 +- netbox/ipam/tables/ip.py | 10 +++- netbox/ipam/tables/vlans.py | 5 +- netbox/ipam/tests/test_views.py | 9 ++++ netbox/netbox/api/serializers.py | 11 +--- netbox/netbox/graphql/types.py | 1 + netbox/netbox/models.py | 21 +++++--- netbox/tenancy/api/serializers.py | 14 +++--- netbox/tenancy/api/views.py | 14 ++---- netbox/tenancy/forms/bulk_edit.py | 6 +-- netbox/tenancy/forms/models.py | 18 +++++-- .../migrations/0004_extend_tag_support.py | 30 +++++++++++ netbox/tenancy/models.py | 6 +-- netbox/tenancy/tables.py | 10 +++- netbox/tenancy/tests/test_views.py | 9 ++++ netbox/virtualization/api/serializers.py | 10 ++-- netbox/virtualization/api/views.py | 4 +- netbox/virtualization/forms/bulk_edit.py | 4 +- netbox/virtualization/forms/models.py | 20 +++++--- .../migrations/0025_extend_tag_support.py | 25 ++++++++++ netbox/virtualization/models.py | 4 +- netbox/virtualization/tables.py | 10 +++- netbox/virtualization/tests/test_views.py | 6 +++ 52 files changed, 463 insertions(+), 154 deletions(-) create mode 100644 netbox/circuits/migrations/0003_extend_tag_support.py create mode 100644 netbox/dcim/migrations/0138_extend_tag_support.py create mode 100644 netbox/ipam/migrations/0051_extend_tag_support.py create mode 100644 netbox/tenancy/migrations/0004_extend_tag_support.py create mode 100644 netbox/virtualization/migrations/0025_extend_tag_support.py diff --git a/docs/development/models.md b/docs/development/models.md index 93a10fff6..59e795cf7 100644 --- a/docs/development/models.md +++ b/docs/development/models.md @@ -19,8 +19,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/ | Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting | | ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | | Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | -| Organizational | :material-check: | :material-check: | :material-check: | :material-check: | | | | -| Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | | | :material-check: | +| Organizational | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | +| Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | :material-check: | | Component | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | | Component Template | :material-check: | :material-check: | :material-check: | | | | | diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md index 29cc8b757..fe6a1ef36 100644 --- a/docs/models/extras/tag.md +++ b/docs/models/extras/tag.md @@ -15,6 +15,3 @@ The `tag` filter can be specified multiple times to match only objects which hav ```no-highlight GET /api/dcim/devices/?tag=monitored&tag=deprecated ``` - -!!! note - Tags have changed substantially in NetBox v2.9. They are no longer created on-demand when editing an object, and their representation in the REST API now includes a complete depiction of the tag rather than only its label. diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index ac6285610..0033e1425 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -5,9 +5,7 @@ from circuits.models import * from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.serializers import CableTerminationSerializer from netbox.api import ChoiceField -from netbox.api.serializers import ( - OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer -) +from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from .nested_serializers import * @@ -48,14 +46,14 @@ class ProviderNetworkSerializer(PrimaryModelSerializer): # Circuits # -class CircuitTypeSerializer(OrganizationalModelSerializer): +class CircuitTypeSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') circuit_count = serializers.IntegerField(read_only=True) class Meta: model = CircuitType fields = [ - 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', ] diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 3bceb2de0..2b3e3b122 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -34,7 +34,7 @@ class ProviderViewSet(CustomFieldModelViewSet): # class CircuitTypeViewSet(CustomFieldModelViewSet): - queryset = CircuitType.objects.annotate( + queryset = CircuitType.objects.prefetch_related('tags').annotate( circuit_count=count_related(Circuit, 'type') ) serializer_class = serializers.CircuitTypeSerializer diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 638426a5e..7bf5644b9 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -79,7 +79,7 @@ class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField ] -class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class CircuitTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=CircuitType.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 659939293..5679dbc94 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -75,11 +75,15 @@ class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm): class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = CircuitType fields = [ - 'name', 'slug', 'description', + 'name', 'slug', 'description', 'tags', ] diff --git a/netbox/circuits/migrations/0003_extend_tag_support.py b/netbox/circuits/migrations/0003_extend_tag_support.py new file mode 100644 index 000000000..e5e6ee262 --- /dev/null +++ b/netbox/circuits/migrations/0003_extend_tag_support.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.8 on 2021-10-21 14:50 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ('circuits', '0002_squashed_0029'), + ] + + operations = [ + migrations.AddField( + model_name='circuittype', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 3d213b48d..e6e03052d 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -128,7 +128,7 @@ class ProviderNetwork(PrimaryModel): return reverse('circuits:providernetwork', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class CircuitType(OrganizationalModel): """ Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 2e31237b6..d0b0797e2 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -82,6 +82,9 @@ class CircuitTypeTable(BaseTable): name = tables.Column( linkify=True ) + tags = TagColumn( + url_name='circuits:circuittype_list' + ) circuit_count = tables.Column( verbose_name='Circuits' ) @@ -89,7 +92,7 @@ class CircuitTypeTable(BaseTable): class Meta(BaseTable.Meta): model = CircuitType - fields = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions') + fields = ('pk', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions') default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions') diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index ccb4a869a..851d52ae8 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -64,10 +64,13 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): CircuitType(name='Circuit Type 3', slug='circuit-type-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Circuit Type X', 'slug': 'circuit-type-x', 'description': 'A new circuit type', + 'tags': [t.pk for t in tags], } cls.csv_data = ( diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 9b0e7f5b3..ef4f49247 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -11,8 +11,7 @@ from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSer from ipam.models import VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( - NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, - WritableNestedSerializer, + NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, ) from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer @@ -87,8 +86,8 @@ class RegionSerializer(NestedGroupModelSerializer): class Meta: model = Region fields = [ - 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', - 'site_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'site_count', '_depth', ] @@ -100,8 +99,8 @@ class SiteGroupSerializer(NestedGroupModelSerializer): class Meta: model = SiteGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', - 'site_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'site_count', '_depth', ] @@ -144,20 +143,20 @@ class LocationSerializer(NestedGroupModelSerializer): class Meta: model = Location fields = [ - 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'custom_fields', + 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', ] -class RackRoleSerializer(OrganizationalModelSerializer): +class RackRoleSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackRole fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'custom_fields', 'created', 'last_updated', - 'rack_count', + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'rack_count', ] @@ -254,7 +253,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): # Device types # -class ManufacturerSerializer(OrganizationalModelSerializer): +class ManufacturerSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') devicetype_count = serializers.IntegerField(read_only=True) inventoryitem_count = serializers.IntegerField(read_only=True) @@ -263,7 +262,7 @@ class ManufacturerSerializer(OrganizationalModelSerializer): class Meta: model = Manufacturer fields = [ - 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'devicetype_count', 'inventoryitem_count', 'platform_count', ] @@ -411,7 +410,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): # Devices # -class DeviceRoleSerializer(OrganizationalModelSerializer): +class DeviceRoleSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) @@ -419,12 +418,12 @@ class DeviceRoleSerializer(OrganizationalModelSerializer): class Meta: model = DeviceRole fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created', - 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'tags', 'custom_fields', + 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] -class PlatformSerializer(OrganizationalModelSerializer): +class PlatformSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) device_count = serializers.IntegerField(read_only=True) @@ -434,7 +433,7 @@ class PlatformSerializer(OrganizationalModelSerializer): model = Platform fields = [ 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', - 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 2b9d9734c..799a5e703 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -110,7 +110,7 @@ class RegionViewSet(CustomFieldModelViewSet): 'region', 'site_count', cumulative=True - ) + ).prefetch_related('tags') serializer_class = serializers.RegionSerializer filterset_class = filtersets.RegionFilterSet @@ -126,7 +126,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet): 'group', 'site_count', cumulative=True - ) + ).prefetch_related('tags') serializer_class = serializers.SiteGroupSerializer filterset_class = filtersets.SiteGroupFilterSet @@ -167,7 +167,7 @@ class LocationViewSet(CustomFieldModelViewSet): 'location', 'rack_count', cumulative=True - ).prefetch_related('site') + ).prefetch_related('site', 'tags') serializer_class = serializers.LocationSerializer filterset_class = filtersets.LocationFilterSet @@ -177,7 +177,7 @@ class LocationViewSet(CustomFieldModelViewSet): # class RackRoleViewSet(CustomFieldModelViewSet): - queryset = RackRole.objects.annotate( + queryset = RackRole.objects.prefetch_related('tags').annotate( rack_count=count_related(Rack, 'role') ) serializer_class = serializers.RackRoleSerializer @@ -261,7 +261,7 @@ class RackReservationViewSet(ModelViewSet): # class ManufacturerViewSet(CustomFieldModelViewSet): - queryset = Manufacturer.objects.annotate( + queryset = Manufacturer.objects.prefetch_related('tags').annotate( devicetype_count=count_related(DeviceType, 'manufacturer'), inventoryitem_count=count_related(InventoryItem, 'manufacturer'), platform_count=count_related(Platform, 'manufacturer') @@ -340,7 +340,7 @@ class DeviceBayTemplateViewSet(ModelViewSet): # class DeviceRoleViewSet(CustomFieldModelViewSet): - queryset = DeviceRole.objects.annotate( + queryset = DeviceRole.objects.prefetch_related('tags').annotate( device_count=count_related(Device, 'device_role'), virtualmachine_count=count_related(VirtualMachine, 'role') ) @@ -353,7 +353,7 @@ class DeviceRoleViewSet(CustomFieldModelViewSet): # class PlatformViewSet(CustomFieldModelViewSet): - queryset = Platform.objects.annotate( + queryset = Platform.objects.prefetch_related('tags').annotate( device_count=count_related(Device, 'platform'), virtualmachine_count=count_related(VirtualMachine, 'platform') ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 06ccc958c..d08692c26 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -51,7 +51,7 @@ __all__ = ( ) -class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RegionBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Region.objects.all(), widget=forms.MultipleHiddenInput @@ -69,7 +69,7 @@ class RegionBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'description'] -class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class SiteGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=SiteGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -132,7 +132,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd ] -class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class LocationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Location.objects.all(), widget=forms.MultipleHiddenInput @@ -161,7 +161,7 @@ class LocationBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'tenant', 'description'] -class RackRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RackRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RackRole.objects.all(), widget=forms.MultipleHiddenInput @@ -303,7 +303,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField nullable_fields = [] -class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ManufacturerBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Manufacturer.objects.all(), widget=forms.MultipleHiddenInput @@ -345,7 +345,7 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel nullable_fields = ['airflow'] -class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class DeviceRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=DeviceRole.objects.all(), widget=forms.MultipleHiddenInput @@ -367,7 +367,7 @@ class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['color', 'description'] -class PlatformBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class PlatformBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Platform.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 8236b1a97..a3dac09dd 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -70,11 +70,15 @@ class RegionForm(BootstrapMixin, CustomFieldModelForm): required=False ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Region fields = ( - 'parent', 'name', 'slug', 'description', + 'parent', 'name', 'slug', 'description', 'tags', ) @@ -84,11 +88,15 @@ class SiteGroupForm(BootstrapMixin, CustomFieldModelForm): required=False ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = SiteGroup fields = ( - 'parent', 'name', 'slug', 'description', + 'parent', 'name', 'slug', 'description', 'tags', ) @@ -187,15 +195,19 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Location fields = ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags', ) fieldsets = ( ('Location', ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags', )), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -203,11 +215,15 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class RackRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = RackRole fields = [ - 'name', 'slug', 'color', 'description', + 'name', 'slug', 'color', 'description', 'tags', ] @@ -343,11 +359,15 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class ManufacturerForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Manufacturer fields = [ - 'name', 'slug', 'description', + 'name', 'slug', 'description', 'tags', ] @@ -392,11 +412,15 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = DeviceRole fields = [ - 'name', 'slug', 'color', 'vm_role', 'description', + 'name', 'slug', 'color', 'vm_role', 'description', 'tags', ] @@ -408,11 +432,15 @@ class PlatformForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField( max_length=64 ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Platform fields = [ - 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', + 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'tags', ] widgets = { 'napalm_args': SmallTextarea(), diff --git a/netbox/dcim/migrations/0138_extend_tag_support.py b/netbox/dcim/migrations/0138_extend_tag_support.py new file mode 100644 index 000000000..763b53c50 --- /dev/null +++ b/netbox/dcim/migrations/0138_extend_tag_support.py @@ -0,0 +1,50 @@ +# Generated by Django 3.2.8 on 2021-10-21 14:50 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ('dcim', '0137_relax_uniqueness_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='devicerole', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='location', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='manufacturer', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='platform', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='rackrole', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='region', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='sitegroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 308a094c3..2b3b80d24 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -36,7 +36,7 @@ __all__ = ( # Device Types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Manufacturer(OrganizationalModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. @@ -351,7 +351,7 @@ class DeviceType(PrimaryModel): # Devices # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceRole(OrganizationalModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a @@ -391,7 +391,7 @@ class DeviceRole(OrganizationalModel): return reverse('dcim:devicerole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Platform(OrganizationalModel): """ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 47fcd42e4..a6be069b6 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -35,7 +35,7 @@ __all__ = ( # Racks # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RackRole(OrganizationalModel): """ Racks can be organized by functional role, similar to Devices. diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index ab9d8e82d..a978e69e6 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -25,7 +25,7 @@ __all__ = ( # Regions # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Region(NestedGroupModel): """ A region represents a geographic collection of sites. For example, you might create regions representing countries, @@ -82,7 +82,7 @@ class Region(NestedGroupModel): # Site groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class SiteGroup(NestedGroupModel): """ A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and @@ -278,7 +278,7 @@ class Site(PrimaryModel): # Locations # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Location(NestedGroupModel): """ A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index a2d3f3da2..f47073848 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -84,11 +84,16 @@ class DeviceRoleTable(BaseTable): ) color = ColorColumn() vm_role = BooleanColumn() + tags = TagColumn( + url_name='dcim:devicerole_list' + ) actions = ButtonsColumn(DeviceRole) class Meta(BaseTable.Meta): model = DeviceRole - fields = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'actions') + fields = ( + 'pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', 'actions', + ) default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions') @@ -111,13 +116,16 @@ class PlatformTable(BaseTable): url_params={'platform_id': 'pk'}, verbose_name='VMs' ) + tags = TagColumn( + url_name='dcim:platform_list' + ) actions = ButtonsColumn(Platform) class Meta(BaseTable.Meta): model = Platform fields = ( 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', - 'description', 'actions', + 'description', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions', diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index b3310d5d2..9631b5709 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -41,12 +41,16 @@ class ManufacturerTable(BaseTable): verbose_name='Platforms' ) slug = tables.Column() + tags = TagColumn( + url_name='dcim:manufacturer_list' + ) actions = ButtonsColumn(Manufacturer) class Meta(BaseTable.Meta): model = Manufacturer fields = ( - 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', + 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'tags', + 'actions', ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index fcc3ed4d2..bdc5ae713 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -24,11 +24,14 @@ class RackRoleTable(BaseTable): name = tables.Column(linkify=True) rack_count = tables.Column(verbose_name='Racks') color = ColorColumn() + tags = TagColumn( + url_name='dcim:rackrole_list' + ) actions = ButtonsColumn(RackRole) class Meta(BaseTable.Meta): model = RackRole - fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'actions') + fields = ('pk', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions') default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 3ff6ab75b..65419e9c8 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -29,11 +29,14 @@ class RegionTable(BaseTable): url_params={'region_id': 'pk'}, verbose_name='Sites' ) + tags = TagColumn( + url_name='dcim:region_list' + ) actions = ButtonsColumn(Region) class Meta(BaseTable.Meta): model = Region - fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'site_count', 'description', 'actions') @@ -51,11 +54,14 @@ class SiteGroupTable(BaseTable): url_params={'group_id': 'pk'}, verbose_name='Sites' ) + tags = TagColumn( + url_name='dcim:sitegroup_list' + ) actions = ButtonsColumn(SiteGroup) class Meta(BaseTable.Meta): model = SiteGroup - fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'site_count', 'description', 'actions') @@ -114,6 +120,9 @@ class LocationTable(BaseTable): url_params={'location_id': 'pk'}, verbose_name='Devices' ) + tags = TagColumn( + url_name='dcim:location_list' + ) actions = ButtonsColumn( model=Location, prepend_template=LOCATION_ELEVATIONS @@ -121,5 +130,7 @@ class LocationTable(BaseTable): class Meta(BaseTable.Meta): model = Location - fields = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'actions') + fields = ( + 'pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags', 'actions', + ) default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions') diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index a9c191679..4565c898b 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -31,11 +31,14 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for region in regions: region.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Region X', 'slug': 'region-x', 'parent': regions[2].pk, 'description': 'A new region', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -65,11 +68,14 @@ class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for sitegroup in sitegroups: sitegroup.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Site Group X', 'slug': 'site-group-x', 'parent': sitegroups[2].pk, 'description': 'A new site group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -169,12 +175,15 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for location in locations: location.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Location X', 'slug': 'location-x', 'site': site.pk, 'tenant': tenant.pk, 'description': 'A new location', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -201,11 +210,14 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): RackRole(name='Rack Role 3', slug='rack-role-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Rack Role X', 'slug': 'rack-role-x', 'color': 'c0c0c0', 'description': 'New role', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -368,10 +380,13 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Manufacturer X', 'slug': 'manufacturer-x', 'description': 'A new manufacturer', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -1034,12 +1049,15 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): DeviceRole(name='Device Role 3', slug='device-role-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Devie Role X', 'slug': 'device-role-x', 'color': 'c0c0c0', 'vm_role': False, 'description': 'New device role', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -1069,6 +1087,8 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): Platform(name='Platform 3', slug='platform-3', manufacturer=manufacturer), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Platform X', 'slug': 'platform-x', @@ -1076,6 +1096,7 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'napalm_driver': 'junos', 'napalm_args': None, 'description': 'A new platform', + 'tags': [t.pk for t in tags], } cls.csv_data = ( diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 183c45b2a..2b221fdab 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -9,7 +9,6 @@ from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES from ipam.models import * from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField -from netbox.api.serializers import OrganizationalModelSerializer from netbox.api.serializers import PrimaryModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import get_serializer_for_model @@ -66,14 +65,14 @@ class RouteTargetSerializer(PrimaryModelSerializer): # RIRs/aggregates # -class RIRSerializer(OrganizationalModelSerializer): +class RIRSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') aggregate_count = serializers.IntegerField(read_only=True) class Meta: model = RIR fields = [ - 'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'custom_fields', 'created', + 'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'aggregate_count', ] @@ -97,7 +96,7 @@ class AggregateSerializer(PrimaryModelSerializer): # VLANs # -class RoleSerializer(OrganizationalModelSerializer): +class RoleSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') prefix_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) @@ -105,12 +104,12 @@ class RoleSerializer(OrganizationalModelSerializer): class Meta: model = Role fields = [ - 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'custom_fields', 'created', 'last_updated', - 'prefix_count', 'vlan_count', + 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'prefix_count', 'vlan_count', ] -class VLANGroupSerializer(OrganizationalModelSerializer): +class VLANGroupSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') scope_type = ContentTypeField( queryset=ContentType.objects.filter( @@ -126,8 +125,8 @@ class VLANGroupSerializer(OrganizationalModelSerializer): class Meta: model = VLANGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'custom_fields', - 'created', 'last_updated', 'vlan_count', + 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'vlan_count', ] validators = [] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 69b6d97f0..a043bd88c 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -48,7 +48,7 @@ class RouteTargetViewSet(CustomFieldModelViewSet): class RIRViewSet(CustomFieldModelViewSet): queryset = RIR.objects.annotate( aggregate_count=count_related(Aggregate, 'rir') - ) + ).prefetch_related('tags') serializer_class = serializers.RIRSerializer filterset_class = filtersets.RIRFilterSet @@ -71,7 +71,7 @@ class RoleViewSet(CustomFieldModelViewSet): queryset = Role.objects.annotate( prefix_count=count_related(Prefix, 'role'), vlan_count=count_related(VLAN, 'role') - ) + ).prefetch_related('tags') serializer_class = serializers.RoleSerializer filterset_class = filtersets.RoleFilterSet @@ -126,7 +126,7 @@ class IPAddressViewSet(CustomFieldModelViewSet): class VLANGroupViewSet(CustomFieldModelViewSet): queryset = VLANGroup.objects.annotate( vlan_count=count_related(VLAN, 'group') - ) + ).prefetch_related('tags') serializer_class = serializers.VLANGroupSerializer filterset_class = filtersets.VLANGroupFilterSet diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 895dbe200..43bf40f88 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -71,7 +71,7 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode ] -class RIRBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RIRBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RIR.objects.all(), widget=forms.MultipleHiddenInput @@ -120,7 +120,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB } -class RoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class RoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Role.objects.all(), widget=forms.MultipleHiddenInput @@ -280,7 +280,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB ] -class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class VLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VLANGroup.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index d28f7b3ae..a9c8a0910 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -82,11 +82,15 @@ class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class RIRForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = RIR fields = [ - 'name', 'slug', 'is_private', 'description', + 'name', 'slug', 'is_private', 'description', 'tags', ] @@ -120,11 +124,15 @@ class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class RoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = Role fields = [ - 'name', 'slug', 'weight', 'description', + 'name', 'slug', 'weight', 'description', 'tags', ] @@ -530,15 +538,19 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): } ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = VLANGroup fields = [ 'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', - 'clustergroup', 'cluster', + 'clustergroup', 'cluster', 'tags', ] fieldsets = ( - ('VLAN Group', ('name', 'slug', 'description')), + ('VLAN Group', ('name', 'slug', 'description', 'tags')), ('Scope', ('scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster')), ) widgets = { diff --git a/netbox/ipam/migrations/0051_extend_tag_support.py b/netbox/ipam/migrations/0051_extend_tag_support.py new file mode 100644 index 000000000..ea31a6645 --- /dev/null +++ b/netbox/ipam/migrations/0051_extend_tag_support.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.8 on 2021-10-21 14:50 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ('ipam', '0050_iprange'), + ] + + operations = [ + migrations.AddField( + model_name='rir', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='role', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='vlangroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 4fc2b5dbb..514e87a62 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -31,7 +31,7 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RIR(OrganizationalModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address @@ -168,7 +168,7 @@ class Aggregate(PrimaryModel): return min(utilization, 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Role(OrganizationalModel): """ A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 4ba8d7041..14eaa7ccc 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -21,7 +21,7 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VLANGroup(OrganizationalModel): """ A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index ddad6c573..a2a0c67b1 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -85,11 +85,14 @@ class RIRTable(BaseTable): url_params={'rir_id': 'pk'}, verbose_name='Aggregates' ) + tags = TagColumn( + url_name='ipam:rir_list' + ) actions = ButtonsColumn(RIR) class Meta(BaseTable.Meta): model = RIR - fields = ('pk', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions') @@ -144,11 +147,14 @@ class RoleTable(BaseTable): url_params={'role_id': 'pk'}, verbose_name='VLANs' ) + tags = TagColumn( + url_name='ipam:role_list' + ) actions = ButtonsColumn(Role) class Meta(BaseTable.Meta): model = Role - fields = ('pk', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'actions') + fields = ('pk', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions') default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions') diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index fd1e92be8..4c0d5d729 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -74,6 +74,9 @@ class VLANGroupTable(BaseTable): url_params={'group_id': 'pk'}, verbose_name='VLANs' ) + tags = TagColumn( + url_name='ipam:vlangroup_list' + ) actions = ButtonsColumn( model=VLANGroup, prepend_template=VLANGROUP_ADD_VLAN @@ -81,7 +84,7 @@ class VLANGroupTable(BaseTable): class Meta(BaseTable.Meta): model = VLANGroup - fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions') + fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions') diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 2a0bfdf32..5440efcb6 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -104,11 +104,14 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): RIR(name='RIR 3', slug='rir-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'RIR X', 'slug': 'rir-x', 'is_private': True, 'description': 'A new RIR', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -177,11 +180,14 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): Role(name='Role 3', slug='role-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Role X', 'slug': 'role-x', 'weight': 200, 'description': 'A new role', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -384,10 +390,13 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'VLAN Group X', 'slug': 'vlan-group-x', 'description': 'A new VLAN group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( diff --git a/netbox/netbox/api/serializers.py b/netbox/netbox/api/serializers.py index d17751e25..9f51d475d 100644 --- a/netbox/netbox/api/serializers.py +++ b/netbox/netbox/api/serializers.py @@ -147,13 +147,6 @@ class NestedTagSerializer(WritableNestedSerializer): # Base model serializers # -class OrganizationalModelSerializer(CustomFieldModelSerializer): - """ - Adds support for custom fields. - """ - pass - - class PrimaryModelSerializer(CustomFieldModelSerializer): """ Adds support for custom fields and tags. @@ -189,9 +182,9 @@ class PrimaryModelSerializer(CustomFieldModelSerializer): return instance -class NestedGroupModelSerializer(CustomFieldModelSerializer): +class NestedGroupModelSerializer(PrimaryModelSerializer): """ - Extends OrganizationalModelSerializer to include MPTT support. + Extends PrimaryModelSerializer to include MPTT support. """ _depth = serializers.IntegerField(source='level', read_only=True) diff --git a/netbox/netbox/graphql/types.py b/netbox/netbox/graphql/types.py index 181b9a0c6..7d71bd1fb 100644 --- a/netbox/netbox/graphql/types.py +++ b/netbox/netbox/graphql/types.py @@ -41,6 +41,7 @@ class ObjectType( class OrganizationalObjectType( ChangelogMixin, CustomFieldsMixin, + TagsMixin, BaseObjectType ): """ diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py index 317548921..95cea6a93 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -143,6 +143,18 @@ class CustomValidationMixin(models.Model): post_clean.send(sender=self.__class__, instance=self) +class TagsMixin(models.Model): + """ + Enable the assignment of Tags. + """ + tags = TaggableManager( + through='extras.TaggedItem' + ) + + class Meta: + abstract = True + + # # Base model classes @@ -166,7 +178,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): abstract = True -class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, BigIDModel): +class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): """ Primary models represent real objects within the infrastructure being modeled. """ @@ -175,15 +187,12 @@ class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, object_id_field='assigned_object_id', content_type_field='assigned_object_type' ) - tags = TaggableManager( - through='extras.TaggedItem' - ) class Meta: abstract = True -class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, BigIDModel, MPTTModel): +class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel): """ Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest recursively using MPTT. Within each parent, each child instance must have a unique name. @@ -225,7 +234,7 @@ class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMi }) -class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, BigIDModel): +class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): """ Organizational models are those which are used solely to categorize and qualify other objects, and do not convey any real information about the infrastructure being modeled (for example, functional device roles). Organizational diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 27a14b350..90c13725c 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import ContentType from rest_framework import serializers from netbox.api import ChoiceField, ContentTypeField -from netbox.api.serializers import NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer +from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer from tenancy.choices import ContactPriorityChoices from tenancy.models import * from .nested_serializers import * @@ -20,8 +20,8 @@ class TenantGroupSerializer(NestedGroupModelSerializer): class Meta: model = TenantGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', - 'tenant_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'tenant_count', '_depth', ] @@ -60,18 +60,18 @@ class ContactGroupSerializer(NestedGroupModelSerializer): class Meta: model = ContactGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', - 'contact_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'contact_count', '_depth', ] -class ContactRoleSerializer(OrganizationalModelSerializer): +class ContactRoleSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail') class Meta: model = ContactRole fields = [ - 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index 7ce16c143..8c7c33aba 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -30,7 +30,7 @@ class TenantGroupViewSet(CustomFieldModelViewSet): 'group', 'tenant_count', cumulative=True - ) + ).prefetch_related('tags') serializer_class = serializers.TenantGroupSerializer filterset_class = filtersets.TenantGroupFilterSet @@ -64,28 +64,24 @@ class ContactGroupViewSet(CustomFieldModelViewSet): 'group', 'contact_count', cumulative=True - ) + ).prefetch_related('tags') serializer_class = serializers.ContactGroupSerializer filterset_class = filtersets.ContactGroupFilterSet class ContactRoleViewSet(CustomFieldModelViewSet): - queryset = ContactRole.objects.all() + queryset = ContactRole.objects.prefetch_related('tags') serializer_class = serializers.ContactRoleSerializer filterset_class = filtersets.ContactRoleFilterSet class ContactViewSet(CustomFieldModelViewSet): - queryset = Contact.objects.prefetch_related( - 'group', 'tags' - ) + queryset = Contact.objects.prefetch_related('group', 'tags') serializer_class = serializers.ContactSerializer filterset_class = filtersets.ContactFilterSet class ContactAssignmentViewSet(CustomFieldModelViewSet): - queryset = ContactAssignment.objects.prefetch_related( - 'contact', 'role' - ) + queryset = ContactAssignment.objects.prefetch_related('contact', 'role') serializer_class = serializers.ContactAssignmentSerializer filterset_class = filtersets.ContactAssignmentFilterSet diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index a34b8def1..f461fe73c 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -17,7 +17,7 @@ __all__ = ( # Tenants # -class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class TenantGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=TenantGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -55,7 +55,7 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk # Contacts # -class ContactGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ContactGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ContactGroup.objects.all(), widget=forms.MultipleHiddenInput @@ -73,7 +73,7 @@ class ContactGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['parent', 'description'] -class ContactRoleBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ContactRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ContactRole.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py index b15065705..0237e4ef8 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/models.py @@ -28,11 +28,15 @@ class TenantGroupForm(BootstrapMixin, CustomFieldModelForm): required=False ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = TenantGroup fields = [ - 'parent', 'name', 'slug', 'description', + 'parent', 'name', 'slug', 'description', 'tags', ] @@ -68,18 +72,26 @@ class ContactGroupForm(BootstrapMixin, CustomFieldModelForm): required=False ) slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = ContactGroup - fields = ['parent', 'name', 'slug', 'description'] + fields = ('parent', 'name', 'slug', 'description', 'tags') class ContactRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = ContactRole - fields = ['name', 'slug', 'description'] + fields = ('name', 'slug', 'description', 'tags') class ContactForm(BootstrapMixin, CustomFieldModelForm): diff --git a/netbox/tenancy/migrations/0004_extend_tag_support.py b/netbox/tenancy/migrations/0004_extend_tag_support.py new file mode 100644 index 000000000..942be38b5 --- /dev/null +++ b/netbox/tenancy/migrations/0004_extend_tag_support.py @@ -0,0 +1,30 @@ +# Generated by Django 3.2.8 on 2021-10-21 14:50 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ('tenancy', '0003_contacts'), + ] + + operations = [ + migrations.AddField( + model_name='contactgroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='contactrole', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='tenantgroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index c709236e2..01ea2d0d5 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -24,7 +24,7 @@ __all__ = ( # Tenants # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class TenantGroup(NestedGroupModel): """ An arbitrary collection of Tenants. @@ -111,7 +111,7 @@ class Tenant(PrimaryModel): # Contacts # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ContactGroup(NestedGroupModel): """ An arbitrary collection of Contacts. @@ -145,7 +145,7 @@ class ContactGroup(NestedGroupModel): return reverse('tenancy:contactgroup', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ContactRole(OrganizationalModel): """ Functional role for a Contact assigned to an object. diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 5b254842b..02c431846 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -55,11 +55,14 @@ class TenantGroupTable(BaseTable): url_params={'group_id': 'pk'}, verbose_name='Tenants' ) + tags = TagColumn( + url_name='tenancy:tenantgroup_list' + ) actions = ButtonsColumn(TenantGroup) class Meta(BaseTable.Meta): model = TenantGroup - fields = ('pk', 'name', 'tenant_count', 'description', 'slug', 'actions') + fields = ('pk', 'name', 'tenant_count', 'description', 'slug', 'tags', 'actions') default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions') @@ -96,11 +99,14 @@ class ContactGroupTable(BaseTable): url_params={'role_id': 'pk'}, verbose_name='Contacts' ) + tags = TagColumn( + url_name='tenancy:contactgroup_list' + ) actions = ButtonsColumn(ContactGroup) class Meta(BaseTable.Meta): model = ContactGroup - fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'actions') + fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'actions') default_columns = ('pk', 'name', 'contact_count', 'description', 'actions') diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index fb7ff3ce3..dcfcc1652 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -16,10 +16,13 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for tenanantgroup in tenant_groups: tenanantgroup.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Tenant Group X', 'slug': 'tenant-group-x', 'description': 'A new tenant group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -90,10 +93,13 @@ class ContactGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): for tenanantgroup in contact_groups: tenanantgroup.save() + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Contact Group X', 'slug': 'contact-group-x', 'description': 'A new contact group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -120,10 +126,13 @@ class ContactRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ContactRole(name='Contact Role 3', slug='contact-role-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Devie Role X', 'slug': 'contact-role-x', 'description': 'New contact role', + 'tags': [t.pk for t in tags], } cls.csv_data = ( diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 1928960a9..ef8c975d3 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -6,7 +6,7 @@ from dcim.choices import InterfaceModeChoices from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN from netbox.api import ChoiceField, SerializedPKRelatedField -from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer +from netbox.api.serializers import PrimaryModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -17,26 +17,26 @@ from .nested_serializers import * # Clusters # -class ClusterTypeSerializer(OrganizationalModelSerializer): +class ClusterTypeSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterType fields = [ - 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'cluster_count', ] -class ClusterGroupSerializer(OrganizationalModelSerializer): +class ClusterGroupSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterGroup fields = [ - 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'cluster_count', ] diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 8eebd2120..d07ace3d5 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -23,7 +23,7 @@ class VirtualizationRootView(APIRootView): class ClusterTypeViewSet(CustomFieldModelViewSet): queryset = ClusterType.objects.annotate( cluster_count=count_related(Cluster, 'type') - ) + ).prefetch_related('tags') serializer_class = serializers.ClusterTypeSerializer filterset_class = filtersets.ClusterTypeFilterSet @@ -31,7 +31,7 @@ class ClusterTypeViewSet(CustomFieldModelViewSet): class ClusterGroupViewSet(CustomFieldModelViewSet): queryset = ClusterGroup.objects.annotate( cluster_count=count_related(Cluster, 'group') - ) + ).prefetch_related('tags') serializer_class = serializers.ClusterGroupSerializer filterset_class = filtersets.ClusterGroupFilterSet diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index c140fbc73..d18d432cd 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -23,7 +23,7 @@ __all__ = ( ) -class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ClusterTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ClusterType.objects.all(), widget=forms.MultipleHiddenInput @@ -37,7 +37,7 @@ class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): nullable_fields = ['description'] -class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldModelBulkEditForm): +class ClusterGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index d66bc9f1f..88ebc9e83 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -28,22 +28,30 @@ __all__ = ( class ClusterTypeForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = ClusterType - fields = [ - 'name', 'slug', 'description', - ] + fields = ( + 'name', 'slug', 'description', 'tags', + ) class ClusterGroupForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) class Meta: model = ClusterGroup - fields = [ - 'name', 'slug', 'description', - ] + fields = ( + 'name', 'slug', 'description', 'tags', + ) class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): diff --git a/netbox/virtualization/migrations/0025_extend_tag_support.py b/netbox/virtualization/migrations/0025_extend_tag_support.py new file mode 100644 index 000000000..c77aee194 --- /dev/null +++ b/netbox/virtualization/migrations/0025_extend_tag_support.py @@ -0,0 +1,25 @@ +# Generated by Django 3.2.8 on 2021-10-21 14:50 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ('virtualization', '0024_cluster_relax_uniqueness'), + ] + + operations = [ + migrations.AddField( + model_name='clustergroup', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='clustertype', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 11792944a..bd64f56cf 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -30,7 +30,7 @@ __all__ = ( # Cluster types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterType(OrganizationalModel): """ A type of Cluster. @@ -64,7 +64,7 @@ class ClusterType(OrganizationalModel): # Cluster groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterGroup(OrganizationalModel): """ An organizational group of Clusters. diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index b0e922e71..64b376e1d 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -40,11 +40,14 @@ class ClusterTypeTable(BaseTable): cluster_count = tables.Column( verbose_name='Clusters' ) + tags = TagColumn( + url_name='virtualization:clustertype_list' + ) actions = ButtonsColumn(ClusterType) class Meta(BaseTable.Meta): model = ClusterType - fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') @@ -60,11 +63,14 @@ class ClusterGroupTable(BaseTable): cluster_count = tables.Column( verbose_name='Clusters' ) + tags = TagColumn( + url_name='virtualization:clustergroup_list' + ) actions = ButtonsColumn(ClusterGroup) class Meta(BaseTable.Meta): model = ClusterGroup - fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'actions') + fields = ('pk', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions') default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 020c9ebc5..138b1afae 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -22,10 +22,13 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Cluster Group X', 'slug': 'cluster-group-x', 'description': 'A new cluster group', + 'tags': [t.pk for t in tags], } cls.csv_data = ( @@ -52,10 +55,13 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): ClusterType(name='Cluster Type 3', slug='cluster-type-3'), ]) + tags = create_tags('Alpha', 'Bravo', 'Charlie') + cls.form_data = { 'name': 'Cluster Type X', 'slug': 'cluster-type-x', 'description': 'A new cluster type', + 'tags': [t.pk for t in tags], } cls.csv_data = ( From 6f05f17c62ee075a7a997554e981ad81489ec2f6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 11:23:31 -0400 Subject: [PATCH 29/68] Standardize & simplify tags panel inclusion --- netbox/templates/circuits/circuit.html | 2 +- netbox/templates/circuits/circuittype.html | 1 + netbox/templates/circuits/provider.html | 2 +- netbox/templates/circuits/providernetwork.html | 2 +- netbox/templates/dcim/cable.html | 2 +- netbox/templates/dcim/consoleport.html | 2 +- netbox/templates/dcim/consoleserverport.html | 2 +- netbox/templates/dcim/device.html | 2 +- netbox/templates/dcim/devicebay.html | 2 +- netbox/templates/dcim/devicerole.html | 1 + netbox/templates/dcim/devicetype.html | 2 +- netbox/templates/dcim/frontport.html | 2 +- netbox/templates/dcim/interface.html | 2 +- netbox/templates/dcim/inventoryitem.html | 2 +- netbox/templates/dcim/location.html | 1 + netbox/templates/dcim/manufacturer.html | 1 + netbox/templates/dcim/platform.html | 1 + netbox/templates/dcim/powerfeed.html | 2 +- netbox/templates/dcim/poweroutlet.html | 2 +- netbox/templates/dcim/powerpanel.html | 2 +- netbox/templates/dcim/powerport.html | 2 +- netbox/templates/dcim/rack.html | 2 +- netbox/templates/dcim/rackreservation.html | 2 +- netbox/templates/dcim/rackrole.html | 1 + netbox/templates/dcim/rearport.html | 2 +- netbox/templates/dcim/region.html | 1 + netbox/templates/dcim/site.html | 3 +-- netbox/templates/dcim/sitegroup.html | 1 + netbox/templates/dcim/virtualchassis.html | 2 +- netbox/templates/inc/panels/tags.html | 15 +++++++++------ netbox/templates/ipam/aggregate.html | 2 +- netbox/templates/ipam/ipaddress.html | 2 +- netbox/templates/ipam/iprange.html | 2 +- netbox/templates/ipam/prefix.html | 2 +- netbox/templates/ipam/rir.html | 1 + netbox/templates/ipam/role.html | 1 + netbox/templates/ipam/routetarget.html | 2 +- netbox/templates/ipam/service.html | 2 +- netbox/templates/ipam/vlan.html | 2 +- netbox/templates/ipam/vlangroup.html | 1 + netbox/templates/ipam/vrf.html | 2 +- netbox/templates/tenancy/contact.html | 2 +- netbox/templates/tenancy/contactgroup.html | 1 + netbox/templates/tenancy/contactrole.html | 1 + netbox/templates/tenancy/tenant.html | 2 +- netbox/templates/tenancy/tenantgroup.html | 1 + netbox/templates/virtualization/cluster.html | 2 +- netbox/templates/virtualization/clustergroup.html | 1 + netbox/templates/virtualization/clustertype.html | 1 + .../templates/virtualization/virtualmachine.html | 2 +- netbox/templates/virtualization/vminterface.html | 4 ++-- 51 files changed, 60 insertions(+), 42 deletions(-) diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index b61dac6fc..22713b592 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -65,7 +65,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:circuit_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/circuits/circuittype.html b/netbox/templates/circuits/circuittype.html index ad81de7e1..57737a6d1 100644 --- a/netbox/templates/circuits/circuittype.html +++ b/netbox/templates/circuits/circuittype.html @@ -28,6 +28,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index d353e4f37..c16afa421 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -47,7 +47,7 @@
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:provider_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/circuits/providernetwork.html b/netbox/templates/circuits/providernetwork.html index 18a11e115..9641c9934 100644 --- a/netbox/templates/circuits/providernetwork.html +++ b/netbox/templates/circuits/providernetwork.html @@ -38,7 +38,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='circuits:providernetwork_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index c5d1f7906..00704e6ca 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -64,7 +64,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:cable_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index c340cbc5c..60711eb9d 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -41,7 +41,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index 91de60252..f65af3285 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -41,7 +41,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 869ab1ec7..ea0c795c5 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -221,7 +221,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:device_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html index 918b6b022..ff8f90db2 100644 --- a/netbox/templates/dcim/devicebay.html +++ b/netbox/templates/dcim/devicebay.html @@ -33,7 +33,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html index 2c2d7fe6f..22385ae27 100644 --- a/netbox/templates/dcim/devicerole.html +++ b/netbox/templates/dcim/devicerole.html @@ -58,6 +58,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 74a3e73d7..21a04e7d0 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -88,7 +88,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:devicetype_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index c6b6cea48..6cc3d482f 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -53,7 +53,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 0715bec58..af038326d 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -103,7 +103,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html index e55d441d4..163d8edb3 100644 --- a/netbox/templates/dcim/inventoryitem.html +++ b/netbox/templates/dcim/inventoryitem.html @@ -65,7 +65,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index eeb891daf..434253d43 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -68,6 +68,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index 792a3e127..d43a206c6 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -34,6 +34,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html index bbdf809dd..8cd26a116 100644 --- a/netbox/templates/dcim/platform.html +++ b/netbox/templates/dcim/platform.html @@ -55,6 +55,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index f29a127e3..1824cac19 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -108,7 +108,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:powerfeed_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 1f960e0d5..396ef42a8 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -45,7 +45,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index a99aabf32..021fa1133 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -39,7 +39,7 @@
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:powerpanel_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index 74ad9603b..dfe428c50 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -45,7 +45,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 586d31771..93bd21fd9 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -163,7 +163,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:rack_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% if power_feeds %}
diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index 07ca55f7c..1e16af675 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -84,7 +84,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:rackreservation_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/rackrole.html b/netbox/templates/dcim/rackrole.html index 2668905f4..2f4661c9f 100644 --- a/netbox/templates/dcim/rackrole.html +++ b/netbox/templates/dcim/rackrole.html @@ -34,6 +34,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index b60e04882..b3ecce3ad 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -47,7 +47,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index c03b11e7d..7452e594e 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -45,6 +45,7 @@
+ {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 8442ae41e..a17c505a9 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -169,7 +169,6 @@
-
{{ object.contact_email }} {% else %} @@ -181,7 +180,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:site_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index dbee2c835..d04330413 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -45,6 +45,7 @@ + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index fd31be60d..8399576f5 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -39,7 +39,7 @@ {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='dcim:virtualchassis_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/inc/panels/tags.html b/netbox/templates/inc/panels/tags.html index e67098c0f..c309afdf0 100644 --- a/netbox/templates/inc/panels/tags.html +++ b/netbox/templates/inc/panels/tags.html @@ -1,11 +1,14 @@ {% load helpers %} +
-
- Tags -
+
Tags
- {% for tag in tags.all %} {% tag tag url %} {% empty %} - No tags assigned - {% endfor %} + {% with url=object|validated_viewname:"list" %} + {% for tag in object.tags.all %} + {% tag tag url %} + {% empty %} + No tags assigned + {% endfor %} + {% endwith %}
diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index 202b6e41c..aca89a526 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -65,7 +65,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:aggregate_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index d98544de4..31782bdd7 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -145,7 +145,7 @@
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:ipaddress_list' %} + {% include 'inc/panels/tags.html' %}
diff --git a/netbox/templates/ipam/iprange.html b/netbox/templates/ipam/iprange.html index e3d37a87a..b549ec7c5 100644 --- a/netbox/templates/ipam/iprange.html +++ b/netbox/templates/ipam/iprange.html @@ -82,7 +82,7 @@ {% plugin_left_page object %}
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:prefix_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 877ed49e0..eaea4e1ec 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -122,7 +122,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:prefix_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/rir.html b/netbox/templates/ipam/rir.html index 26d5e71da..c2f88c278 100644 --- a/netbox/templates/ipam/rir.html +++ b/netbox/templates/ipam/rir.html @@ -38,6 +38,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/ipam/role.html b/netbox/templates/ipam/role.html index 7fc967047..5579010fa 100644 --- a/netbox/templates/ipam/role.html +++ b/netbox/templates/ipam/role.html @@ -32,6 +32,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/ipam/routetarget.html b/netbox/templates/ipam/routetarget.html index f615d2d50..71d6f9601 100644 --- a/netbox/templates/ipam/routetarget.html +++ b/netbox/templates/ipam/routetarget.html @@ -30,7 +30,7 @@
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:routetarget_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index 7609a280b..5a47e44f0 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -61,7 +61,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:service_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index e8c514cca..367ae3641 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -83,7 +83,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:vlan_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/ipam/vlangroup.html b/netbox/templates/ipam/vlangroup.html index 2d31feb22..1c36e92f6 100644 --- a/netbox/templates/ipam/vlangroup.html +++ b/netbox/templates/ipam/vlangroup.html @@ -54,6 +54,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index b320fe6b8..349fe20d3 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -60,7 +60,7 @@ {% plugin_left_page object %}
- {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:vrf_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index 8bdf6c030..3c6ada5a0 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -60,7 +60,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='tenancy:tenant_list' %} + {% include 'inc/panels/tags.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/tenancy/contactgroup.html b/netbox/templates/tenancy/contactgroup.html index 0eef750eb..efb86af91 100644 --- a/netbox/templates/tenancy/contactgroup.html +++ b/netbox/templates/tenancy/contactgroup.html @@ -45,6 +45,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/tenancy/contactrole.html b/netbox/templates/tenancy/contactrole.html index 4ddde3624..3272728f2 100644 --- a/netbox/templates/tenancy/contactrole.html +++ b/netbox/templates/tenancy/contactrole.html @@ -30,6 +30,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index dc51b48c5..f54fd1425 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -36,7 +36,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='tenancy:tenant_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/tenancy/tenantgroup.html b/netbox/templates/tenancy/tenantgroup.html index 31a756d9e..75d2c5a27 100644 --- a/netbox/templates/tenancy/tenantgroup.html +++ b/netbox/templates/tenancy/tenantgroup.html @@ -45,6 +45,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 84b8235ad..b7af89bb2 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -61,7 +61,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='virtualization:cluster_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/virtualization/clustergroup.html b/netbox/templates/virtualization/clustergroup.html index b367d97f7..3979fa0e6 100644 --- a/netbox/templates/virtualization/clustergroup.html +++ b/netbox/templates/virtualization/clustergroup.html @@ -28,6 +28,7 @@ + {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/virtualization/clustertype.html b/netbox/templates/virtualization/clustertype.html index e3c050a1b..de5f3c519 100644 --- a/netbox/templates/virtualization/clustertype.html +++ b/netbox/templates/virtualization/clustertype.html @@ -28,6 +28,7 @@
+ {% include 'inc/panels/tags.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 0d9ea4a22..068d7f164 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -90,7 +90,7 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all url='virtualization:virtualmachine_list' %} + {% include 'inc/panels/tags.html' %} {% include 'inc/panels/comments.html' %} {% plugin_left_page object %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index ef12b63a1..1678013f2 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -70,8 +70,8 @@
{% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' with tags=object.tags.all %} - {% plugin_right_page object %} + {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %}
From 4932e4f8c64560a453542b8729dea49d0b590ee5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 11:28:25 -0400 Subject: [PATCH 30/68] Changelog for #6497 --- docs/release-notes/version-3.1.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 291831500..c829ef2b9 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -20,6 +20,7 @@ When assigning a contact to an object, the user must select a predefined role (e * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces * [#1943](https://github.com/netbox-community/netbox/issues/1943) - Relax uniqueness constraint on cluster names * [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices +* [#6497](https://github.com/netbox-community/netbox/issues/6497) - Extend tag support to organizational models * [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support * [#6715](https://github.com/netbox-community/netbox/issues/6715) - Add tenant assignment for cables * [#6874](https://github.com/netbox-community/netbox/issues/6874) - Add tenant assignment for locations @@ -37,6 +38,23 @@ When assigning a contact to an object, the user must select a predefined role (e * `/api/tenancy/contact-groups/` * `/api/tenancy/contact-roles/` * `/api/tenancy/contacts/` +* Added `tags` field to the following models: + * circuits.CircuitType + * dcim.DeviceRole + * dcim.Location + * dcim.Manufacturer + * dcim.Platform + * dcim.RackRole + * dcim.Region + * dcim.SiteGroup + * ipam.RIR + * ipam.Role + * ipam.VLANGroup + * tenancy.ContactGroup + * tenancy.ContactRole + * tenancy.TenantGroup + * virtualization.ClusterGroup + * virtualization.ClusterType * dcim.Cable * Added `tenant` field * dcim.Device From 1c6a84659cc0a401b43f2abae8334fbf8eaf5774 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 14:11:11 -0400 Subject: [PATCH 31/68] #3979 cleanup --- netbox/dcim/tables/template_code.py | 4 +- netbox/netbox/views/__init__.py | 8 ++- .../templates/wireless/wirelesslink_edit.html | 33 ++++++++++ netbox/wireless/forms/models.py | 62 ++++++++++++++++--- 4 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 netbox/templates/wireless/wirelesslink_edit.html diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index aab15b5ef..f6938807a 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -236,8 +236,8 @@ INTERFACE_BUTTONS = """ {% endif %} {% elif record.is_wireless and perms.wireless.add_wirelesslink %} - - + + {% endif %} """ diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index 2c033e760..b361352d0 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -27,6 +27,7 @@ from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES from netbox.forms import SearchForm from tenancy.models import Tenant from virtualization.models import Cluster, VirtualMachine +from wireless.models import WirelessLAN, WirelessLink class HomeView(View): @@ -92,14 +93,19 @@ class HomeView(View): ("dcim.view_powerpanel", "Power Panels", PowerPanel.objects.restrict(request.user, 'view').count), ("dcim.view_powerfeed", "Power Feeds", PowerFeed.objects.restrict(request.user, 'view').count), ) + wireless = ( + ("wireless.view_wirelesslan", "Wireless LANs", WirelessLAN.objects.restrict(request.user, 'view').count), + ("wireless.view_wirelesslink", "Wireless Links", WirelessLink.objects.restrict(request.user, 'view').count), + ) sections = ( ("Organization", org, "domain"), ("IPAM", ipam, "counter"), ("Virtualization", virtualization, "monitor"), ("Inventory", dcim, "server"), - ("Connections", connections, "cable-data"), ("Circuits", circuits, "transit-connection-variant"), + ("Connections", connections, "cable-data"), ("Power", power, "flash"), + ("Wireless", wireless, "wifi"), ) stats = [] diff --git a/netbox/templates/wireless/wirelesslink_edit.html b/netbox/templates/wireless/wirelesslink_edit.html new file mode 100644 index 000000000..034d147de --- /dev/null +++ b/netbox/templates/wireless/wirelesslink_edit.html @@ -0,0 +1,33 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
+
+
+
Side A
+
+ {% render_field form.device_a %} + {% render_field form.interface_a %} +
+
+
+
+
+
Side B
+
+ {% render_field form.device_b %} + {% render_field form.interface_b %} +
+
+
+ {% if form.custom_fields %} +
+
+
Custom Fields
+
+ {% render_custom_fields form %} +
+ {% endif %} +{% endblock %} diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 544d5823d..f7985a31d 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -1,4 +1,4 @@ -from dcim.models import Device, Interface +from dcim.models import Device, Interface, Location, Site from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import VLAN @@ -64,10 +64,30 @@ class WirelessLANForm(BootstrapMixin, CustomFieldModelForm): class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): + site_a = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + label='Site', + initial_params={ + 'devices': '$device_a', + } + ) + location_a = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + label='Location', + initial_params={ + 'devices': '$device_a', + } + ) device_a = DynamicModelChoiceField( queryset=Device.objects.all(), + query_params={ + 'site_id': '$site_a', + 'location_id': '$location_a', + }, required=False, - label='Device A', + label='Device', initial_params={ 'interfaces': '$interface_a' } @@ -79,12 +99,32 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): 'device_id': '$device_a', }, disabled_indicator='_occupied', - label='Interface A' + label='Interface' + ) + site_b = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + label='Site', + initial_params={ + 'devices': '$device_b', + } + ) + location_b = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + label='Location', + initial_params={ + 'devices': '$device_b', + } ) device_b = DynamicModelChoiceField( queryset=Device.objects.all(), + query_params={ + 'site_id': '$site_b', + 'location_id': '$location_b', + }, required=False, - label='Device B', + label='Device', initial_params={ 'interfaces': '$interface_b' } @@ -96,7 +136,7 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): 'device_id': '$device_b', }, disabled_indicator='_occupied', - label='Interface B' + label='Interface' ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -106,11 +146,13 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = WirelessLink fields = [ - 'device_a', 'interface_a', 'device_b', 'interface_b', 'status', 'ssid', 'description', 'auth_type', - 'auth_cipher', 'auth_psk', 'tags', + 'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b', + 'status', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', ] fieldsets = ( - ('Link', ('device_a', 'interface_a', 'device_b', 'interface_b', 'status', 'ssid', 'description', 'tags')), + ('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')), + ('Side B', ('site_b', 'location_b', 'device_b', 'interface_b')), + ('Link', ('status', 'ssid', 'description', 'tags')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) widgets = { @@ -118,3 +160,7 @@ class WirelessLinkForm(BootstrapMixin, CustomFieldModelForm): 'auth_type': StaticSelect, 'auth_cipher': StaticSelect, } + labels = { + 'auth_type': 'Type', + 'auth_cipher': 'Cipher', + } From 6f66138a1878a9fdafe93c1ecda251954a67bf78 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 15:15:01 -0400 Subject: [PATCH 32/68] Changelog for #3979 --- docs/release-notes/version-3.1.md | 13 ++++++++++++- netbox/netbox/settings.py | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index c829ef2b9..88142bf78 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -13,7 +13,18 @@ A set of new models for tracking contact information has been introduced within When assigning a contact to an object, the user must select a predefined role (e.g. "billing" or "technical") and may optionally indicate a priority relative to other contacts associated with the object. There is no limit on how many contacts can be assigned to an object, nor on how many objects to which a contact can be assigned. -#### +#### Wireless Networks ([#3979](https://github.com/netbox-community/netbox/issues/3979)) + +This release introduces two new models to represent wireless networks: + +* Wireless LAN - A multi-access wireless segment to which any number of wireless interfaces may be attached +* Wireless Link - A point-to-point connection between exactly two wireless interfaces + +Both types of connection include SSID and authentication attributes. Additionally, the interface model has been extended to include several attributes pertinent to wireless operation: + +* Wireless role - Access point or station +* Channel - A predefined channel within a standardized band +* Channel frequency & width - Customizable channel attributes (e.g. for licensed bands) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 6381435f2..279b8c453 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '3.0.9-dev' +VERSION = '3.1-beta1' # Hostname HOSTNAME = platform.node() From c06b3374cef7abee6836d07d31e348197695b3df Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 15:29:52 -0400 Subject: [PATCH 33/68] #6497: Add missing tag fields to filter forms --- netbox/circuits/filtersets.py | 1 + netbox/circuits/forms/filtersets.py | 4 +--- netbox/dcim/filtersets.py | 7 +++++++ netbox/dcim/forms/filtersets.py | 24 +++++++---------------- netbox/ipam/filtersets.py | 3 +++ netbox/ipam/forms/filtersets.py | 12 ++++-------- netbox/tenancy/filtersets.py | 3 +++ netbox/tenancy/forms/filtersets.py | 6 +++--- netbox/virtualization/filtersets.py | 2 ++ netbox/virtualization/forms/filtersets.py | 8 ++------ netbox/wireless/filtersets.py | 1 + netbox/wireless/forms/filtersets.py | 4 +--- 12 files changed, 35 insertions(+), 40 deletions(-) diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 15bc5a8b3..fd582dd99 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -111,6 +111,7 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet): class CircuitTypeFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = CircuitType diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 63b654148..b29f8f772 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -79,14 +79,12 @@ class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = CircuitType - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index f6d8abb0a..e81bd5e43 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -72,6 +72,7 @@ class RegionFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Parent region (slug)', ) + tag = TagFilter() class Meta: model = Region @@ -89,6 +90,7 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Parent site group (slug)', ) + tag = TagFilter() class Meta: model = SiteGroup @@ -208,6 +210,7 @@ class LocationFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Location (slug)', ) + tag = TagFilter() class Meta: model = Location @@ -223,6 +226,7 @@ class LocationFilterSet(OrganizationalModelFilterSet): class RackRoleFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = RackRole @@ -388,6 +392,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class ManufacturerFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = Manufacturer @@ -570,6 +575,7 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent class DeviceRoleFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = DeviceRole @@ -588,6 +594,7 @@ class PlatformFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) + tag = TagFilter() class Meta: model = Platform diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 5c776386a..6530b3b46 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -106,10 +106,6 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = Region - field_groups = [ - ['q'], - ['parent_id'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), @@ -121,14 +117,11 @@ class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Parent region'), fetch_trigger='open' ) + tag = TagFilterField(model) class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = SiteGroup - field_groups = [ - ['q'], - ['parent_id'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), @@ -140,6 +133,7 @@ class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Parent group'), fetch_trigger='open' ) + tag = TagFilterField(model) class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): @@ -219,18 +213,17 @@ class LocationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilt label=_('Parent'), fetch_trigger='open' ) + tag = TagFilterField(model) class RackRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = RackRole - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): @@ -371,14 +364,12 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMo class ManufacturerFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = Manufacturer - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): @@ -456,14 +447,12 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class DeviceRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = DeviceRole - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm): @@ -479,6 +468,7 @@ class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Manufacturer'), fetch_trigger='open' ) + tag = TagFilterField(model) class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm): diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 37a9299dc..56d23387f 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -118,6 +118,7 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class RIRFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = RIR @@ -179,6 +180,7 @@ class RoleFilterSet(OrganizationalModelFilterSet): method='search', label='Search', ) + tag = TagFilter() class Meta: model = Role @@ -636,6 +638,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): cluster = django_filters.NumberFilter( method='filter_scope' ) + tag = TagFilter() class Meta: model = VLANGroup diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 8bc0f10fb..415664f62 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -91,10 +91,6 @@ class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelF class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = RIR - field_groups = [ - ['q'], - ['is_private'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), @@ -107,6 +103,7 @@ class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + tag = TagFilterField(model) class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): @@ -138,14 +135,12 @@ class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = Role - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): @@ -363,7 +358,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): field_groups = [ - ['q'], + ['q', 'tag'], ['region', 'sitegroup', 'site', 'location', 'rack'] ] model = VLANGroup @@ -402,6 +397,7 @@ class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Rack'), fetch_trigger='open' ) + tag = TagFilterField(model) class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index f6d0ac72e..dd73edace 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -33,6 +33,7 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Tenant group (slug)', ) + tag = TagFilter() class Meta: model = TenantGroup @@ -118,6 +119,7 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Contact group (slug)', ) + tag = TagFilter() class Meta: model = ContactGroup @@ -125,6 +127,7 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): class ContactRoleFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = ContactRole diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 69941701f..b693db68f 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -31,6 +31,7 @@ class TenantGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Parent group'), fetch_trigger='open' ) + tag = TagFilterField(model) class TenantFilterForm(BootstrapMixin, CustomFieldModelFilterForm): @@ -71,18 +72,17 @@ class ContactGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Parent group'), fetch_trigger='open' ) + tag = TagFilterField(model) class ContactRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = ContactRole - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class ContactFilterForm(BootstrapMixin, CustomFieldModelFilterForm): diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 3fc1da8ea..e2aac9b80 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -20,6 +20,7 @@ __all__ = ( class ClusterTypeFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = ClusterType @@ -27,6 +28,7 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet): class ClusterGroupFilterSet(OrganizationalModelFilterSet): + tag = TagFilter() class Meta: model = ClusterGroup diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 0bb5c2bd7..1e8156c33 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -22,26 +22,22 @@ __all__ = ( class ClusterTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = ClusterType - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class ClusterGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = ClusterGroup - field_groups = [ - ['q'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), label=_('Search') ) + tag = TagFilterField(model) class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm): diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index cffdcf046..654dd843f 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -25,6 +25,7 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): queryset=WirelessLANGroup.objects.all(), to_field_name='slug' ) + tag = TagFilter() class Meta: model = WirelessLANGroup diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 483d74a7c..b7eeec76b 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -29,6 +29,7 @@ class WirelessLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm): label=_('Parent group'), fetch_trigger='open' ) + tag = TagFilterField(model) class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): @@ -71,9 +72,6 @@ class WirelessLANFilterForm(BootstrapMixin, CustomFieldModelFilterForm): class WirelessLinkFilterForm(BootstrapMixin, CustomFieldModelFilterForm): model = WirelessLink - field_groups = [ - ['q', 'tag'], - ] q = forms.CharField( required=False, widget=forms.TextInput(attrs={'placeholder': _('All Fields')}), From a3e7cab93597be622ca47ed72dddcff0953dfdc4 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 15:33:58 -0400 Subject: [PATCH 34/68] Split tenancy models into separate modules --- netbox/tenancy/models/__init__.py | 2 + .../tenancy/{models.py => models/contacts.py} | 100 +----------------- netbox/tenancy/models/tenants.py | 96 +++++++++++++++++ 3 files changed, 101 insertions(+), 97 deletions(-) create mode 100644 netbox/tenancy/models/__init__.py rename netbox/tenancy/{models.py => models/contacts.py} (66%) create mode 100644 netbox/tenancy/models/tenants.py diff --git a/netbox/tenancy/models/__init__.py b/netbox/tenancy/models/__init__.py new file mode 100644 index 000000000..6d62edd20 --- /dev/null +++ b/netbox/tenancy/models/__init__.py @@ -0,0 +1,2 @@ +from .contacts import * +from .tenants import * diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models/contacts.py similarity index 66% rename from netbox/tenancy/models.py rename to netbox/tenancy/models/contacts.py index 01ea2d0d5..2669aa121 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models/contacts.py @@ -1,116 +1,22 @@ -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse -from mptt.models import MPTTModel, TreeForeignKey +from mptt.models import TreeForeignKey from extras.utils import extras_features from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel +from tenancy.choices import * from utilities.querysets import RestrictedQuerySet -from .choices import * - __all__ = ( 'ContactAssignment', 'Contact', 'ContactGroup', 'ContactRole', - 'Tenant', - 'TenantGroup', ) -# -# Tenants -# - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class TenantGroup(NestedGroupModel): - """ - An arbitrary collection of Tenants. - """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - - class Meta: - ordering = ['name'] - - def get_absolute_url(self): - return reverse('tenancy:tenantgroup', args=[self.pk]) - - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Tenant(PrimaryModel): - """ - A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal - department. - """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - group = models.ForeignKey( - to='tenancy.TenantGroup', - on_delete=models.SET_NULL, - related_name='tenants', - blank=True, - null=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - comments = models.TextField( - blank=True - ) - - # Generic relations - contacts = GenericRelation( - to='tenancy.ContactAssignment' - ) - - objects = RestrictedQuerySet.as_manager() - - clone_fields = [ - 'group', 'description', - ] - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse('tenancy:tenant', args=[self.pk]) - - -# -# Contacts -# - @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ContactGroup(NestedGroupModel): """ diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py new file mode 100644 index 000000000..7dae2c093 --- /dev/null +++ b/netbox/tenancy/models/tenants.py @@ -0,0 +1,96 @@ +from django.contrib.contenttypes.fields import GenericRelation +from django.db import models +from django.urls import reverse +from mptt.models import TreeForeignKey + +from extras.utils import extras_features +from netbox.models import NestedGroupModel, PrimaryModel +from utilities.querysets import RestrictedQuerySet + +__all__ = ( + 'Tenant', + 'TenantGroup', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class TenantGroup(NestedGroupModel): + """ + An arbitrary collection of Tenants. + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + class Meta: + ordering = ['name'] + + def get_absolute_url(self): + return reverse('tenancy:tenantgroup', args=[self.pk]) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class Tenant(PrimaryModel): + """ + A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal + department. + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + group = models.ForeignKey( + to='tenancy.TenantGroup', + on_delete=models.SET_NULL, + related_name='tenants', + blank=True, + null=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + comments = models.TextField( + blank=True + ) + + # Generic relations + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + + objects = RestrictedQuerySet.as_manager() + + clone_fields = [ + 'group', 'description', + ] + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('tenancy:tenant', args=[self.pk]) From e1e2c76ae14b102a0db56997ff36ce43b2b5adce Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 16:30:18 -0400 Subject: [PATCH 35/68] Add bridge field to Interface, VMInterface models --- netbox/dcim/api/serializers.py | 5 +- netbox/dcim/api/views.py | 2 +- netbox/dcim/filtersets.py | 5 ++ netbox/dcim/forms/bulk_edit.py | 15 +++- netbox/dcim/forms/bulk_import.py | 38 ++------- netbox/dcim/forms/models.py | 18 ++-- netbox/dcim/forms/object_create.py | 9 +- netbox/dcim/migrations/0134_interface_wwn.py | 17 ---- .../migrations/0134_interface_wwn_bridge.py | 23 +++++ .../migrations/0135_tenancy_extensions.py | 2 +- netbox/dcim/models/device_components.py | 83 +++++++++++++------ netbox/dcim/tables/devices.py | 14 ++-- netbox/templates/dcim/interface.html | 10 +++ netbox/templates/dcim/interface_edit.html | 1 + .../templates/virtualization/vminterface.html | 10 +++ .../virtualization/vminterface_edit.html | 1 + netbox/virtualization/api/serializers.py | 5 +- netbox/virtualization/filtersets.py | 5 ++ netbox/virtualization/forms/bulk_edit.py | 14 +++- netbox/virtualization/forms/bulk_import.py | 14 +++- netbox/virtualization/forms/models.py | 10 ++- netbox/virtualization/forms/object_create.py | 9 +- .../migrations/0026_vminterface_bridge.py | 19 +++++ netbox/virtualization/models.py | 33 +++++--- netbox/virtualization/tables.py | 17 ++-- 25 files changed, 260 insertions(+), 119 deletions(-) delete mode 100644 netbox/dcim/migrations/0134_interface_wwn.py create mode 100644 netbox/dcim/migrations/0134_interface_wwn_bridge.py create mode 100644 netbox/virtualization/migrations/0026_vminterface_bridge.py diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index bc5e9b54e..1f2897a7f 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -605,6 +605,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices) parent = NestedInterfaceSerializer(required=False, allow_null=True) + bridge = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True) @@ -622,8 +623,8 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con class Meta: model = Interface fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', - 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', + 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 9cbdf7d5d..921ee3a99 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -544,7 +544,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', 'parent', 'lag', '_path__destination', 'cable', '_link_peer', 'ip_addresses', 'tags' + 'device', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', 'ip_addresses', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index e81bd5e43..c049025b7 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -975,6 +975,11 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT queryset=Interface.objects.all(), label='Parent interface (ID)', ) + bridge_id = django_filters.ModelMultipleChoiceFilter( + field_name='bridge', + queryset=Interface.objects.all(), + label='Bridged interface (ID)', + ) lag_id = django_filters.ModelMultipleChoiceFilter( field_name='lag', queryset=Interface.objects.all(), diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 9abdcb8ff..b1dce2281 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -939,8 +939,8 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', - 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', + 'label', 'type', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', + 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', ]), BootstrapMixin, AddRemoveTagsForm, @@ -964,6 +964,10 @@ class InterfaceBulkEditForm( queryset=Interface.objects.all(), required=False ) + bridge = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False + ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -991,7 +995,7 @@ class InterfaceBulkEditForm( class Meta: nullable_fields = [ - 'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', + 'label', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', ] @@ -1000,8 +1004,9 @@ class InterfaceBulkEditForm( if 'device' in self.initial: device = Device.objects.filter(pk=self.initial['device']).first() - # Restrict parent/LAG interface assignment by device + # Restrict parent/bridge/LAG interface assignment by device self.fields['parent'].widget.add_query_param('device_id', device.pk) + self.fields['bridge'].widget.add_query_param('device_id', device.pk) self.fields['lag'].widget.add_query_param('device_id', device.pk) # Limit VLAN choices by device @@ -1029,6 +1034,8 @@ class InterfaceBulkEditForm( self.fields['parent'].choices = () self.fields['parent'].widget.attrs['disabled'] = True + self.fields['bridge'].choices = () + self.fields['bridge'].widget.attrs['disabled'] = True self.fields['lag'].choices = () self.fields['lag'].widget.attrs['disabled'] = True diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index f39e3cd7f..18bdb3d3f 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -570,6 +570,12 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): to_field_name='name', help_text='Parent interface' ) + bridge = CSVModelChoiceField( + queryset=Interface.objects.all(), + required=False, + to_field_name='name', + help_text='Bridged interface' + ) lag = CSVModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -594,39 +600,11 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): class Meta: model = Interface fields = ( - 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'wwn', - 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', + 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', ) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit LAG choices to interfaces belonging to this device (or virtual chassis) - device = None - if self.is_bound and 'device' in self.data: - try: - device = self.fields['device'].to_python(self.data['device']) - except forms.ValidationError: - pass - if device and device.virtual_chassis: - self.fields['lag'].queryset = Interface.objects.filter( - Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis), - type=InterfaceTypeChoices.TYPE_LAG - ) - self.fields['parent'].queryset = Interface.objects.filter( - Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis) - ) - elif device: - self.fields['lag'].queryset = Interface.objects.filter( - device=device, - type=InterfaceTypeChoices.TYPE_LAG - ) - self.fields['parent'].queryset = Interface.objects.filter(device=device) - else: - self.fields['lag'].queryset = Interface.objects.none() - self.fields['parent'].queryset = Interface.objects.none() - def clean_enabled(self): # Make sure enabled is True when it's not included in the uploaded data if 'enabled' not in self.data: diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index e395c67d2..a2b5e7dba 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1093,6 +1093,11 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): required=False, label='Parent interface' ) + bridge = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='Bridged interface' + ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -1143,8 +1148,8 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', - 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', + 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { @@ -1168,13 +1173,14 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device - # Restrict parent/LAG interface assignment by device/VC + # Restrict parent/bridge/LAG interface assignment by device/VC self.fields['parent'].widget.add_query_param('device_id', device.pk) + self.fields['bridge'].widget.add_query_param('device_id', device.pk) + self.fields['lag'].widget.add_query_param('device_id', device.pk) if device.virtual_chassis and device.virtual_chassis.master: - # Get available LAG interfaces by VirtualChassis master + self.fields['parent'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) + self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) - else: - self.fields['lag'].widget.add_query_param('device_id', device.pk) # Limit VLAN choices by device self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 547fe7e68..3beb42c8d 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -446,6 +446,13 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): 'device_id': '$device', } ) + bridge = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } + ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -497,7 +504,7 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): required=False ) field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', + 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' ) diff --git a/netbox/dcim/migrations/0134_interface_wwn.py b/netbox/dcim/migrations/0134_interface_wwn.py deleted file mode 100644 index 0739edbbb..000000000 --- a/netbox/dcim/migrations/0134_interface_wwn.py +++ /dev/null @@ -1,17 +0,0 @@ -import dcim.fields -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('dcim', '0133_port_colors'), - ] - - operations = [ - migrations.AddField( - model_name='interface', - name='wwn', - field=dcim.fields.WWNField(blank=True, null=True), - ), - ] diff --git a/netbox/dcim/migrations/0134_interface_wwn_bridge.py b/netbox/dcim/migrations/0134_interface_wwn_bridge.py new file mode 100644 index 000000000..a900ae6be --- /dev/null +++ b/netbox/dcim/migrations/0134_interface_wwn_bridge.py @@ -0,0 +1,23 @@ +import dcim.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0133_port_colors'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='wwn', + field=dcim.fields.WWNField(blank=True, null=True), + ), + migrations.AddField( + model_name='interface', + name='bridge', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='dcim.interface'), + ), + ] diff --git a/netbox/dcim/migrations/0135_tenancy_extensions.py b/netbox/dcim/migrations/0135_tenancy_extensions.py index 673b5027f..96d765eea 100644 --- a/netbox/dcim/migrations/0135_tenancy_extensions.py +++ b/netbox/dcim/migrations/0135_tenancy_extensions.py @@ -6,7 +6,7 @@ class Migration(migrations.Migration): dependencies = [ ('tenancy', '0002_tenant_ordering'), - ('dcim', '0134_interface_wwn'), + ('dcim', '0134_interface_wwn_bridge'), ] operations = [ diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index c2a37fcae..2a6adfa0c 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -462,6 +462,22 @@ class BaseInterface(models.Model): choices=InterfaceModeChoices, blank=True ) + parent = models.ForeignKey( + to='self', + on_delete=models.SET_NULL, + related_name='child_interfaces', + null=True, + blank=True, + verbose_name='Parent interface' + ) + bridge = models.ForeignKey( + to='self', + on_delete=models.SET_NULL, + related_name='bridge_interfaces', + null=True, + blank=True, + verbose_name='Bridge interface' + ) class Meta: abstract = True @@ -495,14 +511,6 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): max_length=100, blank=True ) - parent = models.ForeignKey( - to='self', - on_delete=models.SET_NULL, - related_name='child_interfaces', - null=True, - blank=True, - verbose_name='Parent interface' - ) lag = models.ForeignKey( to='self', on_delete=models.SET_NULL, @@ -586,7 +594,7 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): related_query_name='interface' ) - clone_fields = ['device', 'parent', 'lag', 'type', 'mgmt_only'] + clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only'] class Meta: ordering = ('device', CollateAsChar('_name')) @@ -610,6 +618,16 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): 'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected." }) + # Parent validation + + # An interface cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({'parent': "An interface cannot be its own parent."}) + + # A physical interface cannot have a parent interface + if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None: + raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."}) + # An interface's parent must belong to the same device or virtual chassis if self.parent and self.parent.device != self.device: if self.device.virtual_chassis is None: @@ -623,13 +641,34 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): f"is not part of virtual chassis {self.device.virtual_chassis}." }) - # An interface cannot be its own parent - if self.pk and self.parent_id == self.pk: - raise ValidationError({'parent': "An interface cannot be its own parent."}) + # Bridge validation - # A physical interface cannot have a parent interface - if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None: - raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."}) + # An interface cannot be bridged to itself + if self.pk and self.bridge_id == self.pk: + raise ValidationError({'bridge': "An interface cannot be bridged to itself."}) + + # A bridged interface belong to the same device or virtual chassis + if self.bridge and self.bridge.device != self.device: + if self.device.virtual_chassis is None: + raise ValidationError({ + 'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different device " + f"({self.bridge.device})." + }) + elif self.bridge.device.virtual_chassis != self.device.virtual_chassis: + raise ValidationError({ + 'bridge': f"The selected bridge interface ({self.bridge}) belongs to {self.bridge.device}, which " + f"is not part of virtual chassis {self.device.virtual_chassis}." + }) + + # LAG validation + + # A virtual interface cannot have a parent LAG + if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None: + raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."}) + + # A LAG interface cannot be its own parent + if self.pk and self.lag_id == self.pk: + raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) # An interface's LAG must belong to the same device or virtual chassis if self.lag and self.lag.device != self.device: @@ -643,13 +682,7 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): f"of virtual chassis {self.device.virtual_chassis}." }) - # A virtual interface cannot have a parent LAG - if self.type == InterfaceTypeChoices.TYPE_VIRTUAL and self.lag is not None: - raise ValidationError({'lag': "Virtual interfaces cannot have a parent LAG interface."}) - - # A LAG interface cannot be its own parent - if self.pk and self.lag_id == self.pk: - raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) + # Wireless validation # RF role & channel may only be set for wireless interfaces if self.rf_role and not self.is_wireless: @@ -679,11 +712,13 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): elif self.rf_channel: self.rf_channel_width = get_channel_attr(self.rf_channel, 'width') + # VLAN validation + # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: raise ValidationError({ - 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent " - "device, or it must be global".format(self.untagged_vlan) + 'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the " + f"interface's parent device, or it must be global." }) @property diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 06c594f6b..8ea27b8a6 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -521,8 +521,10 @@ class DeviceInterfaceTable(InterfaceTable): attrs={'td': {'class': 'text-nowrap'}} ) parent = tables.Column( - linkify=True, - verbose_name='Parent' + linkify=True + ) + bridge = tables.Column( + linkify=True ) lag = tables.Column( linkify=True, @@ -537,10 +539,10 @@ class DeviceInterfaceTable(InterfaceTable): class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color', - 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', - 'tagged_vlans', 'actions', + 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode', + 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', + 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', + 'untagged_vlan', 'tagged_vlans', 'actions', ) order_by = ('name',) default_columns = ( diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 730720b42..eb47f5655 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -69,6 +69,16 @@ {% endif %} + + Bridge + + {% if object.bridge %} + {{ object.bridge }} + {% else %} + None + {% endif %} + + LAG diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index aec88d25a..2afa0a7b6 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -18,6 +18,7 @@ {% render_field form.label %} {% render_field form.type %} {% render_field form.parent %} + {% render_field form.bridge %} {% render_field form.lag %} {% render_field form.mac_address %} {% render_field form.wwn %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index 1678013f2..2646686e8 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -47,6 +47,16 @@ {% endif %} + + Bridge + + {% if object.bridge %} + {{ object.bridge }} + {% else %} + None + {% endif %} + + Description {{ object.description|placeholder }} diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index b4d097513..824f2bf24 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -17,6 +17,7 @@ {% render_field form.name %} {% render_field form.enabled %} {% render_field form.parent %} + {% render_field form.bridge %} {% render_field form.mac_address %} {% render_field form.mtu %} {% render_field form.description %} diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index ef8c975d3..6cdc0e09a 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -107,6 +107,7 @@ class VMInterfaceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail') virtual_machine = NestedVirtualMachineSerializer() parent = NestedVMInterfaceSerializer(required=False, allow_null=True) + bridge = NestedVMInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( @@ -120,8 +121,8 @@ class VMInterfaceSerializer(PrimaryModelSerializer): class Meta: model = VMInterface fields = [ - 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'mtu', 'mac_address', 'description', - 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', + 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', ] diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index e2aac9b80..dc084a67f 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -264,6 +264,11 @@ class VMInterfaceFilterSet(PrimaryModelFilterSet): queryset=VMInterface.objects.all(), label='Parent interface (ID)', ) + bridge_id = django_filters.ModelMultipleChoiceFilter( + field_name='bridge', + queryset=VMInterface.objects.all(), + label='Bridged interface (ID)', + ) mac_address = MultiValueMACAddressFilter( label='MAC address', ) diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index d18d432cd..d6c190904 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -165,6 +165,10 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode queryset=VMInterface.objects.all(), required=False ) + bridge = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False + ) enabled = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect() @@ -195,7 +199,7 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode class Meta: nullable_fields = [ - 'parent', 'mtu', 'description', + 'parent', 'bridge', 'mtu', 'description', ] def __init__(self, *args, **kwargs): @@ -203,8 +207,9 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode if 'virtual_machine' in self.initial: vm_id = self.initial.get('virtual_machine') - # Restrict parent interface assignment by VM + # Restrict parent/bridge interface assignment by VM self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) + self.fields['bridge'].widget.add_query_param('virtual_machine_id', vm_id) # Limit VLAN choices by virtual machine self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) @@ -231,6 +236,11 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + self.fields['parent'].choices = () + self.fields['parent'].widget.attrs['disabled'] = True + self.fields['bridge'].choices = () + self.fields['bridge'].widget.attrs['disabled'] = True + class VMInterfaceBulkRenameForm(BulkRenameForm): pk = forms.ModelMultipleChoiceField( diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index d01418aa0..bd3279959 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -104,6 +104,18 @@ class VMInterfaceCSVForm(CustomFieldModelCSVForm): queryset=VirtualMachine.objects.all(), to_field_name='name' ) + parent = CSVModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + to_field_name='name', + help_text='Parent interface' + ) + bridge = CSVModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + to_field_name='name', + help_text='Bridged interface' + ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, @@ -113,7 +125,7 @@ class VMInterfaceCSVForm(CustomFieldModelCSVForm): class Meta: model = VMInterface fields = ( - 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', + 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', ) def clean_enabled(self): diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 88ebc9e83..7fa5b0fa6 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -277,6 +277,11 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) required=False, label='Parent interface' ) + bridge = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + label='Bridged interface' + ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, @@ -306,8 +311,8 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) class Meta: model = VMInterface fields = [ - 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'description', 'mode', 'tags', - 'untagged_vlan', 'tagged_vlans', + 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', + 'tags', 'untagged_vlan', 'tagged_vlans', ] widgets = { 'virtual_machine': forms.HiddenInput(), @@ -326,6 +331,7 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm) # Restrict parent interface assignment by VM self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) + self.fields['bridge'].widget.add_query_param('virtual_machine_id', vm_id) # Limit VLAN choices by virtual machine self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py index b58fb51f8..332334594 100644 --- a/netbox/virtualization/forms/object_create.py +++ b/netbox/virtualization/forms/object_create.py @@ -35,6 +35,13 @@ class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonFo 'virtual_machine_id': '$virtual_machine', } ) + bridge = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + query_params={ + 'virtual_machine_id': '$virtual_machine', + } + ) mac_address = forms.CharField( required=False, label='MAC Address' @@ -61,7 +68,7 @@ class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonFo required=False ) field_order = ( - 'virtual_machine', 'name_pattern', 'enabled', 'parent', 'mtu', 'mac_address', 'description', 'mode', + 'virtual_machine', 'name_pattern', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' ) diff --git a/netbox/virtualization/migrations/0026_vminterface_bridge.py b/netbox/virtualization/migrations/0026_vminterface_bridge.py new file mode 100644 index 000000000..04909c72c --- /dev/null +++ b/netbox/virtualization/migrations/0026_vminterface_bridge.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.8 on 2021-10-21 20:26 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0025_extend_tag_support'), + ] + + operations = [ + migrations.AddField( + model_name='vminterface', + name='bridge', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bridge_interfaces', to='virtualization.vminterface'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index bd64f56cf..c614618c0 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -378,14 +378,6 @@ class VMInterface(PrimaryModel, BaseInterface): max_length=200, blank=True ) - parent = models.ForeignKey( - to='self', - on_delete=models.SET_NULL, - related_name='child_interfaces', - null=True, - blank=True, - verbose_name='Parent interface' - ) untagged_vlan = models.ForeignKey( to='ipam.VLAN', on_delete=models.SET_NULL, @@ -423,6 +415,12 @@ class VMInterface(PrimaryModel, BaseInterface): def clean(self): super().clean() + # Parent validation + + # An interface cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({'parent': "An interface cannot be its own parent."}) + # An interface's parent must belong to the same virtual machine if self.parent and self.parent.virtual_machine != self.virtual_machine: raise ValidationError({ @@ -430,15 +428,26 @@ class VMInterface(PrimaryModel, BaseInterface): f"({self.parent.virtual_machine})." }) - # An interface cannot be its own parent - if self.pk and self.parent_id == self.pk: - raise ValidationError({'parent': "An interface cannot be its own parent."}) + # Bridge validation + + # An interface cannot be bridged to itself + if self.pk and self.bridge_id == self.pk: + raise ValidationError({'bridge': "An interface cannot be bridged to itself."}) + + # A bridged interface belong to the same virtual machine + if self.bridge and self.bridge.virtual_machine != self.virtual_machine: + raise ValidationError({ + 'bridge': f"The selected bridge interface ({self.bridge}) belongs to a different virtual machine " + f"({self.bridge.virtual_machine})." + }) + + # VLAN validation # Validate untagged VLAN if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]: raise ValidationError({ 'untagged_vlan': f"The untagged VLAN ({self.untagged_vlan}) must belong to the same site as the " - f"interface's parent virtual machine, or it must be global" + f"interface's parent virtual machine, or it must be global." }) def to_objectchange(self, action): diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 64b376e1d..56ad88f1f 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -166,9 +166,6 @@ class VMInterfaceTable(BaseInterfaceTable): name = tables.Column( linkify=True ) - parent = tables.Column( - linkify=True - ) tags = TagColumn( url_name='virtualization:vminterface_list' ) @@ -176,13 +173,19 @@ class VMInterfaceTable(BaseInterfaceTable): class Meta(BaseTable.Meta): model = VMInterface fields = ( - 'pk', 'name', 'virtual_machine', 'enabled', 'parent', 'mac_address', 'mtu', 'mode', 'description', 'tags', + 'pk', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', ) - default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'parent', 'description') + default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') class VirtualMachineVMInterfaceTable(VMInterfaceTable): + parent = tables.Column( + linkify=True + ) + bridge = tables.Column( + linkify=True + ) actions = ButtonsColumn( model=VMInterface, buttons=('edit', 'delete'), @@ -192,8 +195,8 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): class Meta(BaseTable.Meta): model = VMInterface fields = ( - 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', 'ip_addresses', - 'untagged_vlan', 'tagged_vlans', 'actions', + 'pk', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', + 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', ) default_columns = ( 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions', From 5193fa64838720e5d0ed4e67ec15868c16457353 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 16:57:01 -0400 Subject: [PATCH 36/68] Add tests for #6346 --- netbox/dcim/tests/test_api.py | 3 ++- netbox/dcim/tests/test_filtersets.py | 13 +++++++++++++ netbox/dcim/tests/test_views.py | 4 +++- netbox/virtualization/tests/test_api.py | 3 ++- netbox/virtualization/tests/test_filtersets.py | 13 +++++++++++++ netbox/virtualization/tests/test_views.py | 5 ++++- 6 files changed, 37 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index e5977b760..b3f182ce7 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1206,6 +1206,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'name': 'Interface 5', 'type': '1000base-t', 'mode': InterfaceModeChoices.MODE_TAGGED, + 'bridge': interfaces[0].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, @@ -1214,7 +1215,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'name': 'Interface 6', 'type': 'virtual', 'mode': InterfaceModeChoices.MODE_TAGGED, - 'parent': interfaces[0].pk, + 'parent': interfaces[1].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f66ceb855..51cfafaf2 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2125,6 +2125,19 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'parent_id': [parent_interface.pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_bridge(self): + # Create bridged interfaces + bridge_interface = Interface.objects.first() + bridged_interfaces = ( + Interface(device=bridge_interface.device, name='Bridged 1', bridge=bridge_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=bridge_interface.device, name='Bridged 2', bridge=bridge_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=bridge_interface.device, name='Bridged 3', bridge=bridge_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + ) + Interface.objects.bulk_create(bridged_interfaces) + + params = {'bridge_id': [bridge_interface.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_lag(self): # Create LAG members device = Device.objects.first() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index c08eb6e8a..92757f28d 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1581,6 +1581,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): Interface(device=device, name='Interface 2'), Interface(device=device, name='Interface 3'), Interface(device=device, name='LAG', type=InterfaceTypeChoices.TYPE_LAG), + Interface(device=device, name='_BRIDGE', type=InterfaceTypeChoices.TYPE_VIRTUAL), # Must be ordered last ) Interface.objects.bulk_create(interfaces) @@ -1596,10 +1597,10 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.form_data = { 'device': device.pk, - 'virtual_machine': None, 'name': 'Interface X', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'enabled': False, + 'bridge': interfaces[4].pk, 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), @@ -1617,6 +1618,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'name_pattern': 'Interface [4-6]', 'type': InterfaceTypeChoices.TYPE_1GE_GBIC, 'enabled': False, + 'bridge': interfaces[4].pk, 'lag': interfaces[3].pk, 'mac_address': EUI('01:02:03:04:05:06'), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 3245fb9bf..4a9b67bf0 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -246,14 +246,15 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): 'virtual_machine': virtualmachine.pk, 'name': 'Interface 5', 'mode': InterfaceModeChoices.MODE_TAGGED, + 'bridge': interfaces[0].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, { 'virtual_machine': virtualmachine.pk, 'name': 'Interface 6', - 'parent': interfaces[0].pk, 'mode': InterfaceModeChoices.MODE_TAGGED, + 'parent': interfaces[1].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 0ca6364a5..a74ccc4d9 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -452,6 +452,19 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'parent_id': [parent_interface.pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_bridge(self): + # Create bridged interfaces + bridge_interface = VMInterface.objects.first() + bridged_interfaces = ( + VMInterface(virtual_machine=bridge_interface.virtual_machine, name='Bridged 1', bridge=bridge_interface), + VMInterface(virtual_machine=bridge_interface.virtual_machine, name='Bridged 2', bridge=bridge_interface), + VMInterface(virtual_machine=bridge_interface.virtual_machine, name='Bridged 3', bridge=bridge_interface), + ) + VMInterface.objects.bulk_create(bridged_interfaces) + + params = {'bridge_id': [bridge_interface.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_mtu(self): params = {'mtu': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 138b1afae..7dc5660fd 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -248,10 +248,11 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): ) VirtualMachine.objects.bulk_create(virtualmachines) - VMInterface.objects.bulk_create([ + interfaces = VMInterface.objects.bulk_create([ VMInterface(virtual_machine=virtualmachines[0], name='Interface 1'), VMInterface(virtual_machine=virtualmachines[0], name='Interface 2'), VMInterface(virtual_machine=virtualmachines[0], name='Interface 3'), + VMInterface(virtual_machine=virtualmachines[1], name='BRIDGE'), ]) vlans = ( @@ -268,6 +269,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'virtual_machine': virtualmachines[1].pk, 'name': 'Interface X', 'enabled': False, + 'bridge': interfaces[3].pk, 'mac_address': EUI('01-02-03-04-05-06'), 'mtu': 65000, 'description': 'New description', @@ -281,6 +283,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'virtual_machine': virtualmachines[1].pk, 'name_pattern': 'Interface [4-6]', 'enabled': False, + 'bridge': interfaces[3].pk, 'mac_address': EUI('01-02-03-04-05-06'), 'mtu': 2000, 'description': 'New description', From e96f5447f42e72738575c705f039f61fde592587 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 21 Oct 2021 17:03:21 -0400 Subject: [PATCH 37/68] Changelog for #6346 --- docs/release-notes/version-3.1.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 88142bf78..f586f43bb 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -7,6 +7,8 @@ * The `tenant` and `tenant_id` filters for the Cable model now filter on the tenant assigned directly to each cable, rather than on the parent object of either termination. +### New Features + #### Contacts ([#1344](https://github.com/netbox-community/netbox/issues/1344)) A set of new models for tracking contact information has been introduced within the tenancy app. Users may now create individual contact objects to be associated with various models within NetBox. Each contact has a name, title, email address, etc. Contacts can be arranged in hierarchical groups for ease of management. @@ -26,6 +28,12 @@ Both types of connection include SSID and authentication attributes. Additionall * Channel - A predefined channel within a standardized band * Channel frequency & width - Customizable channel attributes (e.g. for licensed bands) +#### Interface Bridging ([#6346](https://github.com/netbox-community/netbox/issues/6346)) + +A `bridge` field has been added to the interface model for devices and virtual machines. This can be set to reference another interface on the same parent device/VM to indicate a direct layer two bridging adjacency. + +Multiple interfaces can be bridged to a single virtual interface to effect a bridge group. Alternatively, two physical interfaces can be bridged to one another, to effect an internal cross-connect. + ### Enhancements * [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces @@ -73,6 +81,9 @@ Both types of connection include SSID and authentication attributes. Additionall * dcim.DeviceType * Added `airflow` field * dcim.Interface + * Added `bridge` field * Added `wwn` field * dcim.Location * Added `tenant` field +* virtualization.VMInterface + * Added `bridge` field From 7e26d921901077522a6f0fa4c4755c26280a1a7d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 22 Oct 2021 16:27:08 -0400 Subject: [PATCH 38/68] Introduce conditions & condition sets --- netbox/extras/conditions.py | 122 +++++++++++++++++++ netbox/extras/tests/test_conditions.py | 160 +++++++++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 netbox/extras/conditions.py create mode 100644 netbox/extras/tests/test_conditions.py diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py new file mode 100644 index 000000000..6aa6e776f --- /dev/null +++ b/netbox/extras/conditions.py @@ -0,0 +1,122 @@ +import functools + +__all__ = ( + 'Condition', + 'ConditionSet', +) + + +LOGIC_TYPES = ( + 'and', + 'or' +) + + +def is_ruleset(data): + """ + Determine whether the given dictionary looks like a rule set. + """ + return type(data) is dict and len(data) == 1 and list(data.keys())[0] in LOGIC_TYPES + + +class Condition: + """ + An individual conditional rule that evaluates a single attribute and its value. + + :param attr: The name of the attribute being evaluated + :param value: The value being compared + :param op: The logical operation to use when evaluating the value (default: 'eq') + """ + EQ = 'eq' + NEQ = 'neq' + GT = 'gt' + GTE = 'gte' + LT = 'lt' + LTE = 'lte' + IN = 'in' + CONTAINS = 'contains' + + OPERATORS = ( + EQ, NEQ, GT, GTE, LT, LTE, IN, CONTAINS + ) + + def __init__(self, attr, value, op=EQ): + self.attr = attr + self.value = value + if op not in self.OPERATORS: + raise ValueError(f"Unknown operator: {op}") + self.eval_func = getattr(self, f'eval_{op}') + + def eval(self, data): + """ + Evaluate the provided data to determine whether it matches the condition. + """ + value = functools.reduce(dict.get, self.attr.split('.'), data) + return self.eval_func(value) + + # Equivalency + + def eval_eq(self, value): + return value == self.value + + def eval_neq(self, value): + return value != self.value + + # Numeric comparisons + + def eval_gt(self, value): + return value > self.value + + def eval_gte(self, value): + return value >= self.value + + def eval_lt(self, value): + return value < self.value + + def eval_lte(self, value): + return value <= self.value + + # Membership + + def eval_in(self, value): + return value in self.value + + def eval_contains(self, value): + return self.value in value + + +class ConditionSet: + """ + A set of one or more Condition to be evaluated per the prescribed logic (AND or OR). Example: + + {"and": [ + {"attr": "foo", "op": "eq", "value": 1}, + {"attr": "bar", "op": "neq", "value": 2} + ]} + + :param ruleset: A dictionary mapping a logical operator to a list of conditional rules + """ + def __init__(self, ruleset): + if type(ruleset) is not dict: + raise ValueError(f"Ruleset must be a dictionary, not {type(ruleset)}.") + if len(ruleset) != 1: + raise ValueError(f"Ruleset must have exactly one logical operator (found {len(ruleset)})") + + # Determine the logic type + logic = list(ruleset.keys())[0] + if type(logic) is not str or logic.lower() not in LOGIC_TYPES: + raise ValueError(f"Invalid logic type: {logic} (must be 'and' or 'or')") + self.logic = logic.lower() + + # Compile the set of Conditions + self.conditions = [ + ConditionSet(rule) if is_ruleset(rule) else Condition(**rule) + for rule in ruleset[self.logic] + ] + + def eval(self, data): + """ + Evaluate the provided data to determine whether it matches this set of conditions. + """ + func = any if self.logic == 'or' else all + return func(d.eval(data) for d in self.conditions) diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py new file mode 100644 index 000000000..7defca5b5 --- /dev/null +++ b/netbox/extras/tests/test_conditions.py @@ -0,0 +1,160 @@ +from django.test import TestCase + +from extras.conditions import Condition, ConditionSet + + +class ConditionTestCase(TestCase): + + def test_dotted_path_access(self): + c = Condition('a.b.c', 1, 'eq') + self.assertTrue(c.eval({'a': {'b': {'c': 1}}})) + self.assertFalse(c.eval({'a': {'b': {'c': 2}}})) + self.assertFalse(c.eval({'a': {'b': {'x': 1}}})) + + def test_undefined_attr(self): + c = Condition('x', 1, 'eq') + self.assertFalse(c.eval({})) + self.assertTrue(c.eval({'x': 1})) + + # + # Operator tests + # + + def test_default_operator(self): + c = Condition('x', 1) + self.assertEqual(c.eval_func, c.eval_eq) + + def test_eq(self): + c = Condition('x', 1, 'eq') + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 2})) + + def test_neq(self): + c = Condition('x', 1, 'neq') + self.assertFalse(c.eval({'x': 1})) + self.assertTrue(c.eval({'x': 2})) + + def test_gt(self): + c = Condition('x', 1, 'gt') + self.assertTrue(c.eval({'x': 2})) + self.assertFalse(c.eval({'x': 1})) + + def test_gte(self): + c = Condition('x', 1, 'gte') + self.assertTrue(c.eval({'x': 2})) + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 0})) + + def test_lt(self): + c = Condition('x', 2, 'lt') + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 2})) + + def test_lte(self): + c = Condition('x', 2, 'lte') + self.assertTrue(c.eval({'x': 1})) + self.assertTrue(c.eval({'x': 2})) + self.assertFalse(c.eval({'x': 3})) + + def test_in(self): + c = Condition('x', [1, 2, 3], 'in') + self.assertTrue(c.eval({'x': 1})) + self.assertFalse(c.eval({'x': 9})) + + def test_contains(self): + c = Condition('x', 1, 'contains') + self.assertTrue(c.eval({'x': [1, 2, 3]})) + self.assertFalse(c.eval({'x': [2, 3, 4]})) + + +class ConditionSetTest(TestCase): + + def test_empty(self): + with self.assertRaises(ValueError): + ConditionSet({}) + + def test_invalid_logic(self): + with self.assertRaises(ValueError): + ConditionSet({'foo': []}) + + def test_and_single_depth(self): + cs = ConditionSet({ + 'and': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'attr': 'b', 'value': 2, 'op': 'eq'}, + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2})) + self.assertFalse(cs.eval({'a': 1, 'b': 3})) + + def test_or_single_depth(self): + cs = ConditionSet({ + 'or': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'attr': 'b', 'value': 1, 'op': 'eq'}, + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2})) + self.assertTrue(cs.eval({'a': 2, 'b': 1})) + self.assertFalse(cs.eval({'a': 2, 'b': 2})) + + def test_and_multi_depth(self): + cs = ConditionSet({ + 'and': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'and': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 3})) + self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 3})) + self.assertFalse(cs.eval({'a': 1, 'b': 9, 'c': 3})) + self.assertFalse(cs.eval({'a': 1, 'b': 2, 'c': 9})) + + def test_or_multi_depth(self): + cs = ConditionSet({ + 'or': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'or': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 9, 'c': 9})) + self.assertTrue(cs.eval({'a': 9, 'b': 2, 'c': 9})) + self.assertTrue(cs.eval({'a': 9, 'b': 9, 'c': 3})) + self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 9})) + + def test_mixed_and(self): + cs = ConditionSet({ + 'and': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'or': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9})) + self.assertTrue(cs.eval({'a': 1, 'b': 9, 'c': 3})) + self.assertFalse(cs.eval({'a': 1, 'b': 9, 'c': 9})) + self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 3})) + + def test_mixed_or(self): + cs = ConditionSet({ + 'or': [ + {'attr': 'a', 'value': 1, 'op': 'eq'}, + {'and': [ + {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'c', 'value': 3, 'op': 'eq'}, + ]} + ] + }) + self.assertTrue(cs.eval({'a': 1, 'b': 9, 'c': 9})) + self.assertTrue(cs.eval({'a': 9, 'b': 2, 'c': 3})) + self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9})) + self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 9})) + self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3})) From 78ecc8673ca71bc6db3dddf065ca4203bc224740 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 22 Oct 2021 17:15:08 -0400 Subject: [PATCH 39/68] Add conditions for webhooks --- netbox/extras/api/serializers.py | 2 +- netbox/extras/forms/bulk_edit.py | 2 +- netbox/extras/forms/models.py | 1 + .../migrations/0063_webhook_conditions.py | 18 +++++++++++++ netbox/extras/models/models.py | 18 ++++++++++--- netbox/extras/tests/test_views.py | 1 + netbox/extras/webhooks_worker.py | 27 ++++++++++--------- 7 files changed, 51 insertions(+), 18 deletions(-) create mode 100644 netbox/extras/migrations/0063_webhook_conditions.py diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index b2049e836..46d295195 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -61,7 +61,7 @@ class WebhookSerializer(ValidatedModelSerializer): fields = [ 'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', - 'ssl_verification', 'ca_file_path', + 'conditions', 'ssl_verification', 'ca_file_path', ] diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index b85a74a5b..937814c5a 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -137,7 +137,7 @@ class WebhookBulkEditForm(BootstrapMixin, BulkEditForm): ) class Meta: - nullable_fields = ['secret', 'ca_file_path'] + nullable_fields = ['secret', 'conditions', 'ca_file_path'] class TagBulkEditForm(BootstrapMixin, BulkEditForm): diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 7e462e62b..23f4872c2 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -102,6 +102,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm): ('HTTP Request', ( 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', )), + ('Conditions', ('conditions',)), ('SSL', ('ssl_verification', 'ca_file_path')), ) widgets = { diff --git a/netbox/extras/migrations/0063_webhook_conditions.py b/netbox/extras/migrations/0063_webhook_conditions.py new file mode 100644 index 000000000..8cc5b1bd3 --- /dev/null +++ b/netbox/extras/migrations/0063_webhook_conditions.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.8 on 2021-10-22 20:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0062_clear_secrets_changelog'), + ] + + operations = [ + migrations.AddField( + model_name='webhook', + name='conditions', + field=models.JSONField(blank=True, null=True), + ), + ] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 75f5242d3..43af19f82 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -9,11 +9,12 @@ from django.db import models from django.http import HttpResponse from django.urls import reverse from django.utils import timezone -from django.utils.formats import date_format, time_format +from django.utils.formats import date_format from rest_framework.utils.encoders import JSONEncoder from extras.choices import * from extras.constants import * +from extras.conditions import ConditionSet from extras.utils import extras_features, FeatureQuery, image_upload from netbox.models import BigIDModel, ChangeLoggedModel from utilities.querysets import RestrictedQuerySet @@ -107,6 +108,11 @@ class Webhook(ChangeLoggedModel): "the secret as the key. The secret is not transmitted in " "the request." ) + conditions = models.JSONField( + blank=True, + null=True, + help_text="A set of conditions which determine whether the webhook will be generated." + ) ssl_verification = models.BooleanField( default=True, verbose_name='SSL verification', @@ -138,9 +144,13 @@ class Webhook(ChangeLoggedModel): # At least one action type must be selected if not self.type_create and not self.type_delete and not self.type_update: - raise ValidationError( - "You must select at least one type: create, update, and/or delete." - ) + raise ValidationError("At least one type must be selected: create, update, and/or delete.") + + if self.conditions: + try: + ConditionSet(self.conditions) + except ValueError as e: + raise ValidationError({'conditions': e}) # CA file path requires SSL verification enabled if not self.ssl_verification and self.ca_file_path: diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 72d965fd0..9ce324a5c 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -145,6 +145,7 @@ class WebhookTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'payload_url': 'http://example.com/?x', 'http_method': 'GET', 'http_content_type': 'application/foo', + 'conditions': None, } cls.csv_data = ( diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index ce63e14ce..6bbfba907 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -6,6 +6,7 @@ from django_rq import job from jinja2.exceptions import TemplateError from .choices import ObjectChangeActionChoices +from .conditions import ConditionSet from .webhooks import generate_signature logger = logging.getLogger('netbox.webhooks_worker') @@ -16,6 +17,12 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user """ Make a POST request to the defined Webhook """ + # Evaluate webhook conditions (if any) + if webhook.conditions: + if not ConditionSet(webhook.conditions).eval(data): + return + + # Prepare context data for headers & body templates context = { 'event': dict(ObjectChangeActionChoices)[event].lower(), 'timestamp': timestamp, @@ -33,14 +40,14 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user try: headers.update(webhook.render_headers(context)) except (TemplateError, ValueError) as e: - logger.error("Error parsing HTTP headers for webhook {}: {}".format(webhook, e)) + logger.error(f"Error parsing HTTP headers for webhook {webhook}: {e}") raise e # Render the request body try: body = webhook.render_body(context) except TemplateError as e: - logger.error("Error rendering request body for webhook {}: {}".format(webhook, e)) + logger.error(f"Error rendering request body for webhook {webhook}: {e}") raise e # Prepare the HTTP request @@ -51,15 +58,13 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user 'data': body.encode('utf8'), } logger.info( - "Sending {} request to {} ({} {})".format( - params['method'], params['url'], context['model'], context['event'] - ) + f"Sending {params['method']} request to {params['url']} ({context['model']} {context['event']})" ) logger.debug(params) try: prepared_request = requests.Request(**params).prepare() except requests.exceptions.RequestException as e: - logger.error("Error forming HTTP request: {}".format(e)) + logger.error(f"Error forming HTTP request: {e}") raise e # If a secret key is defined, sign the request with a hash of the key and its content @@ -74,12 +79,10 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user response = session.send(prepared_request, proxies=settings.HTTP_PROXIES) if 200 <= response.status_code <= 299: - logger.info("Request succeeded; response status {}".format(response.status_code)) - return 'Status {} returned, webhook successfully processed.'.format(response.status_code) + logger.info(f"Request succeeded; response status {response.status_code}") + return f"Status {response.status_code} returned, webhook successfully processed." else: - logger.warning("Request failed; response status {}: {}".format(response.status_code, response.content)) + logger.warning(f"Request failed; response status {response.status_code}: {response.content}") raise requests.exceptions.RequestException( - "Status {} returned with content '{}', webhook FAILED to process.".format( - response.status_code, response.content - ) + f"Status {response.status_code} returned with content '{response.content}', webhook FAILED to process." ) From b92de63245e186b54b8507e11804ffc1e275ce42 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 08:56:20 -0400 Subject: [PATCH 40/68] Improve validation --- netbox/extras/conditions.py | 17 +++++++++++++++-- netbox/extras/tests/test_conditions.py | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py index 6aa6e776f..050d5564c 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -40,11 +40,24 @@ class Condition: EQ, NEQ, GT, GTE, LT, LTE, IN, CONTAINS ) + TYPES = { + str: (EQ, NEQ, CONTAINS), + bool: (EQ, NEQ, CONTAINS), + int: (EQ, NEQ, GT, GTE, LT, LTE, CONTAINS), + float: (EQ, NEQ, GT, GTE, LT, LTE, CONTAINS), + list: (EQ, NEQ, IN, CONTAINS) + } + def __init__(self, attr, value, op=EQ): + if op not in self.OPERATORS: + raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}") + if type(value) not in self.TYPES: + raise ValueError(f"Unsupported value type: {type(value)}") + if op not in self.TYPES[type(value)]: + raise ValueError(f"Invalid type for {op} operation: {type(value)}") + self.attr = attr self.value = value - if op not in self.OPERATORS: - raise ValueError(f"Unknown operator: {op}") self.eval_func = getattr(self, f'eval_{op}') def eval(self, data): diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py index 7defca5b5..2ce55c064 100644 --- a/netbox/extras/tests/test_conditions.py +++ b/netbox/extras/tests/test_conditions.py @@ -16,6 +16,25 @@ class ConditionTestCase(TestCase): self.assertFalse(c.eval({})) self.assertTrue(c.eval({'x': 1})) + # + # Validation tests + # + + def test_invalid_op(self): + with self.assertRaises(ValueError): + # 'blah' is not a valid operator + Condition('x', 1, 'blah') + + def test_invalid_type(self): + with self.assertRaises(ValueError): + # dict type is unsupported + Condition('x', 1, dict()) + + def test_invalid_op_type(self): + with self.assertRaises(ValueError): + # 'gt' supports only numeric values + Condition('x', 'foo', 'gt') + # # Operator tests # From 35c967e6f72ebdc4edb3be7d3737aa0912469688 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 09:09:51 -0400 Subject: [PATCH 41/68] Implement condition negation --- netbox/extras/conditions.py | 36 ++++++++++++++------------ netbox/extras/tests/test_conditions.py | 18 ++++++++++--- 2 files changed, 33 insertions(+), 21 deletions(-) diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py index 050d5564c..7f1d804e8 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -6,17 +6,15 @@ __all__ = ( ) -LOGIC_TYPES = ( - 'and', - 'or' -) +AND = 'and' +OR = 'or' def is_ruleset(data): """ Determine whether the given dictionary looks like a rule set. """ - return type(data) is dict and len(data) == 1 and list(data.keys())[0] in LOGIC_TYPES + return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR) class Condition: @@ -28,7 +26,6 @@ class Condition: :param op: The logical operation to use when evaluating the value (default: 'eq') """ EQ = 'eq' - NEQ = 'neq' GT = 'gt' GTE = 'gte' LT = 'lt' @@ -37,18 +34,18 @@ class Condition: CONTAINS = 'contains' OPERATORS = ( - EQ, NEQ, GT, GTE, LT, LTE, IN, CONTAINS + EQ, GT, GTE, LT, LTE, IN, CONTAINS ) TYPES = { - str: (EQ, NEQ, CONTAINS), - bool: (EQ, NEQ, CONTAINS), - int: (EQ, NEQ, GT, GTE, LT, LTE, CONTAINS), - float: (EQ, NEQ, GT, GTE, LT, LTE, CONTAINS), - list: (EQ, NEQ, IN, CONTAINS) + str: (EQ, CONTAINS), + bool: (EQ, CONTAINS), + int: (EQ, GT, GTE, LT, LTE, CONTAINS), + float: (EQ, GT, GTE, LT, LTE, CONTAINS), + list: (EQ, IN, CONTAINS) } - def __init__(self, attr, value, op=EQ): + def __init__(self, attr, value, op=EQ, negate=False): if op not in self.OPERATORS: raise ValueError(f"Unknown operator: {op}. Must be one of: {', '.join(self.OPERATORS)}") if type(value) not in self.TYPES: @@ -59,13 +56,18 @@ class Condition: self.attr = attr self.value = value self.eval_func = getattr(self, f'eval_{op}') + self.negate = negate def eval(self, data): """ Evaluate the provided data to determine whether it matches the condition. """ value = functools.reduce(dict.get, self.attr.split('.'), data) - return self.eval_func(value) + result = self.eval_func(value) + + if self.negate: + return not result + return result # Equivalency @@ -104,7 +106,7 @@ class ConditionSet: {"and": [ {"attr": "foo", "op": "eq", "value": 1}, - {"attr": "bar", "op": "neq", "value": 2} + {"attr": "bar", "op": "eq", "value": 2, "negate": true} ]} :param ruleset: A dictionary mapping a logical operator to a list of conditional rules @@ -117,8 +119,8 @@ class ConditionSet: # Determine the logic type logic = list(ruleset.keys())[0] - if type(logic) is not str or logic.lower() not in LOGIC_TYPES: - raise ValueError(f"Invalid logic type: {logic} (must be 'and' or 'or')") + if type(logic) is not str or logic.lower() not in (AND, OR): + raise ValueError(f"Invalid logic type: {logic} (must be '{AND}' or '{OR}')") self.logic = logic.lower() # Compile the set of Conditions diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py index 2ce55c064..47ae0b662 100644 --- a/netbox/extras/tests/test_conditions.py +++ b/netbox/extras/tests/test_conditions.py @@ -48,8 +48,8 @@ class ConditionTestCase(TestCase): self.assertTrue(c.eval({'x': 1})) self.assertFalse(c.eval({'x': 2})) - def test_neq(self): - c = Condition('x', 1, 'neq') + def test_eq_negated(self): + c = Condition('x', 1, 'eq', negate=True) self.assertFalse(c.eval({'x': 1})) self.assertTrue(c.eval({'x': 2})) @@ -80,11 +80,21 @@ class ConditionTestCase(TestCase): self.assertTrue(c.eval({'x': 1})) self.assertFalse(c.eval({'x': 9})) + def test_in_negated(self): + c = Condition('x', [1, 2, 3], 'in', negate=True) + self.assertFalse(c.eval({'x': 1})) + self.assertTrue(c.eval({'x': 9})) + def test_contains(self): c = Condition('x', 1, 'contains') self.assertTrue(c.eval({'x': [1, 2, 3]})) self.assertFalse(c.eval({'x': [2, 3, 4]})) + def test_contains_negated(self): + c = Condition('x', 1, 'contains', negate=True) + self.assertFalse(c.eval({'x': [1, 2, 3]})) + self.assertTrue(c.eval({'x': [2, 3, 4]})) + class ConditionSetTest(TestCase): @@ -100,11 +110,11 @@ class ConditionSetTest(TestCase): cs = ConditionSet({ 'and': [ {'attr': 'a', 'value': 1, 'op': 'eq'}, - {'attr': 'b', 'value': 2, 'op': 'eq'}, + {'attr': 'b', 'value': 1, 'op': 'eq', 'negate': True}, ] }) self.assertTrue(cs.eval({'a': 1, 'b': 2})) - self.assertFalse(cs.eval({'a': 1, 'b': 3})) + self.assertFalse(cs.eval({'a': 1, 'b': 1})) def test_or_single_depth(self): cs = ConditionSet({ From 2423e0872ff5e1953b4647a8617954870ec39064 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 09:52:08 -0400 Subject: [PATCH 42/68] Documentation & changelog for #6238 --- docs/models/extras/webhook.md | 14 +++++ docs/reference/conditions.md | 89 +++++++++++++++++++++++++++++++ docs/release-notes/version-3.1.md | 16 ++++++ mkdocs.yml | 2 + 4 files changed, 121 insertions(+) create mode 100644 docs/reference/conditions.md diff --git a/docs/models/extras/webhook.md b/docs/models/extras/webhook.md index ee5e9d059..c71657336 100644 --- a/docs/models/extras/webhook.md +++ b/docs/models/extras/webhook.md @@ -17,6 +17,7 @@ A webhook is a mechanism for conveying to some external system a change that too * **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below). * **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.) * **Secret** - A secret string used to prove authenticity of the request (optional). This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. +* **Conditions** - An optional set of conditions evaluated to determine whether the webhook fires for a given object. * **SSL verification** - Uncheck this option to disable validation of the receiver's SSL certificate. (Disable with caution!) * **CA file path** - The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (optional). @@ -80,3 +81,16 @@ If no body template is specified, the request body will be populated with a JSON } } ``` + +## Conditional Webhooks + +A webhook may include a set of conditional logic expressed in JSON used to control whether a webhook triggers for a specific object. For example, you may wish to trigger a webhook for devices only when the `status` field of an object is "active": + +```json +{ + "attr": "status", + "value": "active" +} +``` + +For more detail, see the reference documentation for NetBox's [conditional logic](../reference/conditions.md). diff --git a/docs/reference/conditions.md b/docs/reference/conditions.md new file mode 100644 index 000000000..c335bf9a8 --- /dev/null +++ b/docs/reference/conditions.md @@ -0,0 +1,89 @@ +# Conditions + +Conditions are NetBox's mechanism for evaluating whether a set data meets a prescribed set of conditions. It allows the author to convey simple logic by declaring an arbitrary number of attribute-value-operation tuples nested within a hierarchy of logical AND and OR statements. + +## Conditions + +A condition is expressed as a JSON object with the following keys: + +| Key name | Required | Default | Description | +|----------|----------|---------|-------------| +| attr | Yes | - | Name of the key within the data being evaluated | +| value | Yes | - | The reference value to which the given data will be compared | +| op | No | `eq` | The logical operation to be performed | +| negate | No | False | Negate (invert) the result of the condition's evaluation | + +### Available Operations + +* `eq`: Equals +* `gt`: Greater than +* `gte`: Greater than or equal to +* `lt`: Less than +* `lte`: Less than or equal to +* `in`: Is present within a list of values +* `contains`: Contains the specified value + +### Examples + +`name` equals "foobar": + +```json +{ + "attr": "name", + "value": "foobar" +} +``` + +`asn` is greater than 65000: + +```json +{ + "attr": "asn", + "value": 65000, + "op": "gt" +} +``` + +`status` is not "planned" or "staging": + +```json +{ + "attr": "status", + "value": ["planned", "staging"], + "op": "in", + "negate": true +} +``` + +## Condition Sets + +Multiple conditions can be combined into nested sets using AND or OR logic. This is done by declaring a JSON object with a single key (`and` or `or`) containing a list of condition objects and/or child condition sets. + +### Examples + +`status` is "active" and `primary_ip` is defined _or_ the "exempt" tag is applied. + +```json +{ + "or": [ + { + "and": [ + { + "attr": "status", + "value": "active" + }, + { + "attr": "primary_ip", + "value": "", + "negate": true + } + ] + }, + { + "attr": "tags", + "value": "exempt", + "op": "contains" + } + ] +} +``` diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index f586f43bb..b047e1320 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -28,6 +28,20 @@ Both types of connection include SSID and authentication attributes. Additionall * Channel - A predefined channel within a standardized band * Channel frequency & width - Customizable channel attributes (e.g. for licensed bands) +#### Conditional Webhooks ([#6238](https://github.com/netbox-community/netbox/issues/6238)) + +Webhooks now include a `conditions` field, which may be used to specify conditions under which a webhook triggers. For example, you may wish to generate outgoing requests for a device webhook only when its status is "active" or "staged". This can be done by declaring conditional logic in JSON: + +```json +{ + "attr": "status", + "op": "in", + "value": ["active", "staged"] +} +``` + +Multiple conditions may be nested using AND/OR logic as well. For more information, please see the [conditional logic documentation](../reference/conditions.md). + #### Interface Bridging ([#6346](https://github.com/netbox-community/netbox/issues/6346)) A `bridge` field has been added to the interface model for devices and virtual machines. This can be set to reference another interface on the same parent device/VM to indicate a direct layer two bridging adjacency. @@ -85,5 +99,7 @@ Multiple interfaces can be bridged to a single virtual interface to effect a bri * Added `wwn` field * dcim.Location * Added `tenant` field +* extras.Webhook + * Added the `conditions` field * virtualization.VMInterface * Added `bridge` field diff --git a/mkdocs.yml b/mkdocs.yml index 001808f0d..9d9bb964a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -93,6 +93,8 @@ nav: - Authentication: 'rest-api/authentication.md' - GraphQL API: - Overview: 'graphql-api/overview.md' + - Reference: + - Conditions: 'reference/conditions.md' - Development: - Introduction: 'development/index.md' - Getting Started: 'development/getting-started.md' From 0d84338e28c4a34036b168dfcdb075b0bf091449 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 10:14:18 -0400 Subject: [PATCH 43/68] Add regex condition op --- netbox/extras/conditions.py | 11 +++++++++-- netbox/extras/tests/test_conditions.py | 10 ++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py index 7f1d804e8..6f1b012eb 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -1,4 +1,5 @@ import functools +import re __all__ = ( 'Condition', @@ -32,13 +33,14 @@ class Condition: LTE = 'lte' IN = 'in' CONTAINS = 'contains' + REGEX = 'regex' OPERATORS = ( - EQ, GT, GTE, LT, LTE, IN, CONTAINS + EQ, GT, GTE, LT, LTE, IN, CONTAINS, REGEX ) TYPES = { - str: (EQ, CONTAINS), + str: (EQ, CONTAINS, REGEX), bool: (EQ, CONTAINS), int: (EQ, GT, GTE, LT, LTE, CONTAINS), float: (EQ, GT, GTE, LT, LTE, CONTAINS), @@ -99,6 +101,11 @@ class Condition: def eval_contains(self, value): return self.value in value + # Regular expressions + + def eval_regex(self, value): + return re.match(self.value, value) is not None + class ConditionSet: """ diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py index 47ae0b662..ee6afeaf6 100644 --- a/netbox/extras/tests/test_conditions.py +++ b/netbox/extras/tests/test_conditions.py @@ -95,6 +95,16 @@ class ConditionTestCase(TestCase): self.assertFalse(c.eval({'x': [1, 2, 3]})) self.assertTrue(c.eval({'x': [2, 3, 4]})) + def test_regex(self): + c = Condition('x', '[a-z]+', 'regex') + self.assertTrue(c.eval({'x': 'abc'})) + self.assertFalse(c.eval({'x': '123'})) + + def test_regex_negated(self): + c = Condition('x', '[a-z]+', 'regex', negate=True) + self.assertFalse(c.eval({'x': 'abc'})) + self.assertTrue(c.eval({'x': '123'})) + class ConditionSetTest(TestCase): From 68081fb9a28619698339b80d431aa2329ddaac22 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 11:07:15 -0400 Subject: [PATCH 44/68] Cleanup & API changelog for #3979 --- docs/release-notes/version-3.1.md | 11 +++++++++++ netbox/dcim/api/serializers.py | 9 ++++++--- netbox/wireless/api/urls.py | 2 +- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index b047e1320..ef7500e1e 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -6,6 +6,7 @@ ### Breaking Changes * The `tenant` and `tenant_id` filters for the Cable model now filter on the tenant assigned directly to each cable, rather than on the parent object of either termination. +* The `cable_peer` and `cable_peer_type` attributes of the interface model has been renamed to `link_peer` and `link_peer_type`, respectively, to accommodate wireless links. ### New Features @@ -71,6 +72,10 @@ Multiple interfaces can be bridged to a single virtual interface to effect a bri * `/api/tenancy/contact-groups/` * `/api/tenancy/contact-roles/` * `/api/tenancy/contacts/` +* Added the following endpoints for wireless networks: + * `/api/wireless/wireless-lans/` + * `/api/wireless/wireless-lan-groups/` + * `/api/wireless/wireless-links/` * Added `tags` field to the following models: * circuits.CircuitType * dcim.DeviceRole @@ -96,7 +101,13 @@ Multiple interfaces can be bridged to a single virtual interface to effect a bri * Added `airflow` field * dcim.Interface * Added `bridge` field + * Added `rf_role` field + * Added `rf_channel` field + * Added `rf_channel_frequency` field + * Added `rf_chanel_width` field * Added `wwn` field + * `cable_peer` has been renamed to `link_peer` + * `cable_peer_type` has been renamed to `link_peer_type` * dcim.Location * Added `tenant` field * extras.Webhook diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 1f2897a7f..d8c5a7771 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -17,6 +17,7 @@ from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedClusterSerializer +from wireless.api.nested_serializers import NestedWirelessLinkSerializer from wireless.choices import * from .nested_serializers import * @@ -618,6 +619,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con many=True ) cable = NestedCableSerializer(read_only=True) + wireless_link = NestedWirelessLinkSerializer(read_only=True) count_ipaddresses = serializers.IntegerField(read_only=True) class Meta: @@ -625,9 +627,10 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con fields = [ 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'link_peer', - 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', - 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied', + 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', + 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', + '_occupied', ] def validate(self, data): diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py index 54f764db6..b02aa67c0 100644 --- a/netbox/wireless/api/urls.py +++ b/netbox/wireless/api/urls.py @@ -5,7 +5,7 @@ from . import views router = OrderedDefaultRouter() router.APIRootView = views.WirelessRootView -router.register('wireless-lan-groupss', views.WirelessLANGroupViewSet) +router.register('wireless-lan-groups', views.WirelessLANGroupViewSet) router.register('wireless-lans', views.WirelessLANViewSet) router.register('wireless-links', views.WirelessLinkViewSet) From 61d2158f7633d14f6d6934e0bb0f48c678029691 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 11:11:58 -0400 Subject: [PATCH 45/68] #6346: Add 'bridge' interface type --- docs/release-notes/version-3.1.md | 2 +- netbox/dcim/choices.py | 2 ++ netbox/dcim/constants.py | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index ef7500e1e..b583d8b44 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -45,7 +45,7 @@ Multiple conditions may be nested using AND/OR logic as well. For more informati #### Interface Bridging ([#6346](https://github.com/netbox-community/netbox/issues/6346)) -A `bridge` field has been added to the interface model for devices and virtual machines. This can be set to reference another interface on the same parent device/VM to indicate a direct layer two bridging adjacency. +A `bridge` field has been added to the interface model for devices and virtual machines. This can be set to reference another interface on the same parent device/VM to indicate a direct layer two bridging adjacency. Additionally, "bridge" has been added as an interface type. (However, interfaces of any type may be designated as bridged.) Multiple interfaces can be bridged to a single virtual interface to effect a bridge group. Alternatively, two physical interfaces can be bridged to one another, to effect an internal cross-connect. diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 9b5363d4c..de46aec8a 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -720,6 +720,7 @@ class InterfaceTypeChoices(ChoiceSet): # Virtual TYPE_VIRTUAL = 'virtual' + TYPE_BRIDGE = 'bridge' TYPE_LAG = 'lag' # Ethernet @@ -820,6 +821,7 @@ class InterfaceTypeChoices(ChoiceSet): 'Virtual interfaces', ( (TYPE_VIRTUAL, 'Virtual'), + (TYPE_BRIDGE, 'Bridge'), (TYPE_LAG, 'Link Aggregation Group (LAG)'), ), ), diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 0d64b357b..2136f06aa 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -34,6 +34,7 @@ INTERFACE_MTU_MAX = 65536 VIRTUAL_IFACE_TYPES = [ InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_LAG, + InterfaceTypeChoices.TYPE_BRIDGE, ] WIRELESS_IFACE_TYPES = [ From 82243732a19f83cb55c60fe206470cbe04df638b Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 25 Oct 2021 14:42:20 -0400 Subject: [PATCH 46/68] Initial work on #5883 --- netbox/dcim/models/devices.py | 4 +- netbox/dcim/tables/devices.py | 17 +-- netbox/extras/admin.py | 68 ++++++++++- netbox/extras/forms/__init__.py | 1 + netbox/extras/forms/config.py | 67 +++++++++++ .../extras/migrations/0064_configrevision.py | 20 ++++ netbox/extras/models/__init__.py | 3 +- netbox/extras/models/models.py | 111 ++++++++++-------- netbox/extras/signals.py | 16 ++- netbox/ipam/models/ip.py | 7 +- netbox/netbox/config/__init__.py | 35 ++++++ netbox/netbox/config/parameters.py | 55 +++++++++ netbox/netbox/context_processors.py | 2 + netbox/netbox/settings.py | 57 ++++----- netbox/templates/base/layout.html | 8 +- netbox/templates/login.html | 4 +- netbox/virtualization/models.py | 5 +- netbox/virtualization/tables.py | 4 +- 18 files changed, 375 insertions(+), 109 deletions(-) create mode 100644 netbox/extras/forms/config.py create mode 100644 netbox/extras/migrations/0064_configrevision.py create mode 100644 netbox/netbox/config/__init__.py create mode 100644 netbox/netbox/config/parameters.py diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 2b3b80d24..d6b23fed4 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -15,6 +15,7 @@ from dcim.constants import * from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features +from netbox.config import ConfigResolver from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField @@ -815,7 +816,8 @@ class Device(PrimaryModel, ConfigContextModel): @property def primary_ip(self): - if settings.PREFER_IPV4 and self.primary_ip4: + config = ConfigResolver() + if config.PREFER_IPV4 and self.primary_ip4: return self.primary_ip4 elif self.primary_ip6: return self.primary_ip6 diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 8ea27b8a6..167dba95d 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -160,18 +160,11 @@ class DeviceTable(BaseTable): linkify=True, verbose_name='Type' ) - if settings.PREFER_IPV4: - primary_ip = tables.Column( - linkify=True, - order_by=('primary_ip4', 'primary_ip6'), - verbose_name='IP Address' - ) - else: - primary_ip = tables.Column( - linkify=True, - order_by=('primary_ip6', 'primary_ip4'), - verbose_name='IP Address' - ) + primary_ip = tables.Column( + linkify=True, + order_by=('primary_ip4', 'primary_ip6'), + verbose_name='IP Address' + ) primary_ip4 = tables.Column( linkify=True, verbose_name='IPv4 Address' diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index dae21c2c9..e99406e49 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,10 +1,74 @@ from django.contrib import admin -from .models import JobResult +from .forms import ConfigRevisionForm +from .models import ConfigRevision, JobResult + + +@admin.register(ConfigRevision) +class ConfigRevisionAdmin(admin.ModelAdmin): + fieldsets = [ + # ('Authentication', { + # 'fields': ('LOGIN_REQUIRED', 'LOGIN_PERSISTENCE', 'LOGIN_TIMEOUT'), + # }), + # ('Rack Elevations', { + # 'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'), + # }), + ('IPAM', { + 'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'), + }), + # ('Security', { + # 'fields': ( + # 'ALLOWED_URL_SCHEMES', 'EXEMPT_VIEW_PERMISSIONS', + # ), + # }), + ('Banners', { + 'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'), + }), + # ('Logging', { + # 'fields': ('CHANGELOG_RETENTION',), + # }), + # ('Pagination', { + # 'fields': ('MAX_PAGE_SIZE', 'PAGINATE_COUNT'), + # }), + # ('Miscellaneous', { + # 'fields': ('GRAPHQL_ENABLED', 'METRICS_ENABLED', 'MAINTENANCE_MODE', 'MAPS_URL'), + # }), + ('Config Revision', { + 'fields': ('comment',), + }) + ] + form = ConfigRevisionForm + list_display = ('id', 'is_active', 'created', 'comment') + ordering = ('-id',) + readonly_fields = ('data',) + + def get_changeform_initial_data(self, request): + """ + Populate initial form data from the most recent ConfigRevision. + """ + latest_revision = ConfigRevision.objects.last() + initial = latest_revision.data if latest_revision else {} + initial.update(super().get_changeform_initial_data(request)) + + return initial + + def has_add_permission(self, request): + # Only superusers may modify the configuration. + return request.user.is_superuser + + def has_change_permission(self, request, obj=None): + # ConfigRevisions cannot be modified once created. + return False + + def has_delete_permission(self, request, obj=None): + # Only inactive ConfigRevisions may be deleted (must be superuser). + return request.user.is_superuser and ( + obj is None or not obj.is_active() + ) # -# Reports +# Reports & scripts # @admin.register(JobResult) diff --git a/netbox/extras/forms/__init__.py b/netbox/extras/forms/__init__.py index 1584e2f51..b470650da 100644 --- a/netbox/extras/forms/__init__.py +++ b/netbox/extras/forms/__init__.py @@ -3,4 +3,5 @@ from .filtersets import * from .bulk_edit import * from .bulk_import import * from .customfields import * +from .config import * from .scripts import * diff --git a/netbox/extras/forms/config.py b/netbox/extras/forms/config.py new file mode 100644 index 000000000..001252f0c --- /dev/null +++ b/netbox/extras/forms/config.py @@ -0,0 +1,67 @@ +from django import forms + +from netbox.config.parameters import PARAMS + +__all__ = ( + 'ConfigRevisionForm', +) + + +EMPTY_VALUES = ('', None, [], ()) + + +class FormMetaclass(forms.models.ModelFormMetaclass): + + def __new__(mcs, name, bases, attrs): + + # Emulate a declared field for each supported configuration parameter + param_fields = {} + for param in PARAMS: + help_text = f'{param.description}
' if param.description else '' + # help_text += f'Current value: {getattr(settings, param.name)}' + param_fields[param.name] = param.field( + required=False, + label=param.label, + help_text=help_text, + **param.field_kwargs + ) + attrs.update(param_fields) + + return super().__new__(mcs, name, bases, attrs) + + +class ConfigRevisionForm(forms.BaseModelForm, metaclass=FormMetaclass): + """ + Form for creating a new ConfigRevision. + """ + class Meta: + widgets = { + 'comment': forms.Textarea(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Bugfix for django-timezone-field: Add empty choice to default options + # self.fields['TIME_ZONE'].choices = [('', ''), *self.fields['TIME_ZONE'].choices] + + def save(self, commit=True): + instance = super().save(commit=False) + + # Populate JSON data on the instance + instance.data = self.render_json() + + if commit: + instance.save() + + return instance + + def render_json(self): + json = {} + + # Iterate through each field and populate non-empty values + for field_name in self.declared_fields: + if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES: + json[field_name] = self.cleaned_data[field_name] + + return json diff --git a/netbox/extras/migrations/0064_configrevision.py b/netbox/extras/migrations/0064_configrevision.py new file mode 100644 index 000000000..c3fce8abe --- /dev/null +++ b/netbox/extras/migrations/0064_configrevision.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0063_webhook_conditions'), + ] + + operations = [ + migrations.CreateModel( + name='ConfigRevision', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('comment', models.CharField(blank=True, max_length=200)), + ('data', models.JSONField(blank=True, null=True)), + ], + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 84676453f..3cb6372be 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,12 +1,13 @@ from .change_logging import ObjectChange from .configcontexts import ConfigContext, ConfigContextModel from .customfields import CustomField -from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, JournalEntry, Report, Script, Webhook +from .models import * from .tags import Tag, TaggedItem __all__ = ( 'ConfigContext', 'ConfigContextModel', + 'ConfigRevision', 'CustomField', 'CustomLink', 'ExportTemplate', diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 43af19f82..4f93b19ce 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -1,9 +1,11 @@ import json import uuid +from django.contrib import admin from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache from django.core.validators import ValidationError from django.db import models from django.http import HttpResponse @@ -20,8 +22,8 @@ from netbox.models import BigIDModel, ChangeLoggedModel from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 - __all__ = ( + 'ConfigRevision', 'CustomLink', 'ExportTemplate', 'ImageAttachment', @@ -33,10 +35,6 @@ __all__ = ( ) -# -# Webhooks -# - @extras_features('webhooks') class Webhook(ChangeLoggedModel): """ @@ -181,10 +179,6 @@ class Webhook(ChangeLoggedModel): return json.dumps(context, cls=JSONEncoder) -# -# Custom links -# - @extras_features('webhooks') class CustomLink(ChangeLoggedModel): """ @@ -240,10 +234,6 @@ class CustomLink(ChangeLoggedModel): return reverse('extras:customlink', args=[self.pk]) -# -# Export templates -# - @extras_features('webhooks') class ExportTemplate(ChangeLoggedModel): content_type = models.ForeignKey( @@ -333,10 +323,6 @@ class ExportTemplate(ChangeLoggedModel): return response -# -# Image attachments -# - class ImageAttachment(BigIDModel): """ An uploaded image which is associated with an object. @@ -409,11 +395,6 @@ class ImageAttachment(BigIDModel): return None -# -# Journal entries -# - - @extras_features('webhooks') class JournalEntry(ChangeLoggedModel): """ @@ -463,36 +444,6 @@ class JournalEntry(ChangeLoggedModel): return JournalEntryKindChoices.CSS_CLASSES.get(self.kind) -# -# Custom scripts -# - -@extras_features('job_results') -class Script(models.Model): - """ - Dummy model used to generate permissions for custom scripts. Does not exist in the database. - """ - class Meta: - managed = False - - -# -# Reports -# - -@extras_features('job_results') -class Report(models.Model): - """ - Dummy model used to generate permissions for reports. Does not exist in the database. - """ - class Meta: - managed = False - - -# -# Job results -# - class JobResult(BigIDModel): """ This model stores the results from running a user-defined report. @@ -582,3 +533,59 @@ class JobResult(BigIDModel): func.delay(*args, job_id=str(job_result.job_id), job_result=job_result, **kwargs) return job_result + + +class ConfigRevision(models.Model): + """ + An atomic revision of NetBox's configuration. + """ + created = models.DateTimeField( + auto_now_add=True + ) + comment = models.CharField( + max_length=200, + blank=True + ) + data = models.JSONField( + blank=True, + null=True, + verbose_name='Configuration data' + ) + + def __str__(self): + return f'Config revision #{self.pk} ({self.created})' + + def __getattr__(self, item): + if item in self.data: + return self.data[item] + return super().__getattribute__(item) + + @admin.display(boolean=True) + def is_active(self): + return cache.get('config_version') == self.pk + + +# +# Custom scripts & reports +# + +@extras_features('job_results') +class Script(models.Model): + """ + Dummy model used to generate permissions for custom scripts. Does not exist in the database. + """ + class Meta: + managed = False + + +# +# Reports +# + +@extras_features('job_results') +class Report(models.Model): + """ + Dummy model used to generate permissions for reports. Does not exist in the database. + """ + class Meta: + managed = False diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 4f09706be..01fd30f15 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -2,13 +2,14 @@ import logging from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.core.cache import cache from django.db.models.signals import m2m_changed, post_save, pre_delete from django.dispatch import receiver, Signal from django_prometheus.models import model_deletes, model_inserts, model_updates from netbox.signals import post_clean from .choices import ObjectChangeActionChoices -from .models import CustomField, ObjectChange +from .models import ConfigRevision, CustomField, ObjectChange from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook @@ -161,3 +162,16 @@ def run_custom_validators(sender, instance, **kwargs): validators = settings.CUSTOM_VALIDATORS.get(model_name, []) for validator in validators: validator(instance) + + +# +# Dynamic configuration +# + +@receiver(post_save, sender=ConfigRevision) +def update_config(sender, instance, **kwargs): + """ + Update the cached NetBox configuration when a new ConfigRevision is created. + """ + cache.set('config', instance.data, None) + cache.set('config_version', instance.pk, None) diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 514e87a62..d655dcb21 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -17,6 +17,7 @@ from ipam.fields import IPNetworkField, IPAddressField from ipam.managers import IPAddressManager from ipam.querysets import PrefixQuerySet from ipam.validators import DNSValidator +from netbox.config import ConfigResolver from utilities.querysets import RestrictedQuerySet from virtualization.models import VirtualMachine @@ -316,7 +317,8 @@ class Prefix(PrimaryModel): }) # Enforce unique IP space (if applicable) - if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): + config = ConfigResolver() + if (self.vrf is None and config.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_prefixes = self.get_duplicates() if duplicate_prefixes: raise ValidationError({ @@ -811,7 +813,8 @@ class IPAddress(PrimaryModel): }) # Enforce unique IP space (if applicable) - if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): + config = ConfigResolver() + if (self.vrf is None and config.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_ips = self.get_duplicates() if duplicate_ips and ( self.role not in IPADDRESS_ROLES_NONUNIQUE or diff --git a/netbox/netbox/config/__init__.py b/netbox/netbox/config/__init__.py new file mode 100644 index 000000000..34ee127fc --- /dev/null +++ b/netbox/netbox/config/__init__.py @@ -0,0 +1,35 @@ +from django.conf import settings +from django.core.cache import cache + +from .parameters import PARAMS + +__all__ = ( + 'ConfigResolver', + 'PARAMS', +) + + +class ConfigResolver: + """ + Active NetBox configuration. + """ + def __init__(self): + self.config = cache.get('config') + self.version = self.config.get('config_version') + self.defaults = {param.name: param.default for param in PARAMS} + + def __getattr__(self, item): + + # Check for hard-coded configuration in settings.py + if hasattr(settings, item): + return getattr(settings, item) + + # Return config value from cache + if item in self.config: + return self.config[item] + + # Fall back to the parameter's default value + if item in self.defaults: + return self.defaults[item] + + raise AttributeError(f"Invalid configuration parameter: {item}") diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py new file mode 100644 index 000000000..604131ec1 --- /dev/null +++ b/netbox/netbox/config/parameters.py @@ -0,0 +1,55 @@ +from django import forms + + +class OptionalBooleanSelect(forms.Select): + """ + An optional boolean field (yes/no/default). + """ + def __init__(self, attrs=None): + choices = ( + ('', 'Default'), + (True, 'Yes'), + (False, 'No'), + ) + super().__init__(attrs, choices) + + +class OptionalBooleanField(forms.NullBooleanField): + widget = OptionalBooleanSelect + + +class ConfigParam: + + def __init__(self, name, label, default, description=None, field=None, field_kwargs=None): + self.name = name + self.label = label + self.default = default + self.field = field or forms.CharField + self.description = description + self.field_kwargs = field_kwargs or {} + + +PARAMS = ( + + # Banners + ConfigParam('BANNER_LOGIN', 'Login banner', ''), + ConfigParam('BANNER_TOP', 'Top banner', ''), + ConfigParam('BANNER_BOTTOM', 'Bottom banner', ''), + + # IPAM + ConfigParam( + name='ENFORCE_GLOBAL_UNIQUE', + label='Globally unique IP space', + default=False, + description="Enforce unique IP addressing within the global table", + field=OptionalBooleanField + ), + ConfigParam( + name='PREFER_IPV4', + label='Prefer IPv4', + default=False, + description="Prefer IPv4 addresses over IPv6", + field=OptionalBooleanField + ), + +) diff --git a/netbox/netbox/context_processors.py b/netbox/netbox/context_processors.py index d6dd67d99..fee32a063 100644 --- a/netbox/netbox/context_processors.py +++ b/netbox/netbox/context_processors.py @@ -1,6 +1,7 @@ from django.conf import settings as django_settings from extras.registry import registry +from netbox.config import ConfigResolver def settings_and_registry(request): @@ -9,6 +10,7 @@ def settings_and_registry(request): """ return { 'settings': django_settings, + 'config': ConfigResolver(), 'registry': registry, 'preferences': request.user.config if request.user.is_authenticated else {}, } diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 279b8c453..0eb164523 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -11,6 +11,8 @@ from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.validators import URLValidator +from netbox.config import PARAMS + # # Environment setup @@ -68,18 +70,11 @@ DATABASE = getattr(configuration, 'DATABASE') REDIS = getattr(configuration, 'REDIS') SECRET_KEY = getattr(configuration, 'SECRET_KEY') -# Set optional parameters +# Set static config parameters ADMINS = getattr(configuration, 'ADMINS', []) -ALLOWED_URL_SCHEMES = getattr(configuration, 'ALLOWED_URL_SCHEMES', ( - 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', -)) -BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '') -BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '') -BANNER_TOP = getattr(configuration, 'BANNER_TOP', '') BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only -CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90) CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) @@ -90,30 +85,12 @@ DEBUG = getattr(configuration, 'DEBUG', False) DEVELOPER = getattr(configuration, 'DEVELOPER', False) DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs')) EMAIL = getattr(configuration, 'EMAIL', {}) -ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) -EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) -GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) LOGGING = getattr(configuration, 'LOGGING', {}) -LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) -LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) -MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) -MAPS_URL = getattr(configuration, 'MAPS_URL', 'https://maps.google.com/?q=') -MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/') -METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) -NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) -NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '') -NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30) -NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '') -PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) -LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) PLUGINS = getattr(configuration, 'PLUGINS', []) PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) -PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) -RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 22) -RACK_ELEVATION_DEFAULT_UNIT_WIDTH = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', 220) REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) @@ -127,7 +104,6 @@ REMOTE_AUTH_SUPERUSERS = getattr(configuration, 'REMOTE_AUTH_SUPERUSERS', []) REMOTE_AUTH_STAFF_GROUPS = getattr(configuration, 'REMOTE_AUTH_STAFF_GROUPS', []) REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', []) REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|') -RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') @@ -141,6 +117,33 @@ STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {}) TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a') TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') +# Check for hard-coded dynamic config parameters +for param in PARAMS: + if hasattr(configuration, param.name): + globals()[param.name] = getattr(configuration, param.name) + +ALLOWED_URL_SCHEMES = getattr(configuration, 'ALLOWED_URL_SCHEMES', ( + 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', +)) +CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90) +EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) +GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True) +LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) +LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) +LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) +MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) +MAPS_URL = getattr(configuration, 'MAPS_URL', 'https://maps.google.com/?q=') +MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) +METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) +NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) +NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '') +NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30) +NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '') +PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) +RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 22) +RACK_ELEVATION_DEFAULT_UNIT_WIDTH = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', 220) +RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) + # Validate update repo URL and timeout if RELEASE_CHECK_URL: validator = URLValidator( diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 9575d4dcb..a4c8c77b6 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -58,9 +58,9 @@ - {% if settings.BANNER_TOP %} + {% if config.BANNER_TOP %} {% endif %} @@ -98,9 +98,9 @@ {% endblock %}
- {% if settings.BANNER_BOTTOM %} + {% if config.BANNER_BOTTOM %} {% endif %} diff --git a/netbox/templates/login.html b/netbox/templates/login.html index 37cdd8e53..a01d75422 100644 --- a/netbox/templates/login.html +++ b/netbox/templates/login.html @@ -7,9 +7,9 @@
{# Login banner #} - {% if settings.BANNER_LOGIN %} + {% if config.BANNER_LOGIN %} {% endif %} diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index c614618c0..51d255fc7 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator @@ -9,6 +8,7 @@ from dcim.models import BaseInterface, Device from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features +from netbox.config import ConfigResolver from netbox.models import OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface @@ -340,7 +340,8 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): @property def primary_ip(self): - if settings.PREFER_IPV4 and self.primary_ip4: + config = ConfigResolver() + if config.PREFER_IPV4 and self.primary_ip4: return self.primary_ip4 elif self.primary_ip6: return self.primary_ip6 diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 56ad88f1f..0a605267d 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -17,8 +17,6 @@ __all__ = ( 'VMInterfaceTable', ) -PRIMARY_IP_ORDERING = ('primary_ip4', 'primary_ip6') if settings.PREFER_IPV4 else ('primary_ip6', 'primary_ip4') - VMINTERFACE_BUTTONS = """ {% if perms.ipam.add_ipaddress %} @@ -136,7 +134,7 @@ class VirtualMachineTable(BaseTable): ) primary_ip = tables.Column( linkify=True, - order_by=PRIMARY_IP_ORDERING, + order_by=('primary_ip4', 'primary_ip6'), verbose_name='IP Address' ) tags = TagColumn( From 7c0f32e8ee73914cca3262c68c685da02c82bf5a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 26 Oct 2021 10:04:56 -0400 Subject: [PATCH 47/68] Introduce ConfigItem; add rack elevation parameters --- netbox/dcim/api/serializers.py | 5 +++-- netbox/dcim/models/devices.py | 6 ++---- netbox/dcim/models/racks.py | 10 +++++++--- netbox/extras/admin.py | 6 +++--- netbox/ipam/models/ip.py | 11 ++++------- netbox/netbox/config/__init__.py | 23 +++++++++++++++++++---- netbox/netbox/config/parameters.py | 16 ++++++++++++++++ netbox/netbox/configuration.example.py | 20 -------------------- netbox/netbox/context_processors.py | 4 ++-- netbox/netbox/settings.py | 2 -- netbox/virtualization/models.py | 5 ++--- 11 files changed, 58 insertions(+), 50 deletions(-) diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d8c5a7771..a5f4ac5fe 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -13,6 +13,7 @@ from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, ) +from netbox.config import ConfigItem from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer from utilities.api import get_serializer_for_model @@ -229,10 +230,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): default=RackElevationDetailRenderChoices.RENDER_JSON ) unit_width = serializers.IntegerField( - default=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH + default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_WIDTH') ) unit_height = serializers.IntegerField( - default=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT') ) legend_width = serializers.IntegerField( default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index d6b23fed4..418944a4a 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1,7 +1,6 @@ from collections import OrderedDict import yaml -from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator @@ -15,7 +14,7 @@ from dcim.constants import * from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features -from netbox.config import ConfigResolver +from netbox.config import ConfigItem from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField @@ -816,8 +815,7 @@ class Device(PrimaryModel, ConfigContextModel): @property def primary_ip(self): - config = ConfigResolver() - if config.PREFER_IPV4 and self.primary_ip4: + if ConfigItem('PREFER_IPV4')() and self.primary_ip4: return self.primary_ip4 elif self.primary_ip6: return self.primary_ip6 diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index a6d7f33af..4a023477f 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,6 +1,5 @@ from collections import OrderedDict -from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.models import ContentType @@ -15,6 +14,7 @@ from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG from extras.utils import extras_features +from netbox.config import Config from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField @@ -373,8 +373,8 @@ class Rack(PrimaryModel): self, face=DeviceFaceChoices.FACE_FRONT, user=None, - unit_width=settings.RACK_ELEVATION_DEFAULT_UNIT_WIDTH, - unit_height=settings.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT, + unit_width=None, + unit_height=None, legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, include_images=True, base_url=None @@ -393,6 +393,10 @@ class Rack(PrimaryModel): :param base_url: Base URL for links and images. If none, URLs will be relative. """ elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url) + if unit_width is None or unit_height is None: + config = Config() + unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH + unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT return elevation.render(face, unit_width, unit_height, legend_width) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index e99406e49..cac600626 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -10,9 +10,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin): # ('Authentication', { # 'fields': ('LOGIN_REQUIRED', 'LOGIN_PERSISTENCE', 'LOGIN_TIMEOUT'), # }), - # ('Rack Elevations', { - # 'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'), - # }), + ('Rack Elevations', { + 'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'), + }), ('IPAM', { 'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'), }), diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index d655dcb21..af114537a 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,10 +1,9 @@ import netaddr -from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models -from django.db.models import F, Q +from django.db.models import F from django.urls import reverse from django.utils.functional import cached_property @@ -17,7 +16,7 @@ from ipam.fields import IPNetworkField, IPAddressField from ipam.managers import IPAddressManager from ipam.querysets import PrefixQuerySet from ipam.validators import DNSValidator -from netbox.config import ConfigResolver +from netbox.config import Config from utilities.querysets import RestrictedQuerySet from virtualization.models import VirtualMachine @@ -317,8 +316,7 @@ class Prefix(PrimaryModel): }) # Enforce unique IP space (if applicable) - config = ConfigResolver() - if (self.vrf is None and config.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): + if (self.vrf is None and Config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_prefixes = self.get_duplicates() if duplicate_prefixes: raise ValidationError({ @@ -813,8 +811,7 @@ class IPAddress(PrimaryModel): }) # Enforce unique IP space (if applicable) - config = ConfigResolver() - if (self.vrf is None and config.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): + if (self.vrf is None and Config().ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_ips = self.get_duplicates() if duplicate_ips and ( self.role not in IPADDRESS_ROLES_NONUNIQUE or diff --git a/netbox/netbox/config/__init__.py b/netbox/netbox/config/__init__.py index 34ee127fc..7e57f3e8d 100644 --- a/netbox/netbox/config/__init__.py +++ b/netbox/netbox/config/__init__.py @@ -4,18 +4,20 @@ from django.core.cache import cache from .parameters import PARAMS __all__ = ( - 'ConfigResolver', + 'Config', + 'ConfigItem', 'PARAMS', ) -class ConfigResolver: +class Config: """ - Active NetBox configuration. + Fetch and store in memory the current NetBox configuration. This class must be instantiated prior to access, and + must be re-instantiated each time it's necessary to check for updates to the cached config. """ def __init__(self): self.config = cache.get('config') - self.version = self.config.get('config_version') + self.version = cache.get('config_version') self.defaults = {param.name: param.default for param in PARAMS} def __getattr__(self, item): @@ -33,3 +35,16 @@ class ConfigResolver: return self.defaults[item] raise AttributeError(f"Invalid configuration parameter: {item}") + + +class ConfigItem: + """ + A callable to retrieve a configuration parameter from the cache. This can serve as a placeholder to defer + referencing a configuration parameter. + """ + def __init__(self, item): + self.item = item + + def __call__(self): + config = Config() + return getattr(config, self.item) diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 604131ec1..4e1ff80f4 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -52,4 +52,20 @@ PARAMS = ( field=OptionalBooleanField ), + # Racks + ConfigParam( + name='RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', + label='Rack Unit Height', + default=22, + description="Default unit height for rendered rack elevations", + field=forms.IntegerField + ), + ConfigParam( + name='RACK_ELEVATION_DEFAULT_UNIT_WIDTH', + label='Rack Unit Width', + default=220, + description="Default unit width for rendered rack elevations", + field=forms.IntegerField + ), + ) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 03023740f..bb4a9021e 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -77,14 +77,6 @@ ALLOWED_URL_SCHEMES = ( 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', ) -# Optionally display a persistent banner at the top and/or bottom of every page. HTML is allowed. To display the same -# content in both banners, define BANNER_TOP and set BANNER_BOTTOM = BANNER_TOP. -BANNER_TOP = '' -BANNER_BOTTOM = '' - -# Text to include on the login page above the login form. HTML is allowed. -BANNER_LOGIN = '' - # Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set: # BASE_PATH = 'netbox/' BASE_PATH = '' @@ -134,10 +126,6 @@ EMAIL = { 'FROM_EMAIL': '', } -# Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce unique IP space within the global table -# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True. -ENFORCE_GLOBAL_UNIQUE = False - # Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and # by anonymous users. List models in the form `.`. Add '*' to this list to exempt all models. EXEMPT_VIEW_PERMISSIONS = [ @@ -229,14 +217,6 @@ PLUGINS = [] # } # } -# When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to -# prefer IPv4 instead. -PREFER_IPV4 = False - -# Rack elevation size defaults, in pixels. For best results, the ratio of width to height should be roughly 10:1. -RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = 22 -RACK_ELEVATION_DEFAULT_UNIT_WIDTH = 220 - # Remote authentication support REMOTE_AUTH_ENABLED = False REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend' diff --git a/netbox/netbox/context_processors.py b/netbox/netbox/context_processors.py index fee32a063..8ae0a0f26 100644 --- a/netbox/netbox/context_processors.py +++ b/netbox/netbox/context_processors.py @@ -1,7 +1,7 @@ from django.conf import settings as django_settings from extras.registry import registry -from netbox.config import ConfigResolver +from netbox.config import Config def settings_and_registry(request): @@ -10,7 +10,7 @@ def settings_and_registry(request): """ return { 'settings': django_settings, - 'config': ConfigResolver(), + 'config': Config(), 'registry': registry, 'preferences': request.user.config if request.user.is_authenticated else {}, } diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 0eb164523..248b3d697 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -140,8 +140,6 @@ NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '') NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30) NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '') PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) -RACK_ELEVATION_DEFAULT_UNIT_HEIGHT = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 22) -RACK_ELEVATION_DEFAULT_UNIT_WIDTH = getattr(configuration, 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH', 220) RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) # Validate update repo URL and timeout diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 51d255fc7..f82550b4f 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -8,7 +8,7 @@ from dcim.models import BaseInterface, Device from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features -from netbox.config import ConfigResolver +from netbox.config import Config from netbox.models import OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface @@ -340,8 +340,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): @property def primary_ip(self): - config = ConfigResolver() - if config.PREFER_IPV4 and self.primary_ip4: + if Config().PREFER_IPV4 and self.primary_ip4: return self.primary_ip4 elif self.primary_ip6: return self.primary_ip6 From 559dc2f8652959c136727ecf11fc3392fd6ac4f5 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 26 Oct 2021 10:24:33 -0400 Subject: [PATCH 48/68] Add ALLOWED_URL_SCHEMES --- netbox/extras/admin.py | 8 +++----- netbox/netbox/config/parameters.py | 14 ++++++++++++++ netbox/netbox/configuration.example.py | 5 ----- netbox/netbox/settings.py | 3 --- netbox/utilities/templatetags/helpers.py | 3 ++- netbox/utilities/validators.py | 9 +++++++-- 6 files changed, 26 insertions(+), 16 deletions(-) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index cac600626..5e2de7e16 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -16,11 +16,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin): ('IPAM', { 'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'), }), - # ('Security', { - # 'fields': ( - # 'ALLOWED_URL_SCHEMES', 'EXEMPT_VIEW_PERMISSIONS', - # ), - # }), + ('Security', { + 'fields': ('ALLOWED_URL_SCHEMES',), + }), ('Banners', { 'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'), }), diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 4e1ff80f4..8ad02a5dd 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -1,4 +1,5 @@ from django import forms +from django.contrib.postgres.forms import SimpleArrayField class OptionalBooleanSelect(forms.Select): @@ -68,4 +69,17 @@ PARAMS = ( field=forms.IntegerField ), + # Security + ConfigParam( + name='ALLOWED_URL_SCHEMES', + label='Allowed URL schemes', + default=( + 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', + 'xmpp', + ), + description="Permitted schemes for URLs in user-provided content", + field=SimpleArrayField, + field_kwargs={'base_field': forms.CharField()} + ), + ) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index bb4a9021e..63e74524a 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -72,11 +72,6 @@ ADMINS = [ # ('John Doe', 'jdoe@example.com'), ] -# URL schemes that are allowed within links in NetBox -ALLOWED_URL_SCHEMES = ( - 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', -) - # Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set: # BASE_PATH = 'netbox/' BASE_PATH = '' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 248b3d697..f42c99dbf 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -122,9 +122,6 @@ for param in PARAMS: if hasattr(configuration, param.name): globals()[param.name] = getattr(configuration, param.name) -ALLOWED_URL_SCHEMES = getattr(configuration, 'ALLOWED_URL_SCHEMES', ( - 'file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp', -)) CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90) EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 1b5bb220d..833d19535 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -14,6 +14,7 @@ from django.utils.html import strip_tags from django.utils.safestring import mark_safe from markdown import markdown +from netbox.config import Config from utilities.forms import get_selected_values, TableConfigForm from utilities.utils import foreground_color @@ -44,7 +45,7 @@ def render_markdown(value): value = strip_tags(value) # Sanitize Markdown links - schemes = '|'.join(settings.ALLOWED_URL_SCHEMES) + schemes = '|'.join(Config().ALLOWED_URL_SCHEMES) pattern = fr'\[(.+)\]\((?!({schemes})).*:(.+)\)' value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) diff --git a/netbox/utilities/validators.py b/netbox/utilities/validators.py index b087b0867..5b5775482 100644 --- a/netbox/utilities/validators.py +++ b/netbox/utilities/validators.py @@ -1,9 +1,10 @@ import re -from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import _lazy_re_compile, BaseValidator, URLValidator +from netbox.config import Config + class EnhancedURLValidator(URLValidator): """ @@ -19,7 +20,11 @@ class EnhancedURLValidator(URLValidator): r'(?::\d{2,5})?' # Port number r'(?:[/?#][^\s]*)?' # Path r'\Z', re.IGNORECASE) - schemes = settings.ALLOWED_URL_SCHEMES + + def __init__(self, schemes=None, **kwargs): + super().__init__(**kwargs) + if schemes is not None: + self.schemes = Config().ALLOWED_URL_SCHEMES class ExclusionValidator(BaseValidator): From 94804fecd8c00043dcdf50c45393f86a1e87b6c7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 26 Oct 2021 10:57:33 -0400 Subject: [PATCH 49/68] Add MAINTENANCE_MODE, MAPS_URL --- netbox/extras/admin.py | 6 +++--- netbox/netbox/config/parameters.py | 19 +++++++++++++++++-- netbox/netbox/configuration.example.py | 6 ------ netbox/netbox/settings.py | 2 -- netbox/templates/base/layout.html | 2 +- netbox/templates/dcim/site.html | 4 ++-- netbox/users/views.py | 4 ++-- 7 files changed, 25 insertions(+), 18 deletions(-) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 5e2de7e16..6df9c55cf 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -28,9 +28,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin): # ('Pagination', { # 'fields': ('MAX_PAGE_SIZE', 'PAGINATE_COUNT'), # }), - # ('Miscellaneous', { - # 'fields': ('GRAPHQL_ENABLED', 'METRICS_ENABLED', 'MAINTENANCE_MODE', 'MAPS_URL'), - # }), + ('Miscellaneous', { + 'fields': ('MAINTENANCE_MODE', 'MAPS_URL'), + }), ('Config Revision', { 'fields': ('comment',), }) diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 8ad02a5dd..4e77cec0e 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -56,14 +56,14 @@ PARAMS = ( # Racks ConfigParam( name='RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', - label='Rack Unit Height', + label='Rack unit height', default=22, description="Default unit height for rendered rack elevations", field=forms.IntegerField ), ConfigParam( name='RACK_ELEVATION_DEFAULT_UNIT_WIDTH', - label='Rack Unit Width', + label='Rack unit width', default=220, description="Default unit width for rendered rack elevations", field=forms.IntegerField @@ -82,4 +82,19 @@ PARAMS = ( field_kwargs={'base_field': forms.CharField()} ), + # Miscellaneous + ConfigParam( + name='MAINTENANCE_MODE', + label='Maintenance mode', + default=False, + description="Enable maintenance mode", + field=OptionalBooleanField + ), + ConfigParam( + name='MAPS_URL', + label='Maps URL', + default='https://maps.google.com/?q=', + description="Base URL for mapping geographic locations" + ), + ) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 63e74524a..c40ea4eff 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -158,12 +158,6 @@ LOGIN_REQUIRED = False # re-authenticate. (Default: 1209600 [14 days]) LOGIN_TIMEOUT = None -# Setting this to True will display a "maintenance mode" banner at the top of every page. -MAINTENANCE_MODE = False - -# The URL to use when mapping physical addresses or GPS coordinates -MAPS_URL = 'https://maps.google.com/?q=' - # An API consumer can request an arbitrary number of objects =by appending the "limit" parameter to the URL (e.g. # "?limit=1000"). This setting defines the maximum limit. Setting it to 0 or None will allow an API consumer to request # all objects by specifying "?limit=0". diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f42c99dbf..beae4d568 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -128,8 +128,6 @@ GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True) LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) -MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) -MAPS_URL = getattr(configuration, 'MAPS_URL', 'https://maps.google.com/?q=') MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index a4c8c77b6..2770a6dc6 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -64,7 +64,7 @@ {% endif %} - {% if settings.MAINTENANCE_MODE %} + {% if config.MAINTENANCE_MODE %}
{{ script.filename }} -
{{ script.source }}
+
{{ script.source }}
{% endblock content-wrapper %} diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html index f463b0f2c..3cbd0c611 100644 --- a/netbox/templates/extras/script_result.html +++ b/netbox/templates/extras/script_result.html @@ -102,11 +102,11 @@ {% endif %}
-
{{ result.data.output }}
+
{{ result.data.output }}

{{ script.filename }}

-
{{ script.source }}
+
{{ script.source }}
{% endblock content-wrapper %}