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
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 17:47 +0000
1"""General filters for InvenTree."""
3from datetime import datetime
5from django.conf import settings
6from django.core.exceptions import ValidationError
7from django.utils import timezone
8from django.utils.timezone import make_aware
10import django_filters.rest_framework.backends as drf_backend
11import django_filters.rest_framework.filters as rest_filters
12from rest_framework import filters
14import InvenTree.helpers
17class InvenTreeDateFilter(rest_filters.DateFilter):
18 """Custom DateFilter class which handles timezones correctly."""
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)
27 return super().filter(qs, value)
30class InvenTreeSearchFilter(filters.SearchFilter):
31 """Custom search filter which allows adjusting of search terms dynamically."""
33 def get_search_fields(self, view, request):
34 """Return a set of search fields for the request, adjusted based on request params.
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 )
44 search_fields = super().get_search_fields(view, request)
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']
50 regex = InvenTree.helpers.str2bool(
51 request.query_params.get('search_regex', False)
52 )
54 fields = []
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
61 fields.append(field)
63 return fields
65 def get_search_terms(self, request):
66 """Return the search terms for this search request.
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 )
74 terms = []
76 search_terms = super().get_search_terms(request)
78 if search_terms:
79 for term in search_terms:
80 term = term.strip()
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
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'
90 terms.append(term)
92 return terms
95class InvenTreeOrderingFilter(filters.OrderingFilter):
96 """Custom OrderingFilter class which allows aliased filtering of related fields.
98 To use, simply specify this filter in the "filter_backends" section.
100 filter_backends = [
101 InvenTreeOrderingFilter,
102 ]
104 Then, specify a ordering_field_aliases attribute:
106 ordering_field_aliases = {
107 'name': 'part__part__name',
108 'SKU': 'part__SKU',
109 }
110 """
112 def get_ordering(self, request, queryset, view):
113 """Override ordering for supporting aliases."""
114 ordering = list(super().get_ordering(request, queryset, view) or [])
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('-')
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."""
124 ordering_initial = ordering
125 ordering = []
127 for field in ordering_initial:
128 field_reversed = field.startswith('-')
129 if field_reversed:
130 field = field[1:]
132 # Are aliases defined for this field?
133 alias = aliases.get(field, field)
135 """
136 Potentially, a single field could be "aliased" to multiple field,
138 (For example to enforce a particular ordering sequence)
140 e.g. to filter first by the integer value...
142 ordering_field_aliases = {
143 "reference": ["integer_ref", "reference"]
144 }
146 """
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
156 for a in alias:
157 if field_reversed:
158 a = '-' + a
160 ordering.append(a)
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)
173 return ordering
176class NumberOrNullFilter(rest_filters.NumberFilter):
177 """Custom NumberFilter that allows filtering by numeric values or the literal string "null".
179 This allows matching either numeric values or NULL values in the database.
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 """
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)
192 @property
193 def field(self):
194 """Allow 'null' as valid input in filter parameters."""
195 field = super().field
196 original_clean = field.clean
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)
204 field.clean = custom_clean
205 return field
208class NumericInFilter(rest_filters.BaseInFilter):
209 """A filter that only accepts numeric values for 'in' queries.
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 """
216 def filter(self, qs, value):
217 """Filter the queryset based on numeric values only."""
218 if not value:
219 return qs
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")
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
232 return super().filter(qs, numeric_values)
235ORDER_FILTER = [drf_backend.DjangoFilterBackend, InvenTreeOrderingFilter]
237SEARCH_ORDER_FILTER = [
238 drf_backend.DjangoFilterBackend,
239 InvenTreeSearchFilter,
240 InvenTreeOrderingFilter,
241]