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

1from django import forms 

2from django.conf import settings 

3from django.utils.translation import gettext_lazy as _ 

4 

5from utilities.forms.widgets.apiselect import APISelect, APISelectMultiple 

6 

7__all__ = ( 

8 'MODIFIER_EMPTY_FALSE', 

9 'MODIFIER_EMPTY_TRUE', 

10 'FilterModifierWidget', 

11) 

12 

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' 

17 

18 

19class FilterModifierWidget(forms.Widget): 

20 """ 

21 Wraps an existing widget to add a modifier dropdown for filter lookups. 

22 

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' 

27 

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', {})) 

38 

39 def value_from_datadict(self, data, files, name): 

40 """ 

41 Extract value from data, checking all possible lookup variants. 

42 

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. 

46 

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 

58 

59 # Try exact field name first 

60 value = self.original_widget.value_from_datadict(data, files, name) 

61 

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 

76 

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 

85 

86 # Return just the value for form validation 

87 return value 

88 

89 def get_context(self, name, value, attrs): 

90 """ 

91 Build context for template rendering. 

92 

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) 

101 

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 

106 

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] 

110 

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] 

114 

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 

125 

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)) 

129 

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 = [] 

136 

137 # Get context from the original widget 

138 original_context = self.original_widget.get_context(name, value, attrs) 

139 

140 # Restore original choices if we modified them 

141 if original_choices is not None: 

142 self.original_widget.choices = original_choices 

143 

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 

149 

150 # Default to 'exact' - JavaScript will update based on URL params 

151 context['widget']['current_modifier'] = 'exact' 

152 context['widget']['current_value'] = value or '' 

153 

154 # Translatable placeholder for empty lookups 

155 context['widget']['empty_placeholder'] = _('(automatically set)') 

156 

157 return context