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

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. 

5 

6The filters are generated via a specific config (cf FilterSet & Filter docs). 

7 

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

10 

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

16 

17from collections.abc import Callable, Collection, Iterable 

18from dataclasses import dataclass, field 

19from typing import Any, Literal 

20 

21from django.http import QueryDict 

22 

23FILTER_QUERY_PARAMETER_PREFIX = "_filter_" 

24 

25 

26def is_collection(value: Any) -> bool: 

27 """ 

28 Whether the given object is a "collection": a Collection other than a string. 

29 

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) 

35 

36 

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"] 

41 

42 

43@dataclass 

44class FilterValue: 

45 """ 

46 Interface for a filter value. 

47 """ 

48 

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) 

55 

56 

57@dataclass 

58class FieldGetter: 

59 """ 

60 Getter object. 

61 

62 Data & logic on how to retrieve a value from an item. 

63 Only one of `attr` or `func` should be given. 

64 """ 

65 

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) 

72 

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 

86 

87 

88@dataclass 

89class Filter: 

90 """ 

91 Interface for a filter. 

92 

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. 

105 

106 The item value and its associated name can be a collection 

107 (ex: `list` or `QuerySet`). 

108 

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

114 

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) 

129 

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) 

136 

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) 

143 

144 def set_active_value(self, value_list: list[IdValue] | list[str]) -> None: 

145 """ 

146 Set the active value on the filter. 

147 

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 

159 

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 

164 

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) 

171 

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) 

178 

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 

184 

185 return item_value 

186 

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) 

193 

194 # Handle collections. 

195 if is_collection(item_name): 

196 return [str(n) for n in item_name] 

197 

198 return str(item_name) 

199 

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] 

206 

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) 

215 

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 

220 

221 # Base case 

222 return item_value in filter.active_values 

223 except Exception: 

224 return False 

225 

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) 

231 

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

238 

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. 

242 

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] 

250 

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) 

256 

257 return 

258 

259 self._add_single_value(item_value, item_name) 

260 

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}" 

266 

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

272 

273 

274@dataclass 

275class FilterSet: 

276 """ 

277 FilterSet interface - Set of filters. 

278 

279 Filters together act as an AND condition. 

280 Multiple values of 1 filter act as an OR condition. 

281 

282 Using dataclasses enables exporting the filters config easily with the asdict 

283 built-in method. 

284 

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

290 

291 # FieldSet name for display 

292 id: str 

293 name: str = field(default="") 

294 filters: list[Filter] = field(default_factory=list) 

295 

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 

301 

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

311 

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) 

321 

322 def populate_filters(self, dataset: Iterable) -> None: 

323 """ 

324 Populate the filter values from the given dataset. 

325 

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 ) 

336 

337 except Exception: 

338 continue 

339 

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) 

349 

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

357 

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] 

364 

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 

371 

372 def filter(self, dataset: Iterable) -> Iterable: 

373 """ 

374 Filter the given dataset with the current active filters. 

375 

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] 

385 

386 for item in dataset: 

387 item_filtered = True 

388 

389 for filter in applied_filters: 

390 if not Filter.filter(filter, item): 

391 item_filtered = False 

392 break 

393 

394 if item_filtered: 

395 filtered_dataset.append(item) 

396 

397 return filtered_dataset