mirror of
https://github.com/netbox-community/netbox.git
synced 2026-04-09 10:53:50 +02:00
158 lines
6.8 KiB
Python
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
|