Coverage for utilities/forms/widgets/modifiers.py: 12%
68 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1from django import forms
2from django.conf import settings
3from django.utils.translation import gettext_lazy as _
5from utilities.forms.widgets.apiselect import APISelect, APISelectMultiple
7__all__ = (
8 'MODIFIER_EMPTY_FALSE',
9 'MODIFIER_EMPTY_TRUE',
10 'FilterModifierWidget',
11)
13# Modifier codes for empty/null checking
14# These map to Django's 'empty' lookup: field__empty=true/false
15MODIFIER_EMPTY_TRUE = 'empty_true'
16MODIFIER_EMPTY_FALSE = 'empty_false'
19class FilterModifierWidget(forms.Widget):
20 """
21 Wraps an existing widget to add a modifier dropdown for filter lookups.
23 The original widget's semantics (name, id, attributes) are preserved.
24 The modifier dropdown controls which lookup type is used (exact, contains, etc.).
25 """
26 template_name = 'widgets/filter_modifier.html'
28 def __init__(self, widget, lookups, attrs=None):
29 """
30 Args:
31 widget: The widget being wrapped (e.g., TextInput, NumberInput)
32 lookups: List of (lookup_code, label) tuples (e.g., [('exact', 'Is'), ('ic', 'Contains')])
33 attrs: Additional widget attributes
34 """
35 self.original_widget = widget
36 self.lookups = lookups
37 super().__init__(attrs or getattr(widget, 'attrs', {}))
39 def value_from_datadict(self, data, files, name):
40 """
41 Extract value from data, checking all possible lookup variants.
43 When form redisplays after validation error, the data may contain
44 serial__ic=test but the field is named serial. This method searches
45 all lookup variants to find the value.
47 Returns:
48 Just the value string for form validation. The modifier is reconstructed
49 during rendering from the query parameter names.
50 """
51 # Special handling for empty modifier: return None so the underlying field does not
52 # attempt to validate 'true'/'false' as a field value (e.g. a model PK). The
53 # `__empty` query parameter is consumed directly by the filterset and by
54 # `applied_filters`, so no value from the field itself is needed here.
55 empty_param = f"{name}__empty"
56 if empty_param in data:
57 return None
59 # Try exact field name first
60 value = self.original_widget.value_from_datadict(data, files, name)
62 # If not found, check all modifier variants
63 # Note: SelectMultiple returns [] (empty list) when not found, not None
64 if value is None or (isinstance(value, list) and len(value) == 0):
65 for lookup, _ in self.lookups:
66 if lookup == 'exact':
67 continue # Already checked above
68 # Skip empty_true/false variants - they're handled above
69 if lookup in (MODIFIER_EMPTY_TRUE, MODIFIER_EMPTY_FALSE):
70 continue
71 lookup_name = f"{name}__{lookup}"
72 test_value = self.original_widget.value_from_datadict(data, files, lookup_name)
73 if test_value is not None:
74 value = test_value
75 break
77 # Return None if no value found (prevents field appearing in changed_data)
78 # Handle all widget empty value representations
79 if value is None:
80 return None
81 if isinstance(value, str) and not value.strip():
82 return None
83 if isinstance(value, (list, tuple)) and len(value) == 0:
84 return None
86 # Return just the value for form validation
87 return value
89 def get_context(self, name, value, attrs):
90 """
91 Build context for template rendering.
93 Includes both the original widget's context and our modifier-specific data.
94 Note: value is now just a simple value (string/int/etc), not a dict.
95 The JavaScript initializeFromURL() will set the correct modifier dropdown
96 value based on URL parameters.
97 """
98 # Propagate any attrs set on the wrapper (like data-url from get_bound_field)
99 # to the original widget before rendering
100 self.original_widget.attrs.update(self.attrs)
102 # For APISelect/APISelectMultiple widgets, temporarily clear choices to prevent queryset evaluation
103 original_choices = None
104 if isinstance(self.original_widget, (APISelect, APISelectMultiple)):
105 original_choices = self.original_widget.choices
107 # Only keep selected choices to preserve the current selection in HTML
108 if value:
109 values = value if isinstance(value, (list, tuple)) else [value]
111 if hasattr(original_choices, 'queryset'):
112 # Extract valid PKs (exclude special null choice string)
113 pk_values = [v for v in values if v != settings.FILTERS_NULL_CHOICE_VALUE]
115 # Build a minimal choice list with just the selected values
116 choices = []
117 if pk_values:
118 try:
119 selected_objects = original_choices.queryset.filter(pk__in=pk_values)
120 choices = [(obj.pk, str(obj)) for obj in selected_objects]
121 except (ValueError, TypeError):
122 # pk_values may contain non-PK strings (e.g. 'true'/'false' from the
123 # empty modifier); silently skip rendering selected choices in that case.
124 pass
126 # Re-add the "None" option if it was selected via the null choice value
127 if settings.FILTERS_NULL_CHOICE_VALUE in values:
128 choices.append((settings.FILTERS_NULL_CHOICE_VALUE, settings.FILTERS_NULL_CHOICE_LABEL))
130 self.original_widget.choices = choices
131 else:
132 self.original_widget.choices = [choice for choice in original_choices if choice[0] in values]
133 else:
134 # No selection - render empty select element
135 self.original_widget.choices = []
137 # Get context from the original widget
138 original_context = self.original_widget.get_context(name, value, attrs)
140 # Restore original choices if we modified them
141 if original_choices is not None:
142 self.original_widget.choices = original_choices
144 # Build our wrapper context
145 context = super().get_context(name, value, attrs)
146 context['widget']['original_widget'] = original_context['widget']
147 context['widget']['lookups'] = self.lookups
148 context['widget']['field_name'] = name
150 # Default to 'exact' - JavaScript will update based on URL params
151 context['widget']['current_modifier'] = 'exact'
152 context['widget']['current_value'] = value or ''
154 # Translatable placeholder for empty lookups
155 context['widget']['empty_placeholder'] = _('(automatically set)')
157 return context