diff --git a/docs/development/application-registry.md b/docs/development/application-registry.md index 8d36ccf96..9503f8ff0 100644 --- a/docs/development/application-registry.md +++ b/docs/development/application-registry.md @@ -20,6 +20,10 @@ A dictionary mapping data backend types to their respective classes. These are u Stores registration made using `netbox.denormalized.register()`. For each model, a list of related models and their field mappings is maintained to facilitate automatic updates. +### `filtersets` + +A dictionary mapping each model (identified by its app and label) to its filterset class, if one has been registered for it. Filtersets are registered using the `@register_filterset` decorator. + ### `model_features` A dictionary of model features (e.g. custom fields, tags, etc.) mapped to the functions used to qualify a model as supporting each feature. Model features are registered using the `register_model_feature()` function in `netbox.utils`. diff --git a/docs/plugins/development/filtersets.md b/docs/plugins/development/filtersets.md index e829ddea5..cc847660d 100644 --- a/docs/plugins/development/filtersets.md +++ b/docs/plugins/development/filtersets.md @@ -6,12 +6,17 @@ Filter sets define the mechanisms available for filtering or searching through a To support additional functionality standard to NetBox models, such as tag assignment and custom field support, the `NetBoxModelFilterSet` class is available for use by plugins. This should be used as the base filter set class for plugin models which inherit from `NetBoxModel`. Within this class, individual filters can be declared as directed by the `django-filters` documentation. An example is provided below. +!!! info "New in NetBox v4.5: FilterSet Registration" + NetBox v4.5 introduced the `register_filterset()` utility function. This enables plugins to register their filtersets to receive advanced functionality, such as the automatic attachment of field-specific lookup modifiers on the filter form. Registration is optional: Unregistered filtersets will continue to work as before, but will not receive the enhanced functionality. + ```python # filtersets.py import django_filters from netbox.filtersets import NetBoxModelFilterSet +from utilities.filtersets import register_filterset from .models import MyModel +@register_filterset class MyFilterSet(NetBoxModelFilterSet): status = django_filters.MultipleChoiceFilter( choices=( @@ -42,7 +47,7 @@ class MyModelListView(ObjectListView): filterset = MyModelFilterSet ``` -To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view: +To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view: ```python # api/views.py @@ -62,7 +67,9 @@ The `ObjectListView` has a field called Quick Search. For Quick Search to work t ```python from django.db.models import Q from netbox.filtersets import NetBoxModelFilterSet +from utilities.filtersets import register_filterset +@register_filterset class MyFilterSet(NetBoxModelFilterSet): ... def search(self, queryset, name, value): @@ -90,7 +97,9 @@ This class filters `tags` using the `slug` field. For example: ```python from django_filters import FilterSet from extras.filters import TagFilter +from utilities.filtersets import register_filterset +@register_filterset class MyModelFilterSet(FilterSet): tag = TagFilter() ``` @@ -106,7 +115,9 @@ This class filters `tags` using the `id` field. For example: ```python from django_filters import FilterSet from extras.filters import TagIDFilter +from utilities.filtersets import register_filterset +@register_filterset class MyModelFilterSet(FilterSet): tag_id = TagIDFilter() ``` diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 77f713899..faf29584f 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -11,6 +11,7 @@ from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter, ) +from utilities.filtersets import register_filterset from .choices import * from .models import * @@ -29,6 +30,7 @@ __all__ = ( ) +@register_filterset class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), @@ -93,6 +95,7 @@ class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): ) +@register_filterset class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): provider_id = django_filters.ModelMultipleChoiceFilter( queryset=Provider.objects.all(), @@ -120,6 +123,7 @@ class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): ).distinct() +@register_filterset class ProviderNetworkFilterSet(PrimaryModelFilterSet): provider_id = django_filters.ModelMultipleChoiceFilter( queryset=Provider.objects.all(), @@ -147,6 +151,7 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet): ).distinct() +@register_filterset class CircuitTypeFilterSet(OrganizationalModelFilterSet): class Meta: @@ -154,6 +159,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet): fields = ('id', 'name', 'slug', 'color', 'description') +@register_filterset class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet): provider_id = django_filters.ModelMultipleChoiceFilter( queryset=Provider.objects.all(), @@ -265,6 +271,7 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt ).distinct() +@register_filterset class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet): q = django_filters.CharFilter( method='search', @@ -360,6 +367,7 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet): ).distinct() +@register_filterset class CircuitGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): class Meta: @@ -367,6 +375,7 @@ class CircuitGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): fields = ('id', 'name', 'slug', 'description') +@register_filterset class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', @@ -466,6 +475,7 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet): ) +@register_filterset class VirtualCircuitTypeFilterSet(OrganizationalModelFilterSet): class Meta: @@ -473,6 +483,7 @@ class VirtualCircuitTypeFilterSet(OrganizationalModelFilterSet): fields = ('id', 'name', 'slug', 'color', 'description') +@register_filterset class VirtualCircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet): provider_id = django_filters.ModelMultipleChoiceFilter( field_name='provider_network__provider', @@ -529,6 +540,7 @@ class VirtualCircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ).distinct() +@register_filterset class VirtualCircuitTerminationFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index ca9089b66..a531c051e 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -7,6 +7,7 @@ from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, Primary from netbox.utils import get_data_backend_choices from users.models import User from utilities.filters import ContentTypeFilter +from utilities.filtersets import register_filterset from .choices import * from .models import * @@ -20,6 +21,7 @@ __all__ = ( ) +@register_filterset class DataSourceFilterSet(PrimaryModelFilterSet): type = django_filters.MultipleChoiceFilter( choices=get_data_backend_choices, @@ -48,6 +50,7 @@ class DataSourceFilterSet(PrimaryModelFilterSet): ) +@register_filterset class DataFileFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search' @@ -75,6 +78,7 @@ class DataFileFilterSet(ChangeLoggedModelFilterSet): ) +@register_filterset class JobFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', @@ -139,6 +143,7 @@ class JobFilterSet(BaseFilterSet): ) +@register_filterset class ObjectTypeFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', @@ -164,6 +169,7 @@ class ObjectTypeFilterSet(BaseFilterSet): return queryset.filter(features__icontains=value) +@register_filterset class ObjectChangeFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', @@ -203,6 +209,7 @@ class ObjectChangeFilterSet(BaseFilterSet): ) +@register_filterset class ConfigRevisionFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 9c161aa54..a3f8ff383 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -22,6 +22,7 @@ from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, ) +from utilities.filtersets import register_filterset from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface from vpn.models import L2VPN from wireless.choices import WirelessChannelChoices, WirelessRoleChoices @@ -84,6 +85,7 @@ __all__ = ( ) +@register_filterset class RegionFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=Region.objects.all(), @@ -114,6 +116,7 @@ class RegionFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet): fields = ('id', 'name', 'slug', 'description') +@register_filterset class SiteGroupFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=SiteGroup.objects.all(), @@ -144,6 +147,7 @@ class SiteGroupFilterSet(NestedGroupModelFilterSet, ContactModelFilterSet): fields = ('id', 'name', 'slug', 'description') +@register_filterset class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet): status = django_filters.MultipleChoiceFilter( choices=SiteStatusChoices, @@ -208,6 +212,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS return queryset.filter(qs_filter).distinct() +@register_filterset class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), @@ -287,6 +292,7 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode return queryset +@register_filterset class RackRoleFilterSet(OrganizationalModelFilterSet): class Meta: @@ -294,6 +300,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet): fields = ('id', 'name', 'slug', 'color', 'description') +@register_filterset class RackTypeFilterSet(PrimaryModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( queryset=Manufacturer.objects.all(), @@ -332,6 +339,7 @@ class RackTypeFilterSet(PrimaryModelFilterSet): ) +@register_filterset class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), @@ -448,6 +456,7 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS ) +@register_filterset class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): rack_id = django_filters.ModelMultipleChoiceFilter( queryset=Rack.objects.all(), @@ -537,6 +546,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ) +@register_filterset class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): class Meta: @@ -544,6 +554,7 @@ class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet) fields = ('id', 'name', 'slug', 'description') +@register_filterset class DeviceTypeFilterSet(PrimaryModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( queryset=Manufacturer.objects.all(), @@ -687,6 +698,7 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): return queryset.exclude(inventoryitemtemplates__isnull=value) +@register_filterset class ModuleTypeProfileFilterSet(PrimaryModelFilterSet): class Meta: @@ -703,6 +715,7 @@ class ModuleTypeProfileFilterSet(PrimaryModelFilterSet): ) +@register_filterset class ModuleTypeFilterSet(AttributeFiltersMixin, PrimaryModelFilterSet): profile_id = django_filters.ModelMultipleChoiceFilter( queryset=ModuleTypeProfile.objects.all(), @@ -819,6 +832,7 @@ class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet): ) +@register_filterset class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: @@ -826,6 +840,7 @@ class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType fields = ('id', 'name', 'label', 'type', 'description') +@register_filterset class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: @@ -833,6 +848,7 @@ class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDevi fields = ('id', 'name', 'label', 'type', 'description') +@register_filterset class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: @@ -840,6 +856,7 @@ class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo fields = ('id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') +@register_filterset class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): feed_leg = django_filters.MultipleChoiceFilter( choices=PowerOutletFeedLegChoices, @@ -855,6 +872,7 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType fields = ('id', 'name', 'label', 'type', 'color', 'feed_leg', 'description') +@register_filterset class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=InterfaceTypeChoices, @@ -879,6 +897,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo fields = ('id', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description') +@register_filterset class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, @@ -893,6 +912,7 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo fields = ('id', 'name', 'label', 'type', 'color', 'rear_port_position', 'description') +@register_filterset class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, @@ -904,6 +924,7 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description') +@register_filterset class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: @@ -911,6 +932,7 @@ class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo fields = ('id', 'name', 'label', 'position', 'description') +@register_filterset class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class Meta: @@ -918,6 +940,7 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent fields = ('id', 'name', 'label', 'description') +@register_filterset class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItemTemplate.objects.all(), @@ -961,6 +984,7 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo return queryset.filter(qs_filter) +@register_filterset class DeviceRoleFilterSet(NestedGroupModelFilterSet): config_template_id = django_filters.ModelMultipleChoiceFilter( queryset=ConfigTemplate.objects.all(), @@ -995,6 +1019,7 @@ class DeviceRoleFilterSet(NestedGroupModelFilterSet): fields = ('id', 'name', 'slug', 'color', 'vm_role', 'description') +@register_filterset class PlatformFilterSet(NestedGroupModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=Platform.objects.all(), @@ -1052,6 +1077,7 @@ class PlatformFilterSet(NestedGroupModelFilterSet): return queryset.filter(Q(manufacturer=None) | Q(manufacturer__device_types=value)) +@register_filterset class DeviceFilterSet( PrimaryModelFilterSet, TenancyFilterSet, @@ -1354,6 +1380,7 @@ class DeviceFilterSet( return queryset.exclude(params) +@register_filterset class VirtualDeviceContextFilterSet(PrimaryModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet): device_id = django_filters.ModelMultipleChoiceFilter( field_name='device', @@ -1403,6 +1430,7 @@ class VirtualDeviceContextFilterSet(PrimaryModelFilterSet, TenancyFilterSet, Pri return queryset.exclude(params) +@register_filterset class ModuleFilterSet(PrimaryModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='module_type__manufacturer', @@ -1691,6 +1719,7 @@ class PathEndpointFilterSet(django_filters.FilterSet): return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False)) +@register_filterset class ConsolePortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, @@ -1702,6 +1731,7 @@ class ConsolePortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_position') +@register_filterset class ConsoleServerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, @@ -1713,6 +1743,7 @@ class ConsoleServerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFi fields = ('id', 'name', 'label', 'speed', 'description', 'mark_connected', 'cable_end', 'cable_position') +@register_filterset class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerPortTypeChoices, @@ -1727,6 +1758,7 @@ class PowerPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, ) +@register_filterset class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerOutletTypeChoices, @@ -1753,6 +1785,7 @@ class PowerOutletFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSe ) +@register_filterset class MACAddressFilterSet(PrimaryModelFilterSet): mac_address = MultiValueMACAddressFilter() assigned_object_type = ContentTypeFilter() @@ -1934,6 +1967,7 @@ class CommonInterfaceFilterSet(django_filters.FilterSet): ) +@register_filterset class InterfaceFilterSet( ModularDeviceComponentFilterSet, CabledObjectFilterSet, @@ -2096,6 +2130,7 @@ class InterfaceFilterSet( ) +@register_filterset class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, @@ -2113,6 +2148,7 @@ class FrontPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet) ) +@register_filterset class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, @@ -2127,6 +2163,7 @@ class RearPortFilterSet(ModularDeviceComponentFilterSet, CabledObjectFilterSet): ) +@register_filterset class ModuleBayFilterSet(ModularDeviceComponentFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=ModuleBay.objects.all(), @@ -2143,6 +2180,7 @@ class ModuleBayFilterSet(ModularDeviceComponentFilterSet): fields = ('id', 'name', 'label', 'position', 'description') +@register_filterset class DeviceBayFilterSet(DeviceComponentFilterSet): installed_device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), @@ -2160,6 +2198,7 @@ class DeviceBayFilterSet(DeviceComponentFilterSet): fields = ('id', 'name', 'label', 'description') +@register_filterset class InventoryItemFilterSet(DeviceComponentFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItem.objects.all(), @@ -2212,6 +2251,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet): return queryset.filter(qs_filter) +@register_filterset class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): class Meta: @@ -2219,6 +2259,7 @@ class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): fields = ('id', 'name', 'slug', 'color', 'description') +@register_filterset class VirtualChassisFilterSet(PrimaryModelFilterSet): master_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), @@ -2295,6 +2336,7 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter).distinct() +@register_filterset class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): termination_a_type = ContentTypeFilter( field_name='terminations__termination_type' @@ -2467,6 +2509,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): return self.filter_by_termination_object(queryset, CircuitTermination, value) +@register_filterset class CableTerminationFilterSet(ChangeLoggedModelFilterSet): termination_type = ContentTypeFilter() @@ -2475,6 +2518,7 @@ class CableTerminationFilterSet(ChangeLoggedModelFilterSet): fields = ('id', 'cable', 'cable_end', 'position', 'termination_type', 'termination_id') +@register_filterset class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), @@ -2533,6 +2577,7 @@ class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): return queryset.filter(qs_filter) +@register_filterset class PowerFeedFilterSet(PrimaryModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet, TenancyFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index ed424081c..9e0ee4780 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -638,6 +638,7 @@ class ModuleTypeProfileFilterForm(PrimaryModelFilterSetForm): FieldSet('q', 'filter_id', 'tag', 'owner_id'), ) selector_fields = ('filter_id', 'q') + tag = TagFilterField(model) class ModuleTypeFilterForm(PrimaryModelFilterSetForm): diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index e2058d9ad..cdc0c75c5 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -12,6 +12,7 @@ from users.models import Group, User from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter ) +from utilities.filtersets import register_filterset from virtualization.models import Cluster, ClusterGroup, ClusterType from .choices import * from .filters import TagFilter, TagIDFilter @@ -40,6 +41,7 @@ __all__ = ( ) +@register_filterset class ScriptFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', @@ -62,6 +64,7 @@ class ScriptFilterSet(BaseFilterSet): ) +@register_filterset class WebhookFilterSet(OwnerFilterMixin, NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', @@ -91,6 +94,7 @@ class WebhookFilterSet(OwnerFilterMixin, NetBoxModelFilterSet): ) +@register_filterset class EventRuleFilterSet(OwnerFilterMixin, NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', @@ -131,6 +135,7 @@ class EventRuleFilterSet(OwnerFilterMixin, NetBoxModelFilterSet): return queryset.filter(event_types__overlap=value) +@register_filterset class CustomFieldFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', @@ -180,6 +185,7 @@ class CustomFieldFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): ) +@register_filterset class CustomFieldChoiceSetFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', @@ -208,6 +214,7 @@ class CustomFieldChoiceSetFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet return queryset.filter(extra_choices__overlap=value) +@register_filterset class CustomLinkFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', @@ -238,6 +245,7 @@ class CustomLinkFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): ) +@register_filterset class ExportTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', @@ -276,6 +284,7 @@ class ExportTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): ) +@register_filterset class SavedFilterFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', @@ -328,6 +337,7 @@ class SavedFilterFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user))) +@register_filterset class TableConfigFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', @@ -381,6 +391,7 @@ class TableConfigFilterSet(ChangeLoggedModelFilterSet): return queryset.filter(Q(enabled=False) | Q(Q(shared=False) & ~Q(user=user))) +@register_filterset class BookmarkFilterSet(BaseFilterSet): created = django_filters.DateTimeFilter() object_type_id = MultiValueNumberFilter() @@ -401,6 +412,7 @@ class BookmarkFilterSet(BaseFilterSet): fields = ('id', 'object_id') +@register_filterset class NotificationGroupFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', @@ -444,6 +456,7 @@ class NotificationGroupFilterSet(ChangeLoggedModelFilterSet): ) +@register_filterset class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', @@ -465,6 +478,7 @@ class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet): ) +@register_filterset class JournalEntryFilterSet(NetBoxModelFilterSet): created = django_filters.DateTimeFromToRangeFilter() assigned_object_type = ContentTypeFilter() @@ -495,6 +509,7 @@ class JournalEntryFilterSet(NetBoxModelFilterSet): return queryset.filter(comments__icontains=value) +@register_filterset class TagFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', @@ -555,6 +570,7 @@ class TagFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): ) +@register_filterset class TaggedItemFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', @@ -590,6 +606,7 @@ class TaggedItemFilterSet(BaseFilterSet): ) +@register_filterset class ConfigContextProfileFilterSet(PrimaryModelFilterSet): q = django_filters.CharFilter( method='search', @@ -620,6 +637,7 @@ class ConfigContextProfileFilterSet(PrimaryModelFilterSet): ) +@register_filterset class ConfigContextFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', @@ -789,6 +807,7 @@ class ConfigContextFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): ) +@register_filterset class ConfigTemplateFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 1526d0d98..0a3036597 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -287,6 +287,7 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): class TableConfigFilterForm(SavedFiltersMixin, FilterForm): + model = TableConfig fieldsets = ( FieldSet('q', 'filter_id'), FieldSet('object_type_id', 'enabled', 'shared', 'weight', name=_('Attributes')), diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 3b738e5a7..5063b6a9c 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -1,6 +1,5 @@ import django_filters import netaddr -from dcim.base_filtersets import ScopedFilterSet from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import Q @@ -10,15 +9,16 @@ from drf_spectacular.utils import extend_schema_field from netaddr.core import AddrFormatError from circuits.models import Provider +from dcim.base_filtersets import ScopedFilterSet from dcim.models import Device, Interface, Region, Site, SiteGroup from netbox.filtersets import ( ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, PrimaryModelFilterSet, ) from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet - from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, ) +from utilities.filtersets import register_filterset from virtualization.models import VirtualMachine, VMInterface from vpn.models import L2VPN from .choices import * @@ -47,6 +47,7 @@ __all__ = ( ) +@register_filterset class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet): import_target_id = django_filters.ModelMultipleChoiceFilter( field_name='import_targets', @@ -85,6 +86,7 @@ class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet): fields = ('id', 'name', 'rd', 'enforce_unique', 'description') +@register_filterset class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): importing_vrf_id = django_filters.ModelMultipleChoiceFilter( field_name='importing_vrfs', @@ -144,6 +146,7 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): fields = ('id', 'name', 'description') +@register_filterset class RIRFilterSet(OrganizationalModelFilterSet): class Meta: @@ -151,6 +154,7 @@ class RIRFilterSet(OrganizationalModelFilterSet): fields = ('id', 'name', 'slug', 'is_private', 'description') +@register_filterset class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet): family = django_filters.NumberFilter( field_name='prefix', @@ -198,6 +202,7 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi return queryset.none() +@register_filterset class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): rir_id = django_filters.ModelMultipleChoiceFilter( queryset=RIR.objects.all(), @@ -223,6 +228,7 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): ) +@register_filterset class ASNFilterSet(PrimaryModelFilterSet, TenancyFilterSet): rir_id = django_filters.ModelMultipleChoiceFilter( queryset=RIR.objects.all(), @@ -285,6 +291,7 @@ class ASNFilterSet(PrimaryModelFilterSet, TenancyFilterSet): return queryset.filter(qs_filter) +@register_filterset class RoleFilterSet(OrganizationalModelFilterSet): class Meta: @@ -292,6 +299,7 @@ class RoleFilterSet(OrganizationalModelFilterSet): fields = ('id', 'name', 'slug', 'description', 'weight') +@register_filterset class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet, ContactModelFilterSet): family = django_filters.NumberFilter( field_name='prefix', @@ -458,6 +466,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet, ).distinct() +@register_filterset class IPRangeFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet): family = django_filters.NumberFilter( field_name='start_address', @@ -550,6 +559,7 @@ class IPRangeFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt return queryset.filter(q) +@register_filterset class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet): family = django_filters.NumberFilter( field_name='address', @@ -786,6 +796,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi ) +@register_filterset class FHRPGroupFilterSet(PrimaryModelFilterSet): protocol = django_filters.MultipleChoiceFilter( choices=FHRPGroupProtocolChoices @@ -833,6 +844,7 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet): return queryset.filter(ip_filter) +@register_filterset class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): interface_type = ContentTypeFilter() group_id = django_filters.ModelMultipleChoiceFilter( @@ -887,6 +899,7 @@ class FHRPGroupAssignmentFilterSet(ChangeLoggedModelFilterSet): ) +@register_filterset class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): scope_type = ContentTypeFilter() region = django_filters.NumberFilter( @@ -936,6 +949,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet): ) +@register_filterset class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), @@ -1087,6 +1101,7 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ).distinct() +@register_filterset class VLANTranslationPolicyFilterSet(PrimaryModelFilterSet): class Meta: @@ -1103,6 +1118,7 @@ class VLANTranslationPolicyFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter) +@register_filterset class VLANTranslationRuleFilterSet(NetBoxModelFilterSet): policy_id = django_filters.ModelMultipleChoiceFilter( queryset=VLANTranslationPolicy.objects.all(), @@ -1134,6 +1150,7 @@ class VLANTranslationRuleFilterSet(NetBoxModelFilterSet): return queryset.filter(qs_filter) +@register_filterset class ServiceTemplateFilterSet(PrimaryModelFilterSet): port = NumericArrayFilter( field_name='ports', @@ -1154,6 +1171,7 @@ class ServiceTemplateFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter) +@register_filterset class ServiceFilterSet(ContactModelFilterSet, PrimaryModelFilterSet): parent_object_type = ContentTypeFilter() device = MultiValueCharFilter( diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 24405cb6c..7ed98209d 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -152,6 +152,7 @@ class BaseFilterSet(django_filters.FilterSet): elif isinstance(existing_filter, ( django_filters.filters.CharFilter, + django_filters.ChoiceFilter, django_filters.MultipleChoiceFilter, filters.MultiValueCharFilter, filters.MultiValueMACAddressFilter diff --git a/netbox/netbox/forms/filtersets.py b/netbox/netbox/forms/filtersets.py index d5967c24b..18c4ef548 100644 --- a/netbox/netbox/forms/filtersets.py +++ b/netbox/netbox/forms/filtersets.py @@ -4,7 +4,8 @@ from django.utils.translation import gettext_lazy as _ from extras.choices import * from users.models import Owner -from utilities.forms.fields import DynamicModelChoiceField +from utilities.forms.fields import DynamicModelChoiceField, QueryField +from utilities.forms.mixins import FilterModifierMixin from .mixins import CustomFieldsMixin, SavedFiltersMixin __all__ = ( @@ -15,7 +16,7 @@ __all__ = ( ) -class NetBoxModelFilterSetForm(CustomFieldsMixin, SavedFiltersMixin, forms.Form): +class NetBoxModelFilterSetForm(FilterModifierMixin, CustomFieldsMixin, SavedFiltersMixin, forms.Form): """ Base form for FilerSet forms. These are used to filter object lists in the NetBox UI. Note that the corresponding FilterSet *must* provide a `q` filter. @@ -27,7 +28,7 @@ class NetBoxModelFilterSetForm(CustomFieldsMixin, SavedFiltersMixin, forms.Form) selector_fields: An iterable of names of fields to display by default when rendering the form as a selector widget """ - q = forms.CharField( + q = QueryField( required=False, label=_('Search') ) diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py index fe5ce4301..a28fd17e7 100644 --- a/netbox/netbox/registry.py +++ b/netbox/netbox/registry.py @@ -26,6 +26,7 @@ registry = Registry({ 'data_backends': dict(), 'denormalized_fields': collections.defaultdict(list), 'event_types': dict(), + 'filtersets': dict(), 'model_features': dict(), 'models': collections.defaultdict(set), 'plugins': dict(), diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 19bcf14d6..892fc7b59 100644 Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 02f5acc38..aaee869a4 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index e39865608..436eb5365 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/forms/filterModifiers.ts b/netbox/project-static/src/forms/filterModifiers.ts new file mode 100644 index 000000000..b304212f1 --- /dev/null +++ b/netbox/project-static/src/forms/filterModifiers.ts @@ -0,0 +1,179 @@ +import { getElements } from '../util'; + +// Modifier codes for empty/null checking +// These map to Django's 'empty' lookup: field__empty=true/false +const MODIFIER_EMPTY_TRUE = 'empty_true'; +const MODIFIER_EMPTY_FALSE = 'empty_false'; + +/** + * Initialize filter modifier functionality. + * + * Handles transformation of field names based on modifier selection + * at form submission time using the FormData API. + */ +export function initFilterModifiers(): void { + for (const form of getElements('form')) { + const modifierSelects = form.querySelectorAll('.modifier-select'); + if (modifierSelects.length === 0) continue; + + initializeFromURL(form); + + modifierSelects.forEach(select => { + select.addEventListener('change', () => handleModifierChange(select)); + handleModifierChange(select); + }); + + // Must use submit event for GET forms + form.addEventListener('submit', e => { + e.preventDefault(); + + const formData = new FormData(form); + handleFormDataTransform(form, formData); + + const params = new URLSearchParams(); + for (const [key, value] of formData.entries()) { + if (value && String(value).trim()) { + params.append(key, String(value)); + } + } + + // Use getAttribute to avoid collision with form fields named 'action' + const actionUrl = form.getAttribute('action') || form.action; + window.location.href = `${actionUrl}?${params.toString()}`; + }); + } +} + +/** + * Handle modifier dropdown changes - disable/enable value input for empty lookups. + */ +function handleModifierChange(modifierSelect: HTMLSelectElement): void { + const group = modifierSelect.closest('.filter-modifier-group'); + if (!group) return; + + const wrapper = group.querySelector('.filter-value-container'); + if (!wrapper) return; + + const valueInput = wrapper.querySelector< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + >('input, select, textarea'); + + if (!valueInput) return; + + const modifier = modifierSelect.value; + + if (modifier === MODIFIER_EMPTY_TRUE || modifier === MODIFIER_EMPTY_FALSE) { + valueInput.disabled = true; + valueInput.value = ''; + const placeholder = modifierSelect.dataset.emptyPlaceholder || '(automatically set)'; + valueInput.setAttribute('placeholder', placeholder); + } else { + valueInput.disabled = false; + valueInput.removeAttribute('placeholder'); + } +} + +/** + * Transform field names in FormData based on modifier selection. + */ +function handleFormDataTransform(form: HTMLFormElement, formData: FormData): void { + const modifierGroups = form.querySelectorAll('.filter-modifier-group'); + + for (const group of modifierGroups) { + const modifierSelect = group.querySelector('.modifier-select'); + const wrapper = group.querySelector('.filter-value-container'); + if (!wrapper) continue; + + const valueInput = wrapper.querySelector< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + >('input, select, textarea'); + + if (!modifierSelect || !valueInput) continue; + + const currentName = valueInput.name; + const modifier = modifierSelect.value; + + if (modifier === MODIFIER_EMPTY_TRUE || modifier === MODIFIER_EMPTY_FALSE) { + formData.delete(currentName); + const boolValue = modifier === MODIFIER_EMPTY_TRUE ? 'true' : 'false'; + formData.set(`${currentName}__empty`, boolValue); + } else { + const values = formData.getAll(currentName); + + if (values.length > 0 && values.some(v => String(v).trim())) { + formData.delete(currentName); + const newName = modifier === 'exact' ? currentName : `${currentName}__${modifier}`; + + for (const value of values) { + if (String(value).trim()) { + formData.append(newName, value); + } + } + } else { + formData.delete(currentName); + } + } + } +} + +/** + * Initialize form state from URL parameters. + * Restores modifier selection and values from query string. + * + * Process: + * 1. Parse URL parameters + * 2. For each modifier group, check which lookup variant exists in URL + * 3. Set modifier dropdown to match + * 4. Populate value field with parameter value + */ +function initializeFromURL(form: HTMLFormElement): void { + const urlParams = new URLSearchParams(window.location.search); + + const modifierGroups = form.querySelectorAll('.filter-modifier-group'); + + for (const group of modifierGroups) { + const modifierSelect = group.querySelector('.modifier-select'); + const wrapper = group.querySelector('.filter-value-container'); + if (!wrapper) continue; + + const valueInput = wrapper.querySelector< + HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement + >('input, select, textarea'); + + if (!modifierSelect || !valueInput) continue; + + const baseFieldName = valueInput.name; + + // Special handling for empty - check if field__empty exists in URL + const emptyParam = `${baseFieldName}__empty`; + if (urlParams.has(emptyParam)) { + const emptyValue = urlParams.get(emptyParam); + const modifier = emptyValue === 'true' ? MODIFIER_EMPTY_TRUE : MODIFIER_EMPTY_FALSE; + modifierSelect.value = modifier; + continue; // Don't set value input for empty + } + + for (const option of modifierSelect.options) { + const lookup = option.value; + + // Skip empty_true/false as they're handled above + if (lookup === MODIFIER_EMPTY_TRUE || lookup === MODIFIER_EMPTY_FALSE) continue; + + const paramName = lookup === 'exact' ? baseFieldName : `${baseFieldName}__${lookup}`; + + if (urlParams.has(paramName)) { + modifierSelect.value = lookup; + + if (valueInput instanceof HTMLSelectElement && valueInput.multiple) { + const values = urlParams.getAll(paramName); + for (const option of valueInput.options) { + option.selected = values.includes(option.value); + } + } else { + valueInput.value = urlParams.get(paramName) || ''; + } + break; + } + } + } +} diff --git a/netbox/project-static/src/forms/index.ts b/netbox/project-static/src/forms/index.ts index 00b872e27..7468e10fb 100644 --- a/netbox/project-static/src/forms/index.ts +++ b/netbox/project-static/src/forms/index.ts @@ -1,8 +1,9 @@ import { initFormElements } from './elements'; +import { initFilterModifiers } from './filterModifiers'; import { initSpeedSelector } from './speedSelector'; export function initForms(): void { - for (const func of [initFormElements, initSpeedSelector]) { + for (const func of [initFormElements, initSpeedSelector, initFilterModifiers]) { func(); } } diff --git a/netbox/project-static/styles/transitional/_forms.scss b/netbox/project-static/styles/transitional/_forms.scss index 147b11b97..ddc6957e2 100644 --- a/netbox/project-static/styles/transitional/_forms.scss +++ b/netbox/project-static/styles/transitional/_forms.scss @@ -32,3 +32,11 @@ form.object-edit { border: 1px solid $red; } } + +// Filter modifier dropdown sizing +.modifier-select { + min-width: 10rem; + max-width: 15rem; + width: auto; + white-space: nowrap; +} diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index b650ea882..aa463ef50 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -6,6 +6,7 @@ from netbox.filtersets import ( NestedGroupModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet, ) from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter +from utilities.filtersets import register_filterset from .models import * __all__ = ( @@ -24,6 +25,7 @@ __all__ = ( # Contacts # +@register_filterset class ContactGroupFilterSet(NestedGroupModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=ContactGroup.objects.all(), @@ -59,6 +61,7 @@ class ContactGroupFilterSet(NestedGroupModelFilterSet): fields = ('id', 'name', 'slug', 'description') +@register_filterset class ContactRoleFilterSet(OrganizationalModelFilterSet): class Meta: @@ -66,6 +69,7 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet): fields = ('id', 'name', 'slug', 'description') +@register_filterset class ContactFilterSet(PrimaryModelFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), @@ -100,6 +104,7 @@ class ContactFilterSet(PrimaryModelFilterSet): ) +@register_filterset class ContactAssignmentFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', @@ -170,6 +175,7 @@ class ContactModelFilterSet(django_filters.FilterSet): # Tenancy # +@register_filterset class TenantGroupFilterSet(NestedGroupModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=TenantGroup.objects.all(), @@ -200,6 +206,7 @@ class TenantGroupFilterSet(NestedGroupModelFilterSet): fields = ('id', 'name', 'slug', 'description') +@register_filterset class TenantFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=TenantGroup.objects.all(), diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 1bc1b6d86..c9587cca1 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -1,5 +1,4 @@ import django_filters - from django.db.models import Q from django.utils.translation import gettext as _ @@ -8,6 +7,7 @@ from extras.models import NotificationGroup from netbox.filtersets import BaseFilterSet from users.models import Group, ObjectPermission, Owner, OwnerGroup, Token, User from utilities.filters import ContentTypeFilter +from utilities.filtersets import register_filterset __all__ = ( 'GroupFilterSet', @@ -19,6 +19,7 @@ __all__ = ( ) +@register_filterset class GroupFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', @@ -64,6 +65,7 @@ class GroupFilterSet(BaseFilterSet): ) +@register_filterset class UserFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', @@ -120,6 +122,7 @@ class UserFilterSet(BaseFilterSet): ) +@register_filterset class TokenFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', @@ -181,6 +184,7 @@ class TokenFilterSet(BaseFilterSet): ) +@register_filterset class ObjectPermissionFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', @@ -248,6 +252,7 @@ class ObjectPermissionFilterSet(BaseFilterSet): return queryset.exclude(actions__contains=[action]) +@register_filterset class OwnerGroupFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', @@ -267,6 +272,7 @@ class OwnerGroupFilterSet(BaseFilterSet): ) +@register_filterset class OwnerFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', diff --git a/netbox/utilities/filtersets.py b/netbox/utilities/filtersets.py new file mode 100644 index 000000000..d828508d4 --- /dev/null +++ b/netbox/utilities/filtersets.py @@ -0,0 +1,17 @@ +from netbox.registry import registry + +__all__ = ( + 'register_filterset', +) + + +def register_filterset(filterset_class): + """ + Decorator for registering a FilterSet with the application registry. + + Uses model identifier as key to match search index pattern. + """ + model = filterset_class._meta.model + label = f'{model._meta.app_label}.{model._meta.model_name}' + registry['filtersets'][label] = filterset_class + return filterset_class diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py index 4e19e60a2..560ef25e9 100644 --- a/netbox/utilities/forms/fields/fields.py +++ b/netbox/utilities/forms/fields/fields.py @@ -17,11 +17,20 @@ __all__ = ( 'JSONField', 'LaxURLField', 'MACAddressField', + 'QueryField', 'SlugField', 'TagFilterField', ) +class QueryField(forms.CharField): + """ + A CharField subclass used for global search/query fields in filter forms. + This field type signals to FilterModifierMixin to skip enhancement with lookup modifiers. + """ + pass + + class CommentField(forms.CharField): """ A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`. diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index b7f86a94b..e7fc61b7e 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -4,7 +4,8 @@ from django import forms from django.utils.translation import gettext as _ from netbox.models.features import ChangeLoggingMixin -from utilities.forms.mixins import BackgroundJobMixin +from utilities.forms.fields import QueryField +from utilities.forms.mixins import BackgroundJobMixin, FilterModifierMixin __all__ = ( 'BulkDeleteForm', @@ -140,11 +141,11 @@ class CSVModelForm(forms.ModelForm): return super().clean() -class FilterForm(forms.Form): +class FilterForm(FilterModifierMixin, forms.Form): """ Base Form class for FilterSet forms. """ - q = forms.CharField( + q = QueryField( required=False, label=_('Search') ) diff --git a/netbox/utilities/forms/mixins.py b/netbox/utilities/forms/mixins.py index d3bf94870..ca943e088 100644 --- a/netbox/utilities/forms/mixins.py +++ b/netbox/utilities/forms/mixins.py @@ -5,13 +5,100 @@ from django import forms from django.core.validators import MaxValueValidator, MinValueValidator from django.utils.translation import gettext_lazy as _ +from netbox.registry import registry +from utilities.forms.fields import ColorField, QueryField, TagFilterField +from utilities.forms.widgets import FilterModifierWidget +from utilities.forms.widgets.modifiers import MODIFIER_EMPTY_FALSE, MODIFIER_EMPTY_TRUE + __all__ = ( 'BackgroundJobMixin', 'CheckLastUpdatedMixin', 'DistanceValidationMixin', + 'FilterModifierMixin', + 'FORM_FIELD_LOOKUPS', ) +# Mapping of form field types to their supported lookups +FORM_FIELD_LOOKUPS = { + QueryField: [], + forms.BooleanField: [], + forms.NullBooleanField: [], + forms.CharField: [ + ('exact', _('is')), + ('n', _('is not')), + ('ic', _('contains')), + ('isw', _('starts with')), + ('iew', _('ends with')), + ('ie', _('equals (case-insensitive)')), + ('regex', _('matches pattern')), + ('iregex', _('matches pattern (case-insensitive)')), + (MODIFIER_EMPTY_TRUE, _('is empty')), + (MODIFIER_EMPTY_FALSE, _('is not empty')), + ], + forms.IntegerField: [ + ('exact', _('is')), + ('n', _('is not')), + ('gt', _('greater than')), + ('gte', _('at least')), + ('lt', _('less than')), + ('lte', _('at most')), + (MODIFIER_EMPTY_TRUE, _('is empty')), + (MODIFIER_EMPTY_FALSE, _('is not empty')), + ], + forms.DecimalField: [ + ('exact', _('is')), + ('n', _('is not')), + ('gt', _('greater than')), + ('gte', _('at least')), + ('lt', _('less than')), + ('lte', _('at most')), + (MODIFIER_EMPTY_TRUE, _('is empty')), + (MODIFIER_EMPTY_FALSE, _('is not empty')), + ], + forms.DateField: [ + ('exact', _('is')), + ('n', _('is not')), + ('gt', _('after')), + ('gte', _('on or after')), + ('lt', _('before')), + ('lte', _('on or before')), + (MODIFIER_EMPTY_TRUE, _('is empty')), + (MODIFIER_EMPTY_FALSE, _('is not empty')), + ], + forms.ModelChoiceField: [ + ('exact', _('is')), + ('n', _('is not')), + (MODIFIER_EMPTY_TRUE, _('is empty')), + (MODIFIER_EMPTY_FALSE, _('is not empty')), + ], + ColorField: [ + ('exact', _('is')), + ('n', _('is not')), + (MODIFIER_EMPTY_TRUE, _('is empty')), + (MODIFIER_EMPTY_FALSE, _('is not empty')), + ], + TagFilterField: [ + ('exact', _('has these tags')), + ('n', _('does not have these tags')), + (MODIFIER_EMPTY_TRUE, _('is empty')), + (MODIFIER_EMPTY_FALSE, _('is not empty')), + ], + forms.ChoiceField: [ + ('exact', _('is')), + ('n', _('is not')), + (MODIFIER_EMPTY_TRUE, _('is empty')), + (MODIFIER_EMPTY_FALSE, _('is not empty')), + ], + forms.MultipleChoiceField: [ + ('exact', _('is')), + ('n', _('is not')), + (MODIFIER_EMPTY_TRUE, _('is empty')), + (MODIFIER_EMPTY_FALSE, _('is not empty')), + ], +} + + class BackgroundJobMixin(forms.Form): background_job = forms.BooleanField( label=_('Background job'), @@ -75,3 +162,68 @@ class DistanceValidationMixin(forms.Form): MaxValueValidator(Decimal(100000)), ] ) + + +class FilterModifierMixin: + """ + Mixin that enhances filter form fields with lookup modifier dropdowns. + + Automatically detects fields that could benefit from multiple lookup options + and wraps their widgets with FilterModifierWidget. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._enhance_fields_with_modifiers() + + def _enhance_fields_with_modifiers(self): + """Wrap compatible field widgets with FilterModifierWidget.""" + + model = getattr(self, 'model', None) + if model is None and hasattr(self, '_meta'): + model = getattr(self._meta, 'model', None) + + filterset_class = None + if model: + key = f'{model._meta.app_label}.{model._meta.model_name}' + filterset_class = registry['filtersets'].get(key) + + filterset = filterset_class() if filterset_class else None + + for field_name, field in self.fields.items(): + lookups = self._get_lookup_choices(field) + + if filterset: + lookups = self._verify_lookups_with_filterset(field_name, lookups, filterset) + + if len(lookups) > 1: + field.widget = FilterModifierWidget( + widget=field.widget, + lookups=lookups + ) + + def _get_lookup_choices(self, field): + """Determine the available lookup choices for a given field. + + Returns an empty list for fields that should not be enhanced. + """ + for field_class in field.__class__.__mro__: + if field_lookups := FORM_FIELD_LOOKUPS.get(field_class): + return field_lookups + + return [] + + def _verify_lookups_with_filterset(self, field_name, lookups, filterset): + """Verify which lookups are actually supported by the FilterSet.""" + verified_lookups = [] + + for lookup_code, lookup_label in lookups: + if lookup_code in (MODIFIER_EMPTY_TRUE, MODIFIER_EMPTY_FALSE): + filter_key = f'{field_name}__empty' + else: + filter_key = f'{field_name}__{lookup_code}' if lookup_code != 'exact' else field_name + + if filter_key in filterset.filters: + verified_lookups.append((lookup_code, lookup_label)) + + return verified_lookups diff --git a/netbox/utilities/forms/widgets/__init__.py b/netbox/utilities/forms/widgets/__init__.py index 9bd9f4faa..dea43314d 100644 --- a/netbox/utilities/forms/widgets/__init__.py +++ b/netbox/utilities/forms/widgets/__init__.py @@ -1,4 +1,5 @@ from .apiselect import * from .datetime import * from .misc import * +from .modifiers import * from .select import * diff --git a/netbox/utilities/forms/widgets/modifiers.py b/netbox/utilities/forms/widgets/modifiers.py new file mode 100644 index 000000000..29c36fa3d --- /dev/null +++ b/netbox/utilities/forms/widgets/modifiers.py @@ -0,0 +1,113 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +__all__ = ( + 'FilterModifierWidget', + 'MODIFIER_EMPTY_FALSE', + 'MODIFIER_EMPTY_TRUE', +) + +# Modifier codes for empty/null checking +# These map to Django's 'empty' lookup: field__empty=true/false +MODIFIER_EMPTY_TRUE = 'empty_true' +MODIFIER_EMPTY_FALSE = 'empty_false' + + +class FilterModifierWidget(forms.Widget): + """ + Wraps an existing widget to add a modifier dropdown for filter lookups. + + The original widget's semantics (name, id, attributes) are preserved. + The modifier dropdown controls which lookup type is used (exact, contains, etc.). + """ + template_name = 'widgets/filter_modifier.html' + + def __init__(self, widget, lookups, attrs=None): + """ + Args: + widget: The widget being wrapped (e.g., TextInput, NumberInput) + lookups: List of (lookup_code, label) tuples (e.g., [('exact', 'Is'), ('ic', 'Contains')]) + attrs: Additional widget attributes + """ + self.original_widget = widget + self.lookups = lookups + super().__init__(attrs or getattr(widget, 'attrs', {})) + + def value_from_datadict(self, data, files, name): + """ + Extract value from data, checking all possible lookup variants. + + When form redisplays after validation error, the data may contain + serial__ic=test but the field is named serial. This method searches + all lookup variants to find the value. + + Returns: + Just the value string for form validation. The modifier is reconstructed + during rendering from the query parameter names. + """ + # Special handling for empty - check if field__empty exists + empty_param = f"{name}__empty" + if empty_param in data: + # Return the boolean value for empty lookup + return data.get(empty_param) + + # Try exact field name first + value = self.original_widget.value_from_datadict(data, files, name) + + # If not found, check all modifier variants + # Note: SelectMultiple returns [] (empty list) when not found, not None + if value is None or (isinstance(value, list) and len(value) == 0): + for lookup, _ in self.lookups: + if lookup == 'exact': + continue # Already checked above + # Skip empty_true/false variants - they're handled above + if lookup in (MODIFIER_EMPTY_TRUE, MODIFIER_EMPTY_FALSE): + continue + lookup_name = f"{name}__{lookup}" + test_value = self.original_widget.value_from_datadict(data, files, lookup_name) + if test_value is not None: + value = test_value + break + + # Return None if no value found (prevents field appearing in changed_data) + # Handle all widget empty value representations + if value is None: + return None + if isinstance(value, str) and not value.strip(): + return None + if isinstance(value, (list, tuple)) and len(value) == 0: + return None + + # Return just the value for form validation + return value + + def get_context(self, name, value, attrs): + """ + Build context for template rendering. + + Includes both the original widget's context and our modifier-specific data. + Note: value is now just a simple value (string/int/etc), not a dict. + The JavaScript initializeFromURL() will set the correct modifier dropdown + value based on URL parameters. + """ + # Propagate any attrs set on the wrapper (like data-url from get_bound_field) + # to the original widget before rendering + self.original_widget.attrs.update(self.attrs) + + # Get context from the original widget + original_context = self.original_widget.get_context(name, value, attrs) + + # Build our wrapper context + context = super().get_context(name, value, attrs) + context['widget']['original_widget'] = original_context['widget'] + context['widget']['lookups'] = self.lookups + context['widget']['field_name'] = name + + # Default to 'exact' - JavaScript will update based on URL params + context['widget']['current_modifier'] = 'exact' + context['widget']['current_value'] = value or '' + + # Translatable placeholder for empty lookups + context['widget']['empty_placeholder'] = _('(automatically set)') + + return context diff --git a/netbox/utilities/templates/widgets/filter_modifier.html b/netbox/utilities/templates/widgets/filter_modifier.html new file mode 100644 index 000000000..fe1a11656 --- /dev/null +++ b/netbox/utilities/templates/widgets/filter_modifier.html @@ -0,0 +1,18 @@ +
+ {% if widget.lookups %} + {# Modifier dropdown - NO name attribute, just a UI control #} + + {% endif %} + + {# Original widget - rendered exactly as it would be without our wrapper #} +
+ {% include widget.original_widget.template_name with widget=widget.original_widget %} +
+
diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 8ea78ffa6..19318111e 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -5,9 +5,11 @@ from urllib.parse import quote from django import template from django.urls import NoReverseMatch, reverse from django.utils.html import conditional_escape +from django.utils.translation import gettext_lazy as _ from core.models import ObjectType from utilities.forms import get_selected_values, TableConfigForm +from utilities.forms.mixins import FORM_FIELD_LOOKUPS from utilities.views import get_viewname, get_action_url from netbox.settings import DISK_BASE_UNIT, RAM_BASE_UNIT @@ -418,7 +420,20 @@ def applied_filters(context, model, form, query_params): continue querydict = query_params.copy() - if filter_name not in querydict: + + # Check if this is a modifier-enhanced field + # Field may be in querydict as field__lookup instead of field + param_name = None + if filter_name in querydict: + param_name = filter_name + else: + # Check for modifier variants (field__ic, field__isw, etc.) + for key in querydict.keys(): + if key.startswith(f'{filter_name}__'): + param_name = key + break + + if param_name is None: continue # Skip saved filters, as they're displayed alongside the quick search widget @@ -426,14 +441,46 @@ def applied_filters(context, model, form, query_params): continue bound_field = form.fields[filter_name].get_bound_field(form, filter_name) - querydict.pop(filter_name) + querydict.pop(param_name) + + # Extract modifier from parameter name (e.g., "serial__ic" → "ic") + if '__' in param_name: + modifier = param_name.split('__', 1)[1] + else: + modifier = 'exact' + + # Get display value display_value = ', '.join([str(v) for v in get_selected_values(form, filter_name)]) + # Get the correct lookup label for this field's type + lookup_label = None + if modifier != 'exact': + field = form.fields[filter_name] + for field_class in field.__class__.__mro__: + if field_lookups := FORM_FIELD_LOOKUPS.get(field_class): + for lookup_code, label in field_lookups: + if lookup_code == modifier: + lookup_label = label + break + if lookup_label: + break + + # Special handling for empty lookup (boolean value) + if modifier == 'empty': + if display_value.lower() in ('true', '1'): + link_text = f'{bound_field.label} {_("is empty")}' + else: + link_text = f'{bound_field.label} {_("is not empty")}' + elif lookup_label: + link_text = f'{bound_field.label} {lookup_label}: {display_value}' + else: + link_text = f'{bound_field.label}: {display_value}' + applied_filters.append({ - 'name': filter_name, - 'value': form.cleaned_data[filter_name], + 'name': param_name, # Use actual param name for removal link + 'value': form.cleaned_data.get(filter_name), 'link_url': f'?{querydict.urlencode()}', - 'link_text': f'{bound_field.label}: {display_value}', + 'link_text': link_text, }) save_link = None diff --git a/netbox/utilities/tests/test_filter_modifiers.py b/netbox/utilities/tests/test_filter_modifiers.py new file mode 100644 index 000000000..70352e7d8 --- /dev/null +++ b/netbox/utilities/tests/test_filter_modifiers.py @@ -0,0 +1,293 @@ +from django import forms +from django.db import models +from django.http import QueryDict +from django.template import Context +from django.test import RequestFactory, TestCase + +import dcim.filtersets # noqa: F401 - Import to register Device filterset +from dcim.forms.filtersets import DeviceFilterForm +from dcim.models import Device +from netbox.filtersets import BaseFilterSet +from utilities.filtersets import register_filterset +from users.models import User +from utilities.forms.fields import TagFilterField +from utilities.forms.mixins import FilterModifierMixin +from utilities.forms.widgets import FilterModifierWidget +from utilities.templatetags.helpers import applied_filters + + +# Test model for FilterModifierMixin tests +class TestModel(models.Model): + """Dummy model for testing filter modifiers.""" + char_field = models.CharField(max_length=100, blank=True) + integer_field = models.IntegerField(null=True, blank=True) + decimal_field = models.DecimalField(max_digits=5, decimal_places=2, null=True, blank=True) + date_field = models.DateField(null=True, blank=True) + boolean_field = models.BooleanField(default=False) + + class Meta: + app_label = 'utilities' + managed = False # Don't create actual database table + + +# Test filterset using BaseFilterSet to automatically generate lookups +@register_filterset +class TestFilterSet(BaseFilterSet): + class Meta: + model = TestModel + fields = ['char_field', 'integer_field', 'decimal_field', 'date_field', 'boolean_field'] + + +class FilterModifierWidgetTest(TestCase): + """Tests for FilterModifierWidget value extraction and rendering.""" + + def test_value_from_datadict_finds_value_in_lookup_variant(self): + """ + Widget should find value from serial__ic when field is named serial. + This is critical for form redisplay after validation errors. + """ + widget = FilterModifierWidget( + widget=forms.TextInput(), + lookups=[('exact', 'Is'), ('ic', 'Contains'), ('isw', 'Starts With')] + ) + data = QueryDict('serial__ic=test123') + + result = widget.value_from_datadict(data, {}, 'serial') + + self.assertEqual(result, 'test123') + + def test_value_from_datadict_handles_exact_match(self): + """Widget should detect exact match when field name has no modifier.""" + widget = FilterModifierWidget( + widget=forms.TextInput(), + lookups=[('exact', 'Is'), ('ic', 'Contains')] + ) + data = QueryDict('serial=test456') + + result = widget.value_from_datadict(data, {}, 'serial') + + self.assertEqual(result, 'test456') + + def test_value_from_datadict_returns_none_when_no_value(self): + """Widget should return None when no data present to avoid appearing in changed_data.""" + widget = FilterModifierWidget( + widget=forms.TextInput(), + lookups=[('exact', 'Is'), ('ic', 'Contains')] + ) + data = QueryDict('') + + result = widget.value_from_datadict(data, {}, 'serial') + + self.assertIsNone(result) + + def test_get_context_includes_original_widget_and_lookups(self): + """Widget context should include original widget context and lookup choices.""" + widget = FilterModifierWidget( + widget=forms.TextInput(), + lookups=[('exact', 'Is'), ('ic', 'Contains'), ('isw', 'Starts With')] + ) + value = 'test' + + context = widget.get_context('serial', value, {}) + + self.assertIn('original_widget', context['widget']) + self.assertEqual( + context['widget']['lookups'], + [('exact', 'Is'), ('ic', 'Contains'), ('isw', 'Starts With')] + ) + self.assertEqual(context['widget']['field_name'], 'serial') + self.assertEqual(context['widget']['current_modifier'], 'exact') # Defaults to exact, JS updates from URL + self.assertEqual(context['widget']['current_value'], 'test') + + def test_widget_renders_modifier_dropdown_and_input(self): + """Widget should render modifier dropdown alongside original input.""" + widget = FilterModifierWidget( + widget=forms.TextInput(), + lookups=[('exact', 'Is'), ('ic', 'Contains')] + ) + + html = widget.render('serial', 'test', {}) + + # Should contain modifier dropdown + self.assertIn('class="form-select modifier-select"', html) + self.assertIn('data-field="serial"', html) + self.assertIn('', html) + self.assertIn('', html) + + # Should contain original input + self.assertIn('type="text"', html) + self.assertIn('name="serial"', html) + self.assertIn('value="test"', html) + + +class FilterModifierMixinTest(TestCase): + """Tests for FilterModifierMixin form field enhancement.""" + + def test_mixin_enhances_char_field_with_modifiers(self): + """CharField should be enhanced with contains/starts/ends modifiers.""" + class TestForm(FilterModifierMixin, forms.Form): + char_field = forms.CharField(required=False) + model = TestModel + + form = TestForm() + + self.assertIsInstance(form.fields['char_field'].widget, FilterModifierWidget) + lookup_codes = [lookup[0] for lookup in form.fields['char_field'].widget.lookups] + expected_lookups = ['exact', 'n', 'ic', 'isw', 'iew', 'ie', 'regex', 'iregex', 'empty_true', 'empty_false'] + self.assertEqual(lookup_codes, expected_lookups) + + def test_mixin_skips_boolean_fields(self): + """Boolean fields should not be enhanced.""" + class TestForm(FilterModifierMixin, forms.Form): + boolean_field = forms.BooleanField(required=False) + model = TestModel + + form = TestForm() + + self.assertNotIsInstance(form.fields['boolean_field'].widget, FilterModifierWidget) + + def test_mixin_enhances_tag_filter_field(self): + """TagFilterField should be enhanced even though it's a MultipleChoiceField.""" + class TestForm(FilterModifierMixin, forms.Form): + tag = TagFilterField(Device) + model = Device + + form = TestForm() + + self.assertIsInstance(form.fields['tag'].widget, FilterModifierWidget) + tag_lookups = [lookup[0] for lookup in form.fields['tag'].widget.lookups] + # Device filterset has tag and tag__n but not tag__empty + expected_lookups = ['exact', 'n'] + self.assertEqual(tag_lookups, expected_lookups) + + def test_mixin_enhances_integer_field(self): + """IntegerField should be enhanced with comparison modifiers.""" + class TestForm(FilterModifierMixin, forms.Form): + integer_field = forms.IntegerField(required=False) + model = TestModel + + form = TestForm() + + self.assertIsInstance(form.fields['integer_field'].widget, FilterModifierWidget) + lookup_codes = [lookup[0] for lookup in form.fields['integer_field'].widget.lookups] + expected_lookups = ['exact', 'n', 'gt', 'gte', 'lt', 'lte', 'empty_true', 'empty_false'] + self.assertEqual(lookup_codes, expected_lookups) + + def test_mixin_enhances_decimal_field(self): + """DecimalField should be enhanced with comparison modifiers.""" + class TestForm(FilterModifierMixin, forms.Form): + decimal_field = forms.DecimalField(required=False) + model = TestModel + + form = TestForm() + + self.assertIsInstance(form.fields['decimal_field'].widget, FilterModifierWidget) + lookup_codes = [lookup[0] for lookup in form.fields['decimal_field'].widget.lookups] + expected_lookups = ['exact', 'n', 'gt', 'gte', 'lt', 'lte', 'empty_true', 'empty_false'] + self.assertEqual(lookup_codes, expected_lookups) + + def test_mixin_enhances_date_field(self): + """DateField should be enhanced with date-appropriate modifiers.""" + class TestForm(FilterModifierMixin, forms.Form): + date_field = forms.DateField(required=False) + model = TestModel + + form = TestForm() + + self.assertIsInstance(form.fields['date_field'].widget, FilterModifierWidget) + lookup_codes = [lookup[0] for lookup in form.fields['date_field'].widget.lookups] + expected_lookups = ['exact', 'n', 'gt', 'gte', 'lt', 'lte', 'empty_true', 'empty_false'] + self.assertEqual(lookup_codes, expected_lookups) + + +class ExtendedLookupFilterPillsTest(TestCase): + """Tests for filter pill rendering of extended lookups.""" + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create(username='test_user') + + def test_negation_lookup_filter_pill(self): + """Filter pill should show 'is not' for negation lookup.""" + query_params = QueryDict('serial__n=ABC123') + form = DeviceFilterForm(query_params) + + request = RequestFactory().get('/', query_params) + request.user = self.user + context = Context({'request': request}) + result = applied_filters(context, Device, form, query_params) + + self.assertGreater(len(result['applied_filters']), 0) + filter_pill = result['applied_filters'][0] + self.assertIn('is not', filter_pill['link_text'].lower()) + self.assertIn('ABC123', filter_pill['link_text']) + + def test_regex_lookup_filter_pill(self): + """Filter pill should show 'matches pattern' for regex lookup.""" + query_params = QueryDict('serial__regex=^ABC.*') + form = DeviceFilterForm(query_params) + + request = RequestFactory().get('/', query_params) + request.user = self.user + context = Context({'request': request}) + result = applied_filters(context, Device, form, query_params) + + self.assertGreater(len(result['applied_filters']), 0) + filter_pill = result['applied_filters'][0] + self.assertIn('matches pattern', filter_pill['link_text'].lower()) + + def test_exact_lookup_filter_pill(self): + """Filter pill should show field label and value without lookup modifier for exact match.""" + query_params = QueryDict('serial=ABC123') + form = DeviceFilterForm(query_params) + + request = RequestFactory().get('/', query_params) + request.user = self.user + context = Context({'request': request}) + result = applied_filters(context, Device, form, query_params) + + self.assertGreater(len(result['applied_filters']), 0) + filter_pill = result['applied_filters'][0] + # Should not contain lookup modifier text + self.assertNotIn('is not', filter_pill['link_text'].lower()) + self.assertNotIn('matches pattern', filter_pill['link_text'].lower()) + self.assertNotIn('contains', filter_pill['link_text'].lower()) + # Should contain field label and value + self.assertIn('Serial', filter_pill['link_text']) + self.assertIn('ABC123', filter_pill['link_text']) + + +class EmptyLookupTest(TestCase): + """Tests for empty (is empty/not empty) lookup support.""" + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create(username='test_user') + + def test_empty_true_appears_in_filter_pills(self): + """Filter pill should show 'Is Empty' for empty=true.""" + query_params = QueryDict('serial__empty=true') + form = DeviceFilterForm(query_params) + + request = RequestFactory().get('/', query_params) + request.user = self.user + context = Context({'request': request}) + result = applied_filters(context, Device, form, query_params) + + self.assertGreater(len(result['applied_filters']), 0) + filter_pill = result['applied_filters'][0] + self.assertIn('empty', filter_pill['link_text'].lower()) + + def test_empty_false_appears_in_filter_pills(self): + """Filter pill should show 'Is Not Empty' for empty=false.""" + query_params = QueryDict('serial__empty=false') + form = DeviceFilterForm(query_params) + + request = RequestFactory().get('/', query_params) + request.user = self.user + context = Context({'request': request}) + result = applied_filters(context, Device, form, query_params) + + self.assertGreater(len(result['applied_filters']), 0) + filter_pill = result['applied_filters'][0] + self.assertIn('not empty', filter_pill['link_text'].lower()) diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index b96f1dc24..2c4521cfd 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -11,9 +11,9 @@ from extras.models import ConfigTemplate from ipam.filtersets import PrimaryIPFilterSet from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet - from users.filterset_mixins import OwnerFilterMixin from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter +from utilities.filtersets import register_filterset from .choices import * from .models import * @@ -27,6 +27,7 @@ __all__ = ( ) +@register_filterset class ClusterTypeFilterSet(OrganizationalModelFilterSet): class Meta: @@ -34,6 +35,7 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet): fields = ('id', 'name', 'slug', 'description') +@register_filterset class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): class Meta: @@ -41,6 +43,7 @@ class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet) fields = ('id', 'name', 'slug', 'description') +@register_filterset class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ScopedFilterSet, ContactModelFilterSet): group_id = django_filters.ModelMultipleChoiceFilter( queryset=ClusterGroup.objects.all(), @@ -81,6 +84,7 @@ class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ScopedFilterSet, ) +@register_filterset class VirtualMachineFilterSet( PrimaryModelFilterSet, TenancyFilterSet, @@ -241,6 +245,7 @@ class VirtualMachineFilterSet( return queryset.exclude(params) +@register_filterset class VMInterfaceFilterSet(CommonInterfaceFilterSet, OwnerFilterMixin, NetBoxModelFilterSet): cluster_id = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine__cluster', @@ -303,6 +308,7 @@ class VMInterfaceFilterSet(CommonInterfaceFilterSet, OwnerFilterMixin, NetBoxMod ) +@register_filterset class VirtualDiskFilterSet(OwnerFilterMixin, NetBoxModelFilterSet): virtual_machine_id = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine', diff --git a/netbox/vpn/filtersets.py b/netbox/vpn/filtersets.py index 59a38159e..e6fcd87fc 100644 --- a/netbox/vpn/filtersets.py +++ b/netbox/vpn/filtersets.py @@ -8,6 +8,7 @@ from ipam.models import IPAddress, RouteTarget, VLAN from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter +from utilities.filtersets import register_filterset from virtualization.models import VirtualMachine, VMInterface from .choices import * from .models import * @@ -26,6 +27,7 @@ __all__ = ( ) +@register_filterset class TunnelGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): class Meta: @@ -33,6 +35,7 @@ class TunnelGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): fields = ('id', 'name', 'slug', 'description') +@register_filterset class TunnelFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet): status = django_filters.MultipleChoiceFilter( choices=TunnelStatusChoices @@ -75,6 +78,7 @@ class TunnelFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilte ) +@register_filterset class TunnelTerminationFilterSet(NetBoxModelFilterSet): tunnel_id = django_filters.ModelMultipleChoiceFilter( field_name='tunnel', @@ -124,6 +128,7 @@ class TunnelTerminationFilterSet(NetBoxModelFilterSet): fields = ('id', 'termination_id') +@register_filterset class IKEProposalFilterSet(PrimaryModelFilterSet): ike_policy_id = django_filters.ModelMultipleChoiceFilter( field_name='ike_policies', @@ -163,6 +168,7 @@ class IKEProposalFilterSet(PrimaryModelFilterSet): ) +@register_filterset class IKEPolicyFilterSet(PrimaryModelFilterSet): version = django_filters.MultipleChoiceFilter( choices=IKEVersionChoices @@ -194,6 +200,7 @@ class IKEPolicyFilterSet(PrimaryModelFilterSet): ) +@register_filterset class IPSecProposalFilterSet(PrimaryModelFilterSet): ipsec_policy_id = django_filters.ModelMultipleChoiceFilter( field_name='ipsec_policies', @@ -227,6 +234,7 @@ class IPSecProposalFilterSet(PrimaryModelFilterSet): ) +@register_filterset class IPSecPolicyFilterSet(PrimaryModelFilterSet): pfs_group = django_filters.MultipleChoiceFilter( choices=DHGroupChoices @@ -255,6 +263,7 @@ class IPSecPolicyFilterSet(PrimaryModelFilterSet): ) +@register_filterset class IPSecProfileFilterSet(PrimaryModelFilterSet): mode = django_filters.MultipleChoiceFilter( choices=IPSecModeChoices @@ -294,6 +303,7 @@ class IPSecProfileFilterSet(PrimaryModelFilterSet): ) +@register_filterset class L2VPNFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet): type = django_filters.MultipleChoiceFilter( choices=L2VPNTypeChoices, @@ -340,6 +350,7 @@ class L2VPNFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilter return queryset.filter(qs_filter) +@register_filterset class L2VPNTerminationFilterSet(NetBoxModelFilterSet): l2vpn_id = django_filters.ModelMultipleChoiceFilter( queryset=L2VPN.objects.all(), diff --git a/netbox/vpn/forms/filtersets.py b/netbox/vpn/forms/filtersets.py index 4085d9ac5..6108b1389 100644 --- a/netbox/vpn/forms/filtersets.py +++ b/netbox/vpn/forms/filtersets.py @@ -245,7 +245,7 @@ class L2VPNFilterForm(ContactModelFilterForm, TenancyFilterForm, PrimaryModelFil class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): model = L2VPNTermination fieldsets = ( - FieldSet('filter_id', 'l2vpn_id'), + FieldSet('filter_id', 'tag', 'l2vpn_id'), FieldSet( 'assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id', name=_('Assigned Object') @@ -303,3 +303,4 @@ class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): }, label=_('Virtual Machine') ) + tag = TagFilterField(model) diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index afd963a5a..3d10497ea 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -1,13 +1,14 @@ import django_filters from django.db.models import Q -from dcim.choices import LinkStatusChoices from dcim.base_filtersets import ScopedFilterSet +from dcim.choices import LinkStatusChoices from dcim.models import Interface from ipam.models import VLAN from netbox.filtersets import NestedGroupModelFilterSet, PrimaryModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter +from utilities.filtersets import register_filterset from .choices import * from .models import * @@ -18,6 +19,7 @@ __all__ = ( ) +@register_filterset class WirelessLANGroupFilterSet(NestedGroupModelFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=WirelessLANGroup.objects.all() @@ -44,6 +46,7 @@ class WirelessLANGroupFilterSet(NestedGroupModelFilterSet): fields = ('id', 'name', 'slug', 'description') +@register_filterset class WirelessLANFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=WirelessLANGroup.objects.all(), @@ -87,6 +90,7 @@ class WirelessLANFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilter return queryset.filter(qs_filter) +@register_filterset class WirelessLinkFilterSet(PrimaryModelFilterSet, TenancyFilterSet): interface_a_id = django_filters.ModelMultipleChoiceFilter( queryset=Interface.objects.all()