diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 57d965538..9b4dc800d 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -14,6 +14,8 @@ * [#8854](https://github.com/netbox-community/netbox/issues/8854) - Fix `REMOTE_AUTH_DEFAULT_GROUPS` for social-auth backends * [#9575](https://github.com/netbox-community/netbox/issues/9575) - Fix AttributeError exception for FHRP group with an IP address assigned * [#9597](https://github.com/netbox-community/netbox/issues/9597) - Include `installed_module` in module bay REST API serializer +* [#9657](https://github.com/netbox-community/netbox/issues/9657) - Fix filtering for custom fields and webhooks in the UI +* [#9682](https://github.com/netbox-community/netbox/issues/9682) - Fix bulk assignment of ASNs to sites --- diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index cca197c73..df0af3541 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -32,6 +32,9 @@ class WebhookFilterSet(BaseFilterSet): method='search', label='Search', ) + content_type_id = MultiValueNumberFilter( + field_name='content_types__id' + ) content_types = ContentTypeFilter() http_method = django_filters.MultipleChoiceFilter( choices=WebhookHttpMethodChoices @@ -40,8 +43,8 @@ class WebhookFilterSet(BaseFilterSet): class Meta: model = Webhook fields = [ - 'id', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', - 'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path', + 'id', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', 'http_method', + 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path', ] def search(self, queryset, name, value): @@ -58,6 +61,12 @@ class CustomFieldFilterSet(BaseFilterSet): method='search', label='Search', ) + type = django_filters.MultipleChoiceFilter( + choices=CustomFieldTypeChoices + ) + content_type_id = MultiValueNumberFilter( + field_name='content_types__id' + ) content_types = ContentTypeFilter() class Meta: diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 56f48f96b..526d47013 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -32,12 +32,13 @@ __all__ = ( class CustomFieldFilterForm(FilterForm): fieldsets = ( (None, ('q',)), - ('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required', 'ui_visibility')), + ('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')), ) - content_types = ContentTypeMultipleChoiceField( + content_type_id = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), - required=False + required=False, + label='Object type' ) type = MultipleChoiceField( choices=CustomFieldTypeChoices, @@ -119,13 +120,14 @@ class ExportTemplateFilterForm(FilterForm): class WebhookFilterForm(FilterForm): fieldsets = ( (None, ('q',)), - ('Attributes', ('content_types', 'http_method', 'enabled')), + ('Attributes', ('content_type_id', 'http_method', 'enabled')), ('Events', ('type_create', 'type_update', 'type_delete')), ) - content_types = ContentTypeMultipleChoiceField( + content_type_id = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('webhooks'), - required=False + required=False, + label='Object type' ) http_method = MultipleChoiceField( choices=WebhookHttpMethodChoices, diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index a88ed9418..9f9483bbb 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -6,8 +6,9 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from circuits.models import Provider -from dcim.models import DeviceRole, DeviceType, Location, Manufacturer, Platform, Rack, Region, Site, SiteGroup -from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices +from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup +from dcim.models import Location +from extras.choices import * from extras.filtersets import * from extras.models import * from ipam.models import IPAddress @@ -16,6 +17,72 @@ from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, cr from virtualization.models import Cluster, ClusterGroup, ClusterType +class CustomFieldTestCase(TestCase, BaseFilterSetTests): + queryset = CustomField.objects.all() + filterset = CustomFieldFilterSet + + @classmethod + def setUpTestData(cls): + content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) + + custom_fields = ( + CustomField( + name='Custom Field 1', + type=CustomFieldTypeChoices.TYPE_TEXT, + required=True, + weight=100, + filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE, + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE + ), + CustomField( + name='Custom Field 2', + type=CustomFieldTypeChoices.TYPE_INTEGER, + required=False, + weight=200, + filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT, + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY + ), + CustomField( + name='Custom Field 3', + type=CustomFieldTypeChoices.TYPE_BOOLEAN, + required=False, + weight=300, + filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN + ), + ) + CustomField.objects.bulk_create(custom_fields) + custom_fields[0].content_types.add(content_types[0]) + custom_fields[1].content_types.add(content_types[1]) + custom_fields[2].content_types.add(content_types[2]) + + def test_name(self): + params = {'name': ['Custom Field 1', 'Custom Field 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_content_types(self): + params = {'content_types': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_required(self): + params = {'required': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_weight(self): + params = {'weight': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_filter_logic(self): + params = {'filter_logic': CustomFieldFilterLogicChoices.FILTER_LOOSE} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_ui_visibility(self): + params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + class WebhookTestCase(TestCase, BaseFilterSetTests): queryset = Webhook.objects.all() filterset = WebhookFilterSet @@ -62,6 +129,8 @@ class WebhookTestCase(TestCase, BaseFilterSetTests): def test_content_types(self): params = {'content_types': 'dcim.site'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_type_create(self): params = {'type_create': True} diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index dd3733d4d..890c0eae3 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -789,8 +789,6 @@ class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'export_targets': [rts[1].pk] } - print(cls.form_data) - class L2VPNTerminationTestCase( ViewTestCases.GetObjectViewTestCase, diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 82244bcd2..20586c298 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError +from django.db.models.fields.reverse_related import ManyToManyRel from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render @@ -455,7 +456,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): setattr(obj, name, None if model_field.null else '') # ManyToManyFields - elif isinstance(model_field, ManyToManyField): + elif isinstance(model_field, (ManyToManyField, ManyToManyRel)): if form.cleaned_data[name]: getattr(obj, name).set(form.cleaned_data[name]) # Normal fields