mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-10 18:39:36 -06:00
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:
parent
20c260b126
commit
fd67acc3ab
@ -13,8 +13,11 @@ from netbox.forms import NetBoxModelFilterSetForm, OrganizationalModelFilterSetF
|
||||
from tenancy.forms import TenancyFilterForm, ContactModelFilterForm
|
||||
from utilities.forms import add_blank_choice
|
||||
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.filterset_mappings import FILTERSET_MAPPINGS
|
||||
from utilities.forms.mixins import FilterModifierMixin
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.widgets import DatePicker, NumberWithOptions
|
||||
from circuits.filtersets import CircuitFilterSet
|
||||
|
||||
__all__ = (
|
||||
'CircuitFilterForm',
|
||||
@ -118,7 +121,7 @@ class CircuitTypeFilterForm(OrganizationalModelFilterSetForm):
|
||||
)
|
||||
|
||||
|
||||
class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, PrimaryModelFilterSetForm):
|
||||
class CircuitFilterForm(FilterModifierMixin, TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
|
||||
model = Circuit
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
@ -397,3 +400,7 @@ class VirtualCircuitTerminationFilterForm(NetBoxModelFilterSetForm):
|
||||
label=_('Provider')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
# Register FilterSet mappings for FilterModifierMixin lookup verification
|
||||
FILTERSET_MAPPINGS[CircuitFilterForm] = CircuitFilterSet
|
||||
|
||||
@ -3,6 +3,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.filtersets import DeviceFilterSet, RackFilterSet, PowerOutletFilterSet
|
||||
from dcim.models import *
|
||||
from extras.forms import LocalConfigContextFilterForm
|
||||
from extras.models import ConfigTemplate
|
||||
@ -16,6 +17,8 @@ from tenancy.forms import ContactModelFilterForm, TenancyFilterForm
|
||||
from users.models import Owner, User
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||
from utilities.forms.fields import ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.filterset_mappings import FILTERSET_MAPPINGS
|
||||
from utilities.forms.mixins import FilterModifierMixin
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.widgets import NumberWithOptions
|
||||
from virtualization.models import Cluster, ClusterGroup, VirtualMachine
|
||||
@ -330,7 +333,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
|
||||
class RackFilterForm(FilterModifierMixin, TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
|
||||
model = Rack
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
@ -778,6 +781,7 @@ class PlatformFilterForm(NestedGroupModelFilterSetForm):
|
||||
|
||||
|
||||
class DeviceFilterForm(
|
||||
FilterModifierMixin,
|
||||
LocalConfigContextFilterForm,
|
||||
TenancyFilterForm,
|
||||
ContactModelFilterForm,
|
||||
@ -1420,7 +1424,7 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
class PowerOutletFilterForm(FilterModifierMixin, PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
model = PowerOutlet
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag', 'owner_id'),
|
||||
@ -1830,3 +1834,9 @@ class InterfaceConnectionFilterForm(FilterForm):
|
||||
},
|
||||
label=_('Device')
|
||||
)
|
||||
|
||||
|
||||
# Register FilterSet mappings for FilterModifierMixin lookup verification
|
||||
FILTERSET_MAPPINGS[DeviceFilterForm] = DeviceFilterSet
|
||||
FILTERSET_MAPPINGS[RackFilterForm] = RackFilterSet
|
||||
FILTERSET_MAPPINGS[PowerOutletFilterForm] = PowerOutletFilterSet
|
||||
|
||||
3013
netbox/project-static/dist/graphiql/graphiql.min.css
vendored
3013
netbox/project-static/dist/graphiql/graphiql.min.css
vendored
File diff suppressed because it is too large
Load Diff
96214
netbox/project-static/dist/graphiql/graphiql.min.js
vendored
96214
netbox/project-static/dist/graphiql/graphiql.min.js
vendored
File diff suppressed because one or more lines are too long
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
198
netbox/project-static/src/forms/filterModifiers.ts
Normal file
198
netbox/project-static/src/forms/filterModifiers.ts
Normal file
@ -0,0 +1,198 @@
|
||||
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<HTMLFormElement>('form')) {
|
||||
// Only process forms with modifier selects
|
||||
const modifierSelects = form.querySelectorAll<HTMLSelectElement>('.modifier-select');
|
||||
if (modifierSelects.length === 0) continue;
|
||||
|
||||
// Initialize form state from URL parameters
|
||||
initializeFromURL(form);
|
||||
|
||||
// Add change listeners to modifier dropdowns to handle isnull
|
||||
modifierSelects.forEach(select => {
|
||||
select.addEventListener('change', () => handleModifierChange(select));
|
||||
// Trigger initial state
|
||||
handleModifierChange(select);
|
||||
});
|
||||
|
||||
// Handle form submission - must use submit event for GET forms
|
||||
form.addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
|
||||
// Build FormData to get all form values
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Transform field names
|
||||
handleFormDataTransform(form, formData);
|
||||
|
||||
// Build URL with transformed parameters
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value && String(value).trim()) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to new URL
|
||||
window.location.href = `${form.action}?${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;
|
||||
|
||||
// Disable and add placeholder for empty modifiers
|
||||
if (modifier === MODIFIER_EMPTY_TRUE || modifier === MODIFIER_EMPTY_FALSE) {
|
||||
valueInput.disabled = true;
|
||||
valueInput.value = '';
|
||||
// Get translatable placeholder from modifier dropdown's data attribute
|
||||
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<HTMLSelectElement>('.modifier-select');
|
||||
// Find input in the wrapper div (more specific selector)
|
||||
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;
|
||||
|
||||
// Handle empty special case
|
||||
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 {
|
||||
// Get all values (handles multi-select)
|
||||
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}`;
|
||||
|
||||
// Set all values with the new name
|
||||
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);
|
||||
|
||||
// Find all modifier groups
|
||||
const modifierGroups = form.querySelectorAll('.filter-modifier-group');
|
||||
|
||||
for (const group of modifierGroups) {
|
||||
const modifierSelect = group.querySelector<HTMLSelectElement>('.modifier-select');
|
||||
// Find input in the wrapper div (more specific selector)
|
||||
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; // e.g., "serial"
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Check each possible lookup in URL
|
||||
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;
|
||||
|
||||
// Handle multi-select vs single-value inputs
|
||||
if (valueInput instanceof HTMLSelectElement && valueInput.multiple) {
|
||||
// For multi-select, set selected on matching options
|
||||
const values = urlParams.getAll(paramName);
|
||||
for (const option of valueInput.options) {
|
||||
option.selected = values.includes(option.value);
|
||||
}
|
||||
} else {
|
||||
// For single-value inputs, set value directly
|
||||
valueInput.value = urlParams.get(paramName) || '';
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
11
netbox/utilities/forms/filterset_mappings.py
Normal file
11
netbox/utilities/forms/filterset_mappings.py
Normal file
@ -0,0 +1,11 @@
|
||||
# Mapping of filter form classes to their corresponding FilterSet classes
|
||||
# This enables the FilterModifierMixin to verify which lookups are actually supported
|
||||
# by checking the FilterSet's auto-generated lookup filters.
|
||||
#
|
||||
# Usage:
|
||||
# from utilities.forms.filterset_mappings import FILTERSET_MAPPINGS
|
||||
# from .forms.filtersets import XFilterForm
|
||||
# from .filtersets import XFilterSet
|
||||
# FILTERSET_MAPPINGS[XFilterForm] = XFilterSet
|
||||
|
||||
FILTERSET_MAPPINGS = {}
|
||||
@ -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
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
from .apiselect import *
|
||||
from .datetime import *
|
||||
from .misc import *
|
||||
from .modifiers import *
|
||||
from .select import *
|
||||
|
||||
105
netbox/utilities/forms/widgets/modifiers.py
Normal file
105
netbox/utilities/forms/widgets/modifiers.py
Normal file
@ -0,0 +1,105 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
__all__ = ('FilterModifierWidget',)
|
||||
|
||||
# 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, original_widget, lookups, attrs=None):
|
||||
"""
|
||||
Args:
|
||||
original_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 = original_widget
|
||||
self.lookups = lookups
|
||||
super().__init__(attrs or getattr(original_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.
|
||||
"""
|
||||
# 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
|
||||
16
netbox/utilities/templates/widgets/filter_modifier.html
Normal file
16
netbox/utilities/templates/widgets/filter_modifier.html
Normal file
@ -0,0 +1,16 @@
|
||||
<div class="d-flex filter-modifier-group">
|
||||
{# Modifier dropdown - NO name attribute, just a UI control #}
|
||||
<select class="form-select modifier-select"
|
||||
data-field="{{ widget.field_name }}"
|
||||
data-empty-placeholder="{{ widget.empty_placeholder }}"
|
||||
aria-label="Modifier">
|
||||
{% for lookup, label in widget.lookups %}
|
||||
<option value="{{ lookup }}"{% if widget.current_modifier == lookup %} selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
{# Original widget - rendered exactly as it would be without our wrapper #}
|
||||
<div class="ms-2 flex-grow-1 filter-value-container">
|
||||
{% include widget.original_widget.template_name with widget=widget.original_widget %}
|
||||
</div>
|
||||
</div>
|
||||
@ -5,6 +5,7 @@ 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
|
||||
@ -418,7 +419,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 +440,48 @@ 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)])
|
||||
|
||||
# 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")}'
|
||||
else:
|
||||
# Add friendly lookup label for other modifier-enhanced fields
|
||||
lookup_labels = {
|
||||
'n': _('is not'),
|
||||
'ic': _('contains'),
|
||||
'isw': _('starts with'),
|
||||
'iew': _('ends with'),
|
||||
'ie': _('equals (case-insensitive)'),
|
||||
'regex': _('matches pattern'),
|
||||
'iregex': _('matches pattern (case-insensitive)'),
|
||||
'gt': _('>'),
|
||||
'gte': _('≥'),
|
||||
'lt': _('<'),
|
||||
'lte': _('≤'),
|
||||
}
|
||||
if modifier != 'exact' and modifier in lookup_labels:
|
||||
link_text = f'{bound_field.label} {lookup_labels[modifier]}: {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
|
||||
|
||||
298
netbox/utilities/tests/test_filter_modifiers.py
Normal file
298
netbox/utilities/tests/test_filter_modifiers.py
Normal file
@ -0,0 +1,298 @@
|
||||
from django import forms
|
||||
from django.http import QueryDict
|
||||
from django.template import Context
|
||||
from django.test import RequestFactory, TestCase
|
||||
|
||||
from dcim.forms.filtersets import DeviceFilterForm
|
||||
from dcim.models import Device
|
||||
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
|
||||
|
||||
|
||||
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(
|
||||
original_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(
|
||||
original_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(
|
||||
original_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(
|
||||
original_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(
|
||||
original_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('value="exact"', html)
|
||||
self.assertIn('>Is</option>', html)
|
||||
self.assertIn('value="ic"', html)
|
||||
self.assertIn('>Contains</option>', 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):
|
||||
name = forms.CharField(required=False)
|
||||
|
||||
form = TestForm()
|
||||
|
||||
self.assertIsInstance(form.fields['name'].widget, FilterModifierWidget)
|
||||
self.assertGreater(len(form.fields['name'].widget.lookups), 1)
|
||||
# Should have exact, ic, isw, iew
|
||||
lookup_codes = [lookup[0] for lookup in form.fields['name'].widget.lookups]
|
||||
self.assertIn('exact', lookup_codes)
|
||||
self.assertIn('ic', lookup_codes)
|
||||
|
||||
def test_mixin_skips_boolean_fields(self):
|
||||
"""Boolean fields should not be enhanced."""
|
||||
class TestForm(FilterModifierMixin, forms.Form):
|
||||
active = forms.BooleanField(required=False)
|
||||
|
||||
form = TestForm()
|
||||
|
||||
self.assertNotIsInstance(form.fields['active'].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)
|
||||
|
||||
form = TestForm()
|
||||
|
||||
self.assertIsInstance(form.fields['tag'].widget, FilterModifierWidget)
|
||||
tag_lookups = [lookup[0] for lookup in form.fields['tag'].widget.lookups]
|
||||
self.assertIn('exact', tag_lookups)
|
||||
self.assertIn('n', tag_lookups)
|
||||
|
||||
def test_mixin_enhances_multi_choice_field(self):
|
||||
"""Plain MultipleChoiceField should be enhanced with choice-appropriate lookups."""
|
||||
class TestForm(FilterModifierMixin, forms.Form):
|
||||
status = forms.MultipleChoiceField(choices=[('a', 'A'), ('b', 'B')], required=False)
|
||||
|
||||
form = TestForm()
|
||||
|
||||
self.assertIsInstance(form.fields['status'].widget, FilterModifierWidget)
|
||||
status_lookups = [lookup[0] for lookup in form.fields['status'].widget.lookups]
|
||||
# Should have choice-based lookups (not text-based like contains/regex)
|
||||
self.assertIn('exact', status_lookups)
|
||||
self.assertIn('n', status_lookups)
|
||||
self.assertIn('empty_true', status_lookups)
|
||||
# Should NOT have text-based lookups
|
||||
self.assertNotIn('ic', status_lookups)
|
||||
self.assertNotIn('regex', status_lookups)
|
||||
|
||||
def test_mixin_enhances_integer_field(self):
|
||||
"""IntegerField should be enhanced with comparison modifiers."""
|
||||
class TestForm(FilterModifierMixin, forms.Form):
|
||||
count = forms.IntegerField(required=False)
|
||||
|
||||
form = TestForm()
|
||||
|
||||
self.assertIsInstance(form.fields['count'].widget, FilterModifierWidget)
|
||||
lookup_codes = [lookup[0] for lookup in form.fields['count'].widget.lookups]
|
||||
self.assertIn('gte', lookup_codes)
|
||||
self.assertIn('lte', lookup_codes)
|
||||
|
||||
def test_mixin_adds_isnull_lookup_to_all_fields(self):
|
||||
"""All field types should include isnull (empty/not empty) lookup."""
|
||||
class TestForm(FilterModifierMixin, forms.Form):
|
||||
name = forms.CharField(required=False)
|
||||
count = forms.IntegerField(required=False)
|
||||
created = forms.DateField(required=False)
|
||||
|
||||
form = TestForm()
|
||||
|
||||
# CharField should have empty_true and empty_false
|
||||
char_lookups = [lookup[0] for lookup in form.fields['name'].widget.lookups]
|
||||
self.assertIn('empty_true', char_lookups)
|
||||
self.assertIn('empty_false', char_lookups)
|
||||
|
||||
# IntegerField should have empty_true and empty_false
|
||||
int_lookups = [lookup[0] for lookup in form.fields['count'].widget.lookups]
|
||||
self.assertIn('empty_true', int_lookups)
|
||||
self.assertIn('empty_false', int_lookups)
|
||||
|
||||
# DateField should have empty_true and empty_false
|
||||
date_lookups = [lookup[0] for lookup in form.fields['created'].widget.lookups]
|
||||
self.assertIn('empty_true', date_lookups)
|
||||
self.assertIn('empty_false', date_lookups)
|
||||
|
||||
def test_char_field_includes_extended_lookups(self):
|
||||
"""CharField should include negation, iexact, and regex lookups."""
|
||||
class TestForm(FilterModifierMixin, forms.Form):
|
||||
name = forms.CharField(required=False)
|
||||
|
||||
form = TestForm()
|
||||
|
||||
char_lookups = [lookup[0] for lookup in form.fields['name'].widget.lookups]
|
||||
self.assertIn('n', char_lookups) # negation
|
||||
self.assertIn('ie', char_lookups) # iexact
|
||||
self.assertIn('regex', char_lookups) # regex
|
||||
self.assertIn('iregex', char_lookups) # case-insensitive regex
|
||||
|
||||
def test_numeric_fields_include_negation(self):
|
||||
"""IntegerField and DecimalField should include negation lookup."""
|
||||
class TestForm(FilterModifierMixin, forms.Form):
|
||||
count = forms.IntegerField(required=False)
|
||||
weight = forms.DecimalField(required=False)
|
||||
|
||||
form = TestForm()
|
||||
|
||||
int_lookups = [lookup[0] for lookup in form.fields['count'].widget.lookups]
|
||||
self.assertIn('n', int_lookups)
|
||||
|
||||
decimal_lookups = [lookup[0] for lookup in form.fields['weight'].widget.lookups]
|
||||
self.assertIn('n', decimal_lookups)
|
||||
|
||||
def test_date_field_includes_negation(self):
|
||||
"""DateField should include negation lookup."""
|
||||
class TestForm(FilterModifierMixin, forms.Form):
|
||||
created = forms.DateField(required=False)
|
||||
|
||||
form = TestForm()
|
||||
|
||||
date_lookups = [lookup[0] for lookup in form.fields['created'].widget.lookups]
|
||||
self.assertIn('n', date_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())
|
||||
|
||||
|
||||
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())
|
||||
Loading…
Reference in New Issue
Block a user