Coverage for src/backend/InvenTree/InvenTree/filters.py: 88%

103 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 17:47 +0000

1"""General filters for InvenTree.""" 

2 

3from datetime import datetime 

4 

5from django.conf import settings 

6from django.core.exceptions import ValidationError 

7from django.utils import timezone 

8from django.utils.timezone import make_aware 

9 

10import django_filters.rest_framework.backends as drf_backend 

11import django_filters.rest_framework.filters as rest_filters 

12from rest_framework import filters 

13 

14import InvenTree.helpers 

15 

16 

17class InvenTreeDateFilter(rest_filters.DateFilter): 

18 """Custom DateFilter class which handles timezones correctly.""" 

19 

20 def filter(self, qs, value): 

21 """Override the filter method to handle timezones correctly.""" 

22 if settings.USE_TZ and value is not None: 

23 tz = timezone.get_current_timezone() 

24 value = datetime(value.year, value.month, value.day) 

25 value = make_aware(value, timezone=tz) 

26 

27 return super().filter(qs, value) 

28 

29 

30class InvenTreeSearchFilter(filters.SearchFilter): 

31 """Custom search filter which allows adjusting of search terms dynamically.""" 

32 

33 def get_search_fields(self, view, request): 

34 """Return a set of search fields for the request, adjusted based on request params. 

35 

36 The following query params are available to 'augment' the search (in decreasing order of priority) 

37 - search_notes: If True, 'notes' is added to the search_fields if it isn't already present 

38 - search_regex: If True, search is performed on 'regex' comparison 

39 """ 

40 search_notes = InvenTree.helpers.str2bool( 

41 request.query_params.get('search_notes', False) 

42 ) 

43 

44 search_fields = super().get_search_fields(view, request) 

45 

46 if search_notes and 'notes' not in search_fields: 46 ↛ 48line 46 didn't jump to line 48 because the condition on line 46 was never true

47 # don't modify existing list, create a new object so further queries aren't affected 

48 search_fields = [*search_fields, 'notes'] 

49 

50 regex = InvenTree.helpers.str2bool( 

51 request.query_params.get('search_regex', False) 

52 ) 

53 

54 fields = [] 

55 

56 if search_fields: 

57 for field in search_fields: 

58 if regex: 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true

59 field = '$' + field 

60 

61 fields.append(field) 

62 

63 return fields 

64 

65 def get_search_terms(self, request): 

66 """Return the search terms for this search request. 

67 

68 Depending on the request parameters, we may "augment" these somewhat 

69 """ 

70 whole = InvenTree.helpers.str2bool( 

71 request.query_params.get('search_whole', False) 

72 ) 

73 

74 terms = [] 

75 

76 search_terms = super().get_search_terms(request) 

77 

78 if search_terms: 

79 for term in search_terms: 

80 term = term.strip() 

81 

82 if not term: 82 ↛ 84line 82 didn't jump to line 84 because the condition on line 82 was never true

83 # Ignore blank inputs 

84 continue 

85 

86 if whole: 86 ↛ 88line 86 didn't jump to line 88 because the condition on line 86 was never true

87 # Wrap the search term to enable word-boundary matching 

88 term = r'\y' + term + r'\y' 

89 

90 terms.append(term) 

91 

92 return terms 

93 

94 

95class InvenTreeOrderingFilter(filters.OrderingFilter): 

96 """Custom OrderingFilter class which allows aliased filtering of related fields. 

97 

98 To use, simply specify this filter in the "filter_backends" section. 

99 

100 filter_backends = [ 

101 InvenTreeOrderingFilter, 

102 ] 

103 

104 Then, specify a ordering_field_aliases attribute: 

105 

106 ordering_field_aliases = { 

107 'name': 'part__part__name', 

108 'SKU': 'part__SKU', 

109 } 

110 """ 

111 

112 def get_ordering(self, request, queryset, view): 

113 """Override ordering for supporting aliases.""" 

114 ordering = list(super().get_ordering(request, queryset, view) or []) 

115 

116 aliases = getattr(view, 'ordering_field_aliases', None) 

117 lookup_field = getattr(view, 'lookup_field', 'pk') 

118 lookup_reversed = len(ordering) > 0 and ordering[-1].startswith('-') 

119 

120 # Attempt to map ordering fields based on provided aliases 

121 if ordering is not None and aliases is not None: 

122 """Ordering fields should be mapped to separate fields.""" 

123 

124 ordering_initial = ordering 

125 ordering = [] 

126 

127 for field in ordering_initial: 

128 field_reversed = field.startswith('-') 

129 if field_reversed: 

130 field = field[1:] 

131 

132 # Are aliases defined for this field? 

133 alias = aliases.get(field, field) 

134 

135 """ 

136 Potentially, a single field could be "aliased" to multiple field, 

137 

138 (For example to enforce a particular ordering sequence) 

139 

140 e.g. to filter first by the integer value... 

141 

142 ordering_field_aliases = { 

143 "reference": ["integer_ref", "reference"] 

144 } 

145 

146 """ 

147 

148 if type(alias) is str: 

149 alias = [alias] 

150 elif type(alias) in [list, tuple]: 150 ↛ 154line 150 didn't jump to line 154 because the condition on line 150 was always true

151 pass 

152 else: 

153 # Unsupported alias type 

154 continue 

155 

156 for a in alias: 

157 if field_reversed: 

158 a = '-' + a 

159 

160 ordering.append(a) 

161 

162 # Ensure that any API filtering appends the primary-key field 

163 # This is to prevent "ambiguous ordering" errors across pagination boundaries 

164 # Ref: https://github.com/inventree/InvenTree/issues/11442 

165 if lookup_field and not any( 

166 field in ordering for field in [lookup_field, f'-{lookup_field}'] 

167 ): 

168 if lookup_reversed: 

169 ordering.append(f'-{lookup_field}') 

170 else: 

171 ordering.append(lookup_field) 

172 

173 return ordering 

174 

175 

176class NumberOrNullFilter(rest_filters.NumberFilter): 

177 """Custom NumberFilter that allows filtering by numeric values or the literal string "null". 

178 

179 This allows matching either numeric values or NULL values in the database. 

180 

181 Example Usage: 

182 ?my_field=20 → filters rows where my_field=20 

183 ?my_field=null → filters rows where my_field IS NULL 

184 """ 

185 

186 def filter(self, qs, value): 

187 """Return queryset filtered by value or NULL if 'null' is passed.""" 

188 if value == 'null': 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true

189 return qs.filter(**{self.field_name: None}) 

190 return super().filter(qs, value) 

191 

192 @property 

193 def field(self): 

194 """Allow 'null' as valid input in filter parameters.""" 

195 field = super().field 

196 original_clean = field.clean 

197 

198 def custom_clean(val): 

199 """Custom clean function for filter input values.""" 

200 if InvenTree.helpers.isNull(val) and val is not None: 200 ↛ 201line 200 didn't jump to line 201 because the condition on line 200 was never true

201 return 'null' 

202 return original_clean(val) 

203 

204 field.clean = custom_clean 

205 return field 

206 

207 

208class NumericInFilter(rest_filters.BaseInFilter): 

209 """A filter that only accepts numeric values for 'in' queries. 

210 

211 This filter ensures that all provided values can be converted to integers 

212 before passing them to the parent filter. Any non-numeric values will 

213 be ignored (or optionally, a ValidationError can be raised). 

214 """ 

215 

216 def filter(self, qs, value): 

217 """Filter the queryset based on numeric values only.""" 

218 if not value: 

219 return qs 

220 

221 # Check that all values are numeric 

222 numeric_values = [] 

223 for v in value: 

224 try: 

225 numeric_values.append(int(v)) 

226 except (ValueError, TypeError): 

227 raise ValidationError(f"'{v}' is not a valid number") 

228 

229 if not numeric_values: 229 ↛ 230line 229 didn't jump to line 230 because the condition on line 229 was never true

230 return qs 

231 

232 return super().filter(qs, numeric_values) 

233 

234 

235ORDER_FILTER = [drf_backend.DjangoFilterBackend, InvenTreeOrderingFilter] 

236 

237SEARCH_ORDER_FILTER = [ 

238 drf_backend.DjangoFilterBackend, 

239 InvenTreeSearchFilter, 

240 InvenTreeOrderingFilter, 

241]