From bb9e1ad85713223b001e677762b849227e39efa1 Mon Sep 17 00:00:00 2001 From: Saria Hajjar Date: Thu, 6 Feb 2020 23:10:38 +0000 Subject: [PATCH 01/38] Fixes #4100: Added device field to device components filter forms --- netbox/dcim/forms.py | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 34f18f6d6..e91695233 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -84,7 +84,18 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): to_field_name='slug', widget=APISelectMultiple( api_url="/api/dcim/sites/", - value_field="slug" + value_field="slug", + filter_for={ + 'device_id': 'site', + } + ) + ) + device_id = FilterChoiceField( + queryset=Device.objects.all(), + required=False, + label='Device', + widget=APISelectMultiple( + api_url='/api/dcim/devices/', ) ) @@ -3642,6 +3653,7 @@ class CableFilterForm(BootstrapMixin, forms.Form): value_field="slug", filter_for={ 'rack_id': 'site', + 'device_id': 'site', } ) ) @@ -3663,6 +3675,9 @@ class CableFilterForm(BootstrapMixin, forms.Form): widget=APISelectMultiple( api_url="/api/dcim/racks/", null_option=True, + filter_for={ + 'device_id': 'rack_id', + } ) ) type = forms.MultipleChoiceField( @@ -3816,6 +3831,9 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", + filter_for={ + 'device_id': 'site', + } ) ) device_id = FilterChoiceField( @@ -3835,6 +3853,9 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form): widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", + filter_for={ + 'device_id': 'site', + } ) ) device_id = FilterChoiceField( @@ -3854,6 +3875,9 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", + filter_for={ + 'device_id': 'site', + } ) ) device_id = FilterChoiceField( From 6d242ec3483b7dc1fa7c70b88e1f58716a6b37ef Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Feb 2020 20:46:19 -0500 Subject: [PATCH 02/38] Correct typo --- .../{device_component_list.html => consoleport_list.html} | 5 ++--- netbox/templates/inc/nav_menu.html | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) rename netbox/templates/dcim/{device_component_list.html => consoleport_list.html} (59%) diff --git a/netbox/templates/dcim/device_component_list.html b/netbox/templates/dcim/consoleport_list.html similarity index 59% rename from netbox/templates/dcim/device_component_list.html rename to netbox/templates/dcim/consoleport_list.html index 28322973e..728d1ce71 100644 --- a/netbox/templates/dcim/device_component_list.html +++ b/netbox/templates/dcim/consoleport_list.html @@ -6,11 +6,10 @@
{% export_button content_type %}
-

{% block title %}{{ table.Meta.model|model_name|capfirst }}s{% endblock %}

+

{% block title %}{{ table.Meta.model|model_name_plural|bettertitle }}{% endblock %}

- {% include 'responsive_table.html' %} - {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% include 'utilities/obj_table.html' %}
{% include 'inc/search_panel.html' %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index a8522eef5..eeb520a57 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -239,7 +239,7 @@
{% endif %} - Power Outlet + Power Outlets {% if perms.dcim.add_devicebay %} From 4563749fd97ca4200715d04a74cb23337073df39 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Feb 2020 20:58:14 -0500 Subject: [PATCH 03/38] Enable bulk edit/delete views for all device components --- netbox/dcim/forms.py | 52 +++++++++++++++++++ netbox/dcim/tests/test_views.py | 14 ++++- netbox/dcim/urls.py | 6 ++- netbox/dcim/views.py | 30 ++++++++--- netbox/templates/dcim/consoleport_list.html | 5 +- .../dcim/consoleserverport_list.html | 17 ++++++ netbox/templates/dcim/devicebay_list.html | 17 ++++++ netbox/templates/dcim/frontport_list.html | 17 ++++++ netbox/templates/dcim/interface_list.html | 17 ++++++ netbox/templates/dcim/poweroutlet_list.html | 17 ++++++ netbox/templates/dcim/powerport_list.html | 17 ++++++ netbox/templates/dcim/rearport_list.html | 17 ++++++ 12 files changed, 211 insertions(+), 15 deletions(-) create mode 100644 netbox/templates/dcim/consoleserverport_list.html create mode 100644 netbox/templates/dcim/devicebay_list.html create mode 100644 netbox/templates/dcim/frontport_list.html create mode 100644 netbox/templates/dcim/interface_list.html create mode 100644 netbox/templates/dcim/poweroutlet_list.html create mode 100644 netbox/templates/dcim/powerport_list.html create mode 100644 netbox/templates/dcim/rearport_list.html diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 9041468d8..b2255fab6 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2371,6 +2371,27 @@ class ConsolePortCreateForm(BootstrapMixin, forms.Form): ) +class ConsolePortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConsolePort.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(ConsolePortTypeChoices), + required=False, + widget=StaticSelect2() + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = ( + 'description', + ) + + class ConsolePortCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), @@ -2544,6 +2565,37 @@ class PowerPortCreateForm(BootstrapMixin, forms.Form): ) +class PowerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerPort.objects.all(), + widget=forms.MultipleHiddenInput() + ) + type = forms.ChoiceField( + choices=add_blank_choice(PowerPortTypeChoices), + required=False, + widget=StaticSelect2() + ) + maximum_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Maximum draw in watts" + ) + allocated_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Allocated draw in watts" + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = ( + 'description', + ) + + class PowerPortCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 2f157d96d..f8282833c 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1070,7 +1070,6 @@ class ConsolePortTestCase(StandardTestCases.Views): # Disable inapplicable views test_get_object = None test_create_object = None - test_bulk_edit_objects = None def test_bulk_create_objects(self): return self._test_bulk_create_objects(expected_count=3) @@ -1101,6 +1100,11 @@ class ConsolePortTestCase(StandardTestCases.Views): 'tags': 'Alpha,Bravo,Charlie', } + cls.bulk_edit_data = { + 'type': ConsolePortTypeChoices.TYPE_RJ45, + 'description': 'New description', + } + cls.csv_data = ( "device,name", "Device 1,Console Port 4", @@ -1164,7 +1168,6 @@ class PowerPortTestCase(StandardTestCases.Views): # Disable inapplicable views test_get_object = None - test_bulk_edit_objects = None test_create_object = None def test_bulk_create_objects(self): @@ -1200,6 +1203,13 @@ class PowerPortTestCase(StandardTestCases.Views): 'tags': 'Alpha,Bravo,Charlie', } + cls.bulk_edit_data = { + 'type': PowerPortTypeChoices.TYPE_IEC_C14, + 'maximum_draw': 100, + 'allocated_draw': 50, + 'description': 'New description', + } + cls.csv_data = ( "device,name", "Device 1,Power Port 4", diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 94d1d82ed..07d86cc36 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -178,7 +178,8 @@ urlpatterns = [ path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'), path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'), path('console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'), - # TODO: Bulk edit, rename, disconnect views for ConsolePorts + path('console-ports/edit/', views.ConsolePortBulkEditView.as_view(), name='consoleport_bulk_edit'), + # TODO: Bulk rename, disconnect views for ConsolePorts path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), path('console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), path('console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), @@ -204,7 +205,8 @@ urlpatterns = [ path('power-ports/', views.PowerPortListView.as_view(), name='powerport_list'), path('power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'), path('power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'), - # TODO: Bulk edit, rename, disconnect views for PowerPorts + path('power-ports/edit/', views.PowerPortBulkEditView.as_view(), name='powerport_bulk_edit'), + # TODO: Bulk rename, disconnect views for PowerPorts path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), path('power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), path('power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index f8d6e2211..824961b3e 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1224,7 +1224,7 @@ class ConsolePortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm table = tables.ConsolePortDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/consoleport_list.html' class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1253,6 +1253,13 @@ class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView): default_return_url = 'dcim:consoleport_list' +class ConsolePortBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_consoleport' + queryset = ConsolePort.objects.all() + table = tables.ConsolePortTable + form = forms.ConsolePortBulkEditForm + + class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleport' queryset = ConsolePort.objects.all() @@ -1270,7 +1277,7 @@ class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm table = tables.ConsoleServerPortDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/consoleserverport_list.html' class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1335,7 +1342,7 @@ class PowerPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm table = tables.PowerPortDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/powerport_list.html' class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1364,6 +1371,13 @@ class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView): default_return_url = 'dcim:powerport_list' +class PowerPortBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_powerport' + queryset = PowerPort.objects.all() + table = tables.PowerPortTable + form = forms.PowerPortBulkEditForm + + class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_powerport' queryset = PowerPort.objects.all() @@ -1381,7 +1395,7 @@ class PowerOutletListView(PermissionRequiredMixin, ObjectListView): filterset = filters.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm table = tables.PowerOutletDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/poweroutlet_list.html' class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1446,7 +1460,7 @@ class InterfaceListView(PermissionRequiredMixin, ObjectListView): filterset = filters.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm table = tables.InterfaceDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/interface_list.html' class InterfaceView(PermissionRequiredMixin, View): @@ -1548,7 +1562,7 @@ class FrontPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm table = tables.FrontPortDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/frontport_list.html' class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1613,7 +1627,7 @@ class RearPortListView(PermissionRequiredMixin, ObjectListView): filterset = filters.RearPortFilterSet filterset_form = forms.RearPortFilterForm table = tables.RearPortDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/rearport_list.html' class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -1680,7 +1694,7 @@ class DeviceBayListView(PermissionRequiredMixin, ObjectListView): filterset = filters.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm table = tables.DeviceBayDetailTable - template_name = 'dcim/device_component_list.html' + template_name = 'dcim/devicebay_list.html' class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView): diff --git a/netbox/templates/dcim/consoleport_list.html b/netbox/templates/dcim/consoleport_list.html index 728d1ce71..0ed840820 100644 --- a/netbox/templates/dcim/consoleport_list.html +++ b/netbox/templates/dcim/consoleport_list.html @@ -1,15 +1,14 @@ {% extends '_base.html' %} {% load buttons %} -{% load helpers %} {% block content %}
{% export_button content_type %}
-

{% block title %}{{ table.Meta.model|model_name_plural|bettertitle }}{% endblock %}

+

{% block title %}Console Ports{% endblock %}

- {% include 'utilities/obj_table.html' %} + {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:consoleport_bulk_edit' bulk_delete_url='dcim:consoleport_bulk_delete' %}
{% include 'inc/search_panel.html' %} diff --git a/netbox/templates/dcim/consoleserverport_list.html b/netbox/templates/dcim/consoleserverport_list.html new file mode 100644 index 000000000..47a8676e3 --- /dev/null +++ b/netbox/templates/dcim/consoleserverport_list.html @@ -0,0 +1,17 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% export_button content_type %} +
+

{% block title %}Console Server Ports{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:consoleserverport_bulk_edit' bulk_delete_url='dcim:consoleserverport_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/devicebay_list.html b/netbox/templates/dcim/devicebay_list.html new file mode 100644 index 000000000..74f64858a --- /dev/null +++ b/netbox/templates/dcim/devicebay_list.html @@ -0,0 +1,17 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% export_button content_type %} +
+

{% block title %}Device Bays{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:devicebay_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/frontport_list.html b/netbox/templates/dcim/frontport_list.html new file mode 100644 index 000000000..a3334b876 --- /dev/null +++ b/netbox/templates/dcim/frontport_list.html @@ -0,0 +1,17 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% export_button content_type %} +
+

{% block title %}Front Ports{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:frontport_bulk_edit' bulk_delete_url='dcim:frontport_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/interface_list.html b/netbox/templates/dcim/interface_list.html new file mode 100644 index 000000000..9dd8f7858 --- /dev/null +++ b/netbox/templates/dcim/interface_list.html @@ -0,0 +1,17 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% export_button content_type %} +
+

{% block title %}Interfaces{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:interface_bulk_edit' bulk_delete_url='dcim:interface_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet_list.html b/netbox/templates/dcim/poweroutlet_list.html new file mode 100644 index 000000000..2e842d699 --- /dev/null +++ b/netbox/templates/dcim/poweroutlet_list.html @@ -0,0 +1,17 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% export_button content_type %} +
+

{% block title %}Power Outlets{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:poweroutlet_bulk_edit' bulk_delete_url='dcim:poweroutlet_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/powerport_list.html b/netbox/templates/dcim/powerport_list.html new file mode 100644 index 000000000..b5830edca --- /dev/null +++ b/netbox/templates/dcim/powerport_list.html @@ -0,0 +1,17 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% export_button content_type %} +
+

{% block title %}Power Ports{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:powerport_bulk_edit' bulk_delete_url='dcim:powerport_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/rearport_list.html b/netbox/templates/dcim/rearport_list.html new file mode 100644 index 000000000..cc603d620 --- /dev/null +++ b/netbox/templates/dcim/rearport_list.html @@ -0,0 +1,17 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
+ {% export_button content_type %} +
+

{% block title %}Rear Ports{% endblock %}

+
+
+ {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rearport_bulk_edit' bulk_delete_url='dcim:rearport_bulk_delete' %} +
+
+ {% include 'inc/search_panel.html' %} +
+
+{% endblock %} From 52257467c377da31fdf6886af7a115a9a0529cc3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Feb 2020 21:44:28 -0500 Subject: [PATCH 04/38] Tweak bulk edit views to dynamically remove device-dependent fields --- netbox/dcim/forms.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index b2255fab6..0877dfbf7 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2747,6 +2747,7 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): ) device = forms.ModelChoiceField( queryset=Device.objects.all(), + required=False, widget=forms.HiddenInput() ) type = forms.ChoiceField( @@ -2778,6 +2779,9 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): if 'device' in self.initial: device = Device.objects.filter(pk=self.initial['device']).first() self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) + else: + self.fields['power_port'].choices = () + self.fields['power_port'].widget.attrs['disabled'] = True class PowerOutletBulkRenameForm(BulkRenameForm): @@ -3021,6 +3025,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): ) device = forms.ModelChoiceField( queryset=Device.objects.all(), + required=False, widget=forms.HiddenInput() ) type = forms.ChoiceField( @@ -3096,6 +3101,9 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): device__in=[device, device.get_vc_master()], type=InterfaceTypeChoices.TYPE_LAG ) + else: + self.fields['lag'].choices = () + self.fields['lag'].widget.attrs['disabled'] = True def clean(self): From 4cc9f2f67de7602ae79f4faedac6766715a88c77 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 6 Feb 2020 21:52:10 -0500 Subject: [PATCH 05/38] Changelog for #4116 --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index cce66932d..35580a1d0 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -3,6 +3,7 @@ ## Enhancements * [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components +* [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views ## Bug Fixes From b3c2b78e8abe28183f360085ba7e4e587e0ce241 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Feb 2020 09:24:51 -0500 Subject: [PATCH 06/38] Chnagelog for #4100 --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 35580a1d0..8f341d2a1 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -2,6 +2,7 @@ ## Enhancements +* [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views * [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components * [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views From b271fd32bdadd6f9cbd5e2261554f70837cd06ac Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Feb 2020 11:36:22 -0500 Subject: [PATCH 07/38] Introduce NaturalOrderingField --- netbox/utilities/fields.py | 33 +++++++++++++++++++++++++++++++++ netbox/utilities/ordering.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 netbox/utilities/ordering.py diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index d2165aea6..6181a7ca1 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -1,6 +1,7 @@ from django.core.validators import RegexValidator from django.db import models +from utilities.ordering import naturalize from .forms import ColorSelect ColorValidator = RegexValidator( @@ -35,3 +36,35 @@ class ColorField(models.CharField): def formfield(self, **kwargs): kwargs['widget'] = ColorSelect return super().formfield(**kwargs) + + +class NaturalOrderingField(models.CharField): + """ + A field which stores a naturalized representation of its target field, to be used for ordering its parent model. + + :param target_field: Name of the field of the parent model to be naturalized + :param naturalize_function: The function used to generate a naturalized value (optional) + """ + description = "Stores a representation of its target field suitable for natural ordering" + + def __init__(self, target_field, naturalize_function=naturalize, *args, **kwargs): + self.target_field = target_field + self.naturalize_function = naturalize_function + super().__init__(*args, **kwargs) + + def pre_save(self, model_instance, add): + """ + Generate a naturalized value from the target field + """ + value = getattr(model_instance, self.target_field) + return self.naturalize_function(value, max_length=self.max_length) + + def deconstruct(self): + kwargs = super().deconstruct()[3] # Pass kwargs from CharField + kwargs['naturalize_function'] = self.naturalize_function + return ( + self.name, + 'utilities.fields.NaturalOrderingField', + ['target_field'], + kwargs, + ) diff --git a/netbox/utilities/ordering.py b/netbox/utilities/ordering.py new file mode 100644 index 000000000..2d6c06f63 --- /dev/null +++ b/netbox/utilities/ordering.py @@ -0,0 +1,31 @@ +import re + + +def naturalize(value, max_length=None, integer_places=8): + """ + Take an alphanumeric string and prepend all integers to `integer_places` places to ensure the strings + are ordered naturally. For example: + + site9router21 + site10router4 + site10router19 + + becomes: + + site00000009router00000021 + site00000010router00000004 + site00000010router00000019 + + :param value: The value to be naturalized + :param max_length: The maximum length of the returned string. Characters beyond this length will be stripped. + :param integer_places: The number of places to which each integer will be expanded. (Default: 8) + """ + output = [] + for segment in re.split(r'(\d+)', value): + if segment.isdigit(): + output.append(segment.rjust(integer_places, '0')) + elif segment: + output.append(segment) + ret = ''.join(output) + + return ret[:max_length] if max_length else ret From 12d09e2274d46e242dcc50128202d117a3415ee8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Feb 2020 11:36:58 -0500 Subject: [PATCH 08/38] Convert device components to use NaturalOrderingField --- .../0093_device_component_ordering.py | 140 ++++++++++++++++++ netbox/dcim/models/device_components.py | 98 +++++++----- 2 files changed, 202 insertions(+), 36 deletions(-) create mode 100644 netbox/dcim/migrations/0093_device_component_ordering.py diff --git a/netbox/dcim/migrations/0093_device_component_ordering.py b/netbox/dcim/migrations/0093_device_component_ordering.py new file mode 100644 index 000000000..371436b64 --- /dev/null +++ b/netbox/dcim/migrations/0093_device_component_ordering.py @@ -0,0 +1,140 @@ +from django.db import migrations +import utilities.fields +import utilities.ordering + + +def _update_model_names(model): + # Update each unique field value in bulk + for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name)) + + +def naturalize_consoleports(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'ConsolePort')) + + +def naturalize_consoleserverports(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'ConsoleServerPort')) + + +def naturalize_powerports(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'PowerPort')) + + +def naturalize_poweroutlets(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'PowerPort')) + + +def naturalize_frontports(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'FrontPort')) + + +def naturalize_rearports(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'RearPort')) + + +def naturalize_devicebays(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'DeviceBay')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0092_fix_rack_outer_unit'), + ] + + operations = [ + migrations.AlterModelOptions( + name='consoleport', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='consoleserverport', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='devicebay', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='frontport', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='inventoryitem', + options={'ordering': ('device__id', 'parent__id', '_name')}, + ), + migrations.AlterModelOptions( + name='poweroutlet', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='powerport', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='rearport', + options={'ordering': ('device', '_name')}, + ), + migrations.AddField( + model_name='consoleport', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='consoleserverport', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='devicebay', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='frontport', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='inventoryitem', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='poweroutlet', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='powerport', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='rearport', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.RunPython( + code=naturalize_consoleports + ), + migrations.RunPython( + code=naturalize_consoleserverports + ), + migrations.RunPython( + code=naturalize_powerports + ), + migrations.RunPython( + code=naturalize_poweroutlets + ), + migrations.RunPython( + code=naturalize_frontports + ), + migrations.RunPython( + code=naturalize_rearports + ), + migrations.RunPython( + code=naturalize_devicebays + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 431419a7a..3eb9ac74e 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -12,7 +12,7 @@ from dcim.exceptions import LoopDetected from dcim.fields import MACAddressField from dcim.managers import InterfaceManager from extras.models import ObjectChange, TaggedItem -from utilities.managers import NaturalOrderingManager +from utilities.fields import NaturalOrderingField from utilities.utils import serialize_object from virtualization.choices import VMInterfaceTypeChoices @@ -181,6 +181,11 @@ class ConsolePort(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, @@ -197,15 +202,13 @@ class ConsolePort(CableTermination, ComponentModel): choices=CONNECTION_STATUS_CHOICES, blank=True ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'description'] class Meta: - ordering = ['device', 'name'] - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -238,6 +241,11 @@ class ConsoleServerPort(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, @@ -247,14 +255,13 @@ class ConsoleServerPort(CableTermination, ComponentModel): choices=CONNECTION_STATUS_CHOICES, blank=True ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'description'] class Meta: - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -287,6 +294,11 @@ class PowerPort(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PowerPortTypeChoices, @@ -322,15 +334,13 @@ class PowerPort(CableTermination, ComponentModel): choices=CONNECTION_STATUS_CHOICES, blank=True ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description'] class Meta: - ordering = ['device', 'name'] - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -433,6 +443,11 @@ class PowerOutlet(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PowerOutletTypeChoices, @@ -455,14 +470,13 @@ class PowerOutlet(CableTermination, ComponentModel): choices=CONNECTION_STATUS_CHOICES, blank=True ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'power_port', 'feed_leg', 'description'] class Meta: - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -761,6 +775,11 @@ class FrontPort(CableTermination, ComponentModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PortTypeChoices @@ -774,20 +793,17 @@ class FrontPort(CableTermination, ComponentModel): default=1, validators=[MinValueValidator(1), MaxValueValidator(64)] ) - - is_path_endpoint = False - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] + is_path_endpoint = False class Meta: - ordering = ['device', 'name'] - unique_together = [ - ['device', 'name'], - ['rear_port', 'rear_port_position'], - ] + ordering = ('device', '_name') + unique_together = ( + ('device', 'name'), + ('rear_port', 'rear_port_position'), + ) def __str__(self): return self.name @@ -831,6 +847,11 @@ class RearPort(CableTermination, ComponentModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PortTypeChoices @@ -839,17 +860,14 @@ class RearPort(CableTermination, ComponentModel): default=1, validators=[MinValueValidator(1), MaxValueValidator(64)] ) - - is_path_endpoint = False - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'positions', 'description'] + is_path_endpoint = False class Meta: - ordering = ['device', 'name'] - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name @@ -881,6 +899,11 @@ class DeviceBay(ComponentModel): max_length=50, verbose_name='Name' ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) installed_device = models.OneToOneField( to='dcim.Device', on_delete=models.SET_NULL, @@ -888,15 +911,13 @@ class DeviceBay(ComponentModel): blank=True, null=True ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'installed_device', 'description'] class Meta: - ordering = ['device', 'name'] - unique_together = ['device', 'name'] + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return '{} - {}'.format(self.device.name, self.name) @@ -960,6 +981,11 @@ class InventoryItem(ComponentModel): max_length=50, verbose_name='Name' ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) manufacturer = models.ForeignKey( to='dcim.Manufacturer', on_delete=models.PROTECT, @@ -997,8 +1023,8 @@ class InventoryItem(ComponentModel): ] class Meta: - ordering = ['device__id', 'parent__id', 'name'] - unique_together = ['device', 'parent', 'name'] + ordering = ('device__id', 'parent__id', '_name') + unique_together = ('device', 'parent', 'name') def __str__(self): return self.name From 705c35288542263113095ec7bd896882f1a8ae4e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Feb 2020 11:42:12 -0500 Subject: [PATCH 09/38] Convert device component templates to use NaturalOrderingField --- ...0094_device_component_template_ordering.py | 131 ++++++++++++++++++ .../dcim/models/device_component_templates.py | 85 +++++++----- 2 files changed, 184 insertions(+), 32 deletions(-) create mode 100644 netbox/dcim/migrations/0094_device_component_template_ordering.py diff --git a/netbox/dcim/migrations/0094_device_component_template_ordering.py b/netbox/dcim/migrations/0094_device_component_template_ordering.py new file mode 100644 index 000000000..42d49c83e --- /dev/null +++ b/netbox/dcim/migrations/0094_device_component_template_ordering.py @@ -0,0 +1,131 @@ +from django.db import migrations +import utilities.fields +import utilities.ordering + + +def _update_model_names(model): + # Update each unique field value in bulk + for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name)) + + +def naturalize_consoleporttemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'ConsolePortTemplate')) + + +def naturalize_consoleserverporttemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'ConsoleServerPortTemplate')) + + +def naturalize_powerporttemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'PowerPortTemplate')) + + +def naturalize_poweroutlettemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'PowerPortTemplate')) + + +def naturalize_frontporttemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'FrontPortTemplate')) + + +def naturalize_rearporttemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'RearPortTemplate')) + + +def naturalize_devicebaytemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'DeviceBayTemplate')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0093_device_component_ordering'), + ] + + operations = [ + migrations.AlterModelOptions( + name='consoleporttemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='consoleserverporttemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='devicebaytemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='frontporttemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='poweroutlettemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='powerporttemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AlterModelOptions( + name='rearporttemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AddField( + model_name='consoleporttemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='frontporttemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='powerporttemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='rearporttemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.RunPython( + code=naturalize_consoleporttemplates + ), + migrations.RunPython( + code=naturalize_consoleserverporttemplates + ), + migrations.RunPython( + code=naturalize_powerporttemplates + ), + migrations.RunPython( + code=naturalize_poweroutlettemplates + ), + migrations.RunPython( + code=naturalize_frontporttemplates + ), + migrations.RunPython( + code=naturalize_rearporttemplates + ), + migrations.RunPython( + code=naturalize_devicebaytemplates + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 2aa46d0ea..0b5c312ba 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -6,7 +6,7 @@ from dcim.choices import * from dcim.constants import * from dcim.managers import InterfaceManager from extras.models import ObjectChange -from utilities.managers import NaturalOrderingManager +from utilities.fields import NaturalOrderingField from utilities.utils import serialize_object from .device_components import ( ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort, @@ -58,17 +58,20 @@ class ConsolePortTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, blank=True ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -93,17 +96,20 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=ConsolePortTypeChoices, blank=True ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -128,6 +134,11 @@ class PowerPortTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PowerPortTypeChoices, @@ -146,11 +157,9 @@ class PowerPortTemplate(ComponentTemplateModel): help_text="Allocated power draw (watts)" ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -176,6 +185,11 @@ class PowerOutletTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PowerOutletTypeChoices, @@ -195,11 +209,9 @@ class PowerOutletTemplate(ComponentTemplateModel): help_text="Phase (for three-phase feeds)" ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -276,6 +288,11 @@ class FrontPortTemplate(ComponentTemplateModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PortTypeChoices @@ -290,14 +307,12 @@ class FrontPortTemplate(ComponentTemplateModel): validators=[MinValueValidator(1), MaxValueValidator(64)] ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = [ - ['device_type', 'name'], - ['rear_port', 'rear_port_position'], - ] + ordering = ('device_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('rear_port', 'rear_port_position'), + ) def __str__(self): return self.name @@ -344,6 +359,11 @@ class RearPortTemplate(ComponentTemplateModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=PortTypeChoices @@ -353,11 +373,9 @@ class RearPortTemplate(ComponentTemplateModel): validators=[MinValueValidator(1), MaxValueValidator(64)] ) - objects = NaturalOrderingManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name @@ -383,12 +401,15 @@ class DeviceBayTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) - - objects = NaturalOrderingManager() + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name From 099c446f389344faec5f60e2d2352dd2740361fd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Feb 2020 11:54:34 -0500 Subject: [PATCH 10/38] Convert remaining DCIM models to use NaturalOrderingField --- .../migrations/0095_primary_model_ordering.py | 67 +++++++++++++++++++ netbox/dcim/models/__init__.py | 48 +++++++------ netbox/dcim/tables.py | 9 +-- netbox/utilities/ordering.py | 2 + 4 files changed, 100 insertions(+), 26 deletions(-) create mode 100644 netbox/dcim/migrations/0095_primary_model_ordering.py diff --git a/netbox/dcim/migrations/0095_primary_model_ordering.py b/netbox/dcim/migrations/0095_primary_model_ordering.py new file mode 100644 index 000000000..7a04aa31d --- /dev/null +++ b/netbox/dcim/migrations/0095_primary_model_ordering.py @@ -0,0 +1,67 @@ +from django.db import migrations +import utilities.fields +import utilities.ordering + + +def _update_model_names(model): + # Update each unique field value in bulk + for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize(name)) + + +def naturalize_sites(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'Site')) + + +def naturalize_racks(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'Rack')) + + +def naturalize_devices(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'Device')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0094_device_component_template_ordering'), + ] + + operations = [ + migrations.AlterModelOptions( + name='device', + options={'ordering': ('_name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))}, + ), + migrations.AlterModelOptions( + name='rack', + options={'ordering': ('site', 'group', '_name', 'pk')}, + ), + migrations.AlterModelOptions( + name='site', + options={'ordering': ('_name',)}, + ), + migrations.AddField( + model_name='device', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='rack', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.AddField( + model_name='site', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + ), + migrations.RunPython( + code=naturalize_sites + ), + migrations.RunPython( + code=naturalize_racks + ), + migrations.RunPython( + code=naturalize_devices + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 2a4988ba4..9b9252bef 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -22,8 +22,7 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import ASNField from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem -from utilities.fields import ColorField -from utilities.managers import NaturalOrderingManager +from utilities.fields import ColorField, NaturalOrderingField from utilities.models import ChangeLoggedModel from utilities.utils import foreground_color, to_meters from .device_component_templates import ( @@ -134,6 +133,11 @@ class Site(ChangeLoggedModel, CustomFieldModel): max_length=50, unique=True ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) slug = models.SlugField( unique=True ) @@ -215,8 +219,6 @@ class Site(ChangeLoggedModel, CustomFieldModel): images = GenericRelation( to='extras.ImageAttachment' ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = [ @@ -235,7 +237,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): } class Meta: - ordering = ['name'] + ordering = ('_name',) def __str__(self): return self.name @@ -516,6 +518,11 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): name = models.CharField( max_length=50 ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) facility_id = models.CharField( max_length=50, blank=True, @@ -612,8 +619,6 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): images = GenericRelation( to='extras.ImageAttachment' ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = [ @@ -634,12 +639,12 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): } class Meta: - ordering = ('site', 'group', 'name', 'pk') # (site, group, name) may be non-unique - unique_together = [ + ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique + unique_together = ( # Name and facility_id must be unique *only* within a RackGroup - ['group', 'name'], - ['group', 'facility_id'], - ] + ('group', 'name'), + ('group', 'facility_id'), + ) def __str__(self): return self.display_name or super().__str__() @@ -1313,6 +1318,11 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): blank=True, null=True ) + _name = NaturalOrderingField( + target_field='name', + max_length=100, + blank=True + ) serial = models.CharField( max_length=50, blank=True, @@ -1407,8 +1417,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): images = GenericRelation( to='extras.ImageAttachment' ) - - objects = NaturalOrderingManager() tags = TaggableManager(through=TaggedItem) csv_headers = [ @@ -1430,12 +1438,12 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): } class Meta: - ordering = ('name', 'pk') # Name may be NULL - unique_together = [ - ['site', 'tenant', 'name'], # See validate_unique below - ['rack', 'position', 'face'], - ['virtual_chassis', 'vc_position'], - ] + ordering = ('_name', 'pk') # Name may be blank + unique_together = ( + ('site', 'tenant', 'name'), # See validate_unique below + ('rack', 'position', 'face'), + ('virtual_chassis', 'vc_position'), + ) permissions = ( ('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'), diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 1b3076c6c..4c1846c54 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -229,7 +229,7 @@ class RegionTable(BaseTable): class SiteTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3')) + name = tables.LinkColumn() status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status') region = tables.TemplateColumn(template_code=SITE_REGION_LINK) tenant = tables.TemplateColumn(template_code=COL_TENANT) @@ -291,7 +291,7 @@ class RackRoleTable(BaseTable): class RackTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(order_by=('_nat1', '_nat2', '_nat3')) + name = tables.LinkColumn() site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') tenant = tables.TemplateColumn(template_code=COL_TENANT) @@ -653,10 +653,7 @@ class PlatformTable(BaseTable): class DeviceTable(BaseTable): pk = ToggleColumn() - name = tables.TemplateColumn( - order_by=('_nat1', '_nat2', '_nat3'), - template_code=DEVICE_LINK - ) + name = tables.TemplateColumn(template_code=DEVICE_LINK) status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status') tenant = tables.TemplateColumn(template_code=COL_TENANT) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) diff --git a/netbox/utilities/ordering.py b/netbox/utilities/ordering.py index 2d6c06f63..fd3010e90 100644 --- a/netbox/utilities/ordering.py +++ b/netbox/utilities/ordering.py @@ -20,6 +20,8 @@ def naturalize(value, max_length=None, integer_places=8): :param max_length: The maximum length of the returned string. Characters beyond this length will be stripped. :param integer_places: The number of places to which each integer will be expanded. (Default: 8) """ + if not value: + return '' output = [] for segment in re.split(r'(\d+)', value): if segment.isdigit(): From 35511cfdc1a2b70c3ac98f951b933e9fe5c076bf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Feb 2020 11:59:32 -0500 Subject: [PATCH 11/38] Remove NaturalOrderingManager --- netbox/utilities/managers.py | 45 ------------------------------------ 1 file changed, 45 deletions(-) delete mode 100644 netbox/utilities/managers.py diff --git a/netbox/utilities/managers.py b/netbox/utilities/managers.py deleted file mode 100644 index ad646a78e..000000000 --- a/netbox/utilities/managers.py +++ /dev/null @@ -1,45 +0,0 @@ -from django.db.models import Manager -from django.db.models.expressions import RawSQL - -NAT1 = r"CAST(SUBSTRING({}.{} FROM '^(\d{{1,9}})') AS integer)" -NAT2 = r"SUBSTRING({}.{} FROM '^\d*(.*?)\d*$')" -NAT3 = r"CAST(SUBSTRING({}.{} FROM '(\d{{1,9}})$') AS integer)" - - -class NaturalOrderingManager(Manager): - """ - Order objects naturally by a designated field (defaults to 'name'). Leading and/or trailing digits of values within - this field will be cast as independent integers and sorted accordingly. For example, "Foo2" will be ordered before - "Foo10", even though the digit 1 is normally ordered before the digit 2. - """ - natural_order_field = 'name' - - def get_queryset(self): - - queryset = super().get_queryset() - - db_table = self.model._meta.db_table - db_field = self.natural_order_field - - # Append the three subfields derived from the designated natural ordering field - queryset = ( - queryset.annotate(_nat1=RawSQL(NAT1.format(db_table, db_field), ())) - .annotate(_nat2=RawSQL(NAT2.format(db_table, db_field), ())) - .annotate(_nat3=RawSQL(NAT3.format(db_table, db_field), ())) - ) - - # Replace any instance of the designated natural ordering field with its three subfields - ordering = [] - for field in self.model._meta.ordering: - if field == self.natural_order_field: - ordering.append('_nat1') - ordering.append('_nat2') - ordering.append('_nat3') - else: - ordering.append(field) - - # Default to using the _nat indexes if Meta.ordering is empty - if not ordering: - ordering = ('_nat1', '_nat2', '_nat3') - - return queryset.order_by(*ordering) From c72a353733420fa40edabf69d4548668b0b8c32b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Feb 2020 12:23:52 -0500 Subject: [PATCH 12/38] Enable reverse migration --- .../0093_device_component_ordering.py | 21 ++++++++++++------- ...0094_device_component_template_ordering.py | 21 ++++++++++++------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/migrations/0093_device_component_ordering.py b/netbox/dcim/migrations/0093_device_component_ordering.py index 371436b64..a7d289892 100644 --- a/netbox/dcim/migrations/0093_device_component_ordering.py +++ b/netbox/dcim/migrations/0093_device_component_ordering.py @@ -117,24 +117,31 @@ class Migration(migrations.Migration): field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.RunPython( - code=naturalize_consoleports + code=naturalize_consoleports, + reverse_code=migrations.RunPython.noop ), migrations.RunPython( - code=naturalize_consoleserverports + code=naturalize_consoleserverports, + reverse_code=migrations.RunPython.noop ), migrations.RunPython( - code=naturalize_powerports + code=naturalize_powerports, + reverse_code=migrations.RunPython.noop ), migrations.RunPython( - code=naturalize_poweroutlets + code=naturalize_poweroutlets, + reverse_code=migrations.RunPython.noop ), migrations.RunPython( - code=naturalize_frontports + code=naturalize_frontports, + reverse_code=migrations.RunPython.noop ), migrations.RunPython( - code=naturalize_rearports + code=naturalize_rearports, + reverse_code=migrations.RunPython.noop ), migrations.RunPython( - code=naturalize_devicebays + code=naturalize_devicebays, + reverse_code=migrations.RunPython.noop ), ] diff --git a/netbox/dcim/migrations/0094_device_component_template_ordering.py b/netbox/dcim/migrations/0094_device_component_template_ordering.py index 42d49c83e..32ae6e383 100644 --- a/netbox/dcim/migrations/0094_device_component_template_ordering.py +++ b/netbox/dcim/migrations/0094_device_component_template_ordering.py @@ -108,24 +108,31 @@ class Migration(migrations.Migration): field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.RunPython( - code=naturalize_consoleporttemplates + code=naturalize_consoleporttemplates, + reverse_code=migrations.RunPython.noop ), migrations.RunPython( - code=naturalize_consoleserverporttemplates + code=naturalize_consoleserverporttemplates, + reverse_code=migrations.RunPython.noop ), migrations.RunPython( - code=naturalize_powerporttemplates + code=naturalize_powerporttemplates, + reverse_code=migrations.RunPython.noop ), migrations.RunPython( - code=naturalize_poweroutlettemplates + code=naturalize_poweroutlettemplates, + reverse_code=migrations.RunPython.noop ), migrations.RunPython( - code=naturalize_frontporttemplates + code=naturalize_frontporttemplates, + reverse_code=migrations.RunPython.noop ), migrations.RunPython( - code=naturalize_rearporttemplates + code=naturalize_rearporttemplates, + reverse_code=migrations.RunPython.noop ), migrations.RunPython( - code=naturalize_devicebaytemplates + code=naturalize_devicebaytemplates, + reverse_code=migrations.RunPython.noop ), ] From dc1b7874ff4b7a7f6a8b2bb96fdb0fc4f58754a2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Feb 2020 12:24:38 -0500 Subject: [PATCH 13/38] Store empty names as null --- netbox/dcim/migrations/0095_primary_model_ordering.py | 11 +++++++---- netbox/dcim/models/__init__.py | 5 +++-- netbox/utilities/ordering.py | 2 +- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/netbox/dcim/migrations/0095_primary_model_ordering.py b/netbox/dcim/migrations/0095_primary_model_ordering.py index 7a04aa31d..9cef0a581 100644 --- a/netbox/dcim/migrations/0095_primary_model_ordering.py +++ b/netbox/dcim/migrations/0095_primary_model_ordering.py @@ -43,7 +43,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='device', name='_name', - field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True), ), migrations.AddField( model_name='rack', @@ -56,12 +56,15 @@ class Migration(migrations.Migration): field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize), ), migrations.RunPython( - code=naturalize_sites + code=naturalize_sites, + reverse_code=migrations.RunPython.noop ), migrations.RunPython( - code=naturalize_racks + code=naturalize_racks, + reverse_code=migrations.RunPython.noop ), migrations.RunPython( - code=naturalize_devices + code=naturalize_devices, + reverse_code=migrations.RunPython.noop ), ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 9b9252bef..c31f4c713 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1321,7 +1321,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): _name = NaturalOrderingField( target_field='name', max_length=100, - blank=True + blank=True, + null=True ) serial = models.CharField( max_length=50, @@ -1438,7 +1439,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): } class Meta: - ordering = ('_name', 'pk') # Name may be blank + ordering = ('_name', 'pk') # Name may be null unique_together = ( ('site', 'tenant', 'name'), # See validate_unique below ('rack', 'position', 'face'), diff --git a/netbox/utilities/ordering.py b/netbox/utilities/ordering.py index fd3010e90..88a46d3d3 100644 --- a/netbox/utilities/ordering.py +++ b/netbox/utilities/ordering.py @@ -21,7 +21,7 @@ def naturalize(value, max_length=None, integer_places=8): :param integer_places: The number of places to which each integer will be expanded. (Default: 8) """ if not value: - return '' + return value output = [] for segment in re.split(r'(\d+)', value): if segment.isdigit(): From 12c7d83a9178ed8bcde13bee53a9217e23f0b61b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Feb 2020 12:43:53 -0500 Subject: [PATCH 14/38] Fix PowerOutlet migrations --- netbox/dcim/migrations/0093_device_component_ordering.py | 2 +- .../dcim/migrations/0094_device_component_template_ordering.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/migrations/0093_device_component_ordering.py b/netbox/dcim/migrations/0093_device_component_ordering.py index a7d289892..017241c8b 100644 --- a/netbox/dcim/migrations/0093_device_component_ordering.py +++ b/netbox/dcim/migrations/0093_device_component_ordering.py @@ -22,7 +22,7 @@ def naturalize_powerports(apps, schema_editor): def naturalize_poweroutlets(apps, schema_editor): - _update_model_names(apps.get_model('dcim', 'PowerPort')) + _update_model_names(apps.get_model('dcim', 'PowerOutlet')) def naturalize_frontports(apps, schema_editor): diff --git a/netbox/dcim/migrations/0094_device_component_template_ordering.py b/netbox/dcim/migrations/0094_device_component_template_ordering.py index 32ae6e383..fc39f76b2 100644 --- a/netbox/dcim/migrations/0094_device_component_template_ordering.py +++ b/netbox/dcim/migrations/0094_device_component_template_ordering.py @@ -22,7 +22,7 @@ def naturalize_powerporttemplates(apps, schema_editor): def naturalize_poweroutlettemplates(apps, schema_editor): - _update_model_names(apps.get_model('dcim', 'PowerPortTemplate')) + _update_model_names(apps.get_model('dcim', 'PowerOutletTemplate')) def naturalize_frontporttemplates(apps, schema_editor): From 9adeed55fb14f49ffaeab18056e2fafebc518f4e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Feb 2020 12:44:51 -0500 Subject: [PATCH 15/38] Update table field ordering --- netbox/dcim/tables.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 4c1846c54..473d465bd 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -229,7 +229,7 @@ class RegionTable(BaseTable): class SiteTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.LinkColumn(order_by=('_name',)) status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status') region = tables.TemplateColumn(template_code=SITE_REGION_LINK) tenant = tables.TemplateColumn(template_code=COL_TENANT) @@ -291,7 +291,7 @@ class RackRoleTable(BaseTable): class RackTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn() + name = tables.LinkColumn(order_by=('_name',)) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') tenant = tables.TemplateColumn(template_code=COL_TENANT) @@ -409,6 +409,7 @@ class DeviceTypeTable(BaseTable): class ConsolePortTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('consoleporttemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -432,6 +433,7 @@ class ConsolePortImportTable(BaseTable): class ConsoleServerPortTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('consoleserverporttemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -455,6 +457,7 @@ class ConsoleServerPortImportTable(BaseTable): class PowerPortTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('powerporttemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -478,6 +481,7 @@ class PowerPortImportTable(BaseTable): class PowerOutletTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('poweroutlettemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -526,6 +530,7 @@ class InterfaceImportTable(BaseTable): class FrontPortTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) rear_port_position = tables.Column( verbose_name='Position' ) @@ -552,6 +557,7 @@ class FrontPortImportTable(BaseTable): class RearPortTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('rearporttemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -575,6 +581,7 @@ class RearPortImportTable(BaseTable): class DeviceBayTemplateTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) actions = tables.TemplateColumn( template_code=get_component_template_actions('devicebaytemplate'), attrs={'td': {'class': 'text-right noprint'}}, @@ -653,7 +660,10 @@ class PlatformTable(BaseTable): class DeviceTable(BaseTable): pk = ToggleColumn() - name = tables.TemplateColumn(template_code=DEVICE_LINK) + name = tables.TemplateColumn( + order_by=('_name',), + template_code=DEVICE_LINK + ) status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status') tenant = tables.TemplateColumn(template_code=COL_TENANT) site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')]) @@ -701,6 +711,7 @@ class DeviceImportTable(BaseTable): class DeviceComponentDetailTable(BaseTable): pk = ToggleColumn() + name = tables.Column(order_by=('_name',)) cable = tables.LinkColumn() class Meta(BaseTable.Meta): @@ -710,6 +721,7 @@ class DeviceComponentDetailTable(BaseTable): class ConsolePortTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = ConsolePort @@ -724,6 +736,7 @@ class ConsolePortDetailTable(DeviceComponentDetailTable): class ConsoleServerPortTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = ConsoleServerPort @@ -738,6 +751,7 @@ class ConsoleServerPortDetailTable(DeviceComponentDetailTable): class PowerPortTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = PowerPort @@ -752,6 +766,7 @@ class PowerPortDetailTable(DeviceComponentDetailTable): class PowerOutletTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = PowerOutlet @@ -783,6 +798,7 @@ class InterfaceDetailTable(DeviceComponentDetailTable): class FrontPortTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = FrontPort @@ -798,6 +814,7 @@ class FrontPortDetailTable(DeviceComponentDetailTable): class RearPortTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = RearPort @@ -813,6 +830,7 @@ class RearPortDetailTable(DeviceComponentDetailTable): class DeviceBayTable(BaseTable): + name = tables.Column(order_by=('_name',)) class Meta(BaseTable.Meta): model = DeviceBay From 7c74d2ca657e3ed019abfee0230c59e3b425fd3c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Feb 2020 15:47:53 -0500 Subject: [PATCH 16/38] Convert interface models to use NaturalOrderingField --- netbox/dcim/managers.py | 56 +------------------ .../migrations/0096_interface_ordering.py | 53 ++++++++++++++++++ .../dcim/models/device_component_templates.py | 14 +++-- netbox/dcim/models/device_components.py | 15 +++-- netbox/utilities/ordering.py | 47 ++++++++++++++++ 5 files changed, 120 insertions(+), 65 deletions(-) create mode 100644 netbox/dcim/migrations/0096_interface_ordering.py diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py index e1124b84e..502719646 100644 --- a/netbox/dcim/managers.py +++ b/netbox/dcim/managers.py @@ -1,18 +1,7 @@ from django.db.models import Manager, QuerySet -from django.db.models.expressions import RawSQL from .constants import NONCONNECTABLE_IFACE_TYPES -# Regular expressions for parsing Interface names -TYPE_RE = r"SUBSTRING({} FROM '^([^0-9\.:]+)')" -SLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(\d{{1,9}})/') AS integer), NULL)" -SUBSLOT_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?\d{{1,9}}/(\d{{1,9}})') AS integer), NULL)" -POSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{2}}(\d{{1,9}})') AS integer), NULL)" -SUBPOSITION_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^(?:[^0-9]+)?(?:\d{{1,9}}/){{3}}(\d{{1,9}})') AS integer), NULL)" -ID_RE = r"CAST(SUBSTRING({} FROM '^(?:[^0-9\.:]+)?(\d{{1,9}})([^/]|$)') AS integer)" -CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*:(\d{{1,9}})(\.\d{{1,9}})?$') AS integer), 0)" -VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*\.(\d{{1,9}})$') AS integer), 0)" - class InterfaceQuerySet(QuerySet): @@ -27,47 +16,4 @@ class InterfaceQuerySet(QuerySet): class InterfaceManager(Manager): def get_queryset(self): - """ - Naturally order interfaces by their type and numeric position. To order interfaces naturally, the `name` field - is split into eight distinct components: leading text (type), slot, subslot, position, subposition, ID, channel, - and virtual circuit: - - {type}{slot or ID}/{subslot}/{position}/{subposition}:{channel}.{vc} - - Components absent from the interface name are coalesced to zero or null. For example, an interface named - GigabitEthernet1/2/3 would be parsed as follows: - - type = 'GigabitEthernet' - slot = 1 - subslot = 2 - position = 3 - subposition = None - id = None - channel = 0 - vc = 0 - - The original `name` field is considered in its entirety to serve as a fallback in the event interfaces do not - match any of the prescribed fields. - - The `id` field is included to enforce deterministic ordering of interfaces in similar vein of other device - components. - """ - - sql_col = '{}.name'.format(self.model._meta.db_table) - ordering = [ - '_slot', '_subslot', '_position', '_subposition', '_type', '_id', '_channel', '_vc', 'name', 'pk' - - ] - - fields = { - '_type': RawSQL(TYPE_RE.format(sql_col), []), - '_id': RawSQL(ID_RE.format(sql_col), []), - '_slot': RawSQL(SLOT_RE.format(sql_col), []), - '_subslot': RawSQL(SUBSLOT_RE.format(sql_col), []), - '_position': RawSQL(POSITION_RE.format(sql_col), []), - '_subposition': RawSQL(SUBPOSITION_RE.format(sql_col), []), - '_channel': RawSQL(CHANNEL_RE.format(sql_col), []), - '_vc': RawSQL(VC_RE.format(sql_col), []), - } - - return InterfaceQuerySet(self.model, using=self._db).annotate(**fields).order_by(*ordering) + return InterfaceQuerySet(self.model, using=self._db) diff --git a/netbox/dcim/migrations/0096_interface_ordering.py b/netbox/dcim/migrations/0096_interface_ordering.py new file mode 100644 index 000000000..284066462 --- /dev/null +++ b/netbox/dcim/migrations/0096_interface_ordering.py @@ -0,0 +1,53 @@ +from django.db import migrations +import utilities.fields +import utilities.ordering + + +def _update_model_names(model): + # Update each unique field value in bulk + for name in model.objects.values_list('name', flat=True).order_by('name').distinct(): + model.objects.filter(name=name).update(_name=utilities.ordering.naturalize_interface(name)) + + +def naturalize_interfacetemplates(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'InterfaceTemplate')) + + +def naturalize_interfaces(apps, schema_editor): + _update_model_names(apps.get_model('dcim', 'Interface')) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0095_primary_model_ordering'), + ] + + operations = [ + migrations.AlterModelOptions( + name='interface', + options={'ordering': ('device', '_name')}, + ), + migrations.AlterModelOptions( + name='interfacetemplate', + options={'ordering': ('device_type', '_name')}, + ), + migrations.AddField( + model_name='interface', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), + ), + migrations.AddField( + model_name='interfacetemplate', + name='_name', + field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface), + ), + migrations.RunPython( + code=naturalize_interfacetemplates, + reverse_code=migrations.RunPython.noop + ), + migrations.RunPython( + code=naturalize_interfaces, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 0b5c312ba..ab4a078cf 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -4,9 +4,9 @@ from django.db import models from dcim.choices import * from dcim.constants import * -from dcim.managers import InterfaceManager from extras.models import ObjectChange from utilities.fields import NaturalOrderingField +from utilities.ordering import naturalize_interface from utilities.utils import serialize_object from .device_components import ( ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort, @@ -249,6 +249,12 @@ class InterfaceTemplate(ComponentTemplateModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + naturalize_function=naturalize_interface, + max_length=100, + blank=True + ) type = models.CharField( max_length=50, choices=InterfaceTypeChoices @@ -258,11 +264,9 @@ class InterfaceTemplate(ComponentTemplateModel): verbose_name='Management only' ) - objects = InterfaceManager() - class Meta: - ordering = ['device_type', 'name'] - unique_together = ['device_type', 'name'] + ordering = ('device_type', '_name') + unique_together = ('device_type', 'name') def __str__(self): return self.name diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 3eb9ac74e..a41eda576 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -10,9 +10,9 @@ from dcim.choices import * from dcim.constants import * from dcim.exceptions import LoopDetected from dcim.fields import MACAddressField -from dcim.managers import InterfaceManager from extras.models import ObjectChange, TaggedItem from utilities.fields import NaturalOrderingField +from utilities.ordering import naturalize_interface from utilities.utils import serialize_object from virtualization.choices import VMInterfaceTypeChoices @@ -529,6 +529,12 @@ class Interface(CableTermination, ComponentModel): name = models.CharField( max_length=64 ) + _name = NaturalOrderingField( + target_field='name', + naturalize_function=naturalize_interface, + max_length=100, + blank=True + ) _connected_interface = models.OneToOneField( to='self', on_delete=models.SET_NULL, @@ -597,8 +603,6 @@ class Interface(CableTermination, ComponentModel): blank=True, verbose_name='Tagged VLANs' ) - - objects = InterfaceManager() tags = TaggableManager(through=TaggedItem) csv_headers = [ @@ -607,8 +611,9 @@ class Interface(CableTermination, ComponentModel): ] class Meta: - ordering = ['device', 'name'] - unique_together = ['device', 'name'] + # TODO: ordering and unique_together should include virtual_machine + ordering = ('device', '_name') + unique_together = ('device', 'name') def __str__(self): return self.name diff --git a/netbox/utilities/ordering.py b/netbox/utilities/ordering.py index 88a46d3d3..d459e6f6c 100644 --- a/netbox/utilities/ordering.py +++ b/netbox/utilities/ordering.py @@ -1,5 +1,14 @@ import re +INTERFACE_NAME_REGEX = r'(^(?P[^\d\.:]+)?)' \ + r'((?P\d+)/)?' \ + r'((?P\d+)/)?' \ + r'((?P\d+)/)?' \ + r'((?P\d+)/)?' \ + r'((?P\d+))?' \ + r'(:(?P\d+))?' \ + r'(.(?P\d+)$)?' + def naturalize(value, max_length=None, integer_places=8): """ @@ -31,3 +40,41 @@ def naturalize(value, max_length=None, integer_places=8): ret = ''.join(output) return ret[:max_length] if max_length else ret + + +def naturalize_interface(value, max_length=None): + """ + Similar in nature to naturalize(), but takes into account a particular naming format adapted from the old + InterfaceManager. + + :param value: The value to be naturalized + :param max_length: The maximum length of the returned string. Characters beyond this length will be stripped. + """ + output = [] + match = re.search(INTERFACE_NAME_REGEX, value) + if match is None: + return value + + # First, we order by slot/position, padding each to four digits. If a field is not present, + # set it to 9999 to ensure it is ordered last. + for part_name in ('slot', 'subslot', 'position', 'subposition'): + part = match.group(part_name) + if part is not None: + output.append(part.rjust(4, '0')) + else: + output.append('9999') + + # Append the type, if any. + if match.group('type') is not None: + output.append(match.group('type')) + + # Finally, append any remaining fields, left-padding to eight digits each. + for part_name in ('id', 'channel', 'vc'): + part = match.group(part_name) + if part is not None: + output.append(part.rjust(6, '0')) + else: + output.append('000000') + + ret = ''.join(output) + return ret[:max_length] if max_length else ret From 5bfd65b5fe278880c331c6b8168377e3b52051fb Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Feb 2020 16:18:15 -0500 Subject: [PATCH 17/38] Changelog for #3799 --- docs/release-notes/version-2.7.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 8f341d2a1..44298fec3 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -2,6 +2,7 @@ ## Enhancements +* [#3799](https://github.com/netbox-community/netbox/issues/3799) - Greatly improve performance when ordering device components * [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views * [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components * [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views From 885ea8a4d5f7191afd564501e3b013b1c9ff85f6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 7 Feb 2020 18:04:40 -0500 Subject: [PATCH 18/38] Override get_bound_field() on FilterChoiceFieldMixin to restrict the queryset of bound fields --- netbox/utilities/forms.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 355484673..28b906140 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -8,6 +8,7 @@ from django import forms from django.conf import settings from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput from django.db.models import Count +from django.forms import BoundField from mptt.forms import TreeNodeMultipleChoiceField from .choices import unpack_grouped_choices @@ -607,12 +608,26 @@ class FilterChoiceFieldMixin(object): kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6}) super().__init__(*args, **kwargs) - def label_from_instance(self, obj): - label = super().label_from_instance(obj) - obj_count = getattr(obj, self.count_attr, None) - if obj_count is not None: - return '{} ({})'.format(label, obj_count) - return label + # def label_from_instance(self, obj): + # label = super().label_from_instance(obj) + # obj_count = getattr(obj, self.count_attr, None) + # if obj_count is not None: + # return '{} ({})'.format(label, obj_count) + # return label + + def get_bound_field(self, form, field_name): + + bound_field = BoundField(form, self, field_name) + + # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options + # will be populated on-demand via the APISelect widget. + if bound_field.data: + kwargs = {'{}__in'.format(self.to_field_name or 'pk'): bound_field.data} + self.queryset = self.queryset.filter(**kwargs) + else: + self.queryset = self.queryset.none() + + return bound_field class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField): From d5c4a9d159cf2d76c589e175883f60ed6e8179cb Mon Sep 17 00:00:00 2001 From: kobayashi Date: Sun, 2 Feb 2020 15:45:34 -0500 Subject: [PATCH 19/38] Fixes #3507: Filtering IP by multiple devices --- docs/release-notes/version-2.7.md | 1 + netbox/ipam/filters.py | 15 ++++++++------- netbox/ipam/tests/test_filters.py | 11 +++++------ 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 44298fec3..81c4edd39 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -26,6 +26,7 @@ ## Bug Fixes +* [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IPaddress by multiple devices * [#4030](https://github.com/netbox-community/netbox/issues/4030) - Fix exception when bulk editing interfaces (revised) * [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts * [#4049](https://github.com/netbox-community/netbox/issues/4049) - Restore missing `tags` field in IPAM service serializer diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 67ad769cc..082890f82 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -304,14 +304,15 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF to_field_name='rd', label='VRF (RD)', ) - device = django_filters.CharFilter( - method='filter_device', - field_name='name', - label='Device', + device = django_filters.ModelMultipleChoiceFilter( + field_name='interface__device__name', + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', ) - device_id = django_filters.NumberFilter( - method='filter_device', - field_name='pk', + device_id = django_filters.ModelMultipleChoiceFilter( + field_name='interface__device', + queryset=Device.objects.all(), label='Device (ID)', ) virtual_machine_id = django_filters.ModelMultipleChoiceFilter( diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 80ada0df8..4737a0f53 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -392,13 +392,12 @@ class IPAddressTestCase(TestCase): params = {'vrf': [vrfs[0].rd, vrfs[1].rd]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - # TODO: Test for multiple values def test_device(self): - device = Device.objects.first() - params = {'device_id': device.pk} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - params = {'device': device.name} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + devices = Device.objects.all()[:2] + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device': [devices[0].name, devices[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_virtual_machine(self): vms = VirtualMachine.objects.all()[:2] From 505cb9cab88ec735d99e4f2510abde20187ac88c Mon Sep 17 00:00:00 2001 From: kobayashi Date: Sun, 9 Feb 2020 00:58:54 -0500 Subject: [PATCH 20/38] Enabled filtering virtual chassis devices --- docs/release-notes/version-2.7.md | 2 +- netbox/ipam/filters.py | 21 +++++++++++---------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 81c4edd39..7992e9ae9 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -9,6 +9,7 @@ ## Bug Fixes +* [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IPaddress by multiple devices * [#4089](https://github.com/netbox-community/netbox/issues/4089) - Selection of power outlet type during bulk update is optional * [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view * [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form @@ -26,7 +27,6 @@ ## Bug Fixes -* [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IPaddress by multiple devices * [#4030](https://github.com/netbox-community/netbox/issues/4030) - Fix exception when bulk editing interfaces (revised) * [#4043](https://github.com/netbox-community/netbox/issues/4043) - Fix toggling of required fields in custom scripts * [#4049](https://github.com/netbox-community/netbox/issues/4049) - Restore missing `tags` field in IPAM service serializer diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 082890f82..5f8bcabff 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -8,7 +8,7 @@ from dcim.models import Device, Interface, Region, Site from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( - MultiValueCharFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, + MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import VirtualMachine from .choices import * @@ -304,15 +304,14 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF to_field_name='rd', label='VRF (RD)', ) - device = django_filters.ModelMultipleChoiceFilter( - field_name='interface__device__name', - queryset=Device.objects.all(), - to_field_name='name', + device = MultiValueCharFilter( + method='filter_device', + field_name='name', label='Device (name)', ) - device_id = django_filters.ModelMultipleChoiceFilter( - field_name='interface__device', - queryset=Device.objects.all(), + device_id = MultiValueNumberFilter( + method='filter_device', + field_name='pk', label='Device (ID)', ) virtual_machine_id = django_filters.ModelMultipleChoiceFilter( @@ -386,8 +385,10 @@ class IPAddressFilterSet(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedF def filter_device(self, queryset, name, value): try: - device = Device.objects.prefetch_related('device_type').get(**{name: value}) - vc_interface_ids = [i['id'] for i in device.vc_interfaces.values('id')] + devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value}) + vc_interface_ids = [] + for device in devices: + vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')]) return queryset.filter(interface_id__in=vc_interface_ids) except Device.DoesNotExist: return queryset.none() From 5ddfde2214f535239652fd6ab3ef00f7436a24a9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 09:40:34 -0500 Subject: [PATCH 21/38] Clean up unneeded code relevant to FilterChoiceField --- netbox/dcim/forms.py | 11 -------- netbox/ipam/forms.py | 8 ------ netbox/tenancy/forms.py | 3 --- netbox/utilities/forms.py | 47 ++++++---------------------------- netbox/virtualization/forms.py | 7 ----- 5 files changed, 8 insertions(+), 68 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 3c3ae8b2e..81a0be2d2 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -734,7 +734,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): 'site' ), label='Rack group', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", null_option=True @@ -748,7 +747,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): role = FilterChoiceField( queryset=RackRole.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/rack-roles/", value_field="slug", @@ -874,7 +872,6 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): group_id = FilterChoiceField( queryset=RackGroup.objects.prefetch_related('site'), label='Rack group', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", null_option=True, @@ -2182,7 +2179,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt rack_id = FilterChoiceField( queryset=Rack.objects.all(), label='Rack', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/racks/", null_option=True, @@ -2219,7 +2215,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt platform = FilterChoiceField( queryset=Platform.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/platforms/", value_field="slug", @@ -3913,7 +3908,6 @@ class CableFilterForm(BootstrapMixin, forms.Form): rack_id = FilterChoiceField( queryset=Rack.objects.all(), label='Rack', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/racks/", null_option=True, @@ -4471,7 +4465,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): tenant_group = FilterChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", @@ -4484,7 +4477,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): tenant = FilterChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field="slug", @@ -4592,7 +4584,6 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): rack_group_id = FilterChoiceField( queryset=RackGroup.objects.all(), label='Rack group (ID)', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", null_option=True, @@ -4826,7 +4817,6 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): power_panel_id = FilterChoiceField( queryset=PowerPanel.objects.all(), label='Power panel', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/power-panels/", null_option=True, @@ -4835,7 +4825,6 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): rack_id = FilterChoiceField( queryset=Rack.objects.all(), label='Rack', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/racks/", null_option=True, diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 24f044f79..71aa73d18 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -528,7 +528,6 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) vrf_id = FilterChoiceField( queryset=VRF.objects.all(), label='VRF', - null_label='-- Global --', widget=APISelectMultiple( api_url="/api/ipam/vrfs/", null_option=True, @@ -554,7 +553,6 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -564,7 +562,6 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) role = FilterChoiceField( queryset=Role.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/ipam/roles/", value_field="slug", @@ -999,7 +996,6 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo vrf_id = FilterChoiceField( queryset=VRF.objects.all(), label='VRF', - null_label='-- Global --', widget=APISelectMultiple( api_url="/api/ipam/vrfs/", null_option=True, @@ -1080,7 +1076,6 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- Global --', widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -1279,7 +1274,6 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- Global --', widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -1289,7 +1283,6 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): group_id = FilterChoiceField( queryset=VLANGroup.objects.all(), label='VLAN group', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/ipam/vlan-groups/", null_option=True, @@ -1303,7 +1296,6 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): role = FilterChoiceField( queryset=Role.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/ipam/roles/", value_field="slug", diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index b0468b37a..4babd753f 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -108,7 +108,6 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): group = FilterChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", @@ -163,7 +162,6 @@ class TenancyFilterForm(forms.Form): tenant_group = FilterChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", @@ -176,7 +174,6 @@ class TenancyFilterForm(forms.Form): tenant = FilterChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field="slug", diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 28b906140..464495fa0 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -582,47 +582,24 @@ class TagFilterField(forms.MultipleChoiceField): super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) -class FilterChoiceIterator(forms.models.ModelChoiceIterator): - - def __iter__(self): - # Filter on "empty" choice using FILTERS_NULL_CHOICE_VALUE (instead of an empty string) - if self.field.null_label is not None: - yield (settings.FILTERS_NULL_CHOICE_VALUE, self.field.null_label) - queryset = self.queryset.all() - # Can't use iterator() when queryset uses prefetch_related() - if not queryset._prefetch_related_lookups: - queryset = queryset.iterator() - for obj in queryset: - yield self.choice(obj) - - -class FilterChoiceFieldMixin(object): - iterator = FilterChoiceIterator - - def __init__(self, null_label=None, count_attr='filter_count', *args, **kwargs): - self.null_label = null_label - self.count_attr = count_attr +class FilterChoiceField(forms.ModelMultipleChoiceField): + """ + Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be + rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. + """ + def __init__(self, *args, **kwargs): + # Filter fields are not required by default if 'required' not in kwargs: kwargs['required'] = False - if 'widget' not in kwargs: - kwargs['widget'] = forms.SelectMultiple(attrs={'size': 6}) super().__init__(*args, **kwargs) - # def label_from_instance(self, obj): - # label = super().label_from_instance(obj) - # obj_count = getattr(obj, self.count_attr, None) - # if obj_count is not None: - # return '{} ({})'.format(label, obj_count) - # return label - def get_bound_field(self, form, field_name): - bound_field = BoundField(form, self, field_name) # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. if bound_field.data: - kwargs = {'{}__in'.format(self.to_field_name or 'pk'): bound_field.data} + kwargs = {'{}__in'.format(self.to_field_name): bound_field.data} self.queryset = self.queryset.filter(**kwargs) else: self.queryset = self.queryset.none() @@ -630,14 +607,6 @@ class FilterChoiceFieldMixin(object): return bound_field -class FilterChoiceField(FilterChoiceFieldMixin, forms.ModelMultipleChoiceField): - pass - - -class FilterTreeNodeMultipleChoiceField(FilterChoiceFieldMixin, TreeNodeMultipleChoiceField): - pass - - class LaxURLField(forms.URLField): """ Modifies Django's built-in URLField in two ways: diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 1560a683f..96136b4de 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -213,7 +213,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- None --', required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", @@ -224,7 +223,6 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm group = FilterChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', - null_label='-- None --', required=False, widget=APISelectMultiple( api_url="/api/virtualization/cluster-groups/", @@ -562,7 +560,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil cluster_group = FilterChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url='/api/virtualization/cluster-groups/', value_field="slug", @@ -572,7 +569,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil cluster_type = FilterChoiceField( queryset=ClusterType.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url='/api/virtualization/cluster-types/', value_field="slug", @@ -601,7 +597,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url='/api/dcim/sites/', value_field="slug", @@ -611,7 +606,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil role = FilterChoiceField( queryset=DeviceRole.objects.filter(vm_role=True), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url='/api/dcim/device-roles/', value_field="slug", @@ -629,7 +623,6 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil platform = FilterChoiceField( queryset=Platform.objects.all(), to_field_name='slug', - null_label='-- None --', widget=APISelectMultiple( api_url='/api/dcim/platforms/', value_field="slug", From 55f5ede9708d23b5daaa79d687efde19929b3569 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 09:58:33 -0500 Subject: [PATCH 22/38] Standardize usage of FilterChoiceField --- netbox/circuits/forms.py | 2 +- netbox/dcim/forms.py | 3 +-- netbox/extras/forms.py | 7 +++++-- netbox/utilities/forms.py | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index caf8d9d36..39b694b1c 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -311,7 +311,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm required=False, widget=StaticSelect2Multiple() ) - region = forms.ModelMultipleChoiceField( + region = FilterChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 81a0be2d2..b12d273a9 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -369,10 +369,9 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - region = forms.ModelMultipleChoiceField( + region = FilterChoiceField( queryset=Region.objects.all(), to_field_name='slug', - required=False, widget=APISelectMultiple( api_url="/api/dcim/regions/", value_field="slug", diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 8c9113d39..f9b765379 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -387,11 +387,14 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): ) action = forms.ChoiceField( choices=add_blank_choice(ObjectChangeActionChoices), - required=False + required=False, + widget=StaticSelect2() ) + # TODO: Convert to FilterChoiceField once we have an API endpoint for users user = forms.ModelChoiceField( queryset=User.objects.order_by('username'), - required=False + required=False, + widget=StaticSelect2() ) changed_object_type = forms.ModelChoiceField( queryset=ContentType.objects.order_by('model'), diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 464495fa0..7b31f1e94 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -212,7 +212,7 @@ class SelectWithPK(StaticSelect2): option_template_name = 'widgets/select_option_with_pk.html' -class ContentTypeSelect(forms.Select): +class ContentTypeSelect(StaticSelect2): """ Appends an `api-value` attribute equal to the slugified model name for each ContentType. For example: From 009fc4f301e9a74016b03ecbaea6e9095c8f6f9c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 10:02:42 -0500 Subject: [PATCH 23/38] Remove custom template for APISelect widget --- netbox/utilities/forms.py | 3 --- netbox/utilities/templates/widgets/select_api.html | 9 --------- 2 files changed, 12 deletions(-) delete mode 100644 netbox/utilities/templates/widgets/select_api.html diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 7b31f1e94..85fb762bb 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -260,9 +260,6 @@ class APISelect(SelectWithDisabled): name of the query param and the value if the query param's value. :param null_option: If true, include the static null option in the selection list. """ - # Only preload the selected option(s); new options are dynamically displayed and added via the API - template_name = 'widgets/select_api.html' - def __init__( self, api_url, diff --git a/netbox/utilities/templates/widgets/select_api.html b/netbox/utilities/templates/widgets/select_api.html deleted file mode 100644 index d9516086b..000000000 --- a/netbox/utilities/templates/widgets/select_api.html +++ /dev/null @@ -1,9 +0,0 @@ - From 5008526db145059f66031f2e7b0fc6180327ed22 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 10:08:20 -0500 Subject: [PATCH 24/38] Set a default self.to_field_name for FilterChoiceField --- netbox/utilities/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 85fb762bb..cd78b249e 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -596,7 +596,7 @@ class FilterChoiceField(forms.ModelMultipleChoiceField): # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. if bound_field.data: - kwargs = {'{}__in'.format(self.to_field_name): bound_field.data} + kwargs = {'{}__in'.format(self.to_field_name or 'pk'): bound_field.data} self.queryset = self.queryset.filter(**kwargs) else: self.queryset = self.queryset.none() From d4789b7c9e14ed5b180230c0511774c4c8c01de2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 10:20:06 -0500 Subject: [PATCH 25/38] Changelog for #4108 --- docs/release-notes/version-2.7.md | 1 + netbox/utilities/forms.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 44298fec3..fedf9f170 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -13,6 +13,7 @@ * [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view * [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form * [#4099](https://github.com/netbox-community/netbox/issues/4099) - Linkify interfaces on global interfaces list +* [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms # v2.7.4 (2020-02-04) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index cd78b249e..8c0f0d8d1 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -9,7 +9,6 @@ from django.conf import settings from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput from django.db.models import Count from django.forms import BoundField -from mptt.forms import TreeNodeMultipleChoiceField from .choices import unpack_grouped_choices from .constants import * From 26ca6b4a8445cea3645334f7e14f1a718edbfebd Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 10:57:23 -0500 Subject: [PATCH 26/38] #4108: Fix null choice population --- netbox/project-static/js/forms.js | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index b1ba8a37c..4e1c9b0cc 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -220,19 +220,19 @@ $(document).ready(function() { } if( record.group !== undefined && record.group !== null && record.site !== undefined && record.site !== null ) { - results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] } + results[record.site.name + ":" + record.group.name] = results[record.site.name + ":" + record.group.name] || { text: record.site.name + " / " + record.group.name, children: [] }; results[record.site.name + ":" + record.group.name].children.push(record); } else if( record.group !== undefined && record.group !== null ) { - results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] } + results[record.group.name] = results[record.group.name] || { text: record.group.name, children: [] }; results[record.group.name].children.push(record); } else if( record.site !== undefined && record.site !== null ) { - results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] } + results[record.site.name] = results[record.site.name] || { text: record.site.name, children: [] }; results[record.site.name].children.push(record); } else if ( (record.group !== undefined || record.group == null) && (record.site !== undefined || record.site === null) ) { - results['global'] = results['global'] || { text: 'Global', children: [] } + results['global'] = results['global'] || { text: 'Global', children: [] }; results['global'].children.push(record); } else { @@ -246,10 +246,9 @@ $(document).ready(function() { // Handle the null option, but only add it once if (element.getAttribute('data-null-option') && data.previous === null) { - var null_option = $(element).children()[0]; results.unshift({ - id: null_option.value, - text: null_option.text + id: 'null', + text: 'None' }); } From a966a4c8ace7594062326172c5f9ea71b159ac00 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 11:43:51 -0500 Subject: [PATCH 27/38] Closes #4129: Add individual deletion views for device type components --- docs/release-notes/version-2.7.md | 3 ++ netbox/dcim/tables.py | 5 +++ netbox/dcim/tests/test_views.py | 8 ---- netbox/dcim/urls.py | 8 ++++ netbox/dcim/views.py | 70 ++++++++++++++++++++++++++++++- 5 files changed, 85 insertions(+), 9 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 1415be129..0aa62555b 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -6,6 +6,7 @@ * [#4100](https://github.com/netbox-community/netbox/issues/4100) - Add device filter to component list views * [#4113](https://github.com/netbox-community/netbox/issues/4113) - Add bulk edit functionality for device type components * [#4116](https://github.com/netbox-community/netbox/issues/4116) - Enable bulk edit and delete functions for device component list views +* [#4129](https://github.com/netbox-community/netbox/issues/4129) - Add buttons to delete individual device type components ## Bug Fixes @@ -16,6 +17,8 @@ * [#4099](https://github.com/netbox-community/netbox/issues/4099) - Linkify interfaces on global interfaces list * [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms +--- + # v2.7.4 (2020-02-04) ## Enhancements diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 473d465bd..1f67b93f1 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -200,6 +200,11 @@ def get_component_template_actions(model_name): {{% endif %}} + {{% if perms.dcim.delete_{model_name} %}} + + + + {{% endif %}} """.format(model_name=model_name).strip() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index f8282833c..75e3f9871 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -535,7 +535,6 @@ class ConsolePortTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None def test_bulk_create_objects(self): @@ -580,7 +579,6 @@ class ConsoleServerPortTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None def test_bulk_create_objects(self): @@ -625,7 +623,6 @@ class PowerPortTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None def test_bulk_create_objects(self): @@ -676,7 +673,6 @@ class PowerOutletTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None def test_bulk_create_objects(self): @@ -727,7 +723,6 @@ class InterfaceTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None def test_bulk_create_objects(self): @@ -775,7 +770,6 @@ class FrontPortTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None def test_bulk_create_objects(self): @@ -831,7 +825,6 @@ class RearPortTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None def test_bulk_create_objects(self): @@ -878,7 +871,6 @@ class DeviceBayTemplateTestCase(StandardTestCases.Views): test_get_object = None test_list_objects = None test_create_object = None - test_delete_object = None test_import_objects = None test_bulk_edit_objects = None diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 07d86cc36..165ca9e02 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -95,48 +95,56 @@ urlpatterns = [ path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'), path('console-port-templates/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='consoleporttemplate_bulk_delete'), path('console-port-templates//edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'), + path('console-port-templates//delete/', views.ConsolePortTemplateDeleteView.as_view(), name='consoleporttemplate_delete'), # Console server port templates path('console-server-port-templates/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='consoleserverporttemplate_add'), path('console-server-port-templates/edit/', views.ConsoleServerPortTemplateBulkEditView.as_view(), name='consoleserverporttemplate_bulk_edit'), path('console-server-port-templates/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='consoleserverporttemplate_bulk_delete'), path('console-server-port-templates//edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'), + path('console-server-port-templates//delete/', views.ConsoleServerPortTemplateDeleteView.as_view(), name='consoleserverporttemplate_delete'), # Power port templates path('power-port-templates/add/', views.PowerPortTemplateCreateView.as_view(), name='powerporttemplate_add'), path('power-port-templates/edit/', views.PowerPortTemplateBulkEditView.as_view(), name='powerporttemplate_bulk_edit'), path('power-port-templates/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='powerporttemplate_bulk_delete'), path('power-port-templates//edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'), + path('power-port-templates//delete/', views.PowerPortTemplateDeleteView.as_view(), name='powerporttemplate_delete'), # Power outlet templates path('power-outlet-templates/add/', views.PowerOutletTemplateCreateView.as_view(), name='poweroutlettemplate_add'), path('power-outlet-templates/edit/', views.PowerOutletTemplateBulkEditView.as_view(), name='poweroutlettemplate_bulk_edit'), path('power-outlet-templates/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='poweroutlettemplate_bulk_delete'), path('power-outlet-templates//edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'), + path('power-outlet-templates//delete/', views.PowerOutletTemplateDeleteView.as_view(), name='poweroutlettemplate_delete'), # Interface templates path('interface-templates/add/', views.InterfaceTemplateCreateView.as_view(), name='interfacetemplate_add'), path('interface-templates/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='interfacetemplate_bulk_edit'), path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'), path('interface-templates//edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'), + path('interface-templates//delete/', views.InterfaceTemplateDeleteView.as_view(), name='interfacetemplate_delete'), # Front port templates path('front-port-templates/add/', views.FrontPortTemplateCreateView.as_view(), name='frontporttemplate_add'), path('front-port-templates/edit/', views.FrontPortTemplateBulkEditView.as_view(), name='frontporttemplate_bulk_edit'), path('front-port-templates/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='frontporttemplate_bulk_delete'), path('front-port-templates//edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'), + path('front-port-templates//delete/', views.FrontPortTemplateDeleteView.as_view(), name='frontporttemplate_delete'), # Rear port templates path('rear-port-templates/add/', views.RearPortTemplateCreateView.as_view(), name='rearporttemplate_add'), path('rear-port-templates/edit/', views.RearPortTemplateBulkEditView.as_view(), name='rearporttemplate_bulk_edit'), path('rear-port-templates/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='rearporttemplate_bulk_delete'), path('rear-port-templates//edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'), + path('rear-port-templates//delete/', views.RearPortTemplateDeleteView.as_view(), name='rearporttemplate_delete'), # Device bay templates path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'), # path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'), path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'), path('device-bay-templates//edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'), + path('device-bay-templates//delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'), # Device roles path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 824961b3e..ae59890a3 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -700,7 +700,7 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # -# Device type components +# Console port templates # class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): @@ -717,6 +717,11 @@ class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.ConsolePortTemplateForm +class ConsolePortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_consoleporttemplate' + model = ConsolePortTemplate + + class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_consoleporttemplate' queryset = ConsolePortTemplate.objects.all() @@ -730,6 +735,10 @@ class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView) table = tables.ConsolePortTemplateTable +# +# Console server port templates +# + class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_consoleserverporttemplate' model = ConsoleServerPortTemplate @@ -744,6 +753,11 @@ class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView) model_form = forms.ConsoleServerPortTemplateForm +class ConsoleServerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_consoleserverporttemplate' + model = ConsoleServerPortTemplate + + class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_consoleserverporttemplate' queryset = ConsoleServerPortTemplate.objects.all() @@ -757,6 +771,10 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet table = tables.ConsoleServerPortTemplateTable +# +# Power port templates +# + class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_powerporttemplate' model = PowerPortTemplate @@ -771,6 +789,11 @@ class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.PowerPortTemplateForm +class PowerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_powerporttemplate' + model = PowerPortTemplate + + class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_powerporttemplate' queryset = PowerPortTemplate.objects.all() @@ -784,6 +807,10 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.PowerPortTemplateTable +# +# Power outlet templates +# + class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_poweroutlettemplate' model = PowerOutletTemplate @@ -798,6 +825,11 @@ class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.PowerOutletTemplateForm +class PowerOutletTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_poweroutlettemplate' + model = PowerOutletTemplate + + class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_poweroutlettemplate' queryset = PowerOutletTemplate.objects.all() @@ -811,6 +843,10 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView) table = tables.PowerOutletTemplateTable +# +# Interface templates +# + class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_interfacetemplate' model = InterfaceTemplate @@ -825,6 +861,11 @@ class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.InterfaceTemplateForm +class InterfaceTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_interfacetemplate' + model = InterfaceTemplate + + class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_interfacetemplate' queryset = InterfaceTemplate.objects.all() @@ -838,6 +879,10 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.InterfaceTemplateTable +# +# Front port templates +# + class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_frontporttemplate' model = FrontPortTemplate @@ -852,6 +897,11 @@ class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.FrontPortTemplateForm +class FrontPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_frontporttemplate' + model = FrontPortTemplate + + class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_frontporttemplate' queryset = FrontPortTemplate.objects.all() @@ -865,6 +915,10 @@ class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.FrontPortTemplateTable +# +# Rear port templates +# + class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_rearporttemplate' model = RearPortTemplate @@ -879,6 +933,11 @@ class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.RearPortTemplateForm +class RearPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_rearporttemplate' + model = RearPortTemplate + + class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): permission_required = 'dcim.change_rearporttemplate' queryset = RearPortTemplate.objects.all() @@ -892,6 +951,10 @@ class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): table = tables.RearPortTemplateTable +# +# Device bay templates +# + class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView): permission_required = 'dcim.add_devicebaytemplate' model = DeviceBayTemplate @@ -906,6 +969,11 @@ class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView): model_form = forms.DeviceBayTemplateForm +class DeviceBayTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_devicebaytemplate' + model = DeviceBayTemplate + + # class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): # permission_required = 'dcim.change_devicebaytemplate' # queryset = DeviceBayTemplate.objects.all() From 7388fa35568f9605473457fbf97fe5acd7cc6992 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 15:10:33 -0500 Subject: [PATCH 28/38] Fixes #4083: Permit nullifying applicable choice fields via API requests --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/api/serializers.py | 28 +++++++++++++++--------- netbox/ipam/api/serializers.py | 4 ++-- netbox/utilities/api.py | 21 ++++++++++++++++-- netbox/virtualization/api/serializers.py | 2 +- 5 files changed, 41 insertions(+), 15 deletions(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index 0aa62555b..e789c9803 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -11,6 +11,7 @@ ## Bug Fixes * [#3507](https://github.com/netbox-community/netbox/issues/3507) - Fix filtering IPaddress by multiple devices +* [#4083](https://github.com/netbox-community/netbox/issues/4083) - Permit nullifying applicable choice fields via API requests * [#4089](https://github.com/netbox-community/netbox/issues/4089) - Selection of power outlet type during bulk update is optional * [#4090](https://github.com/netbox-community/netbox/issues/4090) - Render URL custom fields as links under object view * [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index f0382a3f5..234a9fb1c 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -117,9 +117,9 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=RackStatusChoices, required=False) role = NestedRackRoleSerializer(required=False, allow_null=True) - type = ChoiceField(choices=RackTypeChoices, required=False, allow_null=True) + type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False) width = ChoiceField(choices=RackWidthChoices, required=False) - outer_unit = ChoiceField(choices=RackDimensionUnitChoices, required=False) + outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False) tags = TagListSerializerField(required=False) device_count = serializers.IntegerField(read_only=True) powerfeed_count = serializers.IntegerField(read_only=True) @@ -212,7 +212,7 @@ class ManufacturerSerializer(ValidatedModelSerializer): class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): manufacturer = NestedManufacturerSerializer() - subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, required=False, allow_null=True) + subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) tags = TagListSerializerField(required=False) device_count = serializers.IntegerField(read_only=True) @@ -228,6 +228,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) @@ -240,6 +241,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) @@ -252,6 +254,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=PowerPortTypeChoices, + allow_blank=True, required=False ) @@ -264,6 +267,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() type = ChoiceField( choices=PowerOutletTypeChoices, + allow_blank=True, required=False ) power_port = PowerPortTemplateSerializer( @@ -271,8 +275,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): ) feed_leg = ChoiceField( choices=PowerOutletFeedLegChoices, - required=False, - allow_null=True + allow_blank=True, + required=False ) class Meta: @@ -351,7 +355,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): platform = NestedPlatformSerializer(required=False, allow_null=True) site = NestedSiteSerializer() rack = NestedRackSerializer(required=False, allow_null=True) - face = ChoiceField(choices=DeviceFaceChoices, required=False, allow_null=True) + face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False) status = ChoiceField(choices=DeviceStatusChoices, required=False) primary_ip = NestedIPAddressSerializer(read_only=True) primary_ip4 = NestedIPAddressSerializer(required=False, allow_null=True) @@ -420,6 +424,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer) device = NestedDeviceSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) cable = NestedCableSerializer(read_only=True) @@ -437,6 +442,7 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=ConsolePortTypeChoices, + allow_blank=True, required=False ) cable = NestedCableSerializer(read_only=True) @@ -454,6 +460,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=PowerOutletTypeChoices, + allow_blank=True, required=False ) power_port = NestedPowerPortSerializer( @@ -461,8 +468,8 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): ) feed_leg = ChoiceField( choices=PowerOutletFeedLegChoices, - required=False, - allow_null=True + allow_blank=True, + required=False ) cable = NestedCableSerializer( read_only=True @@ -483,6 +490,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField( choices=PowerPortTypeChoices, + allow_blank=True, required=False ) cable = NestedCableSerializer(read_only=True) @@ -500,7 +508,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices, required=False) lag = NestedInterfaceSerializer(required=False, allow_null=True) - mode = ChoiceField(choices=InterfaceModeChoices, 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( queryset=VLAN.objects.all(), @@ -617,7 +625,7 @@ class CableSerializer(ValidatedModelSerializer): termination_a = serializers.SerializerMethodField(read_only=True) termination_b = serializers.SerializerMethodField(read_only=True) status = ChoiceField(choices=CableStatusChoices, required=False) - length_unit = ChoiceField(choices=CableLengthUnitChoices, required=False, allow_null=True) + length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) class Meta: model = Cable diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index e52c172e5..e6d9adecd 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -202,7 +202,7 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): vrf = NestedVRFSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=IPAddressStatusChoices, required=False) - role = ChoiceField(choices=IPAddressRoleChoices, required=False, allow_null=True) + role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False) interface = IPAddressInterfaceSerializer(required=False, allow_null=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) nat_outside = NestedIPAddressSerializer(read_only=True) @@ -240,7 +240,7 @@ class AvailableIPSerializer(serializers.Serializer): class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer): device = NestedDeviceSerializer(required=False, allow_null=True) virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) - protocol = ChoiceField(choices=ServiceProtocolChoices) + protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) ipaddresses = SerializedPKRelatedField( queryset=IPAddress.objects.all(), serializer=NestedIPAddressSerializer, diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 5ef4156aa..95de2a25d 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -61,10 +61,14 @@ class IsAuthenticatedOrLoginNotRequired(BasePermission): class ChoiceField(Field): """ - Represent a ChoiceField as {'value': , 'label': }. + Represent a ChoiceField as {'value': , 'label': }. Accepts a single value on write. + + :param choices: An iterable of choices in the form (value, key). + :param allow_blank: Allow blank values in addition to the listed choices. """ - def __init__(self, choices, **kwargs): + def __init__(self, choices, allow_blank=False, **kwargs): self.choiceset = choices + self.allow_blank = allow_blank self._choices = dict() # Unpack grouped choices @@ -77,6 +81,15 @@ class ChoiceField(Field): super().__init__(**kwargs) + def validate_empty_values(self, data): + # Convert null to an empty string unless allow_null == True + if data is None: + if self.allow_null: + return True, None + else: + data = '' + return super().validate_empty_values(data) + def to_representation(self, obj): if obj is '': return None @@ -93,6 +106,10 @@ class ChoiceField(Field): return data def to_internal_value(self, data): + if data is '': + if self.allow_blank: + return data + raise ValidationError("This field may not be blank.") # Provide an explicit error message if the request is trying to write a dict or list if isinstance(data, (dict, list)): diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 8725cbee1..a294cdb6f 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -100,7 +100,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): virtual_machine = NestedVirtualMachineSerializer() type = ChoiceField(choices=VMInterfaceTypeChoices, default=VMInterfaceTypeChoices.TYPE_VIRTUAL, required=False) - mode = ChoiceField(choices=InterfaceModeChoices, 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( queryset=VLAN.objects.all(), From 5ea30c862846e82b5778c99419b4be340e929657 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 10 Feb 2020 17:23:52 -0500 Subject: [PATCH 29/38] Replace ChainedModelChoiceField with DynamicModelChoiceField --- netbox/dcim/forms.py | 90 ++++++++----------------------- netbox/ipam/forms.py | 40 ++++---------- netbox/tenancy/forms.py | 15 +++--- netbox/utilities/forms.py | 97 ++++++++-------------------------- netbox/virtualization/forms.py | 29 +++------- 5 files changed, 67 insertions(+), 204 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index b12d273a9..29d5ba551 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -22,9 +22,9 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, - BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ConfirmationForm, - CSVChoiceField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField, SelectWithPK, - SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, + ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField, SelectWithPK, SmallTextarea, SlugField, + StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine from .choices import * @@ -472,11 +472,8 @@ class RackRoleCSVForm(forms.ModelForm): # class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - group = ChainedModelChoiceField( + group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, widget=APISelect( api_url='/api/dcim/rack-groups/', @@ -761,13 +758,9 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class RackElevationFilterForm(RackFilterForm): field_order = ['q', 'region', 'site', 'group_id', 'id', 'status', 'role', 'tenant_group', 'tenant'] - id = ChainedModelChoiceField( + id = FilterChoiceField( queryset=Rack.objects.all(), label='Rack', - chains=( - ('site', 'site'), - ('group_id', 'group_id'), - ), required=False, widget=APISelectMultiple( api_url='/api/dcim/racks/', @@ -1706,11 +1699,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - rack = ChainedModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), required=False, widget=APISelect( api_url='/api/dcim/racks/', @@ -1737,11 +1727,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - device_type = ChainedModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), - chains=( - ('manufacturer', 'manufacturer'), - ), label='Device type', widget=APISelect( api_url='/api/dcim/device-types/', @@ -1761,11 +1748,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - cluster = ChainedModelChoiceField( + cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - chains=( - ('group', 'cluster_group'), - ), required=False, widget=APISelect( api_url='/api/virtualization/clusters/', @@ -3433,7 +3417,7 @@ class RearPortBulkDisconnectForm(ConfirmationForm): # Cables # -class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): +class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm): """ Base form for connecting a Cable to a Device component """ @@ -3449,11 +3433,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFo } ) ) - termination_b_rack = ChainedModelChoiceField( + termination_b_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'termination_b_site'), - ), label='Rack', required=False, widget=APISelect( @@ -3466,12 +3447,8 @@ class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFo } ) ) - termination_b_device = ChainedModelChoiceField( + termination_b_device = DynamicModelChoiceField( queryset=Device.objects.all(), - chains=( - ('site', 'termination_b_site'), - ('rack', 'termination_b_rack'), - ), label='Device', required=False, widget=APISelect( @@ -3569,7 +3546,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm): ) -class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): +class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): termination_b_provider = forms.ModelChoiceField( queryset=Provider.objects.all(), label='Provider', @@ -3581,7 +3558,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f } ) ) - termination_b_site = forms.ModelChoiceField( + termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, @@ -3592,11 +3569,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f } ) ) - termination_b_circuit = ChainedModelChoiceField( + termination_b_circuit = DynamicModelChoiceField( queryset=Circuit.objects.all(), - chains=( - ('provider', 'termination_b_provider'), - ), label='Circuit', widget=APISelect( api_url='/api/circuits/circuits/', @@ -3623,7 +3597,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f ] -class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): +class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): termination_b_site = forms.ModelChoiceField( queryset=Site.objects.all(), label='Site', @@ -3637,12 +3611,9 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode } ) ) - termination_b_rackgroup = ChainedModelChoiceField( + termination_b_rackgroup = DynamicModelChoiceField( queryset=RackGroup.objects.all(), label='Rack Group', - chains=( - ('site', 'termination_b_site'), - ), required=False, widget=APISelect( api_url='/api/dcim/rack-groups/', @@ -3652,12 +3623,8 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode } ) ) - termination_b_powerpanel = ChainedModelChoiceField( + termination_b_powerpanel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), - chains=( - ('site', 'termination_b_site'), - ('rack_group', 'termination_b_rackgroup'), - ), label='Power Panel', required=False, widget=APISelect( @@ -4380,10 +4347,9 @@ class DeviceVCMembershipForm(forms.ModelForm): return vc_position -class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): +class VCMemberSelectForm(BootstrapMixin, forms.Form): site = forms.ModelChoiceField( queryset=Site.objects.all(), - label='Site', required=False, widget=APISelect( api_url="/api/dcim/sites/", @@ -4393,12 +4359,8 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - rack = ChainedModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), - label='Rack', required=False, widget=APISelect( api_url='/api/dcim/racks/', @@ -4410,15 +4372,10 @@ class VCMemberSelectForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - device = ChainedModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.filter( virtual_chassis__isnull=True ), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), - label='Device', widget=APISelect( api_url='/api/dcim/devices/', display_field='display_name', @@ -4490,11 +4447,8 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): # class PowerPanelForm(BootstrapMixin, forms.ModelForm): - rack_group = ChainedModelChoiceField( + rack_group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, widget=APISelect( api_url='/api/dcim/rack-groups/', @@ -4595,7 +4549,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): # class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): - site = ChainedModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 71aa73d18..059587082 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -10,9 +10,9 @@ from extras.forms import ( from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, - CSVChoiceField, DatePicker, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm, - SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES + add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, + DatePicker, DynamicModelChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, + ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import VirtualMachine from .constants import * @@ -271,7 +271,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): site = forms.ModelChoiceField( queryset=Site.objects.all(), required=False, - label='Site', widget=APISelect( api_url="/api/dcim/sites/", filter_for={ @@ -283,11 +282,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - vlan_group = ChainedModelChoiceField( + vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, label='VLAN group', widget=APISelect( @@ -300,12 +296,8 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - vlan = ChainedModelChoiceField( + vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - chains=( - ('site', 'site'), - ('group', 'vlan_group'), - ), required=False, label='VLAN', widget=APISelect( @@ -603,11 +595,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel } ) ) - nat_rack = ChainedModelChoiceField( + nat_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'nat_site'), - ), required=False, label='Rack', widget=APISelect( @@ -621,12 +610,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel } ) ) - nat_device = ChainedModelChoiceField( + nat_device = DynamicModelChoiceField( queryset=Device.objects.all(), - chains=( - ('site', 'nat_site'), - ('rack', 'nat_rack'), - ), required=False, label='Device', widget=APISelect( @@ -648,11 +633,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel } ) ) - nat_inside = ChainedModelChoiceField( + nat_inside = DynamicModelChoiceField( queryset=IPAddress.objects.all(), - chains=( - ('interface__device', 'nat_device'), - ), required=False, label='IP Address', widget=APISelect( @@ -1102,13 +1084,9 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - group = ChainedModelChoiceField( + group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), - chains=( - ('site', 'site'), - ), required=False, - label='Group', widget=APISelect( api_url='/api/ipam/vlan-groups/', ) diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 4babd753f..553e79f1b 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -2,11 +2,11 @@ from django import forms from taggit.forms import TagField from extras.forms import ( - AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, + AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, ) from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, - FilterChoiceField, SlugField, TagFilterField + APISelect, APISelectMultiple, BootstrapMixin, CommentField, DynamicModelChoiceField, FilterChoiceField, SlugField, + TagFilterField, ) from .models import Tenant, TenantGroup @@ -121,8 +121,8 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): # Form extensions # -class TenancyForm(ChainedFieldsMixin, forms.Form): - tenant_group = forms.ModelChoiceField( +class TenancyForm(forms.Form): + tenant_group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False, widget=APISelect( @@ -135,11 +135,8 @@ class TenancyForm(ChainedFieldsMixin, forms.Form): } ) ) - tenant = ChainedModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - chains=( - ('group', 'tenant_group'), - ), required=False, widget=APISelect( api_url='/api/tenancy/tenants/' diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 8c0f0d8d1..49d7e882d 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -522,34 +522,6 @@ class FlexibleModelChoiceField(forms.ModelChoiceField): return value -class ChainedModelChoiceField(forms.ModelChoiceField): - """ - A ModelChoiceField which is initialized based on the values of other fields within a form. `chains` is a dictionary - mapping of model fields to peer fields within the form. For example: - - country1 = forms.ModelChoiceField(queryset=Country.objects.all()) - city1 = ChainedModelChoiceField(queryset=City.objects.all(), chains={'country': 'country1'} - - The queryset of the `city1` field will be modified as - - .filter(country=) - - where is the value of the `country1` field. (Note: The form must inherit from ChainedFieldsMixin.) - """ - def __init__(self, chains=None, *args, **kwargs): - self.chains = chains - super().__init__(*args, **kwargs) - - -class ChainedModelMultipleChoiceField(forms.ModelMultipleChoiceField): - """ - See ChainedModelChoiceField - """ - def __init__(self, chains=None, *args, **kwargs): - self.chains = chains - super().__init__(*args, **kwargs) - - class SlugField(forms.SlugField): """ Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. @@ -578,16 +550,12 @@ class TagFilterField(forms.MultipleChoiceField): super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) -class FilterChoiceField(forms.ModelMultipleChoiceField): +class DynamicModelChoiceField(forms.ModelChoiceField): """ Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. """ - def __init__(self, *args, **kwargs): - # Filter fields are not required by default - if 'required' not in kwargs: - kwargs['required'] = False - super().__init__(*args, **kwargs) + field_modifier = '' def get_bound_field(self, form, field_name): bound_field = BoundField(form, self, field_name) @@ -595,7 +563,8 @@ class FilterChoiceField(forms.ModelMultipleChoiceField): # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. if bound_field.data: - kwargs = {'{}__in'.format(self.to_field_name or 'pk'): bound_field.data} + field_name = '{}{}'.format(self.to_field_name or 'pk', self.field_modifier) + kwargs = {field_name: bound_field.data} self.queryset = self.queryset.filter(**kwargs) else: self.queryset = self.queryset.none() @@ -603,6 +572,24 @@ class FilterChoiceField(forms.ModelMultipleChoiceField): return bound_field +class DynamicModelMultipleChoiceField(DynamicModelChoiceField): + """ + A multiple-choice version of DynamicModelChoiceField. + """ + field_modifier = '__in' + + +class FilterChoiceField(DynamicModelMultipleChoiceField): + """ + A version of DynamicModelMultipleChoiceField which defaults to required=False. + """ + def __init__(self, *args, **kwargs): + # Filter fields are not required by default + if 'required' not in kwargs: + kwargs['required'] = False + super().__init__(*args, **kwargs) + + class LaxURLField(forms.URLField): """ Modifies Django's built-in URLField in two ways: @@ -655,46 +642,6 @@ class BootstrapMixin(forms.BaseForm): field.widget.attrs['placeholder'] = field.label -class ChainedFieldsMixin(forms.BaseForm): - """ - Iterate through all ChainedModelChoiceFields in the form and modify their querysets based on chained fields. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - for field_name, field in self.fields.items(): - - if isinstance(field, ChainedModelChoiceField): - - filters_dict = {} - for (db_field, parent_field) in field.chains: - if self.is_bound and parent_field in self.data and self.data[parent_field]: - filters_dict[db_field] = self.data[parent_field] or None - elif self.initial.get(parent_field): - filters_dict[db_field] = self.initial[parent_field] - elif self.fields[parent_field].widget.attrs.get('nullable'): - filters_dict[db_field] = None - else: - break - - # Limit field queryset by chained field values - if filters_dict: - field.queryset = field.queryset.filter(**filters_dict) - # Editing an existing instance; limit field to its current value - elif not self.is_bound and getattr(self, 'instance', None) and hasattr(self.instance, field_name): - obj = getattr(self.instance, field_name) - if obj is not None: - field.queryset = field.queryset.filter(pk=obj.pk) - else: - field.queryset = field.queryset.none() - # Creating a new instance with no bound data; nullify queryset - elif not self.data.get(field_name): - field.queryset = field.queryset.none() - # Creating a new instance with bound data; limit queryset to the specified value - else: - field.queryset = field.queryset.filter(pk=self.data.get(field_name)) - - class ReturnURLForm(forms.Form): """ Provides a hidden return URL field to control where the user is directed after the form is submitted. diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 96136b4de..8c66f1c23 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -14,9 +14,9 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ConfirmationForm, - CSVChoiceField, ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea, StaticSelect2, - StaticSelect2Multiple, TagFilterField, + CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, + TagFilterField, ) from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -233,7 +233,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm tag = TagFilterField(model) -class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): +class ClusterAddDevicesForm(BootstrapMixin, forms.Form): region = forms.ModelChoiceField( queryset=Region.objects.all(), required=False, @@ -247,11 +247,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - site = ChainedModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), - chains=( - ('region', 'region'), - ), required=False, widget=APISelect( api_url='/api/dcim/sites/', @@ -261,11 +258,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - rack = ChainedModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - chains=( - ('site', 'site'), - ), required=False, widget=APISelect( api_url='/api/dcim/racks/', @@ -277,12 +271,8 @@ class ClusterAddDevicesForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): } ) ) - devices = ChainedModelMultipleChoiceField( + devices = DynamicModelMultipleChoiceField( queryset=Device.objects.filter(cluster__isnull=True), - chains=( - ('site', 'site'), - ('rack', 'rack'), - ), widget=APISelectMultiple( api_url='/api/dcim/devices/', display_field='display_name', @@ -342,11 +332,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } ) ) - cluster = ChainedModelChoiceField( + cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - chains=( - ('group', 'cluster_group'), - ), widget=APISelect( api_url='/api/virtualization/clusters/' ) From da68968d7561f7d6a51f9db42a53c8551a9d6bb3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 11 Feb 2020 09:26:39 -0500 Subject: [PATCH 30/38] Replace FilterChoiceField with DynamicModelMultipleChoiceField --- netbox/circuits/forms.py | 18 +++-- netbox/dcim/forms.py | 138 ++++++++++++++++++++------------- netbox/extras/forms.py | 33 +++++--- netbox/ipam/forms.py | 38 +++++---- netbox/secrets/forms.py | 5 +- netbox/tenancy/forms.py | 13 ++-- netbox/utilities/forms.py | 11 --- netbox/virtualization/forms.py | 31 ++++---- 8 files changed, 168 insertions(+), 119 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 39b694b1c..77683e530 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -9,7 +9,7 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker, - FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField + DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, ) from .choices import CircuitStatusChoices from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -107,7 +107,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -119,9 +119,10 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -290,17 +291,19 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm required=False, label='Search' ) - type = FilterChoiceField( + type = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/circuits/circuit-types/", value_field="slug", ) ) - provider = FilterChoiceField( + provider = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/circuits/providers/", value_field="slug", @@ -311,7 +314,7 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm required=False, widget=StaticSelect2Multiple() ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -323,9 +326,10 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 29d5ba551..478474fa3 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -23,8 +23,8 @@ from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, - ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField, SelectWithPK, SmallTextarea, SlugField, - StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + DynamicModelMultipleChoiceField, ExpandableNameField, FlexibleModelChoiceField, JSONField, SelectWithPK, + SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine from .choices import * @@ -66,7 +66,7 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -78,9 +78,10 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -89,7 +90,7 @@ class DeviceComponentFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -369,9 +370,10 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/regions/", value_field="slug", @@ -419,7 +421,7 @@ class RackGroupCSVForm(forms.ModelForm): class RackGroupFilterForm(BootstrapMixin, forms.Form): - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -431,9 +433,10 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -702,7 +705,7 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -714,9 +717,10 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -725,10 +729,11 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): } ) ) - group_id = FilterChoiceField( + group_id = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.prefetch_related( 'site' ), + required=False, label='Rack group', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", @@ -740,9 +745,10 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=RackRole.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/rack-roles/", value_field="slug", @@ -758,7 +764,7 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): class RackElevationFilterForm(RackFilterForm): field_order = ['q', 'region', 'site', 'group_id', 'id', 'status', 'role', 'tenant_group', 'tenant'] - id = FilterChoiceField( + id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), label='Rack', required=False, @@ -853,16 +859,18 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): required=False, label='Search' ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", ) ) - group_id = FilterChoiceField( + group_id = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.prefetch_related('site'), + required=False, label='Rack group', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", @@ -968,9 +976,10 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - manufacturer = FilterChoiceField( + manufacturer = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/manufacturers/", value_field="slug", @@ -2123,7 +2132,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -2135,9 +2144,10 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -2147,10 +2157,9 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - rack_group_id = FilterChoiceField( - queryset=RackGroup.objects.prefetch_related( - 'site' - ), + rack_group_id = DynamicModelMultipleChoiceField( + queryset=RackGroup.objects.all(), + required=False, label='Rack group', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", @@ -2159,24 +2168,27 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - rack_id = FilterChoiceField( + rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), + required=False, label='Rack', widget=APISelectMultiple( api_url="/api/dcim/racks/", null_option=True, ) ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/device-roles/", value_field="slug", ) ) - manufacturer_id = FilterChoiceField( + manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), + required=False, label='Manufacturer', widget=APISelectMultiple( api_url="/api/dcim/manufacturers/", @@ -2185,19 +2197,19 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt } ) ) - device_type_id = FilterChoiceField( - queryset=DeviceType.objects.prefetch_related( - 'manufacturer' - ), + device_type_id = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False, label='Model', widget=APISelectMultiple( api_url="/api/dcim/device-types/", display_field="model", ) ) - platform = FilterChoiceField( + platform = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/platforms/", value_field="slug", @@ -3848,9 +3860,10 @@ class CableFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -3860,9 +3873,10 @@ class CableFilterForm(BootstrapMixin, forms.Form): } ) ) - tenant = FilterChoiceField( + tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field='slug', @@ -3871,8 +3885,9 @@ class CableFilterForm(BootstrapMixin, forms.Form): } ) ) - rack_id = FilterChoiceField( + rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), + required=False, label='Rack', widget=APISelectMultiple( api_url="/api/dcim/racks/", @@ -3897,7 +3912,7 @@ class CableFilterForm(BootstrapMixin, forms.Form): required=False, widget=ColorSelect() ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -4029,9 +4044,10 @@ class DeviceBayBulkRenameForm(BulkRenameForm): # class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4040,7 +4056,7 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -4051,9 +4067,10 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): class PowerConnectionFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4062,7 +4079,7 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -4073,9 +4090,10 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form): class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4084,7 +4102,7 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -4219,7 +4237,7 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -4231,9 +4249,10 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4242,7 +4261,7 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): } ) ) - device_id = FilterChoiceField( + device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, label='Device', @@ -4250,9 +4269,10 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): api_url='/api/dcim/devices/', ) ) - manufacturer = FilterChoiceField( + manufacturer = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), to_field_name='slug', + required=False, widget=APISelect( api_url="/api/dcim/manufacturers/", value_field="slug", @@ -4398,7 +4418,7 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -4410,17 +4430,19 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", ) ) - tenant_group = FilterChoiceField( + tenant_group = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", @@ -4430,9 +4452,10 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - tenant = FilterChoiceField( + tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field="slug", @@ -4511,7 +4534,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -4523,9 +4546,10 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4534,8 +4558,9 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - rack_group_id = FilterChoiceField( + rack_group_id = DynamicModelMultipleChoiceField( queryset=RackGroup.objects.all(), + required=False, label='Rack group (ID)', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", @@ -4743,7 +4768,7 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -4755,9 +4780,10 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -4767,16 +4793,18 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): } ) ) - power_panel_id = FilterChoiceField( + power_panel_id = DynamicModelMultipleChoiceField( queryset=PowerPanel.objects.all(), + required=False, label='Power panel', widget=APISelectMultiple( api_url="/api/dcim/power-panels/", null_option=True, ) ) - rack_id = FilterChoiceField( + rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), + required=False, label='Rack', widget=APISelectMultiple( api_url="/api/dcim/racks/", diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index f9b765379..a110c75e1 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -7,8 +7,8 @@ from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, - CommentField, ContentTypeSelect, DateTimePicker, FilterChoiceField, JSONField, SlugField, StaticSelect2, - BOOLEAN_WITH_BLANK_CHOICES, + CommentField, ContentTypeSelect, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, + StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup from .choices import * @@ -265,72 +265,81 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/regions/", value_field="slug", ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", ) ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/device-roles/", value_field="slug", ) ) - platform = FilterChoiceField( + platform = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/platforms/", value_field="slug", ) ) - cluster_group = FilterChoiceField( + cluster_group = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/virtualization/cluster-groups/", value_field="slug", ) ) - cluster_id = FilterChoiceField( + cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), + required=False, label='Cluster', widget=APISelectMultiple( api_url="/api/virtualization/clusters/", ) ) - tenant_group = FilterChoiceField( + tenant_group = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", ) ) - tenant = FilterChoiceField( + tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field="slug", ) ) - tag = FilterChoiceField( + tag = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/extras/tags/", value_field="slug", @@ -390,7 +399,7 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): required=False, widget=StaticSelect2() ) - # TODO: Convert to FilterChoiceField once we have an API endpoint for users + # TODO: Convert to DynamicModelMultipleChoiceField once we have an API endpoint for users user = forms.ModelChoiceField( queryset=User.objects.order_by('username'), required=False, diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 059587082..6a65c4e1d 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -11,8 +11,9 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, - DatePicker, DynamicModelChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, - ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, + FlexibleModelChoiceField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, + BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import VirtualMachine from .constants import * @@ -226,9 +227,10 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): label='Address family', widget=StaticSelect2() ) - rir = FilterChoiceField( + rir = DynamicModelMultipleChoiceField( queryset=RIR.objects.all(), to_field_name='slug', + required=False, label='RIR', widget=APISelectMultiple( api_url="/api/ipam/rirs/", @@ -517,8 +519,9 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) label='Mask length', widget=StaticSelect2() ) - vrf_id = FilterChoiceField( + vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), + required=False, label='VRF', widget=APISelectMultiple( api_url="/api/ipam/vrfs/", @@ -530,7 +533,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) required=False, widget=StaticSelect2Multiple() ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -542,18 +545,20 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", null_option=True, ) ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=Role.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/ipam/roles/", value_field="slug", @@ -975,8 +980,9 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo label='Mask length', widget=StaticSelect2() ) - vrf_id = FilterChoiceField( + vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), + required=False, label='VRF', widget=APISelectMultiple( api_url="/api/ipam/vrfs/", @@ -1043,7 +1049,7 @@ class VLANGroupCSVForm(forms.ModelForm): class VLANGroupFilterForm(BootstrapMixin, forms.Form): - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -1055,9 +1061,10 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", @@ -1236,7 +1243,7 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label='Search' ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -1249,17 +1256,19 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", null_option=True, ) ) - group_id = FilterChoiceField( + group_id = DynamicModelMultipleChoiceField( queryset=VLANGroup.objects.all(), + required=False, label='VLAN group', widget=APISelectMultiple( api_url="/api/ipam/vlan-groups/", @@ -1271,9 +1280,10 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=Role.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/ipam/roles/", value_field="slug", diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 2b5e059ca..7554e6278 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -8,7 +8,7 @@ from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, ) from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, FilterChoiceField, FlexibleModelChoiceField, SlugField, + APISelect, APISelectMultiple, BootstrapMixin, DynamicModelMultipleChoiceField, FlexibleModelChoiceField, SlugField, StaticSelect2Multiple, TagFilterField ) from .constants import * @@ -181,9 +181,10 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=SecretRole.objects.all(), to_field_name='slug', + required=True, widget=APISelectMultiple( api_url="/api/secrets/secret-roles/", value_field="slug", diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 553e79f1b..f3379faa0 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -5,8 +5,8 @@ from extras.forms import ( AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, ) from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, CommentField, DynamicModelChoiceField, FilterChoiceField, SlugField, - TagFilterField, + APISelect, APISelectMultiple, BootstrapMixin, CommentField, DynamicModelChoiceField, + DynamicModelMultipleChoiceField, SlugField, TagFilterField, ) from .models import Tenant, TenantGroup @@ -105,9 +105,10 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) - group = FilterChoiceField( + group = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", @@ -156,9 +157,10 @@ class TenancyForm(forms.Form): class TenancyFilterForm(forms.Form): - tenant_group = FilterChoiceField( + tenant_group = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenant-groups/", value_field="slug", @@ -168,9 +170,10 @@ class TenancyFilterForm(forms.Form): } ) ) - tenant = FilterChoiceField( + tenant = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url="/api/tenancy/tenants/", value_field="slug", diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 49d7e882d..494556b5f 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -579,17 +579,6 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceField): field_modifier = '__in' -class FilterChoiceField(DynamicModelMultipleChoiceField): - """ - A version of DynamicModelMultipleChoiceField which defaults to required=False. - """ - def __init__(self, *args, **kwargs): - # Filter fields are not required by default - if 'required' not in kwargs: - kwargs['required'] = False - super().__init__(*args, **kwargs) - - class LaxURLField(forms.URLField): """ Modifies Django's built-in URLField in two ways: diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 8c66f1c23..41b94a3c9 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -15,8 +15,7 @@ from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, - TagFilterField, + ExpandableNameField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField, ) from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -189,7 +188,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm 'q', 'type', 'region', 'site', 'group', 'tenant_group', 'tenant' ] q = forms.CharField(required=False, label='Search') - type = FilterChoiceField( + type = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), to_field_name='slug', required=False, @@ -198,7 +197,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm value_field='slug', ) ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -210,7 +209,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', required=False, @@ -220,7 +219,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm null_option=True, ) ) - group = FilterChoiceField( + group = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', required=False, @@ -544,32 +543,35 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil required=False, label='Search' ) - cluster_group = FilterChoiceField( + cluster_group = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url='/api/virtualization/cluster-groups/', value_field="slug", null_option=True, ) ) - cluster_type = FilterChoiceField( + cluster_type = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url='/api/virtualization/cluster-types/', value_field="slug", null_option=True, ) ) - cluster_id = FilterChoiceField( + cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), + required=False, label='Cluster', widget=APISelectMultiple( api_url='/api/virtualization/clusters/', ) ) - region = FilterChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), to_field_name='slug', required=False, @@ -581,18 +583,20 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil } ) ) - site = FilterChoiceField( + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url='/api/dcim/sites/', value_field="slug", null_option=True, ) ) - role = FilterChoiceField( + role = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.filter(vm_role=True), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url='/api/dcim/device-roles/', value_field="slug", @@ -607,9 +611,10 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil required=False, widget=StaticSelect2Multiple() ) - platform = FilterChoiceField( + platform = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), to_field_name='slug', + required=False, widget=APISelectMultiple( api_url='/api/dcim/platforms/', value_field="slug", From 221805a63efa965b62f3b646d707ffbaa2c639ff Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 11 Feb 2020 09:50:33 -0500 Subject: [PATCH 31/38] Use TreeNode choice fields for region assignment --- netbox/dcim/forms.py | 22 +++++++++------------- netbox/extras/forms.py | 8 +++++++- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 478474fa3..4de1c1eca 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -182,18 +182,18 @@ class MACAddressField(forms.Field): # class RegionForm(BootstrapMixin, forms.ModelForm): + parent = TreeNodeChoiceField( + queryset=Region.objects.all(), + required=False, + widget=StaticSelect2() + ) slug = SlugField() class Meta: model = Region - fields = [ + fields = ( 'parent', 'name', 'slug', - ] - widgets = { - 'parent': APISelect( - api_url="/api/dcim/regions/" - ) - } + ) class RegionCSVForm(forms.ModelForm): @@ -232,9 +232,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): region = TreeNodeChoiceField( queryset=Region.objects.all(), required=False, - widget=APISelect( - api_url="/api/dcim/regions/" - ) + widget=StaticSelect2() ) slug = SlugField() comments = CommentField() @@ -325,9 +323,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor region = TreeNodeChoiceField( queryset=Region.objects.all(), required=False, - widget=APISelect( - api_url="/api/dcim/regions/" - ) + widget=StaticSelect2() ) tenant = forms.ModelChoiceField( queryset=Tenant.objects.all(), diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index a110c75e1..45a42135d 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -1,6 +1,7 @@ from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType +from mptt.forms import TreeNodeMultipleChoiceField from taggit.forms import TagField from dcim.models import DeviceRole, Platform, Region, Site @@ -8,7 +9,7 @@ from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, CommentField, ContentTypeSelect, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, - StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, + StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup from .choices import * @@ -190,6 +191,11 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm): # class ConfigContextForm(BootstrapMixin, forms.ModelForm): + regions = TreeNodeMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + widget=StaticSelect2Multiple() + ) tags = forms.ModelMultipleChoiceField( queryset=Tag.objects.all(), to_field_name='slug', From fb56d5bc660fef2555e5d7ea69b8306c7a6f2de8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 11 Feb 2020 10:21:44 -0500 Subject: [PATCH 32/38] Account for initial data when binding a DynamicModelChoiceField --- netbox/utilities/forms.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index 494556b5f..fc724224b 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -562,10 +562,11 @@ class DynamicModelChoiceField(forms.ModelChoiceField): # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. + field_name = '{}{}'.format(self.to_field_name or 'pk', self.field_modifier) if bound_field.data: - field_name = '{}{}'.format(self.to_field_name or 'pk', self.field_modifier) - kwargs = {field_name: bound_field.data} - self.queryset = self.queryset.filter(**kwargs) + self.queryset = self.queryset.filter(**{field_name: bound_field.data}) + elif bound_field.initial: + self.queryset = self.queryset.filter(**{field_name: bound_field.initial}) else: self.queryset = self.queryset.none() From 7177fcfa61456f58efe511b67ad066526da7f791 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 11 Feb 2020 10:43:22 -0500 Subject: [PATCH 33/38] Use DynamicModelChoiceField for all fields using APISelect --- netbox/circuits/forms.py | 27 ++-- netbox/dcim/forms.py | 244 +++++++++++++++++++-------------- netbox/extras/forms.py | 81 +++++++---- netbox/ipam/forms.py | 105 ++++++++------ netbox/secrets/forms.py | 17 +-- netbox/tenancy/forms.py | 18 +-- netbox/utilities/forms.py | 20 +-- netbox/virtualization/forms.py | 97 +++++++------ 8 files changed, 363 insertions(+), 246 deletions(-) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 77683e530..0b0378a7a 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -9,7 +9,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, DatePicker, - DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2, + StaticSelect2Multiple, TagFilterField, ) from .choices import CircuitStatusChoices from .models import Circuit, CircuitTermination, CircuitType, Provider @@ -165,6 +166,18 @@ class CircuitTypeCSVForm(forms.ModelForm): # class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + widget=APISelect( + api_url="/api/circuits/providers/" + ) + ) + type = DynamicModelChoiceField( + queryset=CircuitType.objects.all(), + widget=APISelect( + api_url="/api/circuits/circuit-types/" + ) + ) comments = CommentField() tags = TagField( required=False @@ -181,12 +194,6 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'commit_rate': "Committed rate", } widgets = { - 'provider': APISelect( - api_url="/api/circuits/providers/" - ), - 'type': APISelect( - api_url="/api/circuits/circuit-types/" - ), 'status': StaticSelect2(), 'install_date': DatePicker(), } @@ -236,14 +243,14 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput ) - type = forms.ModelChoiceField( + type = DynamicModelChoiceField( queryset=CircuitType.objects.all(), required=False, widget=APISelect( api_url="/api/circuits/circuit-types/" ) ) - provider = forms.ModelChoiceField( + provider = DynamicModelChoiceField( queryset=Provider.objects.all(), required=False, widget=APISelect( @@ -256,7 +263,7 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit initial='', widget=StaticSelect2() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 4de1c1eca..99bb470f8 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -325,7 +325,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor required=False, widget=StaticSelect2() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -383,18 +383,20 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): # class RackGroupForm(BootstrapMixin, forms.ModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/" + ) + ) slug = SlugField() class Meta: model = RackGroup - fields = [ + fields = ( 'site', 'name', 'slug', - ] - widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/" - ) - } + ) class RackGroupCSVForm(forms.ModelForm): @@ -471,6 +473,15 @@ class RackRoleCSVForm(forms.ModelForm): # class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + widget=APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'group': 'site_id', + } + ) + ) group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), required=False, @@ -478,6 +489,13 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): api_url='/api/dcim/rack-groups/', ) ) + role = DynamicModelChoiceField( + queryset=RackRole.objects.all(), + required=False, + widget=APISelect( + api_url='/api/dcim/rack-roles/', + ) + ) comments = CommentField() tags = TagField( required=False @@ -496,16 +514,7 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'u_height': "Height in rack units", } widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/", - filter_for={ - 'group': 'site_id', - } - ), 'status': StaticSelect2(), - 'role': APISelect( - api_url="/api/dcim/rack-roles/" - ), 'type': StaticSelect2(), 'width': StaticSelect2(), 'outer_unit': StaticSelect2(), @@ -605,7 +614,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput ) - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -615,14 +624,14 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor } ) ) - group = forms.ModelChoiceField( + group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/rack-groups", ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -635,7 +644,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor initial='', widget=StaticSelect2() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=RackRole.objects.all(), required=False, widget=APISelect( @@ -833,7 +842,7 @@ class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=StaticSelect2() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -905,6 +914,12 @@ class ManufacturerCSVForm(forms.ModelForm): # class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + widget=APISelect( + api_url="/api/dcim/manufacturers/", + ) + ) slug = SlugField( slug_source='model' ) @@ -920,9 +935,6 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): 'tags', ] widgets = { - 'manufacturer': APISelect( - api_url="/api/dcim/manufacturers/" - ), 'subdevice_role': StaticSelect2() } @@ -945,11 +957,11 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkE queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput() ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False, widget=APISelect( - api_url="/api/dcim/manufactureres" + api_url="/api/dcim/manufacturers" ) ) u_height = forms.IntegerField( @@ -1048,7 +1060,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1091,7 +1103,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1134,7 +1146,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1207,7 +1219,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1276,7 +1288,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1339,7 +1351,7 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): class FrontPortTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1433,7 +1445,7 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class RearPortTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1482,7 +1494,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form): - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), widget=APISelect( api_url='/api/dcim/device-types/' @@ -1653,6 +1665,13 @@ class DeviceRoleCSVForm(forms.ModelForm): # class PlatformForm(BootstrapMixin, forms.ModelForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/manufacturers/", + ) + ) slug = SlugField( max_length=64 ) @@ -1663,9 +1682,6 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', ] widgets = { - 'manufacturer': APISelect( - api_url="/api/dcim/manufacturers/" - ), 'napalm_args': SmallTextarea(), } @@ -1695,7 +1711,7 @@ class PlatformCSVForm(forms.ModelForm): # class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), widget=APISelect( api_url="/api/dcim/sites/", @@ -1721,7 +1737,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): disabled_indicator='device' ) ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False, widget=APISelect( @@ -1734,13 +1750,28 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), - label='Device type', widget=APISelect( api_url='/api/dcim/device-types/', display_field='model' ) ) - cluster_group = forms.ModelChoiceField( + device_role = DynamicModelChoiceField( + queryset=DeviceRole.objects.all(), + widget=APISelect( + api_url='/api/dcim/device-roles/' + ) + ) + platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/platforms/", + additional_query_params={ + "manufacturer_id": "null" + } + ) + ) + cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, widget=APISelect( @@ -1786,16 +1817,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'position': 'face' } ), - 'device_role': APISelect( - api_url='/api/dcim/device-roles/' - ), 'status': StaticSelect2(), - 'platform': APISelect( - api_url="/api/dcim/platforms/", - additional_query_params={ - "manufacturer_id": "null" - } - ), 'primary_ip4': StaticSelect2(), 'primary_ip6': StaticSelect2(), } @@ -2069,31 +2091,29 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() ) - device_type = forms.ModelChoiceField( + device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), required=False, - label='Type', widget=APISelect( api_url="/api/dcim/device-types/", display_field='display_name' ) ) - device_role = forms.ModelChoiceField( + device_role = DynamicModelChoiceField( queryset=DeviceRole.objects.all(), required=False, - label='Role', widget=APISelect( api_url="/api/dcim/device-roles/" ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( api_url="/api/tenancy/tenants/" ) ) - platform = forms.ModelChoiceField( + platform = DynamicModelChoiceField( queryset=Platform.objects.all(), required=False, widget=APISelect( @@ -2103,7 +2123,6 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF status = forms.ChoiceField( choices=add_blank_choice(DeviceStatusChoices), required=False, - initial='', widget=StaticSelect2() ) serial = forms.CharField( @@ -2345,7 +2364,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): class ConsolePortCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -2430,7 +2449,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -2529,7 +2548,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): class PowerPortCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -2647,7 +2666,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): class PowerOutletCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -2806,25 +2825,26 @@ class InterfaceFilterForm(DeviceComponentFilterForm): class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, + label='Untagged VLAN', widget=APISelect( api_url="/api/ipam/vlans/", display_field='display_name', full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, + label='Tagged VLANs', widget=APISelectMultiple( api_url="/api/ipam/vlans/", display_field='display_name', full=True ) ) - tags = TagField( required=False ) @@ -2866,7 +2886,7 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm): class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -2916,7 +2936,7 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): tags = TagField( required=False ) - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -2925,7 +2945,7 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( @@ -3064,7 +3084,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): required=False, widget=StaticSelect2() ) - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -3073,7 +3093,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( @@ -3166,7 +3186,7 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm): # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic class FrontPortCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -3344,7 +3364,7 @@ class RearPortForm(BootstrapMixin, forms.ModelForm): class RearPortCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -3429,7 +3449,7 @@ class ConnectCableToDeviceForm(BootstrapMixin, forms.ModelForm): """ Base form for connecting a Cable to a Device component """ - termination_b_site = forms.ModelChoiceField( + termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, @@ -3555,7 +3575,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm): class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): - termination_b_provider = forms.ModelChoiceField( + termination_b_provider = DynamicModelChoiceField( queryset=Provider.objects.all(), label='Provider', required=False, @@ -3606,7 +3626,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): class ConnectCableToPowerFeedForm(BootstrapMixin, forms.ModelForm): - termination_b_site = forms.ModelChoiceField( + termination_b_site = DynamicModelChoiceField( queryset=Site.objects.all(), label='Site', required=False, @@ -3943,7 +3963,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm): class DeviceBayCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -4113,6 +4133,19 @@ class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): # class InventoryItemForm(BootstrapMixin, forms.ModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer'), + widget=APISelect( + api_url="/api/dcim/devices/" + ) + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/manufacturers/" + ) + ) tags = TagField( required=False ) @@ -4122,18 +4155,10 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): fields = [ 'name', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags', ] - widgets = { - 'device': APISelect( - api_url="/api/dcim/devices/" - ), - 'manufacturer': APISelect( - api_url="/api/dcim/manufacturers/" - ) - } class InventoryItemCreateForm(BootstrapMixin, forms.Form): - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.prefetch_related('device_type__manufacturer'), widget=APISelect( api_url="/api/dcim/devices/", @@ -4142,7 +4167,7 @@ class InventoryItemCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( label='Name' ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False, widget=APISelect( @@ -4197,14 +4222,14 @@ class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm): queryset=InventoryItem.objects.all(), widget=forms.MultipleHiddenInput() ) - device = forms.ModelChoiceField( + device = DynamicModelChoiceField( queryset=Device.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/devices/" ) ) - manufacturer = forms.ModelChoiceField( + manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False, widget=APISelect( @@ -4364,7 +4389,7 @@ class DeviceVCMembershipForm(forms.ModelForm): class VCMemberSelectForm(BootstrapMixin, forms.Form): - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -4466,6 +4491,16 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): # class PowerPanelForm(BootstrapMixin, forms.ModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'rack_group': 'site_id', + } + ) + ) rack_group = DynamicModelChoiceField( queryset=RackGroup.objects.all(), required=False, @@ -4479,14 +4514,6 @@ class PowerPanelForm(BootstrapMixin, forms.ModelForm): fields = [ 'site', 'rack_group', 'name', ] - widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/", - filter_for={ - 'rack_group': 'site_id', - } - ), - } class PowerPanelCSVForm(forms.ModelForm): @@ -4581,6 +4608,19 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): } ) ) + power_panel = DynamicModelChoiceField( + queryset=PowerPanel.objects.all(), + widget=APISelect( + api_url="/api/dcim/power-panels/" + ) + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/racks/" + ) + ) comments = CommentField() tags = TagField( required=False @@ -4593,12 +4633,6 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): 'max_utilization', 'comments', 'tags', ] widgets = { - 'power_panel': APISelect( - api_url="/api/dcim/power-panels/" - ), - 'rack': APISelect( - api_url="/api/dcim/racks/" - ), 'status': StaticSelect2(), 'type': StaticSelect2(), 'supply': StaticSelect2(), @@ -4697,7 +4731,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=PowerFeed.objects.all(), widget=forms.MultipleHiddenInput ) - power_panel = forms.ModelChoiceField( + power_panel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), required=False, widget=APISelect( @@ -4707,7 +4741,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd } ) ) - rack = forms.ModelChoiceField( + rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, widget=APISelect( diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 45a42135d..d6a5406b7 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -196,7 +196,56 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): required=False, widget=StaticSelect2Multiple() ) - tags = forms.ModelMultipleChoiceField( + sites = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/sites/" + ) + ) + roles = DynamicModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/device-roles/" + ) + ) + platforms = DynamicModelMultipleChoiceField( + queryset=Platform.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/platforms/" + ) + ) + cluster_groups = DynamicModelMultipleChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/virtualization/cluster-groups/" + ) + ) + clusters = DynamicModelMultipleChoiceField( + queryset=Cluster.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/virtualization/clusters/" + ) + ) + tenant_groups = DynamicModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/tenancy/tenant-groups/" + ) + ) + tenants = DynamicModelMultipleChoiceField( + queryset=Tenant.objects.all(), + required=False, + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/" + ) + ) + tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), to_field_name='slug', required=False, @@ -210,36 +259,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConfigContext - fields = [ + fields = ( 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', - ] - widgets = { - 'regions': APISelectMultiple( - api_url="/api/dcim/regions/" - ), - 'sites': APISelectMultiple( - api_url="/api/dcim/sites/" - ), - 'roles': APISelectMultiple( - api_url="/api/dcim/device-roles/" - ), - 'platforms': APISelectMultiple( - api_url="/api/dcim/platforms/" - ), - 'cluster_groups': APISelectMultiple( - api_url="/api/virtualization/cluster-groups/" - ), - 'clusters': APISelectMultiple( - api_url="/api/virtualization/clusters/" - ), - 'tenant_groups': APISelectMultiple( - api_url="/api/tenancy/tenant-groups/" - ), - 'tenants': APISelectMultiple( - api_url="/api/tenancy/tenants/" - ), - } + ) class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 6a65c4e1d..2b7fb2a6b 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -76,7 +76,7 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput() ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -149,6 +149,12 @@ class RIRFilterForm(BootstrapMixin, forms.Form): # class AggregateForm(BootstrapMixin, CustomFieldModelForm): + rir = DynamicModelChoiceField( + queryset=RIR.objects.all(), + widget=APISelect( + api_url="/api/ipam/rirs/" + ) + ) tags = TagField( required=False ) @@ -163,9 +169,6 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm): 'rir': "Regional Internet Registry responsible for this prefix", } widgets = { - 'rir': APISelect( - api_url="/api/ipam/rirs/" - ), 'date_added': DatePicker(), } @@ -190,7 +193,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput() ) - rir = forms.ModelChoiceField( + rir = DynamicModelChoiceField( queryset=RIR.objects.all(), required=False, label='RIR', @@ -270,7 +273,14 @@ class RoleCSVForm(forms.ModelForm): # class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - site = forms.ModelChoiceField( + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/vrfs/", + ) + ) + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -307,6 +317,13 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): display_field='display_name' ) ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/roles/" + ) + ) tags = TagField(required=False) class Meta: @@ -316,13 +333,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'tags', ] widgets = { - 'vrf': APISelect( - api_url="/api/ipam/vrfs/" - ), 'status': StaticSelect2(), - 'role': APISelect( - api_url="/api/ipam/roles/" - ) } def __init__(self, *args, **kwargs): @@ -433,14 +444,14 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput() ) - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/sites/" ) ) - vrf = forms.ModelChoiceField( + vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, label='VRF', @@ -453,7 +464,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF max_value=PREFIX_LENGTH_MAX, required=False ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -465,7 +476,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF required=False, widget=StaticSelect2() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=Role.objects.all(), required=False, widget=APISelect( @@ -588,7 +599,15 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel queryset=Interface.objects.all(), required=False ) - nat_site = forms.ModelChoiceField( + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF', + widget=APISelect( + api_url="/api/ipam/vrfs/" + ) + ) + nat_site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, label='Site', @@ -664,9 +683,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel widgets = { 'status': StaticSelect2(), 'role': StaticSelect2(), - 'vrf': APISelect( - api_url="/api/ipam/vrfs/" - ) } def __init__(self, *args, **kwargs): @@ -741,6 +757,14 @@ class IPAddressBulkCreateForm(BootstrapMixin, forms.Form): class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF', + widget=APISelect( + api_url="/api/ipam/vrfs/" + ) + ) class Meta: model = IPAddress @@ -750,9 +774,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): widgets = { 'status': StaticSelect2(), 'role': StaticSelect2(), - 'vrf': APISelect( - api_url="/api/ipam/vrfs/" - ) } def __init__(self, *args, **kwargs): @@ -888,7 +909,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput() ) - vrf = forms.ModelChoiceField( + vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, label='VRF', @@ -901,7 +922,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd max_value=IPADDRESS_MASK_LENGTH_MAX, required=False ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -934,7 +955,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd class IPAddressAssignForm(BootstrapMixin, forms.Form): - vrf_id = forms.ModelChoiceField( + vrf_id = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, label='VRF', @@ -1014,6 +1035,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo # class VLANGroupForm(BootstrapMixin, forms.ModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/" + ) + ) slug = SlugField() class Meta: @@ -1021,11 +1049,6 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): fields = [ 'site', 'name', 'slug', ] - widgets = { - 'site': APISelect( - api_url="/api/dcim/sites/" - ) - } class VLANGroupCSVForm(forms.ModelForm): @@ -1078,7 +1101,7 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): # class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -1098,6 +1121,13 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): api_url='/api/ipam/vlan-groups/', ) ) + role = DynamicModelChoiceField( + queryset=Role.objects.all(), + required=False, + widget=APISelect( + api_url="/api/ipam/roles/" + ) + ) tags = TagField(required=False) class Meta: @@ -1115,9 +1145,6 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } widgets = { 'status': StaticSelect2(), - 'role': APISelect( - api_url="/api/ipam/roles/" - ) } @@ -1192,21 +1219,21 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput() ) - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( api_url="/api/dcim/sites/" ) ) - group = forms.ModelChoiceField( + group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), required=False, widget=APISelect( api_url="/api/ipam/vlan-groups/" ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( @@ -1218,7 +1245,7 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor required=False, widget=StaticSelect2() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=Role.objects.all(), required=False, widget=APISelect( diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 7554e6278..79064e0dd 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -8,8 +8,8 @@ from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, ) from utilities.forms import ( - APISelect, APISelectMultiple, BootstrapMixin, DynamicModelMultipleChoiceField, FlexibleModelChoiceField, SlugField, - StaticSelect2Multiple, TagFilterField + APISelect, APISelectMultiple, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + FlexibleModelChoiceField, SlugField, StaticSelect2Multiple, TagFilterField, ) from .constants import * from .models import Secret, SecretRole, UserKey @@ -87,6 +87,12 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): label='Plaintext (verify)', widget=forms.PasswordInput() ) + role = DynamicModelChoiceField( + queryset=SecretRole.objects.all(), + widget=APISelect( + api_url="/api/secrets/secret-roles/" + ) + ) tags = TagField( required=False ) @@ -96,11 +102,6 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): fields = [ 'role', 'name', 'plaintext', 'plaintext2', 'tags', ] - widgets = { - 'role': APISelect( - api_url="/api/secrets/secret-roles/" - ) - } def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -157,7 +158,7 @@ class SecretBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput() ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=SecretRole.objects.all(), required=False, widget=APISelect( diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index f3379faa0..5b828b661 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -42,6 +42,13 @@ class TenantGroupCSVForm(forms.ModelForm): class TenantForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() + group = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False, + widget=APISelect( + api_url="/api/tenancy/tenant-groups/" + ) + ) comments = CommentField() tags = TagField( required=False @@ -49,14 +56,9 @@ class TenantForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Tenant - fields = [ + fields = ( 'name', 'slug', 'group', 'description', 'comments', 'tags', - ] - widgets = { - 'group': APISelect( - api_url="/api/tenancy/tenant-groups/" - ) - } + ) class TenantCSVForm(CustomFieldModelForm): @@ -85,7 +87,7 @@ class TenantBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Tenant.objects.all(), widget=forms.MultipleHiddenInput() ) - group = forms.ModelChoiceField( + group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False, widget=APISelect( diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index fc724224b..c9a857ad0 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -550,11 +550,7 @@ class TagFilterField(forms.MultipleChoiceField): super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) -class DynamicModelChoiceField(forms.ModelChoiceField): - """ - Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be - rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. - """ +class DynamicModelChoiceMixin: field_modifier = '' def get_bound_field(self, form, field_name): @@ -564,16 +560,24 @@ class DynamicModelChoiceField(forms.ModelChoiceField): # will be populated on-demand via the APISelect widget. field_name = '{}{}'.format(self.to_field_name or 'pk', self.field_modifier) if bound_field.data: - self.queryset = self.queryset.filter(**{field_name: bound_field.data}) + self.queryset = self.queryset.filter(**{field_name: self.prepare_value(bound_field.data)}) elif bound_field.initial: - self.queryset = self.queryset.filter(**{field_name: bound_field.initial}) + self.queryset = self.queryset.filter(**{field_name: self.prepare_value(bound_field.initial)}) else: self.queryset = self.queryset.none() return bound_field -class DynamicModelMultipleChoiceField(DynamicModelChoiceField): +class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField): + """ + Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be + rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. + """ + pass + + +class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField): """ A multiple-choice version of DynamicModelChoiceField. """ diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 41b94a3c9..12393d400 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -76,6 +76,26 @@ class ClusterGroupCSVForm(forms.ModelForm): # class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + type = DynamicModelChoiceField( + queryset=ClusterType.objects.all(), + widget=APISelect( + api_url="/api/virtualization/cluster-types/" + ) + ) + group = DynamicModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + widget=APISelect( + api_url="/api/virtualization/cluster-groups/" + ) + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/sites/" + ) + ) comments = CommentField() tags = TagField( required=False @@ -83,20 +103,9 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Cluster - fields = [ + fields = ( 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', - ] - widgets = { - 'type': APISelect( - api_url="/api/virtualization/cluster-types/" - ), - 'group': APISelect( - api_url="/api/virtualization/cluster-groups/" - ), - 'site': APISelect( - api_url="/api/dcim/sites/" - ), - } + ) class ClusterCSVForm(CustomFieldModelCSVForm): @@ -146,25 +155,28 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit queryset=Cluster.objects.all(), widget=forms.MultipleHiddenInput() ) - type = forms.ModelChoiceField( + type = DynamicModelChoiceField( queryset=ClusterType.objects.all(), required=False, widget=APISelect( api_url="/api/virtualization/cluster-types/" ) ) - group = forms.ModelChoiceField( + group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, widget=APISelect( api_url="/api/virtualization/cluster-groups/" ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), - required=False + required=False, + widget=APISelect( + api_url="/api/tenancy/tenants/" + ) ) - site = forms.ModelChoiceField( + site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, widget=APISelect( @@ -233,7 +245,7 @@ class ClusterFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm class ClusterAddDevicesForm(BootstrapMixin, forms.Form): - region = forms.ModelChoiceField( + region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, widget=APISelect( @@ -318,7 +330,7 @@ class ClusterRemoveDevicesForm(ConfirmationForm): # class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): - cluster_group = forms.ModelChoiceField( + cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, widget=APISelect( @@ -337,6 +349,22 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): api_url='/api/virtualization/clusters/' ) ) + role = DynamicModelChoiceField( + queryset=DeviceRole.objects.all(), + widget=APISelect( + api_url="/api/dcim/device-roles/", + additional_query_params={ + "vm_role": "True" + } + ) + ) + platform = DynamicModelChoiceField( + queryset=Platform.objects.all(), + required=False, + widget=APISelect( + api_url='/api/dcim/platforms/' + ) + ) tags = TagField( required=False ) @@ -357,17 +385,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): } widgets = { "status": StaticSelect2(), - "role": APISelect( - api_url="/api/dcim/device-roles/", - additional_query_params={ - "vm_role": "True" - } - ), 'primary_ip4': StaticSelect2(), 'primary_ip6': StaticSelect2(), - 'platform': APISelect( - api_url='/api/dcim/platforms/' - ) } def __init__(self, *args, **kwargs): @@ -477,14 +496,14 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB initial='', widget=StaticSelect2(), ) - cluster = forms.ModelChoiceField( + cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), required=False, widget=APISelect( api_url='/api/virtualization/clusters/' ) ) - role = forms.ModelChoiceField( + role = DynamicModelChoiceField( queryset=DeviceRole.objects.filter( vm_role=True ), @@ -496,14 +515,14 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB } ) ) - tenant = forms.ModelChoiceField( + tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False, widget=APISelect( api_url='/api/tenancy/tenants/' ) ) - platform = forms.ModelChoiceField( + platform = DynamicModelChoiceField( queryset=Platform.objects.all(), required=False, widget=APISelect( @@ -633,7 +652,7 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil # class InterfaceForm(BootstrapMixin, forms.ModelForm): - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -642,7 +661,7 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( @@ -759,7 +778,7 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): required=False, widget=StaticSelect2(), ) - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -768,7 +787,7 @@ class InterfaceCreateForm(BootstrapMixin, forms.Form): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( @@ -847,7 +866,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): required=False, widget=StaticSelect2() ) - untagged_vlan = forms.ModelChoiceField( + untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelect( @@ -856,7 +875,7 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): full=True ) ) - tagged_vlans = forms.ModelMultipleChoiceField( + tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, widget=APISelectMultiple( From 009c0ba31c445ddc9ff4323c83ae99c869fad0b0 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 11 Feb 2020 14:18:45 -0500 Subject: [PATCH 34/38] Fixes #4134: Device power ports and outlets should inherit type from the parent device type --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/models/device_component_templates.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index e789c9803..a796ea924 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -17,6 +17,7 @@ * [#4091](https://github.com/netbox-community/netbox/issues/4091) - Fix filtering of objects by custom fields using UI search form * [#4099](https://github.com/netbox-community/netbox/issues/4099) - Linkify interfaces on global interfaces list * [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms +* [#4134](https://github.com/netbox-community/netbox/issues/4134) - Device power ports and outlets should inherit type from the parent device type --- diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index ab4a078cf..faa42b035 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -168,6 +168,7 @@ class PowerPortTemplate(ComponentTemplateModel): return PowerPort( device=device, name=self.name, + type=self.type, maximum_draw=self.maximum_draw, allocated_draw=self.allocated_draw ) @@ -232,6 +233,7 @@ class PowerOutletTemplate(ComponentTemplateModel): return PowerOutlet( device=device, name=self.name, + type=self.type, power_port=power_port, feed_leg=self.feed_leg ) From 55886d679379845f0d1b1c9089c936a4e8f91264 Mon Sep 17 00:00:00 2001 From: Anton Tokarev Date: Tue, 11 Feb 2020 22:22:32 +0300 Subject: [PATCH 35/38] fix typo (#4135) Co-authored-by: Jeremy Stretch --- docs/installation/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/index.md b/docs/installation/index.md index 4962eb7d0..59631bf7a 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -4,7 +4,7 @@ The following sections detail how to set up a new instance of NetBox: 1. [PostgreSQL database](1-postgresql.md) 2. [NetBox components](2-netbox.md) -3. [HTTP dameon](3-http-daemon.md) +3. [HTTP daemon](3-http-daemon.md) 4. [LDAP authentication](4-ldap.md) (optional) # Upgrading From 2503978555e2dcb569d048581b862838cd524bf4 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 11 Feb 2020 14:32:41 -0500 Subject: [PATCH 36/38] Add navigation menu link for adding config contexts --- netbox/templates/inc/nav_menu.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index eeb520a57..5e6621813 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -479,6 +479,11 @@ Config Contexts + {% if perms.extras.add_configcontext %} +
+ +
+ {% endif %} Scripts From 2ab382eec5a6a282bc99209073690d9c19cb55a8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 11 Feb 2020 14:56:02 -0500 Subject: [PATCH 37/38] Fixes #4137: Disable occupied terminations when connecting a cable to a circuit --- docs/release-notes/version-2.7.md | 1 + netbox/dcim/forms.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index a796ea924..4fea49d6b 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -18,6 +18,7 @@ * [#4099](https://github.com/netbox-community/netbox/issues/4099) - Linkify interfaces on global interfaces list * [#4108](https://github.com/netbox-community/netbox/issues/4108) - Avoid extraneous database queries when rendering search forms * [#4134](https://github.com/netbox-community/netbox/issues/4134) - Device power ports and outlets should inherit type from the parent device type +* [#4137](https://github.com/netbox-community/netbox/issues/4137) - Disable occupied terminations when connecting a cable to a circuit --- diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 99bb470f8..8f035ccbb 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -3613,7 +3613,8 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, forms.ModelForm): widget=APISelect( api_url='/api/circuits/circuit-terminations/', disabled_indicator='cable', - display_field='term_side' + display_field='term_side', + full=True ) ) From ab378ed218f6ad1109588c0a684c7b20428aa47d Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Tue, 11 Feb 2020 20:39:50 -0600 Subject: [PATCH 38/38] Fixes: #4130 Corrects service name for the RQ worker service in docs/installation/upgrading.md --- docs/installation/upgrading.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 6a2c0188f..e5cf93a28 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -88,7 +88,7 @@ Finally, restart the WSGI services to run the new code. If you followed this gui ```no-highlight # sudo systemctl restart netbox -# sudo systemctl restart netbox-rqworker +# sudo systemctl restart netbox-rq ``` !!! note