Files
netbox/netbox/utilities/forms/fields/fields.py
Jason Novinger 7eefb07554
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, actions) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
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>
2025-12-05 15:13:37 -05:00

157 lines
5.0 KiB
Python

import json
from django import forms
from django.conf import settings
from django.db.models import Count
from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _
from netaddr import AddrFormatError, EUI
from utilities.forms import widgets
from utilities.validators import EnhancedURLValidator
__all__ = (
'ColorField',
'CommentField',
'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`.
"""
widget = widgets.MarkdownWidget
label = _('Comments')
help_text = _(
'<i class="mdi mdi-information-outline"></i> '
'<a href="{url}" target="_blank" tabindex="-1">Markdown</a> syntax is supported'
).format(url=static('docs/reference/markdown/'))
def __init__(self, *, label=label, help_text=help_text, required=False, **kwargs):
super().__init__(label=label, help_text=help_text, required=required, **kwargs)
class SlugField(forms.SlugField):
"""
Extend Django's built-in SlugField to automatically populate from a field called `name` unless otherwise specified.
Parameters:
slug_source: Name of the form field from which the slug value will be derived
"""
widget = widgets.SlugWidget
label = _('Slug')
help_text = _("URL-friendly unique shorthand")
def __init__(self, *, slug_source='name', label=label, help_text=help_text, **kwargs):
super().__init__(label=label, help_text=help_text, **kwargs)
self.widget.attrs['slug-source'] = slug_source
def get_bound_field(self, form, field_name):
if prefix := form.prefix:
slug_source = self.widget.attrs.get('slug-source')
if slug_source and not slug_source.startswith(f'{prefix}-'):
self.widget.attrs['slug-source'] = f"{prefix}-{slug_source}"
return super().get_bound_field(form, field_name)
class ColorField(forms.CharField):
"""
A field which represents a color value in hexadecimal `RRGGBB` format. Utilizes NetBox's `ColorSelect` widget to
render choices.
"""
widget = widgets.ColorSelect
class TagFilterField(forms.MultipleChoiceField):
"""
A filter field for the tags of a model. Only the tags used by a model are displayed.
:param model: The model of the filter
"""
def __init__(self, model, *args, **kwargs):
def get_choices():
tags = model.tags.annotate(
count=Count('extras_taggeditem_items')
).order_by('name')
return [
(settings.FILTERS_NULL_CHOICE_VALUE, settings.FILTERS_NULL_CHOICE_LABEL), # "None" option
*[(str(tag.slug), f'{tag.name} ({tag.count})') for tag in tags]
]
# Choices are fetched each time the form is initialized
super().__init__(label=_('Tags'), choices=get_choices, required=False, *args, **kwargs)
class LaxURLField(forms.URLField):
"""
Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names
(e.g. http://myserver/ is valid)
"""
default_validators = [EnhancedURLValidator()]
class JSONField(_JSONField):
"""
Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text.
"""
empty_values = [None, '', ()]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.widget.attrs['placeholder'] = ''
self.widget.attrs['class'] = 'font-monospace'
if not self.help_text:
self.help_text = _('Enter context data in <a href="https://json.org/">JSON</a> format.')
def prepare_value(self, value):
if isinstance(value, InvalidJSONInput):
return value
if value in ('', None):
return ''
if type(value) is str:
try:
value = json.loads(value, cls=self.decoder)
except json.decoder.JSONDecodeError:
return f'"{value}"'
return json.dumps(value, sort_keys=True, indent=4, ensure_ascii=False, cls=self.encoder)
class MACAddressField(forms.Field):
"""
Validates a 48-bit MAC address.
"""
widget = forms.CharField
default_error_messages = {
'invalid': _('MAC address must be in EUI-48 format'),
}
def to_python(self, value):
value = super().to_python(value)
# Validate MAC address format
try:
value = EUI(value.strip())
except AddrFormatError:
raise forms.ValidationError(self.error_messages['invalid'], code='invalid')
return value