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

226 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2025-12-28 15:23 -0600

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

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

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2025 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 datetime 

28import logging 

29from collections import OrderedDict 

30 

31try: 

32 from enum import EnumType 

33except ImportError: # pragma: no cover 

34 # nb. python <= 3.10 

35 from enum import EnumMeta as EnumType 

36 

37import sqlalchemy as sa 

38 

39from wuttjamaican.util import UNSPECIFIED 

40 

41 

42log = logging.getLogger(__name__) 

43 

44 

45class VerbNotSupported(Exception): # pylint: disable=empty-docstring 

46 """ """ 

47 

48 def __init__(self, verb): 

49 self.verb = verb 

50 

51 def __str__(self): 

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

53 

54 

55class GridFilter: # pylint: disable=too-many-instance-attributes 

56 """ 

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

58 as "state" for the filter. 

59 

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

61 

62 :param nullable: Boolean indicating whether the filter should 

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

64 specified, the column will be inspected (if possible) and use 

65 its nullable flag. 

66 

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

68 on the filter instance. 

69 

70 Filter instances have the following attributes: 

71 

72 .. attribute:: key 

73 

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

75 name" for the grid, but not always. 

76 

77 .. attribute:: label 

78 

79 Display label for the filter field. 

80 

81 .. attribute:: data_type 

82 

83 Simplistic "data type" which the filter supports. So far this 

84 will be one of: 

85 

86 * ``'string'`` 

87 * ``'date'`` 

88 * ``'choice'`` 

89 

90 Note that this mainly applies to the "value input" used by the 

91 filter. There is no data type for boolean since it does not 

92 need a value input; the verb is enough. 

93 

94 .. attribute:: active 

95 

96 Boolean indicating whether the filter is currently active. 

97 

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

99 

100 .. attribute:: verb 

101 

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

103 

104 See also :attr:`value`. 

105 

106 .. attribute:: choices 

107 

108 OrderedDict of possible values for the filter. 

109 

110 This is safe to read from, but use :meth:`set_choices()` to 

111 update it. 

112 

113 .. attribute:: value 

114 

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

116 

117 See also :attr:`verb`. 

118 

119 .. attribute:: default_active 

120 

121 Boolean indicating whether the filter should be active by 

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

123 

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

125 

126 .. attribute:: default_verb 

127 

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

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

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

131 

132 See also :attr:`default_value`. 

133 

134 .. attribute:: default_value 

135 

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

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

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

139 

140 See also :attr:`default_verb`. 

141 """ 

142 

143 data_type = "string" 

144 default_verbs = ["equal", "not_equal"] 

145 

146 default_verb_labels = { 

147 "is_any": "is any", 

148 "equal": "equal to", 

149 "not_equal": "not equal to", 

150 "greater_than": "greater than", 

151 "greater_equal": "greater than or equal to", 

152 "less_than": "less than", 

153 "less_equal": "less than or equal to", 

154 # 'between': "between", 

155 "is_true": "is true", 

156 "is_false": "is false", 

157 "is_false_null": "is false or null", 

158 "is_null": "is null", 

159 "is_not_null": "is not null", 

160 "contains": "contains", 

161 "does_not_contain": "does not contain", 

162 } 

163 

164 valueless_verbs = [ 

165 "is_any", 

166 "is_true", 

167 "is_false", 

168 "is_false_null", 

169 "is_null", 

170 "is_not_null", 

171 ] 

172 

173 def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments 

174 self, 

175 request, 

176 key, 

177 label=None, 

178 verbs=None, 

179 choices=None, 

180 nullable=None, 

181 default_active=False, 

182 default_verb=None, 

183 default_value=None, 

184 **kwargs, 

185 ): 

186 self.request = request 

187 self.key = key 

188 self.config = self.request.wutta_config 

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

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

191 

192 # active 

193 self.default_active = default_active 

194 self.active = self.default_active 

195 

196 # verb 

197 if verbs is not None: 

198 self.verbs = verbs 

199 if default_verb: 

200 self.default_verb = default_verb 

201 self.verb = None # active verb is set later 

202 

203 # choices 

204 self.set_choices(choices or {}) 

205 

206 # nullable 

207 self.nullable = nullable 

208 

209 # value 

210 self.default_value = default_value 

211 self.value = self.default_value 

212 

213 self.__dict__.update(kwargs) 

214 

215 def __repr__(self): 

216 verb = getattr(self, "verb", None) 

217 return ( 

218 f"{self.__class__.__name__}(" 

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

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

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

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

223 ) 

224 

225 def get_verbs(self): 

226 """ 

227 Returns the list of verbs supported by the filter. 

228 """ 

229 verbs = None 

230 

231 if hasattr(self, "verbs"): 

232 verbs = self.verbs 

233 

234 else: 

235 verbs = self.default_verbs 

236 

237 if callable(verbs): 

238 verbs = verbs() 

239 verbs = list(verbs) 

240 

241 if self.nullable: 

242 if "is_null" not in verbs: 

243 verbs.append("is_null") 

244 if "is_not_null" not in verbs: 

245 verbs.append("is_not_null") 

246 

247 if "is_any" not in verbs: 

248 verbs.append("is_any") 

249 

250 return verbs 

251 

252 def get_verb_labels(self): 

253 """ 

254 Returns a dict of all defined verb labels. 

255 """ 

256 # TODO: should traverse hierarchy 

257 labels = {verb: verb for verb in self.get_verbs()} 

258 labels.update(self.default_verb_labels) 

259 return labels 

260 

261 def get_valueless_verbs(self): 

262 """ 

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

264 """ 

265 return self.valueless_verbs 

266 

267 def get_default_verb(self): 

268 """ 

269 Returns the default verb for the filter. 

270 """ 

271 verb = None 

272 

273 if hasattr(self, "default_verb"): 

274 verb = self.default_verb 

275 

276 elif hasattr(self, "verb"): 

277 verb = self.verb 

278 

279 if not verb: 

280 verbs = self.get_verbs() 

281 if verbs: 

282 verb = verbs[0] 

283 

284 return verb 

285 

286 def set_choices(self, choices): 

287 """ 

288 Set the value choices for the filter. 

289 

290 If ``choices`` is non-empty, it is passed to 

291 :meth:`normalize_choices()` and the result is assigned to 

292 :attr:`choices`. Also, the :attr:`data_type` is set to 

293 ``'choice'`` so the UI will present the value input as a 

294 dropdown. 

295 

296 But if ``choices`` is empty, :attr:`choices` is set to an 

297 empty dict, and :attr:`data_type` is set (back) to 

298 ``'string'``. 

299 

300 :param choices: Collection of "choices" or ``None``. 

301 """ 

302 if choices: 

303 self.choices = self.normalize_choices(choices) 

304 self.data_type = "choice" 

305 else: 

306 self.choices = {} 

307 self.data_type = "string" 

308 

309 def normalize_choices(self, choices): 

310 """ 

311 Normalize a collection of "choices" to standard ``OrderedDict``. 

312 

313 This is called automatically by :meth:`set_choices()`. 

314 

315 :param choices: A collection of "choices" in one of the following 

316 formats: 

317 

318 * :class:`python:enum.Enum` class 

319 * simple list, each value of which should be a string, 

320 which is assumed to be able to serve as both key and 

321 value (ordering of choices will be preserved) 

322 * simple dict, keys and values of which will define the 

323 choices (note that the final choices will be sorted by 

324 key!) 

325 * OrderedDict, keys and values of which will define the 

326 choices (ordering of choices will be preserved) 

327 

328 :rtype: :class:`python:collections.OrderedDict` 

329 """ 

330 normalized = choices 

331 

332 if isinstance(choices, EnumType): 

333 normalized = OrderedDict( 

334 [(member.name, member.value) for member in choices] 

335 ) 

336 

337 elif isinstance(choices, OrderedDict): 

338 normalized = choices 

339 

340 elif isinstance(choices, dict): 

341 normalized = OrderedDict([(key, choices[key]) for key in sorted(choices)]) 

342 

343 elif isinstance(choices, list): 

344 normalized = OrderedDict([(key, key) for key in choices]) 

345 

346 return normalized 

347 

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

349 """ 

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

351 

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

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

354 

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

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

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

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

359 unchanged. 

360 

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

362 """ 

363 if verb is None: 

364 verb = self.verb 

365 if not verb: 

366 verb = self.get_default_verb() 

367 log.warning( 

368 "missing verb for '%s' filter, will use default verb: %s", 

369 self.key, 

370 verb, 

371 ) 

372 

373 # only attempt for known verbs 

374 if verb not in self.get_verbs(): 

375 raise VerbNotSupported(verb) 

376 

377 # fallback value 

378 if value is UNSPECIFIED: 

379 value = self.value 

380 

381 # locate filter method 

382 func = getattr(self, f"filter_{verb}", None) 

383 if not func: 

384 raise VerbNotSupported(verb) 

385 

386 # invoke filter method 

387 return func(data, value) # pylint: disable=not-callable 

388 

389 def filter_is_any(self, data, value): # pylint: disable=unused-argument 

390 """ 

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

392 data as-is. 

393 """ 

394 return data 

395 

396 

397class AlchemyFilter(GridFilter): 

398 """ 

399 Filter option for a grid with SQLAlchemy query data. 

400 

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

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

403 

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

405 column by which to filter. For instance, 

406 ``model.Person.full_name``. 

407 """ 

408 

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

410 self.model_property = kwargs.pop("model_property") 

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

412 

413 if self.nullable is None: 

414 columns = self.model_property.prop.columns 

415 if len(columns) == 1: 

416 self.nullable = columns[0].nullable 

417 

418 def coerce_value(self, value): 

419 """ 

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

421 the filter. 

422 

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

424 """ 

425 return value 

426 

427 def filter_equal(self, query, value): 

428 """ 

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

430 """ 

431 value = self.coerce_value(value) 

432 if value is None: 

433 return query 

434 

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

436 

437 def filter_not_equal(self, query, value): 

438 """ 

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

440 """ 

441 value = self.coerce_value(value) 

442 if value is None: 

443 return query 

444 

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

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

447 return query.filter( 

448 sa.or_( 

449 self.model_property == None, # pylint: disable=singleton-comparison 

450 self.model_property != value, 

451 ) 

452 ) 

453 

454 def filter_greater_than(self, query, value): 

455 """ 

456 Filter data with a greater than (``>``) condition. 

457 """ 

458 value = self.coerce_value(value) 

459 if value is None: 

460 return query 

461 return query.filter(self.model_property > value) 

462 

463 def filter_greater_equal(self, query, value): 

464 """ 

465 Filter data with a greater than or equal (``>=``) condition. 

466 """ 

467 value = self.coerce_value(value) 

468 if value is None: 

469 return query 

470 return query.filter(self.model_property >= value) 

471 

472 def filter_less_than(self, query, value): 

473 """ 

474 Filter data with a less than (``<``) condition. 

475 """ 

476 value = self.coerce_value(value) 

477 if value is None: 

478 return query 

479 return query.filter(self.model_property < value) 

480 

481 def filter_less_equal(self, query, value): 

482 """ 

483 Filter data with a less than or equal (``<=``) condition. 

484 """ 

485 value = self.coerce_value(value) 

486 if value is None: 

487 return query 

488 return query.filter(self.model_property <= value) 

489 

490 def filter_is_null(self, query, value): # pylint: disable=unused-argument 

491 """ 

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

493 """ 

494 return query.filter( 

495 self.model_property == None # pylint: disable=singleton-comparison 

496 ) 

497 

498 def filter_is_not_null(self, query, value): # pylint: disable=unused-argument 

499 """ 

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

501 ignored. 

502 """ 

503 return query.filter( 

504 self.model_property != None # pylint: disable=singleton-comparison 

505 ) 

506 

507 

508class StringAlchemyFilter(AlchemyFilter): 

509 """ 

510 SQLAlchemy filter option for a text data column. 

511 

512 Subclass of :class:`AlchemyFilter`. 

513 """ 

514 

515 default_verbs = ["contains", "does_not_contain", "equal", "not_equal"] 

516 

517 def coerce_value(self, value): # pylint: disable=empty-docstring 

518 """ """ 

519 if value is not None: 

520 value = str(value) 

521 if value: 

522 return value 

523 return None 

524 

525 def filter_contains(self, query, value): 

526 """ 

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

528 """ 

529 value = self.coerce_value(value) 

530 if not value: 

531 return query 

532 

533 criteria = [] 

534 for val in value.split(): 

535 val = val.replace("_", r"\_") 

536 val = f"%{val}%" 

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

538 

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

540 

541 def filter_does_not_contain(self, query, value): 

542 """ 

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

544 """ 

545 value = self.coerce_value(value) 

546 if not value: 

547 return query 

548 

549 criteria = [] 

550 for val in value.split(): 

551 val = val.replace("_", r"\_") 

552 val = f"%{val}%" 

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

554 

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

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

557 return query.filter( 

558 sa.or_( 

559 self.model_property == None, # pylint: disable=singleton-comparison 

560 sa.and_(*criteria), 

561 ) 

562 ) 

563 

564 

565class NumericAlchemyFilter(AlchemyFilter): 

566 """ 

567 SQLAlchemy filter option for a numeric data column. 

568 

569 Subclass of :class:`AlchemyFilter`. 

570 """ 

571 

572 default_verbs = [ 

573 "equal", 

574 "not_equal", 

575 "greater_than", 

576 "greater_equal", 

577 "less_than", 

578 "less_equal", 

579 ] 

580 

581 

582class IntegerAlchemyFilter(NumericAlchemyFilter): 

583 """ 

584 SQLAlchemy filter option for an integer data column. 

585 

586 Subclass of :class:`NumericAlchemyFilter`. 

587 """ 

588 

589 def coerce_value(self, value): # pylint: disable=empty-docstring 

590 """ """ 

591 if value: 

592 try: 

593 return int(value) 

594 except Exception: # pylint: disable=broad-exception-caught 

595 pass 

596 return None 

597 

598 

599class BooleanAlchemyFilter(AlchemyFilter): 

600 """ 

601 SQLAlchemy filter option for a boolean data column. 

602 

603 Subclass of :class:`AlchemyFilter`. 

604 """ 

605 

606 default_verbs = ["is_true", "is_false"] 

607 

608 def get_verbs(self): # pylint: disable=empty-docstring 

609 """ """ 

610 

611 # get basic verbs from caller, or default list 

612 verbs = getattr(self, "verbs", self.default_verbs) 

613 if callable(verbs): 

614 verbs = verbs() 

615 verbs = list(verbs) 

616 

617 # add some more if column is nullable 

618 if self.nullable: 

619 for verb in ("is_false_null", "is_null", "is_not_null"): 

620 if verb not in verbs: 

621 verbs.append(verb) 

622 

623 # add wildcard 

624 if "is_any" not in verbs: 

625 verbs.append("is_any") 

626 

627 return verbs 

628 

629 def coerce_value(self, value): # pylint: disable=empty-docstring 

630 """ """ 

631 if value is not None: 

632 return bool(value) 

633 return None 

634 

635 def filter_is_true(self, query, value): # pylint: disable=unused-argument 

636 """ 

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

638 ignored. 

639 """ 

640 return query.filter( 

641 self.model_property == True # pylint: disable=singleton-comparison 

642 ) 

643 

644 def filter_is_false(self, query, value): # pylint: disable=unused-argument 

645 """ 

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

647 ignored. 

648 """ 

649 return query.filter( 

650 self.model_property == False # pylint: disable=singleton-comparison 

651 ) 

652 

653 def filter_is_false_null(self, query, value): # pylint: disable=unused-argument 

654 """ 

655 Filter data with "is false or null" condition. The value is 

656 ignored. 

657 """ 

658 return query.filter( 

659 sa.or_( 

660 self.model_property == False, # pylint: disable=singleton-comparison 

661 self.model_property == None, # pylint: disable=singleton-comparison 

662 ) 

663 ) 

664 

665 

666class DateAlchemyFilter(AlchemyFilter): 

667 """ 

668 SQLAlchemy filter option for a 

669 :class:`sqlalchemy:sqlalchemy.types.Date` column. 

670 

671 Subclass of :class:`AlchemyFilter`. 

672 """ 

673 

674 data_type = "date" 

675 default_verbs = [ 

676 "equal", 

677 "not_equal", 

678 "greater_than", 

679 "greater_equal", 

680 "less_than", 

681 "less_equal", 

682 # 'between', 

683 ] 

684 

685 default_verb_labels = { 

686 "equal": "on", 

687 "not_equal": "not on", 

688 "greater_than": "after", 

689 "greater_equal": "on or after", 

690 "less_than": "before", 

691 "less_equal": "on or before", 

692 # 'between': "between", 

693 } 

694 

695 def coerce_value(self, value): # pylint: disable=empty-docstring 

696 """ """ 

697 if value: 

698 if isinstance(value, datetime.date): 

699 return value 

700 

701 try: 

702 dt = datetime.datetime.strptime(value, "%Y-%m-%d") 

703 except ValueError: 

704 log.warning("invalid date value: %s", value) 

705 else: 

706 return dt.date() 

707 

708 return None 

709 

710 

711default_sqlalchemy_filters = { 

712 None: AlchemyFilter, 

713 sa.String: StringAlchemyFilter, 

714 sa.Text: StringAlchemyFilter, 

715 sa.Numeric: NumericAlchemyFilter, 

716 sa.Integer: IntegerAlchemyFilter, 

717 sa.Boolean: BooleanAlchemyFilter, 

718 sa.Date: DateAlchemyFilter, 

719}