Files
netbox/netbox/utilities/forms/widgets/modifiers.py

158 lines
6.8 KiB
Python

from django import forms
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from utilities.forms.widgets.apiselect import APISelect, APISelectMultiple
__all__ = (
'MODIFIER_EMPTY_FALSE',
'MODIFIER_EMPTY_TRUE',
'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, 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 modifier: return None so the underlying field does not
# attempt to validate 'true'/'false' as a field value (e.g. a model PK). The
# `__empty` query parameter is consumed directly by the filterset and by
# `applied_filters`, so no value from the field itself is needed here.
empty_param = f"{name}__empty"
if empty_param in data:
return None
# 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)
# For APISelect/APISelectMultiple widgets, temporarily clear choices to prevent queryset evaluation
original_choices = None
if isinstance(self.original_widget, (APISelect, APISelectMultiple)):
original_choices = self.original_widget.choices
# Only keep selected choices to preserve the current selection in HTML
if value:
values = value if isinstance(value, (list, tuple)) else [value]
if hasattr(original_choices, 'queryset'):
# Extract valid PKs (exclude special null choice string)
pk_values = [v for v in values if v != settings.FILTERS_NULL_CHOICE_VALUE]
# Build a minimal choice list with just the selected values
choices = []
if pk_values:
try:
selected_objects = original_choices.queryset.filter(pk__in=pk_values)
choices = [(obj.pk, str(obj)) for obj in selected_objects]
except (ValueError, TypeError):
# pk_values may contain non-PK strings (e.g. 'true'/'false' from the
# empty modifier); silently skip rendering selected choices in that case.
pass
# Re-add the "None" option if it was selected via the null choice value
if settings.FILTERS_NULL_CHOICE_VALUE in values:
choices.append((settings.FILTERS_NULL_CHOICE_VALUE, settings.FILTERS_NULL_CHOICE_LABEL))
self.original_widget.choices = choices
else:
self.original_widget.choices = [choice for choice in original_choices if choice[0] in values]
else:
# No selection - render empty select element
self.original_widget.choices = []
# Get context from the original widget
original_context = self.original_widget.get_context(name, value, attrs)
# Restore original choices if we modified them
if original_choices is not None:
self.original_widget.choices = original_choices
# 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