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
This commit is contained in:
Jason Novinger
2025-10-26 22:49:59 -05:00
parent bcffc383bf
commit 149b024eeb
15 changed files with 893 additions and 18 deletions

View File

@@ -5,10 +5,14 @@ from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator
from django.utils.translation import gettext_lazy as _
from utilities.forms.fields import ColorField, TagFilterField
from utilities.forms.widgets.modifiers import MODIFIER_EMPTY_FALSE, MODIFIER_EMPTY_TRUE
__all__ = (
'BackgroundJobMixin',
'CheckLastUpdatedMixin',
'DistanceValidationMixin',
'FilterModifierMixin',
)
@@ -75,3 +79,171 @@ 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.
"""
# Mapping of form field types to their supported lookups
FORM_FIELD_LOOKUPS = {
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.MultipleChoiceField: [
('exact', _('Is')),
('n', _('Is Not')),
(MODIFIER_EMPTY_TRUE, _('Is Empty')),
(MODIFIER_EMPTY_FALSE, _('Is Not Empty')),
],
}
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."""
from utilities.forms.widgets import FilterModifierWidget
from utilities.forms.filterset_mappings import FILTERSET_MAPPINGS
# Get the corresponding FilterSet if registered
filterset_class = FILTERSET_MAPPINGS.get(self.__class__)
filterset = filterset_class() if filterset_class else None
for field_name, field in self.fields.items():
if self._should_skip_field(field_name, field):
continue
lookups = self._get_lookup_choices(field, field_name)
# Verify lookups against FilterSet if available
if filterset:
lookups = self._verify_lookups_with_filterset(field_name, lookups, filterset)
if len(lookups) > 1:
field.widget = FilterModifierWidget(
original_widget=field.widget,
lookups=lookups
)
def _should_skip_field(self, field_name, field):
"""Determine if a field should be skipped for enhancement."""
# Skip the global search field
if field_name == 'q':
return True
# Skip boolean fields (no benefit from modifiers)
if isinstance(field, (forms.BooleanField, forms.NullBooleanField)):
return True
# MultipleChoiceField and TagFilterField are now supported
# (no longer skipped)
# Skip API widget fields
if self._is_api_widget_field(field):
return True
return False
def _get_lookup_choices(self, field, field_name=None):
"""Determine the available lookup choices for a given field."""
# Walk up the MRO to find a known field type
for field_class in field.__class__.__mro__:
if field_class in self.FORM_FIELD_LOOKUPS:
return self.FORM_FIELD_LOOKUPS[field_class]
# Unknown field type - return single exact option (no enhancement)
return [('exact', _('Is'))]
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:
# Handle special empty_true/false codes that map to __empty
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
# Check if this filter exists in the FilterSet
if filter_key in filterset.filters:
verified_lookups.append((lookup_code, lookup_label))
return verified_lookups
def _is_api_widget_field(self, field):
"""Check if a field uses an API-based widget."""
# Check field class name
if 'Dynamic' in field.__class__.__name__:
return True
# Check widget attributes for API-related data
if hasattr(field.widget, 'attrs') and field.widget.attrs:
api_attrs = ['data-url', 'data-api-url', 'data-static-params']
if any(attr in field.widget.attrs for attr in api_attrs):
return True
return False