Skip to content

Commit 7eefb07

Browse files
Closes #7604: Add filter modifier dropdowns for advanced lookup operators (#20747)
* Fixes #7604: Add filter modifier dropdowns for advanced lookup operators Implements dynamic filter modifier UI that allows users to select lookup operators (exact, contains, starts with, regex, negation, empty/not empty) directly in filter forms without manual URL parameter editing. Supports filters for all scalar types and strings, as well as some related object filters. Explicitly does not support filters on fields that use APIWidget. That has been broken out in to follow up work. **Backend:** - FilterModifierWidget: Wraps form widgets with lookup modifier dropdown - FilterModifierMixin: Auto-enhances filterset fields with appropriate lookups - Extended lookup support: Adds negation (n), regex, iregex, empty_true/false lookups - Field-type-aware: CharField gets text lookups, IntegerField gets comparison operators, etc. **Frontend:** - TypeScript handler syncs modifier dropdown with URL parameters - Dynamically updates form field names (serial → serial__ic) on modifier change - Flexible-width modifier dropdowns with semantic CSS classes * Remove extraneous TS comments * Fix import order * Fix CircuitFilterForm inheritance * Enable filter form modifiers on DCIM models * Enable filter form modifiers on Tenancy models * Enable filter form modifiers on Wireless models * Enable filter form modifiers on IPAM models * Enable filter form modifiers on VPN models * Enable filter form modifiers on Virtualization models * Enable filter form modifiers on Circuit models * Enable filter form modifiers on Users models * Enable filter form modifiers on Core models * Enable filter form modifiers on Extras models * Add ChoiceField support to FilterModifierMixin Enable filter modifiers for single-choice ChoiceFields in addition to the existing MultipleChoiceField support. ChoiceFields can now display modifier dropdowns with "Is", "Is Not", "Is Empty", and "Is Not Empty" options when the corresponding FilterSet defines those lookups. The mixin correctly verifies lookup availability against the FilterSet, so modifiers only appear when multiple lookup options are actually supported. Currently most FilterSets only define 'exact' for single-choice fields, but this change enables future FilterSet enhancements to expose additional lookups for ChoiceFields. * Address PR feedback: Replace global filterset mappings with registry * Address PR feedback: Move FilterModifierMixin into base filter form classes Incorporates FilterModifierMixin into NetBoxModelFilterSetForm and FilterForm, making filter modifiers automatic for all filter forms throughout the application. * Fix filter modifier form submission bug with 'action' field collision Forms with a field named "action" (e.g., ObjectChangeFilterForm) were causing the form.action property to be shadowed by the field element, resulting in [object HTMLSelectElement] appearing in the URL path. Use form.getAttribute('action') instead of form.action to reliably retrieve the form's action URL without collision from form fields. Fixes form submission on /core/changelog/ and any other forms with an 'action' field using filter modifiers. * Address PR feedback: Move FORM_FIELD_LOOKUPS to module-level constant Extracts the field type to lookup mappings from FilterModifierMixin class attribute to a module-level constant for better reusability. * Address PR feedback: Refactor and consolidate field filtering logic Consolidated field enhancement logic in FilterModifierMixin by: - Creating QueryField marker type (CharField subclass) for search fields - Updating FilterForm and NetBoxModelFilterSetForm to use QueryField for 'q' - Moving all skip logic into _get_lookup_choices() to return empty list for fields that shouldn't be enhanced - Removing separate _should_skip_field() method - Removing unused field_name parameter from _get_lookup_choices() - Replacing hardcoded field name check ('q') with type-based detection * Address PR feedback: Refactor applied_filters to use FORM_FIELD_LOOKUPS * Address PR feedback: Rename FilterModifierWidget parameter to widget * Fix registry pattern to use model identifiers as keys Changed filterset registration to use model identifiers ('{app_label}.{model_name}') as registry keys instead of form classes, matching NetBox's pattern for search indexes. * Address PR feedback: refactor brittle test for APISelect useage Now checks if widget is actually APISelect, rather than trying to infer from the class name. * Refactor register_filterset to be more generic and simple * Remove unneeded imports left from earlier registry work * Update app registry for new `filtersets` store * Remove unused star import, leftover from earlier work * Enables filter modifiers on APISelect based fields * Support filter modifiers for ChoiceField * Include MODIFIER_EMPTY_FALSE/_TRUE in __all__ Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com> * Fix filterset registration for doubly-registered models * Removed explicit checks against QueryField and [Null]BooleanField I did add them to FORM_FIELD_LOOKUPS, though, to underscore that they were considered and are intentially empty for future devs. * Switch to sentence case for filter pill text * Fix applied_filters template tag to use field-type-specific lookup labelsresolves E.g. resolves gt="after" for dates vs "greater than" for numbers * Verifies that filter pills for exact matches (no lookup Add test for exact lookup filter pill rendering * Add guard for FilterModifierWidget with no lookups * Remove comparison symbols from numeric filter labels * Match complete tags in widget rendering test assertions * Check all expected lookups in field enhancement tests * Move register_filterset to netbox.plugins.registration * Require registered filterset for filter modifier enhancements Updates FilterModifierMixin to only enhance form fields when the associated model has a registered filterset. This provides plugin safety by ensuring unregistered plugin filtersets fall back to simple filters without lookup modifiers. Test changes: - Create TestModel and TestFilterSet using BaseFilterSet for automatic lookup generation - Import dcim.filtersets to ensure Device filterset registration - Adjust tag field expectations to match actual Device filterset (has exact/n but not empty lookups) * Attempt to resolve static conflicts * Move register_filterset() back to utilities.filtersets * Add register_filterset() to plugins documentation for filtersets * Reorder import statements --------- Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
1 parent 20c260b commit 7eefb07

File tree

33 files changed

+1023
-28
lines changed

33 files changed

+1023
-28
lines changed

docs/development/application-registry.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ A dictionary mapping data backend types to their respective classes. These are u
2020

2121
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.
2222

23+
### `filtersets`
24+
25+
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.
26+
2327
### `model_features`
2428

2529
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`.

docs/plugins/development/filtersets.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@ Filter sets define the mechanisms available for filtering or searching through a
66

77
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.
88

9+
!!! info "New in NetBox v4.5: FilterSet Registration"
10+
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.
11+
912
```python
1013
# filtersets.py
1114
import django_filters
1215
from netbox.filtersets import NetBoxModelFilterSet
16+
from utilities.filtersets import register_filterset
1317
from .models import MyModel
1418

19+
@register_filterset
1520
class MyFilterSet(NetBoxModelFilterSet):
1621
status = django_filters.MultipleChoiceFilter(
1722
choices=(
@@ -42,7 +47,7 @@ class MyModelListView(ObjectListView):
4247
filterset = MyModelFilterSet
4348
```
4449

45-
To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view:
50+
To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view:
4651

4752
```python
4853
# api/views.py
@@ -62,7 +67,9 @@ The `ObjectListView` has a field called Quick Search. For Quick Search to work t
6267
```python
6368
from django.db.models import Q
6469
from netbox.filtersets import NetBoxModelFilterSet
70+
from utilities.filtersets import register_filterset
6571

72+
@register_filterset
6673
class MyFilterSet(NetBoxModelFilterSet):
6774
...
6875
def search(self, queryset, name, value):
@@ -90,7 +97,9 @@ This class filters `tags` using the `slug` field. For example:
9097
```python
9198
from django_filters import FilterSet
9299
from extras.filters import TagFilter
100+
from utilities.filtersets import register_filterset
93101

102+
@register_filterset
94103
class MyModelFilterSet(FilterSet):
95104
tag = TagFilter()
96105
```
@@ -106,7 +115,9 @@ This class filters `tags` using the `id` field. For example:
106115
```python
107116
from django_filters import FilterSet
108117
from extras.filters import TagIDFilter
118+
from utilities.filtersets import register_filterset
109119

120+
@register_filterset
110121
class MyModelFilterSet(FilterSet):
111122
tag_id = TagIDFilter()
112123
```

netbox/circuits/filtersets.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from utilities.filters import (
1212
ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, TreeNodeMultipleChoiceFilter,
1313
)
14+
from utilities.filtersets import register_filterset
1415
from .choices import *
1516
from .models import *
1617

@@ -29,6 +30,7 @@
2930
)
3031

3132

33+
@register_filterset
3234
class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
3335
region_id = TreeNodeMultipleChoiceFilter(
3436
queryset=Region.objects.all(),
@@ -93,6 +95,7 @@ def search(self, queryset, name, value):
9395
)
9496

9597

98+
@register_filterset
9699
class ProviderAccountFilterSet(PrimaryModelFilterSet, ContactModelFilterSet):
97100
provider_id = django_filters.ModelMultipleChoiceFilter(
98101
queryset=Provider.objects.all(),
@@ -120,6 +123,7 @@ def search(self, queryset, name, value):
120123
).distinct()
121124

122125

126+
@register_filterset
123127
class ProviderNetworkFilterSet(PrimaryModelFilterSet):
124128
provider_id = django_filters.ModelMultipleChoiceFilter(
125129
queryset=Provider.objects.all(),
@@ -147,13 +151,15 @@ def search(self, queryset, name, value):
147151
).distinct()
148152

149153

154+
@register_filterset
150155
class CircuitTypeFilterSet(OrganizationalModelFilterSet):
151156

152157
class Meta:
153158
model = CircuitType
154159
fields = ('id', 'name', 'slug', 'color', 'description')
155160

156161

162+
@register_filterset
157163
class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
158164
provider_id = django_filters.ModelMultipleChoiceFilter(
159165
queryset=Provider.objects.all(),
@@ -265,6 +271,7 @@ def search(self, queryset, name, value):
265271
).distinct()
266272

267273

274+
@register_filterset
268275
class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
269276
q = django_filters.CharFilter(
270277
method='search',
@@ -360,13 +367,15 @@ def search(self, queryset, name, value):
360367
).distinct()
361368

362369

370+
@register_filterset
363371
class CircuitGroupFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
364372

365373
class Meta:
366374
model = CircuitGroup
367375
fields = ('id', 'name', 'slug', 'description')
368376

369377

378+
@register_filterset
370379
class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
371380
q = django_filters.CharFilter(
372381
method='search',
@@ -466,13 +475,15 @@ def filter_provider(self, queryset, name, value):
466475
)
467476

468477

478+
@register_filterset
469479
class VirtualCircuitTypeFilterSet(OrganizationalModelFilterSet):
470480

471481
class Meta:
472482
model = VirtualCircuitType
473483
fields = ('id', 'name', 'slug', 'color', 'description')
474484

475485

486+
@register_filterset
476487
class VirtualCircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
477488
provider_id = django_filters.ModelMultipleChoiceFilter(
478489
field_name='provider_network__provider',
@@ -529,6 +540,7 @@ def search(self, queryset, name, value):
529540
).distinct()
530541

531542

543+
@register_filterset
532544
class VirtualCircuitTerminationFilterSet(NetBoxModelFilterSet):
533545
q = django_filters.CharFilter(
534546
method='search',

netbox/core/filtersets.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from netbox.utils import get_data_backend_choices
88
from users.models import User
99
from utilities.filters import ContentTypeFilter
10+
from utilities.filtersets import register_filterset
1011
from .choices import *
1112
from .models import *
1213

@@ -20,6 +21,7 @@
2021
)
2122

2223

24+
@register_filterset
2325
class DataSourceFilterSet(PrimaryModelFilterSet):
2426
type = django_filters.MultipleChoiceFilter(
2527
choices=get_data_backend_choices,
@@ -48,6 +50,7 @@ def search(self, queryset, name, value):
4850
)
4951

5052

53+
@register_filterset
5154
class DataFileFilterSet(ChangeLoggedModelFilterSet):
5255
q = django_filters.CharFilter(
5356
method='search'
@@ -75,6 +78,7 @@ def search(self, queryset, name, value):
7578
)
7679

7780

81+
@register_filterset
7882
class JobFilterSet(BaseFilterSet):
7983
q = django_filters.CharFilter(
8084
method='search',
@@ -139,6 +143,7 @@ def search(self, queryset, name, value):
139143
)
140144

141145

146+
@register_filterset
142147
class ObjectTypeFilterSet(BaseFilterSet):
143148
q = django_filters.CharFilter(
144149
method='search',
@@ -164,6 +169,7 @@ def filter_features(self, queryset, name, value):
164169
return queryset.filter(features__icontains=value)
165170

166171

172+
@register_filterset
167173
class ObjectChangeFilterSet(BaseFilterSet):
168174
q = django_filters.CharFilter(
169175
method='search',
@@ -203,6 +209,7 @@ def search(self, queryset, name, value):
203209
)
204210

205211

212+
@register_filterset
206213
class ConfigRevisionFilterSet(BaseFilterSet):
207214
q = django_filters.CharFilter(
208215
method='search',

0 commit comments

Comments
 (0)