Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/grids/filters.py: 100%

142 statements  

« prev     ^ index     » next       coverage.py v7.6.1, created at 2024-08-23 14:51 -0500

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Grid Filters 

25""" 

26 

27import logging 

28 

29import sqlalchemy as sa 

30 

31from wuttjamaican.util import UNSPECIFIED 

32 

33 

34log = logging.getLogger(__name__) 

35 

36 

37class VerbNotSupported(Exception): 

38 """ """ 

39 

40 def __init__(self, verb): 

41 self.verb = verb 

42 

43 def __str__(self): 

44 return f"unknown filter verb not supported: {self.verb}" 

45 

46 

47class GridFilter: 

48 """ 

49 Filter option for a grid. Represents both the "features" as well 

50 as "state" for the filter. 

51 

52 :param request: Current :term:`request` object. 

53 

54 :param model_property: Property of a model class, representing the 

55 column by which to filter. For instance, 

56 ``model.Person.full_name``. 

57 

58 :param \**kwargs: Any additional kwargs will be set as attributes 

59 on the filter instance. 

60 

61 Filter instances have the following attributes: 

62 

63 .. attribute:: key 

64 

65 Unique key for the filter. This often corresponds to a "column 

66 name" for the grid, but not always. 

67 

68 .. attribute:: label 

69 

70 Display label for the filter field. 

71 

72 .. attribute:: active 

73 

74 Boolean indicating whether the filter is currently active. 

75 

76 See also :attr:`verb` and :attr:`value`. 

77 

78 .. attribute:: verb 

79 

80 Verb for current filter, if :attr:`active` is true. 

81 

82 See also :attr:`value`. 

83 

84 .. attribute:: value 

85 

86 Value for current filter, if :attr:`active` is true. 

87 

88 See also :attr:`verb`. 

89 

90 .. attribute:: default_active 

91 

92 Boolean indicating whether the filter should be active by 

93 default, i.e. when first displaying the grid. 

94 

95 See also :attr:`default_verb` and :attr:`default_value`. 

96 

97 .. attribute:: default_verb 

98 

99 Filter verb to use by default. This will be auto-selected when 

100 the filter is first activated, or when first displaying the 

101 grid if :attr:`default_active` is true. 

102 

103 See also :attr:`default_value`. 

104 

105 .. attribute:: default_value 

106 

107 Filter value to use by default. This will be auto-populated 

108 when the filter is first activated, or when first displaying 

109 the grid if :attr:`default_active` is true. 

110 

111 See also :attr:`default_verb`. 

112 """ 

113 default_verbs = ['equal', 'not_equal'] 

114 

115 default_verb_labels = { 

116 'is_any': "is any", 

117 'equal': "equal to", 

118 'not_equal': "not equal to", 

119 'is_null': "is null", 

120 'is_not_null': "is not null", 

121 'is_true': "is true", 

122 'is_false': "is false", 

123 'contains': "contains", 

124 'does_not_contain': "does not contain", 

125 } 

126 

127 valueless_verbs = [ 

128 'is_any', 

129 'is_null', 

130 'is_not_null', 

131 'is_true', 

132 'is_false', 

133 ] 

134 

135 def __init__( 

136 self, 

137 request, 

138 key, 

139 label=None, 

140 verbs=None, 

141 default_active=False, 

142 default_verb=None, 

143 default_value=None, 

144 **kwargs, 

145 ): 

146 self.request = request 

147 self.key = key 

148 self.config = self.request.wutta_config 

149 self.app = self.config.get_app() 

150 self.label = label or self.app.make_title(self.key) 

151 

152 # active 

153 self.default_active = default_active 

154 self.active = self.default_active 

155 

156 # verb 

157 if verbs is not None: 

158 self.verbs = verbs 

159 if default_verb: 

160 self.default_verb = default_verb 

161 

162 # value 

163 self.default_value = default_value 

164 self.value = self.default_value 

165 

166 self.__dict__.update(kwargs) 

167 

168 def __repr__(self): 

169 verb = getattr(self, 'verb', None) 

170 return (f"{self.__class__.__name__}(" 

171 f"key='{self.key}', " 

172 f"active={self.active}, " 

173 f"verb={repr(verb)}, " 

174 f"value={repr(self.value)})") 

175 

176 def get_verbs(self): 

177 """ 

178 Returns the list of verbs supported by the filter. 

179 """ 

180 verbs = None 

181 

182 if hasattr(self, 'verbs'): 

183 verbs = self.verbs 

184 

185 else: 

186 verbs = self.default_verbs 

187 

188 if callable(verbs): 

189 verbs = verbs() 

190 verbs = list(verbs) 

191 

192 if self.nullable: 

193 if 'is_null' not in verbs: 

194 verbs.append('is_null') 

195 if 'is_not_null' not in verbs: 

196 verbs.append('is_not_null') 

197 

198 if 'is_any' not in verbs: 

199 verbs.append('is_any') 

200 

201 return verbs 

202 

203 def get_verb_labels(self): 

204 """ 

205 Returns a dict of all defined verb labels. 

206 """ 

207 # TODO: should traverse hierarchy 

208 labels = dict([(verb, verb) for verb in self.get_verbs()]) 

209 labels.update(self.default_verb_labels) 

210 return labels 

211 

212 def get_valueless_verbs(self): 

213 """ 

214 Returns a list of verb names which do not need a value. 

215 """ 

216 return self.valueless_verbs 

217 

218 def get_default_verb(self): 

219 """ 

220 Returns the default verb for the filter. 

221 """ 

222 verb = None 

223 

224 if hasattr(self, 'default_verb'): 

225 verb = self.default_verb 

226 

227 elif hasattr(self, 'verb'): 

228 verb = self.verb 

229 

230 if not verb: 

231 verbs = self.get_verbs() 

232 if verbs: 

233 verb = verbs[0] 

234 

235 return verb 

236 

237 def apply_filter(self, data, verb=None, value=UNSPECIFIED): 

238 """ 

239 Filter the given data set according to a verb/value pair. 

240 

241 If verb and/or value are not specified, will use :attr:`verb` 

242 and/or :attr:`value` instead. 

243 

244 This method does not directly filter the data; rather it 

245 delegates (based on ``verb``) to some other method. The 

246 latter may choose *not* to filter the data, e.g. if ``value`` 

247 is empty, in which case this may return the original data set 

248 unchanged. 

249 

250 :returns: The (possibly) filtered data set. 

251 """ 

252 if verb is None: 

253 verb = self.verb 

254 if not verb: 

255 verb = self.get_default_verb() 

256 log.warn("missing verb for '%s' filter, will use default verb: %s", 

257 self.key, verb) 

258 

259 # only attempt for known verbs 

260 if verb not in self.get_verbs(): 

261 raise VerbNotSupported(verb) 

262 

263 # fallback value 

264 if value is UNSPECIFIED: 

265 value = self.value 

266 

267 # locate filter method 

268 func = getattr(self, f'filter_{verb}', None) 

269 if not func: 

270 raise VerbNotSupported(verb) 

271 

272 # invoke filter method 

273 return func(data, value) 

274 

275 def filter_is_any(self, data, value): 

276 """ 

277 This is a no-op which always ignores the value and returns the 

278 data as-is. 

279 """ 

280 return data 

281 

282 

283class AlchemyFilter(GridFilter): 

284 """ 

285 Filter option for a grid with SQLAlchemy query data. 

286 

287 This is a subclass of :class:`GridFilter`. It requires a 

288 ``model_property`` to know how to filter the query. 

289 

290 :param model_property: Property of a model class, representing the 

291 column by which to filter. For instance, 

292 ``model.Person.full_name``. 

293 

294 :param nullable: Boolean indicating whether the filter should 

295 include ``is_null`` and ``is_not_null`` verbs. If not 

296 specified, the column will be inspected and use its nullable 

297 flag. 

298 """ 

299 

300 def __init__(self, *args, **kwargs): 

301 nullable = kwargs.pop('nullable', None) 

302 super().__init__(*args, **kwargs) 

303 

304 self.nullable = nullable 

305 if self.nullable is None: 

306 columns = self.model_property.prop.columns 

307 if len(columns) == 1: 

308 self.nullable = columns[0].nullable 

309 

310 def coerce_value(self, value): 

311 """ 

312 Coerce the given value to the correct type/format for use with 

313 the filter. 

314 

315 Default logic returns value as-is; subclass may override. 

316 """ 

317 return value 

318 

319 def filter_equal(self, query, value): 

320 """ 

321 Filter data with an equal (``=``) condition. 

322 """ 

323 value = self.coerce_value(value) 

324 if value is None: 

325 return query 

326 

327 return query.filter(self.model_property == value) 

328 

329 def filter_not_equal(self, query, value): 

330 """ 

331 Filter data with a not equal (``!=``) condition. 

332 """ 

333 value = self.coerce_value(value) 

334 if value is None: 

335 return query 

336 

337 # sql probably excludes null values from results, but user 

338 # probably does not expect that, so explicitly include them. 

339 return query.filter(sa.or_( 

340 self.model_property == None, 

341 self.model_property != value, 

342 )) 

343 

344 def filter_is_null(self, query, value): 

345 """ 

346 Filter data with an ``IS NULL`` query. The value is ignored. 

347 """ 

348 return query.filter(self.model_property == None) 

349 

350 def filter_is_not_null(self, query, value): 

351 """ 

352 Filter data with an ``IS NOT NULL`` query. The value is 

353 ignored. 

354 """ 

355 return query.filter(self.model_property != None) 

356 

357 

358class StringAlchemyFilter(AlchemyFilter): 

359 """ 

360 SQLAlchemy filter option for a text data column. 

361 

362 Subclass of :class:`AlchemyFilter`. 

363 """ 

364 default_verbs = ['contains', 'does_not_contain', 

365 'equal', 'not_equal'] 

366 

367 def coerce_value(self, value): 

368 """ """ 

369 if value is not None: 

370 value = str(value) 

371 if value: 

372 return value 

373 

374 def filter_contains(self, query, value): 

375 """ 

376 Filter data with an ``ILIKE`` condition. 

377 """ 

378 value = self.coerce_value(value) 

379 if not value: 

380 return query 

381 

382 criteria = [] 

383 for val in value.split(): 

384 val = val.replace('_', r'\_') 

385 val = f'%{val}%' 

386 criteria.append(self.model_property.ilike(val)) 

387 

388 return query.filter(sa.and_(*criteria)) 

389 

390 def filter_does_not_contain(self, query, value): 

391 """ 

392 Filter data with a ``NOT ILIKE`` condition. 

393 """ 

394 value = self.coerce_value(value) 

395 if not value: 

396 return query 

397 

398 criteria = [] 

399 for val in value.split(): 

400 val = val.replace('_', r'\_') 

401 val = f'%{val}%' 

402 criteria.append(~self.model_property.ilike(val)) 

403 

404 # sql probably excludes null values from results, but user 

405 # probably does not expect that, so explicitly include them. 

406 return query.filter(sa.or_( 

407 self.model_property == None, 

408 sa.and_(*criteria))) 

409 

410 

411class BooleanAlchemyFilter(AlchemyFilter): 

412 """ 

413 SQLAlchemy filter option for a boolean data column. 

414 

415 Subclass of :class:`AlchemyFilter`. 

416 """ 

417 default_verbs = ['is_true', 'is_false'] 

418 

419 def coerce_value(self, value): 

420 """ """ 

421 if value is not None: 

422 return bool(value) 

423 

424 def filter_is_true(self, query, value): 

425 """ 

426 Filter data with an "is true" condition. The value is 

427 ignored. 

428 """ 

429 return query.filter(self.model_property == True) 

430 

431 def filter_is_false(self, query, value): 

432 """ 

433 Filter data with an "is false" condition. The value is 

434 ignored. 

435 """ 

436 return query.filter(self.model_property == False) 

437 

438 

439default_sqlalchemy_filters = { 

440 None: AlchemyFilter, 

441 sa.String: StringAlchemyFilter, 

442 sa.Text: StringAlchemyFilter, 

443 sa.Boolean: BooleanAlchemyFilter, 

444}