Coverage for src/mesh/model/filters.py: 90%
153 statements
« prev ^ index » next coverage.py v7.7.0, created at 2025-04-28 07:45 +0000
« prev ^ index » next coverage.py v7.7.0, created at 2025-04-28 07:45 +0000
1"""
2MESH filters.
3This offer a generic implementation of filters for any dataset.
4This was made expecting a queryset but any iterable dataset can be filtered.
6The filters are generated via a specific config (cf FilterSet & Filter docs).
8This should be used with the `filter_set.html` template and it requires a bit of
9Javascript located in `mesh.js` (this can be externalized for a full component behavior).
11Usage:
12 - Create a FilterSet instance with as many Filter instance as required.
13 - Initialize the filters by calling `filter_set.init_filters(dataset, request.GET)`
14 - Filter your data by calling `filtered_data = filter_set.filter(dataset)`
15"""
17from collections.abc import Callable, Collection, Iterable
18from dataclasses import dataclass, field
19from typing import Any, Literal
21from django.http import QueryDict
23FILTER_QUERY_PARAMETER_PREFIX = "_filter_"
26def is_collection(value: Any) -> bool:
27 """
28 Whether the given object is a "collection": a Collection other than a string.
30 What we actually want to check is whether the value is a single value or
31 values such as a list, set, dict, QuerySet, ...
32 TODO: this might fail for lazy strings ?
33 """
34 return isinstance(value, Collection) and not isinstance(value, str)
37## Type aliases
38IdValue = int | str
39# Available filter types. Any other type will be handled as the default "string" type.
40FilterType = Literal["string", "int", "model"]
43@dataclass
44class FilterValue:
45 """
46 Interface for a filter value.
47 """
49 # Actual filter value ~~ ID
50 value: IdValue
51 # Name to display for the filter value
52 name: str
53 # Whether the filter value is active (ie. selected).
54 active: bool = field(default=False)
57@dataclass
58class FieldGetter:
59 """
60 Getter object.
62 Data & logic on how to retrieve a value from an item.
63 Only one of `attr` or `func` should be given.
64 """
66 # The attribute name to look for in the given item.
67 attr: str = field(default="")
68 # Whether the attribute is a callable. TODO: Automate this ? By checking if callable
69 callable: bool = field(default=False)
70 # A function with one parameter (the item) to be called to get the value.
71 func: Callable | None = field(default=None)
73 def get_value(self, item: Any) -> Any:
74 """
75 Get the given item's value.
76 """
77 if self.attr: 77 ↛ 81line 77 didn't jump to line 81 because the condition on line 77 was always true
78 value = getattr(item, self.attr)
79 if self.callable:
80 value = value()
81 elif self.func:
82 value = self.func(item)
83 else:
84 value = None
85 return value
88@dataclass
89class Filter:
90 """
91 Interface for a filter.
93 About the retrieval of the filter value & name for an item:
94 - Default: the filter ID is the direct attribute of the item
95 (not callable) that holds the value. Same value is used for
96 the value associated name.
97 - `getter`: Use this field to indicate a different behavior to retrieve
98 the value. This is used for both the value and its
99 associated name unless one of the below is defined.
100 Cf. FieldGetter for how to define a getter.
101 - `value_getter`: Override the `getter`, if any, for retrieving the value
102 only.
103 - `name_getter`: Override the `getter`, if any, for retrieving the value's
104 associated name only.
106 The item value and its associated name can be a collection
107 (ex: `list` or `QuerySet`).
109 Specific type:
110 - "model": When the filter is a Django model. The item value should be
111 a model instance. The default associated name is the casting
112 of the instance to string (`__str__` method).
113 """
115 # Filter ID
116 id: str
117 # Filter name for display
118 name: str
119 # Common getter function for both value and name
120 getter: FieldGetter | None = field(default=None)
121 # Getter function only for the value
122 value_getter: FieldGetter | None = field(default=None)
123 # Getter function only for the name
124 name_getter: FieldGetter | None = field(default=None)
125 # Filter type - Impacts the handling of an item's value
126 type: FilterType = field(default="string")
127 # The available values of the Filter
128 values: list[FilterValue] = field(default_factory=list)
130 @property
131 def value_field(self) -> FieldGetter:
132 """
133 Return the config indicating how to retrieve an item value for this filter.
134 """
135 return self.value_getter or self.getter or FieldGetter(attr=self.id)
137 @property
138 def name_field(self) -> FieldGetter:
139 """
140 Return the config indicating how to retrieve an item value associated name.
141 """
142 return self.name_getter or self.getter or FieldGetter(attr=self.id)
144 def set_active_value(self, value_list: list[IdValue] | list[str]) -> None:
145 """
146 Set the active value on the filter.
148 Casts to correct type according to the filter type.
149 The matching is made on the filter value (ID).
150 """
151 for v in value_list:
152 try:
153 if self.type in ["model", "int"]:
154 value = int(v)
155 else:
156 value = v
157 except Exception:
158 continue
160 # Flag the matching value as active
161 match_value = next((v for v in self.values if v.value == value), None)
162 if match_value: 162 ↛ 151line 162 didn't jump to line 151 because the condition on line 162 was always true
163 match_value.active = True
165 @property
166 def active(self) -> bool:
167 """
168 Whether the filter is active (= at least 1 active value).
169 """
170 return next((True for v in self.values if v.active), False)
172 @staticmethod
173 def get_item_value(filter: "Filter", item: Any) -> list[IdValue] | IdValue | None:
174 """
175 Get the item value corresponding to the given filter config.
176 """
177 item_value = filter.value_field.get_value(item)
179 # Handle "model" type
180 if filter.type == "model":
181 if is_collection(item_value):
182 return [i.pk for i in item_value]
183 return item_value.pk
185 return item_value
187 @staticmethod
188 def get_item_name(filter: "Filter", item: Any) -> list[str] | str:
189 """
190 Get the item value's name corresponding to the given filter config.
191 """
192 item_name = filter.name_field.get_value(item)
194 # Handle collections.
195 if is_collection(item_name):
196 return [str(n) for n in item_name]
198 return str(item_name)
200 @property
201 def active_values(self) -> list[IdValue]:
202 """
203 The list of active values (as `IdValue`, ie. `str` or `int`)
204 """
205 return [v.value for v in self.values if v.active]
207 @staticmethod
208 def filter(filter: "Filter", item: Any) -> bool:
209 """
210 Whether the given item matches the given filter values.
211 """
212 try:
213 # Get the item value
214 item_value = Filter.get_item_value(filter, item)
216 # Check that the value is within the applied values
217 # Case of a colletion
218 if is_collection(item_value): 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true
219 return any(v in filter.active_values for v in item_value) # type:ignore
221 # Base case
222 return item_value in filter.active_values
223 except Exception:
224 return False
226 def value_exists(self, value: IdValue) -> bool:
227 """
228 Whether the given value is already present.
229 """
230 return next((True for v in self.values if v.value == value), False)
232 def _add_single_value(self, item_value: Any, item_name: str) -> None:
233 """
234 Add a single value to the filter's values if not already present.
235 """
236 if item_value not in [None, 0, ""] and not self.value_exists(item_value):
237 self.values.append(FilterValue(value=item_value, name=item_name))
239 def add_value(self, item_value: Any, item_name: Any) -> None:
240 """
241 Add the given value and associated name to the filter values.
243 The value and the name can be collections.
244 """
245 # Vectorize if the value is a collection
246 if is_collection(item_value):
247 # Check that the item_name is also an iterable, otherwise cast to `list`
248 if not is_collection(item_name): 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true
249 item_name = [item_name]
251 # If both iterable are not on same length, we will miss some values.
252 # Nothing to do here, just fix the getting of value & name so that
253 # their length match.
254 for v, n in zip(item_value, item_name): 254 ↛ 255line 254 didn't jump to line 255 because the loop on line 254 never started
255 self._add_single_value(v, n)
257 return
259 self._add_single_value(item_value, item_name)
261 def get_query_param(self) -> str:
262 """
263 Returns the query param corresponding to this filter.
264 """
265 return f"{FILTER_QUERY_PARAMETER_PREFIX}{self.id}"
267 def sort_values(self) -> None:
268 """
269 Sort alphabetically the filter values and place active values first.
270 """
271 self.values.sort(key=lambda v: (not v.active, v.name))
274@dataclass
275class FilterSet:
276 """
277 FilterSet interface - Set of filters.
279 Filters together act as an AND condition.
280 Multiple values of 1 filter act as an OR condition.
282 Using dataclasses enables exporting the filters config easily with the asdict
283 built-in method.
285 WARNING: Watch performance. We use standard Python for loops everywhere to populate
286 and filter the data. This is probably OK if the involved datasets are < 1K entries.
287 If this becomes problematic, we might want to translate it to much faster JavaScript
288 (but quite a bummer to implement without a framework).
289 """
291 # FieldSet name for display
292 id: str
293 name: str = field(default="")
294 filters: list[Filter] = field(default_factory=list)
296 def init_filters(self, query_dict: QueryDict, *args: Iterable):
297 """
298 Initialize the filter set with the given dataset and query dictionnary:
299 - populate all filters from the given data
300 - get the active filters from the query dict
302 Params:
303 - `query_dict` The query dictionnary
304 - `args` An arbitrary number of datasets used to populate
305 the data.
306 """
307 for dataset in args:
308 self.populate_filters(dataset)
309 self.parse_query_filters(query_dict)
310 self.sort_filters()
312 def get_filter(self, filter_id: str, prefix: str = "") -> Filter | None:
313 """
314 Retrieve the filter corresponding to the given filter ID.
315 """
316 if prefix:
317 if not filter_id.startswith(prefix): 317 ↛ 318line 317 didn't jump to line 318 because the condition on line 317 was never true
318 return None
319 filter_id = filter_id.removeprefix(prefix)
320 return next((f for f in self.filters if f.id == filter_id), None)
322 def populate_filters(self, dataset: Iterable) -> None:
323 """
324 Populate the filter values from the given dataset.
326 The item value can be an iterable. In that case we create one FilterValue
327 for each item value.
328 """
329 for filter in self.filters:
330 for item in dataset:
331 try:
332 filter.add_value(
333 Filter.get_item_value(filter, item),
334 Filter.get_item_name(filter, item),
335 )
337 except Exception:
338 continue
340 def parse_query_filters(self, query_dict: QueryDict) -> None:
341 """
342 Parse and fill the active filters from the given query dictionnary.
343 """
344 for key, values_list in query_dict.lists():
345 filter = self.get_filter(key, prefix=FILTER_QUERY_PARAMETER_PREFIX)
346 if not filter: 346 ↛ 347line 346 didn't jump to line 347 because the condition on line 346 was never true
347 continue
348 filter.set_active_value(values_list)
350 def sort_filters(self) -> None:
351 """
352 Sort the filters by name and sort each filter's values.
353 """
354 self.filters.sort(key=lambda f: f.name)
355 for filter in self.filters:
356 filter.sort_values()
358 @property
359 def applied_filters(self) -> list[Filter]:
360 """
361 The list of applied/active filters.
362 """
363 return [f for f in self.filters if f.active]
365 @property
366 def active(self) -> bool:
367 """
368 Whether the FilterSet is active (= at least 1 applied/active filter).
369 """
370 return len(self.applied_filters) > 0
372 def filter(self, dataset: Iterable) -> Iterable:
373 """
374 Filter the given dataset with the current active filters.
376 TODO: Do it together with the populate_filters method ? To end with
377 only 1 iteration over the full dataset.
378 OR cache the result tuple (item_value, item_value_name) for each filter
379 directly in the dataset to avoid "computing" the values multiple times.
380 """
381 filtered_dataset = []
382 applied_filters = self.applied_filters
383 if not applied_filters:
384 return [d for d in dataset]
386 for item in dataset:
387 item_filtered = True
389 for filter in applied_filters:
390 if not Filter.filter(filter, item):
391 item_filtered = False
392 break
394 if item_filtered:
395 filtered_dataset.append(item)
397 return filtered_dataset