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

702 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-10-24 16:36 -0500

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

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

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024-2026 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""" 

24Base grid classes 

25""" 

26 

27# pylint: disable=too-many-lines 

28 

29import functools 

30import logging 

31import warnings 

32from collections import namedtuple, OrderedDict 

33 

34try: 

35 from enum import EnumType 

36except ImportError: # pragma: no cover 

37 # nb. python < 3.11 

38 from enum import EnumMeta as EnumType 

39 

40import sqlalchemy as sa 

41from sqlalchemy import orm 

42 

43import paginate 

44from paginate_sqlalchemy import SqlalchemyOrmPage 

45from pyramid.renderers import render 

46from webhelpers2.html import HTML 

47 

48from wuttjamaican.db.util import UUID 

49from wuttaweb.util import ( 

50 FieldList, 

51 get_model_fields, 

52 make_json_safe, 

53 render_vue_finalize, 

54) 

55from wuttaweb.grids.filters import default_sqlalchemy_filters, VerbNotSupported 

56 

57log = logging.getLogger(__name__) 

58 

59 

60SortInfo = namedtuple("SortInfo", ["sortkey", "sortdir"]) 

61SortInfo.__doc__ = """ 

62Named tuple to track sorting info. 

63 

64Elements of :attr:`~Grid.sort_defaults` will be of this type. 

65""" 

66 

67 

68class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-methods 

69 """ 

70 Base class for all :term:`grids <grid>`. 

71 

72 :param request: Reference to current :term:`request` object. 

73 

74 :param columns: List of column names for the grid. This is 

75 optional; if not specified an attempt will be made to deduce 

76 the list automatically. See also :attr:`columns`. 

77 

78 .. note:: 

79 

80 Some parameters are not explicitly described above. However 

81 their corresponding attributes are described below. 

82 

83 Grid instances contain the following attributes: 

84 

85 .. attribute:: key 

86 

87 Presumably unique key for the grid; used to track per-grid 

88 sort/filter settings etc. 

89 

90 .. attribute:: vue_tagname 

91 

92 String name for Vue component tag. By default this is 

93 ``'wutta-grid'``. See also :meth:`render_vue_tag()` 

94 and :attr:`vue_component`. 

95 

96 .. attribute:: model_class 

97 

98 Model class for the grid, if applicable. When set, this is 

99 usually a SQLAlchemy mapped class. This may be used for 

100 deriving the default :attr:`columns` among other things. 

101 

102 .. attribute:: columns 

103 

104 :class:`~wuttaweb.util.FieldList` instance containing string 

105 column names for the grid. Columns will appear in the same 

106 order as they are in this list. 

107 

108 See also :meth:`set_columns()` and :meth:`get_columns()`. 

109 

110 .. attribute:: data 

111 

112 Data set for the grid. This should either be a list of dicts 

113 (or objects with dict-like access to fields, corresponding to 

114 model records) or else an object capable of producing such a 

115 list, e.g. SQLAlchemy query. 

116 

117 This is the "full" data set; see also 

118 :meth:`get_visible_data()`. 

119 

120 .. attribute:: labels 

121 

122 Dict of column and/or filter label overrides. 

123 

124 See also :attr:`column_labels`, :meth:`set_label()`, 

125 :meth:`get_column_label()` and :meth:`get_filter_label()`. 

126 

127 .. attribute:: column_labels 

128 

129 Dict of label overrides for column only. 

130 

131 See also :attr:`labels`, :meth:`set_label()` and 

132 :meth:`get_column_label()`. 

133 

134 .. attribute:: centered 

135 

136 Dict of column "centered" flags. 

137 

138 See also :meth:`is_centered()` and :meth:`set_centered()`. 

139 

140 .. attribute:: renderers 

141 

142 Dict of column (cell) value renderer overrides. 

143 

144 See also :meth:`set_renderer()` and 

145 :meth:`set_default_renderers()`. 

146 

147 .. attribute:: enums 

148 

149 Dict of "enum" collections, for supported columns. 

150 

151 See also :meth:`set_enum()`. 

152 

153 .. attribute:: checkable 

154 

155 Boolean indicating whether the grid should expose per-row 

156 checkboxes. 

157 

158 .. attribute:: row_class 

159 

160 This represents the CSS ``class`` attribute for a row within 

161 the grid. Default is ``None``. 

162 

163 This can be a simple string, in which case the same class is 

164 applied to all rows. 

165 

166 Or it can be a callable, which can then return different 

167 class(es) depending on each row. The callable must take three 

168 args: ``(obj, data, i)`` - for example:: 

169 

170 def my_row_class(obj, data, i): 

171 if obj.archived: 

172 return 'poser-archived' 

173 

174 grid = Grid(request, key='foo', row_class=my_row_class) 

175 

176 See :meth:`get_row_class()` for more info. 

177 

178 .. attribute:: actions 

179 

180 List of :class:`GridAction` instances represenging action links 

181 to be shown for each record in the grid. 

182 

183 .. attribute:: linked_columns 

184 

185 List of column names for which auto-link behavior should be 

186 applied. 

187 

188 See also :meth:`set_link()` and :meth:`is_linked()`. 

189 

190 .. attribute:: hidden_columns 

191 

192 List of column names which should be hidden from view. 

193 

194 Hidden columns are sometimes useful to pass "extra" data into 

195 the grid, for use by other component logic etc. 

196 

197 See also :meth:`set_hidden()` and :meth:`is_hidden()`. 

198 

199 .. attribute:: sortable 

200 

201 Boolean indicating whether *any* column sorting is allowed for 

202 the grid. Default is ``False``. 

203 

204 See also :attr:`sort_multiple` and :attr:`sort_on_backend`. 

205 

206 .. attribute:: sort_multiple 

207 

208 Boolean indicating whether "multi-column" sorting is allowed. 

209 This is true by default, where possible. If false then only 

210 one column may be sorted at a time. 

211 

212 Only relevant if :attr:`sortable` is true, but applies to both 

213 frontend and backend sorting. 

214 

215 .. warning:: 

216 

217 This feature is limited by frontend JS capabilities, 

218 regardless of :attr:`sort_on_backend` value (i.e. for both 

219 frontend and backend sorting). 

220 

221 In particular, if the app theme templates use Vue 2 + Buefy, 

222 then multi-column sorting should work. 

223 

224 But not so with Vue 3 + Oruga, *yet* - see also the `open 

225 issue <https://github.com/oruga-ui/oruga/issues/962>`_ 

226 regarding that. For now this flag is simply ignored for 

227 Vue 3 + Oruga templates. 

228 

229 Additionally, even with Vue 2 + Buefy this flag can only 

230 allow the user to *request* a multi-column sort. Whereas 

231 the "default sort" in the Vue component can only ever be 

232 single-column, regardless of :attr:`sort_defaults`. 

233 

234 .. attribute:: sort_on_backend 

235 

236 Boolean indicating whether the grid data should be sorted on the 

237 backend. Default is ``True``. 

238 

239 If ``False``, the client-side Vue component will handle the 

240 sorting. 

241 

242 Only relevant if :attr:`sortable` is also true. 

243 

244 .. attribute:: sorters 

245 

246 Dict of functions to use for backend sorting. 

247 

248 Only relevant if both :attr:`sortable` and 

249 :attr:`sort_on_backend` are true. 

250 

251 See also :meth:`set_sorter()`, :attr:`sort_defaults` and 

252 :attr:`active_sorters`. 

253 

254 .. attribute:: sort_defaults 

255 

256 List of options to be used for default sorting, until the user 

257 requests a different sorting method. 

258 

259 This list usually contains either zero or one elements. (More 

260 are allowed if :attr:`sort_multiple` is true, but see note 

261 below.) Each list element is a :class:`SortInfo` tuple and 

262 must correspond to an entry in :attr:`sorters`. 

263 

264 Used with both frontend and backend sorting. 

265 

266 See also :meth:`set_sort_defaults()` and 

267 :attr:`active_sorters`. 

268 

269 .. warning:: 

270 

271 While the grid logic is built to handle multi-column 

272 sorting, this feature is limited by frontend JS 

273 capabilities. 

274 

275 Even if ``sort_defaults`` contains multiple entries 

276 (i.e. for multi-column sorting to be used "by default" for 

277 the grid), only the *first* entry (i.e. single-column 

278 sorting) will actually be used as the default for the Vue 

279 component. 

280 

281 See also :attr:`sort_multiple` for more details. 

282 

283 .. attribute:: active_sorters 

284 

285 List of sorters currently in effect for the grid; used by 

286 :meth:`sort_data()`. 

287 

288 Whereas :attr:`sorters` defines all "available" sorters, and 

289 :attr:`sort_defaults` defines the "default" sorters, 

290 ``active_sorters`` defines the "current/effective" sorters. 

291 

292 This attribute is set by :meth:`load_settings()`; until that is 

293 called its value will be ``None``. 

294 

295 This is conceptually a "subset" of :attr:`sorters` although a 

296 different format is used here:: 

297 

298 grid.active_sorters = [ 

299 {'key': 'name', 'dir': 'asc'}, 

300 {'key': 'id', 'dir': 'asc'}, 

301 ] 

302 

303 The above is for example only; there is usually no reason to 

304 set this attribute directly. 

305 

306 This list may contain multiple elements only if 

307 :attr:`sort_multiple` is true. Otherewise it should always 

308 have either zero or one element. 

309 

310 .. attribute:: paginated 

311 

312 Boolean indicating whether the grid data should be paginated, 

313 i.e. split up into pages. Default is ``False`` which means all 

314 data is shown at once. 

315 

316 See also :attr:`pagesize` and :attr:`page`, and 

317 :attr:`paginate_on_backend`. 

318 

319 .. attribute:: paginate_on_backend 

320 

321 Boolean indicating whether the grid data should be paginated on 

322 the backend. Default is ``True`` which means only one "page" 

323 of data is sent to the client-side component. 

324 

325 If this is ``False``, the full set of grid data is sent for 

326 each request, and the client-side Vue component will handle the 

327 pagination. 

328 

329 Only relevant if :attr:`paginated` is also true. 

330 

331 .. attribute:: pagesize_options 

332 

333 List of "page size" options for the grid. See also 

334 :attr:`pagesize`. 

335 

336 Only relevant if :attr:`paginated` is true. If not specified, 

337 constructor will call :meth:`get_pagesize_options()` to get the 

338 value. 

339 

340 .. attribute:: pagesize 

341 

342 Number of records to show in a data page. See also 

343 :attr:`pagesize_options` and :attr:`page`. 

344 

345 Only relevant if :attr:`paginated` is true. If not specified, 

346 constructor will call :meth:`get_pagesize()` to get the value. 

347 

348 .. attribute:: page 

349 

350 The current page number (of data) to display in the grid. See 

351 also :attr:`pagesize`. 

352 

353 Only relevant if :attr:`paginated` is true. If not specified, 

354 constructor will assume ``1`` (first page). 

355 

356 .. attribute:: searchable_columns 

357 

358 Set of columns declared as searchable for the Vue component. 

359 

360 See also :meth:`set_searchable()` and :meth:`is_searchable()`. 

361 

362 .. attribute:: filterable 

363 

364 Boolean indicating whether the grid should show a "filters" 

365 section where user can filter data in various ways. Default is 

366 ``False``. 

367 

368 .. attribute:: filters 

369 

370 Dict of :class:`~wuttaweb.grids.filters.GridFilter` instances 

371 available for use with backend filtering. 

372 

373 Only relevant if :attr:`filterable` is true. 

374 

375 See also :meth:`set_filter()`. 

376 

377 .. attribute:: filter_defaults 

378 

379 Dict containing default state preferences for the filters. 

380 

381 See also :meth:`set_filter_defaults()`. 

382 

383 .. attribute:: joiners 

384 

385 Dict of "joiner" functions for use with backend filtering and 

386 sorting. 

387 

388 See :meth:`set_joiner()` for more info. 

389 

390 .. attribute:: tools 

391 

392 Dict of "tool" elements for the grid. Tools are usually buttons 

393 (e.g. "Delete Results"), shown on top right of the grid. 

394 

395 The keys for this dict are somewhat arbitrary, defined by the 

396 caller. Values should be HTML literal elements. 

397 

398 See also :meth:`add_tool()` and :meth:`set_tools()`. 

399 """ 

400 

401 active_sorters = None 

402 joined = None 

403 pager = None 

404 

405 def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-branches,too-many-statements 

406 self, 

407 request, 

408 vue_tagname="wutta-grid", 

409 model_class=None, 

410 key=None, 

411 columns=None, 

412 data=None, 

413 labels=None, 

414 centered=None, 

415 renderers=None, 

416 enums=None, 

417 checkable=False, 

418 row_class=None, 

419 actions=None, 

420 linked_columns=None, 

421 hidden_columns=None, 

422 sortable=False, 

423 sort_multiple=None, 

424 sort_on_backend=True, 

425 sorters=None, 

426 sort_defaults=None, 

427 paginated=False, 

428 paginate_on_backend=True, 

429 pagesize_options=None, 

430 pagesize=None, 

431 page=1, 

432 searchable_columns=None, 

433 filterable=False, 

434 filters=None, 

435 filter_defaults=None, 

436 joiners=None, 

437 tools=None, 

438 ): 

439 self.request = request 

440 self.vue_tagname = vue_tagname 

441 self.model_class = model_class 

442 self.key = key 

443 self.data = data 

444 self.labels = labels or {} 

445 self.column_labels = {} 

446 self.checkable = checkable 

447 self.row_class = row_class 

448 self.actions = actions or [] 

449 self.linked_columns = linked_columns or [] 

450 self.hidden_columns = hidden_columns or [] 

451 self.joiners = joiners or {} 

452 

453 self.config = self.request.wutta_config 

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

455 

456 self.set_columns(columns or self.get_columns()) 

457 self.centered = centered or {} 

458 self.renderers = {} 

459 if renderers: 

460 for k, val in renderers.items(): 

461 self.set_renderer(k, val) 

462 self.set_default_renderers() 

463 self.set_tools(tools) 

464 

465 # sorting 

466 self.sortable = sortable 

467 if sort_multiple is not None: 

468 self.sort_multiple = sort_multiple 

469 elif self.request.use_oruga: 

470 self.sort_multiple = False 

471 else: 

472 self.sort_multiple = bool(self.model_class) 

473 if self.sort_multiple and self.request.use_oruga: 

474 log.warning( 

475 "grid.sort_multiple is not implemented for Oruga-based templates" 

476 ) 

477 self.sort_multiple = False 

478 self.sort_on_backend = sort_on_backend 

479 if sorters is not None: 

480 self.sorters = sorters 

481 elif self.sortable and self.sort_on_backend: 

482 self.sorters = self.make_backend_sorters() 

483 else: 

484 self.sorters = {} 

485 self.set_sort_defaults(sort_defaults or []) 

486 

487 # paging 

488 self.paginated = paginated 

489 self.paginate_on_backend = paginate_on_backend 

490 self.pagesize_options = pagesize_options or self.get_pagesize_options() 

491 self.pagesize = pagesize or self.get_pagesize() 

492 self.page = page 

493 

494 # searching 

495 self.searchable_columns = set(searchable_columns or []) 

496 

497 # filtering 

498 self.filterable = filterable 

499 if filters is not None: 

500 self.filters = filters 

501 elif self.filterable: 

502 self.filters = self.make_backend_filters() 

503 else: 

504 self.filters = {} 

505 self.set_filter_defaults(**(filter_defaults or {})) 

506 

507 # enums 

508 self.enums = {} 

509 for k in enums or {}: 

510 self.set_enum(k, enums[k]) 

511 

512 def get_columns(self): 

513 """ 

514 Returns the official list of column names for the grid, or 

515 ``None``. 

516 

517 If :attr:`columns` is set and non-empty, it is returned. 

518 

519 Or, if :attr:`model_class` is set, the field list is derived 

520 from that, via :meth:`get_model_columns()`. 

521 

522 Otherwise ``None`` is returned. 

523 """ 

524 if hasattr(self, "columns") and self.columns: 

525 return self.columns 

526 

527 columns = self.get_model_columns() 

528 if columns: 

529 return columns 

530 

531 return [] 

532 

533 def get_model_columns(self, model_class=None): 

534 """ 

535 This method is a shortcut which calls 

536 :func:`~wuttaweb.util.get_model_fields()`. 

537 

538 :param model_class: Optional model class for which to return 

539 fields. If not set, the grid's :attr:`model_class` is 

540 assumed. 

541 """ 

542 return get_model_fields( 

543 self.config, model_class=model_class or self.model_class 

544 ) 

545 

546 @property 

547 def vue_component(self): 

548 """ 

549 String name for the Vue component, e.g. ``'WuttaGrid'``. 

550 

551 This is a generated value based on :attr:`vue_tagname`. 

552 """ 

553 words = self.vue_tagname.split("-") 

554 return "".join([word.capitalize() for word in words]) 

555 

556 def set_columns(self, columns): 

557 """ 

558 Explicitly set the list of grid columns. 

559 

560 This will overwrite :attr:`columns` with a new 

561 :class:`~wuttaweb.util.FieldList` instance. 

562 

563 :param columns: List of string column names. 

564 """ 

565 self.columns = FieldList(columns) 

566 

567 def append(self, *keys): 

568 """ 

569 Add some columns(s) to the grid. 

570 

571 This is a convenience to allow adding multiple columns at 

572 once:: 

573 

574 grid.append('first_field', 

575 'second_field', 

576 'third_field') 

577 

578 It will add each column to :attr:`columns`. 

579 """ 

580 for key in keys: 

581 if key not in self.columns: 

582 self.columns.append(key) 

583 

584 def remove(self, *keys): 

585 """ 

586 Remove some column(s) from the grid. 

587 

588 This is a convenience to allow removal of multiple columns at 

589 once:: 

590 

591 grid.remove('first_field', 

592 'second_field', 

593 'third_field') 

594 

595 It will remove each column from :attr:`columns`. 

596 """ 

597 for key in keys: 

598 if key in self.columns: 

599 self.columns.remove(key) 

600 

601 def set_hidden(self, key, hidden=True): 

602 """ 

603 Set/override the hidden flag for a column. 

604 

605 Hidden columns are sometimes useful to pass "extra" data into 

606 the grid, for use by other component logic etc. 

607 

608 See also :meth:`is_hidden()`; the list is tracked via 

609 :attr:`hidden_columns`. 

610 

611 :param key: Column key as string. 

612 

613 :param hidden: Flag indicating whether column should be hidden 

614 (vs. shown). 

615 """ 

616 if hidden: 

617 if key not in self.hidden_columns: 

618 self.hidden_columns.append(key) 

619 else: # un-hide 

620 if self.hidden_columns and key in self.hidden_columns: 

621 self.hidden_columns.remove(key) 

622 

623 def is_hidden(self, key): 

624 """ 

625 Returns boolean indicating if the column is hidden from view. 

626 

627 See also :meth:`set_hidden()` and :attr:`hidden_columns`. 

628 

629 :param key: Column key as string. 

630 

631 :rtype: bool 

632 """ 

633 if self.hidden_columns: 

634 if key in self.hidden_columns: 

635 return True 

636 return False 

637 

638 def set_label(self, key, label, column_only=False): 

639 """ 

640 Set/override the label for a column and/or filter. 

641 

642 :param key: Key for the column/filter. 

643 

644 :param label: New label for the column and/or filter. 

645 

646 :param column_only: Boolean indicating whether the label 

647 should be applied *only* to the column header (if 

648 ``True``), vs. applying also to the filter (if ``False``). 

649 

650 See also :meth:`get_column_label()` and 

651 :meth:`get_filter_label()`. Label overrides are tracked via 

652 :attr:`labels` and :attr:`column_labels`. 

653 """ 

654 if column_only: 

655 self.column_labels[key] = label 

656 else: 

657 self.labels[key] = label 

658 if key in self.filters: 

659 self.filters[key].label = label 

660 

661 def get_label(self, key): # pylint: disable=missing-function-docstring 

662 warnings.warn( 

663 "Grid.get_label() is deprecated; please use " 

664 "get_filter_label() or get_column_label() instead", 

665 DeprecationWarning, 

666 stacklevel=2, 

667 ) 

668 return self.get_filter_label(key) 

669 

670 def get_filter_label(self, key): 

671 """ 

672 Returns the label text for a given filter. 

673 

674 If no override is defined, the label is derived from ``key``. 

675 

676 See also :meth:`set_label()` and :meth:`get_column_label()`. 

677 """ 

678 if key in self.labels: 

679 return self.labels[key] 

680 

681 return self.app.make_title(key) 

682 

683 def get_column_label(self, key): 

684 """ 

685 Returns the label text for a given column. 

686 

687 If no override is defined, the label is derived from ``key``. 

688 

689 See also :meth:`set_label()` and :meth:`get_filter_label()`. 

690 """ 

691 if key in self.column_labels: 

692 return self.column_labels[key] 

693 

694 return self.get_filter_label(key) 

695 

696 def set_centered(self, key, centered=True): 

697 """ 

698 Set/override the "centered" flag for a column. 

699 

700 :param key: Name of column. 

701 

702 :param centered: Whether the column data should be centered. 

703 

704 See also :meth:`is_centered()`. Column flags are tracked via 

705 :attr:`centered`. 

706 """ 

707 self.centered[key] = centered 

708 

709 def is_centered(self, key): 

710 """ 

711 Check if the given column should be centered. 

712 

713 :param key: Name of column. 

714 

715 :rtype: boolean 

716 

717 See also :meth:`set_centered()`. Column flags are tracked via 

718 :attr:`centered`. 

719 """ 

720 return self.centered.get(key, False) 

721 

722 def set_renderer(self, key, renderer, **kwargs): 

723 """ 

724 Set/override the value renderer for a column. 

725 

726 :param key: Name of column. 

727 

728 :param renderer: Callable as described below. 

729 

730 Depending on the nature of grid data, sometimes a cell's 

731 "as-is" value will be undesirable for display purposes. 

732 

733 The logic in :meth:`get_vue_context()` will first "convert" 

734 all grid data as necessary so that it is at least 

735 JSON-compatible. 

736 

737 But then it also will invoke a renderer override (if defined) 

738 to obtain the "final" cell value. 

739 

740 A renderer must be a callable which accepts 3 args ``(record, 

741 key, value)``: 

742 

743 * ``record`` is the "original" record from :attr:`data` 

744 * ``key`` is the column name 

745 * ``value`` is the JSON-safe cell value 

746 

747 Whatever the renderer returns, is then used as final cell 

748 value. For instance:: 

749 

750 from webhelpers2.html import HTML 

751 

752 def render_foo(record, key, value): 

753 return HTML.literal("<p>this is the final cell value</p>") 

754 

755 grid = Grid(request, columns=['foo', 'bar']) 

756 grid.set_renderer('foo', render_foo) 

757 

758 For convenience, in lieu of a renderer callable, you may 

759 specify one of the following strings, which will be 

760 interpreted as a built-in renderer callable, as shown below: 

761 

762 * ``'batch_id'`` -> :meth:`render_batch_id()` 

763 * ``'boolean'`` -> :meth:`render_boolean()` 

764 * ``'currency'`` -> :meth:`render_currency()` 

765 * ``'date'`` -> :meth:`render_date()` 

766 * ``'datetime'`` -> :meth:`render_datetime()` 

767 * ``'quantity'`` -> :meth:`render_quantity()` 

768 * ``'percent'`` -> :meth:`render_percent()` 

769 

770 Renderer overrides are tracked via :attr:`renderers`. 

771 """ 

772 builtins = { 

773 "batch_id": self.render_batch_id, 

774 "boolean": self.render_boolean, 

775 "currency": self.render_currency, 

776 "date": self.render_date, 

777 "datetime": self.render_datetime, 

778 "quantity": self.render_quantity, 

779 "percent": self.render_percent, 

780 } 

781 

782 if renderer in builtins: # pylint: disable=consider-using-get 

783 renderer = builtins[renderer] 

784 

785 if kwargs: 

786 renderer = functools.partial(renderer, **kwargs) 

787 self.renderers[key] = renderer 

788 

789 def set_default_renderers(self): 

790 """ 

791 Set default column value renderers, where applicable. 

792 

793 This is called automatically from the class constructor. It 

794 will add new entries to :attr:`renderers` for columns whose 

795 data type implies a default renderer. This is only possible 

796 if :attr:`model_class` is set to a SQLAlchemy mapped class. 

797 

798 This only looks for a few data types, and configures as 

799 follows: 

800 

801 * :class:`sqlalchemy:sqlalchemy.types.Boolean` -> 

802 :meth:`render_boolean()` 

803 * :class:`sqlalchemy:sqlalchemy.types.Date` -> 

804 :meth:`render_date()` 

805 * :class:`sqlalchemy:sqlalchemy.types.DateTime` -> 

806 :meth:`render_datetime()` 

807 """ 

808 if not self.model_class: 

809 return 

810 

811 for key in self.columns: 

812 if key in self.renderers: 

813 continue 

814 

815 attr = getattr(self.model_class, key, None) 

816 if attr: 

817 prop = getattr(attr, "prop", None) 

818 if prop and isinstance(prop, orm.ColumnProperty): 

819 column = prop.columns[0] 

820 if isinstance(column.type, sa.Date): 

821 self.set_renderer(key, self.render_date) 

822 elif isinstance(column.type, sa.DateTime): 

823 self.set_renderer(key, self.render_datetime) 

824 elif isinstance(column.type, sa.Boolean): 

825 self.set_renderer(key, self.render_boolean) 

826 

827 def set_enum(self, key, enum): 

828 """ 

829 Set the "enum" collection for a given column. 

830 

831 This will set the column renderer to show the appropriate enum 

832 value for each row in the grid. See also 

833 :meth:`render_enum()`. 

834 

835 If the grid has a corresponding filter for the column, it will 

836 be modified to show "choices" for values contained in the 

837 enum. 

838 

839 :param key: Name of column. 

840 

841 :param enum: Instance of :class:`python:enum.Enum`, or a dict. 

842 """ 

843 self.enums[key] = enum 

844 self.set_renderer(key, self.render_enum, enum=enum) 

845 if key in self.filters: 

846 self.filters[key].set_choices(enum) 

847 

848 def set_link(self, key, link=True): 

849 """ 

850 Explicitly enable or disable auto-link behavior for a given 

851 column. 

852 

853 If a column has auto-link enabled, then each of its cell 

854 contents will automatically be wrapped with a hyperlink. The 

855 URL for this will be the same as for the "View" 

856 :class:`GridAction` 

857 (aka. :meth:`~wuttaweb.views.master.MasterView.view()`). 

858 Although of course each cell in the column gets a different 

859 link depending on which data record it points to. 

860 

861 It is typical to enable auto-link for fields relating to ID, 

862 description etc. or some may prefer to auto-link all columns. 

863 

864 See also :meth:`is_linked()`; the list is tracked via 

865 :attr:`linked_columns`. 

866 

867 :param key: Column key as string. 

868 

869 :param link: Boolean indicating whether column's cell contents 

870 should be auto-linked. 

871 """ 

872 if link: 

873 if key not in self.linked_columns: 

874 self.linked_columns.append(key) 

875 else: # unlink 

876 if self.linked_columns and key in self.linked_columns: 

877 self.linked_columns.remove(key) 

878 

879 def is_linked(self, key): 

880 """ 

881 Returns boolean indicating if auto-link behavior is enabled 

882 for a given column. 

883 

884 See also :meth:`set_link()` which describes auto-link behavior. 

885 

886 :param key: Column key as string. 

887 """ 

888 if self.linked_columns: 

889 if key in self.linked_columns: 

890 return True 

891 return False 

892 

893 def set_searchable(self, key, searchable=True): 

894 """ 

895 (Un)set the given column's searchable flag for the Vue 

896 component. 

897 

898 See also :meth:`is_searchable()`. Flags are tracked via 

899 :attr:`searchable_columns`. 

900 """ 

901 if searchable: 

902 self.searchable_columns.add(key) 

903 elif key in self.searchable_columns: 

904 self.searchable_columns.remove(key) 

905 

906 def is_searchable(self, key): 

907 """ 

908 Check if the given column is marked as searchable for the Vue 

909 component. 

910 

911 See also :meth:`set_searchable()`. 

912 """ 

913 return key in self.searchable_columns 

914 

915 def add_action(self, key, **kwargs): 

916 """ 

917 Convenience to add a new :class:`GridAction` instance to the 

918 grid's :attr:`actions` list. 

919 """ 

920 self.actions.append(GridAction(self.request, key, **kwargs)) 

921 

922 def set_tools(self, tools): 

923 """ 

924 Set the :attr:`tools` attribute using the given tools collection. 

925 This will normalize the list/dict to desired internal format. 

926 

927 See also :meth:`add_tool()`. 

928 """ 

929 if tools and isinstance(tools, list): 

930 if not any(isinstance(t, (tuple, list)) for t in tools): 

931 tools = [(self.app.make_true_uuid().hex, t) for t in tools] 

932 self.tools = OrderedDict(tools or []) 

933 

934 def add_tool(self, html, key=None): 

935 """ 

936 Add a new HTML snippet to the :attr:`tools` dict. 

937 

938 :param html: HTML literal for the tool element. 

939 

940 :param key: Optional key to use when adding to the 

941 :attr:`tools` dict. If not specified, a random string is 

942 generated. 

943 

944 See also :meth:`set_tools()`. 

945 """ 

946 if not key: 

947 key = self.app.make_true_uuid().hex 

948 self.tools[key] = html 

949 

950 ############################## 

951 # joining methods 

952 ############################## 

953 

954 def set_joiner(self, key, joiner): 

955 """ 

956 Set/override the backend joiner for a column. 

957 

958 A "joiner" is sometimes needed when a column with "related but 

959 not primary" data is involved in a sort or filter operation. 

960 

961 A sorter or filter may need to "join" other table(s) to get at 

962 the appropriate data. But if a given column has both a sorter 

963 and filter defined, and both are used at the same time, we 

964 don't want the join to happen twice. 

965 

966 Hence we track joiners separately, also keyed by column name 

967 (as are sorters and filters). When a column's sorter **and/or** 

968 filter is needed, the joiner will be invoked. 

969 

970 :param key: Name of column. 

971 

972 :param joiner: A joiner callable, as described below. 

973 

974 A joiner callable must accept just one ``(data)`` arg and 

975 return the "joined" data/query, for example:: 

976 

977 model = app.model 

978 grid = Grid(request, model_class=model.Person) 

979 

980 def join_external_profile_value(query): 

981 return query.join(model.ExternalProfile) 

982 

983 def sort_external_profile(query, direction): 

984 sortspec = getattr(model.ExternalProfile.description, direction) 

985 return query.order_by(sortspec()) 

986 

987 grid.set_joiner('external_profile', join_external_profile) 

988 grid.set_sorter('external_profile', sort_external_profile) 

989 

990 See also :meth:`remove_joiner()`. Backend joiners are tracked 

991 via :attr:`joiners`. 

992 """ 

993 self.joiners[key] = joiner 

994 

995 def remove_joiner(self, key): 

996 """ 

997 Remove the backend joiner for a column. 

998 

999 Note that this removes the joiner *function*, so there is no 

1000 way to apply joins for this column unless another joiner is 

1001 later defined for it. 

1002 

1003 See also :meth:`set_joiner()`. 

1004 """ 

1005 self.joiners.pop(key, None) 

1006 

1007 ############################## 

1008 # sorting methods 

1009 ############################## 

1010 

1011 def make_backend_sorters(self, sorters=None): 

1012 """ 

1013 Make backend sorters for all columns in the grid. 

1014 

1015 This is called by the constructor, if both :attr:`sortable` 

1016 and :attr:`sort_on_backend` are true. 

1017 

1018 For each column in the grid, this checks the provided 

1019 ``sorters`` and if the column is not yet in there, will call 

1020 :meth:`make_sorter()` to add it. 

1021 

1022 .. note:: 

1023 

1024 This only works if grid has a :attr:`model_class`. If not, 

1025 this method just returns the initial sorters (or empty 

1026 dict). 

1027 

1028 :param sorters: Optional dict of initial sorters. Any 

1029 existing sorters will be left intact, not replaced. 

1030 

1031 :returns: Final dict of all sorters. Includes any from the 

1032 initial ``sorters`` param as well as any which were 

1033 created. 

1034 """ 

1035 sorters = sorters or {} 

1036 

1037 if self.model_class: 

1038 for key in self.columns: 

1039 if key in sorters: 

1040 continue 

1041 prop = getattr(self.model_class, key, None) 

1042 if ( 

1043 prop 

1044 and hasattr(prop, "property") 

1045 and isinstance(prop.property, orm.ColumnProperty) 

1046 ): 

1047 sorters[prop.key] = self.make_sorter(prop) 

1048 

1049 return sorters 

1050 

1051 def make_sorter(self, columninfo, keyfunc=None, foldcase=True): 

1052 """ 

1053 Returns a function suitable for use as a backend sorter on the 

1054 given column. 

1055 

1056 Code usually does not need to call this directly. See also 

1057 :meth:`set_sorter()`, which calls this method automatically. 

1058 

1059 :param columninfo: Can be either a model property (see below), 

1060 or a column name. 

1061 

1062 :param keyfunc: Optional function to use as the "sort key 

1063 getter" callable, if the sorter is manual (as opposed to 

1064 SQLAlchemy query). More on this below. If not specified, 

1065 a default function is used. 

1066 

1067 :param foldcase: If the sorter is manual (not SQLAlchemy), and 

1068 the column data is of text type, this may be used to 

1069 automatically "fold case" for the sorting. Defaults to 

1070 ``True`` since this behavior is presumably expected, but 

1071 may be disabled if needed. 

1072 

1073 The term "model property" is a bit technical, an example 

1074 should help to clarify:: 

1075 

1076 model = app.model 

1077 grid = Grid(request, model_class=model.Person) 

1078 

1079 # explicit property 

1080 sorter = grid.make_sorter(model.Person.full_name) 

1081 

1082 # property name works if grid has model class 

1083 sorter = grid.make_sorter('full_name') 

1084 

1085 # nb. this will *not* work 

1086 person = model.Person(full_name="John Doe") 

1087 sorter = grid.make_sorter(person.full_name) 

1088 

1089 The ``keyfunc`` param allows you to override the way sort keys 

1090 are obtained from data records (this only applies for a 

1091 "manual" sort, where data is a list and not a SQLAlchemy 

1092 query):: 

1093 

1094 data = [ 

1095 {'foo': 1}, 

1096 {'bar': 2}, 

1097 ] 

1098 

1099 # nb. no model_class, just as an example 

1100 grid = Grid(request, columns=['foo', 'bar'], data=data) 

1101 

1102 def getkey(obj): 

1103 if obj.get('foo') 

1104 return obj['foo'] 

1105 if obj.get('bar'): 

1106 return obj['bar'] 

1107 return '' 

1108 

1109 # nb. sortfunc will ostensibly sort by 'foo' column, but in 

1110 # practice it is sorted per value from getkey() above 

1111 sortfunc = grid.make_sorter('foo', keyfunc=getkey) 

1112 sorted_data = sortfunc(data, 'asc') 

1113 

1114 :returns: A function suitable for backend sorting. This 

1115 function will behave differently when it is given a 

1116 SQLAlchemy query vs. a "list" of data. In either case it 

1117 will return the sorted result. 

1118 

1119 This function may be called as shown above. It expects 2 

1120 args: ``(data, direction)`` 

1121 """ 

1122 model_class = None 

1123 model_property = None 

1124 if isinstance(columninfo, str): 

1125 key = columninfo 

1126 model_class = self.model_class 

1127 model_property = getattr(self.model_class, key, None) 

1128 else: 

1129 model_property = columninfo 

1130 model_class = model_property.class_ 

1131 key = model_property.key 

1132 

1133 def sorter(data, direction): 

1134 

1135 # query is sorted with order_by() 

1136 if isinstance(data, orm.Query): 

1137 if not model_property: 

1138 raise TypeError( 

1139 f"grid sorter for '{key}' does not map to a model property" 

1140 ) 

1141 query = data 

1142 return query.order_by(getattr(model_property, direction)()) 

1143 

1144 # other data is sorted manually. first step is to 

1145 # identify the function used to produce a sort key for 

1146 # each record 

1147 kfunc = keyfunc 

1148 if not kfunc: 

1149 if model_property: 

1150 # TODO: may need this for String etc. as well? 

1151 if isinstance(model_property.type, sa.Text): 

1152 if foldcase: 

1153 

1154 def kfunc_folded(obj): 

1155 return (obj[key] or "").lower() 

1156 

1157 kfunc = kfunc_folded 

1158 

1159 else: 

1160 

1161 def kfunc_standard(obj): 

1162 return obj[key] or "" 

1163 

1164 kfunc = kfunc_standard 

1165 

1166 if not kfunc: 

1167 # nb. sorting with this can raise error if data 

1168 # contains varying types, e.g. str and None 

1169 

1170 def kfunc_fallback(obj): 

1171 return obj[key] 

1172 

1173 kfunc = kfunc_fallback 

1174 

1175 # then sort the data and return 

1176 return sorted(data, key=kfunc, reverse=direction == "desc") 

1177 

1178 # TODO: this should be improved; is needed in tailbone for 

1179 # multi-column sorting with sqlalchemy queries 

1180 if model_property: 

1181 sorter._class = model_class # pylint: disable=protected-access 

1182 sorter._column = model_property # pylint: disable=protected-access 

1183 

1184 return sorter 

1185 

1186 def set_sorter(self, key, sortinfo=None): 

1187 """ 

1188 Set/override the backend sorter for a column. 

1189 

1190 Only relevant if both :attr:`sortable` and 

1191 :attr:`sort_on_backend` are true. 

1192 

1193 :param key: Name of column. 

1194 

1195 :param sortinfo: Can be either a sorter callable, or else a 

1196 model property (see below). 

1197 

1198 If ``sortinfo`` is a callable, it will be used as-is for the 

1199 backend sorter. 

1200 

1201 Otherwise :meth:`make_sorter()` will be called to obtain the 

1202 backend sorter. The ``sortinfo`` will be passed along to that 

1203 call; if it is empty then ``key`` will be used instead. 

1204 

1205 A backend sorter callable must accept ``(data, direction)`` 

1206 args and return the sorted data/query, for example:: 

1207 

1208 model = app.model 

1209 grid = Grid(request, model_class=model.Person) 

1210 

1211 def sort_full_name(query, direction): 

1212 sortspec = getattr(model.Person.full_name, direction) 

1213 return query.order_by(sortspec()) 

1214 

1215 grid.set_sorter('full_name', sort_full_name) 

1216 

1217 See also :meth:`remove_sorter()` and :meth:`is_sortable()`. 

1218 Backend sorters are tracked via :attr:`sorters`. 

1219 """ 

1220 sorter = None 

1221 

1222 if sortinfo and callable(sortinfo): 

1223 sorter = sortinfo 

1224 else: 

1225 sorter = self.make_sorter(sortinfo or key) 

1226 

1227 self.sorters[key] = sorter 

1228 

1229 def remove_sorter(self, key): 

1230 """ 

1231 Remove the backend sorter for a column. 

1232 

1233 Note that this removes the sorter *function*, so there is 

1234 no way to sort by this column unless another sorter is 

1235 later defined for it. 

1236 

1237 See also :meth:`set_sorter()`. 

1238 """ 

1239 self.sorters.pop(key, None) 

1240 

1241 def set_sort_defaults(self, *args): 

1242 """ 

1243 Set the default sorting method for the grid. This sorting is 

1244 used unless/until the user requests a different sorting 

1245 method. 

1246 

1247 ``args`` for this method are interpreted as follows: 

1248 

1249 If 2 args are received, they should be for ``sortkey`` and 

1250 ``sortdir``; for instance:: 

1251 

1252 grid.set_sort_defaults('name', 'asc') 

1253 

1254 If just one 2-tuple arg is received, it is handled similarly:: 

1255 

1256 grid.set_sort_defaults(('name', 'asc')) 

1257 

1258 If just one string arg is received, the default ``sortdir`` is 

1259 assumed:: 

1260 

1261 grid.set_sort_defaults('name') # assumes 'asc' 

1262 

1263 Otherwise there should be just one list arg, elements of 

1264 which are each 2-tuples of ``(sortkey, sortdir)`` info:: 

1265 

1266 grid.set_sort_defaults([('name', 'asc'), 

1267 ('value', 'desc')]) 

1268 

1269 .. note:: 

1270 

1271 Note that :attr:`sort_multiple` determines whether the grid 

1272 is actually allowed to have multiple sort defaults. The 

1273 defaults requested by the method call may be pruned if 

1274 necessary to accommodate that. 

1275 

1276 Default sorting info is tracked via :attr:`sort_defaults`. 

1277 """ 

1278 

1279 # convert args to sort defaults 

1280 sort_defaults = [] 

1281 if len(args) == 1: 

1282 if isinstance(args[0], str): 

1283 sort_defaults = [SortInfo(args[0], "asc")] 

1284 elif isinstance(args[0], tuple) and len(args[0]) == 2: 

1285 sort_defaults = [SortInfo(*args[0])] 

1286 elif isinstance(args[0], list): 

1287 sort_defaults = [SortInfo(*tup) for tup in args[0]] 

1288 else: 

1289 raise ValueError( 

1290 "for just one positional arg, must pass string, 2-tuple or list" 

1291 ) 

1292 elif len(args) == 2: 

1293 sort_defaults = [SortInfo(*args)] 

1294 else: 

1295 raise ValueError("must pass just one or two positional args") 

1296 

1297 # prune if multi-column requested but not supported 

1298 if len(sort_defaults) > 1 and not self.sort_multiple: 

1299 log.warning( 

1300 "multi-column sorting is not enabled for the instance; " 

1301 "list will be pruned to first element for '%s' grid: %s", 

1302 self.key, 

1303 sort_defaults, 

1304 ) 

1305 sort_defaults = [sort_defaults[0]] 

1306 

1307 self.sort_defaults = sort_defaults 

1308 

1309 def is_sortable(self, key): 

1310 """ 

1311 Returns boolean indicating if a given column should allow 

1312 sorting. 

1313 

1314 If :attr:`sortable` is false, this always returns ``False``. 

1315 

1316 For frontend sorting (i.e. :attr:`sort_on_backend` is false), 

1317 this always returns ``True``. 

1318 

1319 For backend sorting, may return true or false depending on 

1320 whether the column is listed in :attr:`sorters`. 

1321 

1322 :param key: Column key as string. 

1323 

1324 See also :meth:`set_sorter()`. 

1325 """ 

1326 if not self.sortable: 

1327 return False 

1328 if self.sort_on_backend: 

1329 return key in self.sorters 

1330 return True 

1331 

1332 ############################## 

1333 # filtering methods 

1334 ############################## 

1335 

1336 def make_backend_filters(self, filters=None): 

1337 """ 

1338 Make "automatic" backend filters for the grid. 

1339 

1340 This is called by the constructor, if :attr:`filterable` is 

1341 true. 

1342 

1343 For each "column" in the model class, this will call 

1344 :meth:`make_filter()` to add an automatic filter. However it 

1345 first checks the provided ``filters`` and will not override 

1346 any of those. 

1347 

1348 .. note:: 

1349 

1350 This only works if grid has a :attr:`model_class`. If not, 

1351 this method just returns the initial filters (or empty 

1352 dict). 

1353 

1354 :param filters: Optional dict of initial filters. Any 

1355 existing filters will be left intact, not replaced. 

1356 

1357 :returns: Final dict of all filters. Includes any from the 

1358 initial ``filters`` param as well as any which were 

1359 created. 

1360 """ 

1361 filters = filters or {} 

1362 

1363 if self.model_class: 

1364 

1365 # nb. i have found this confusing for some reason. some 

1366 # things i've tried so far include: 

1367 # 

1368 # i first tried self.get_model_columns() but my notes say 

1369 # that was too aggressive in many cases. 

1370 # 

1371 # then i tried using the *subset* of self.columns, just 

1372 # the ones which correspond to a property on the model 

1373 # class. but sometimes that skips filters we need. 

1374 # 

1375 # then i tried get_columns() from sa-utils to give the 

1376 # "true" column list, but that fails when the underlying 

1377 # column has different name than the prop/attr key. 

1378 # 

1379 # so now, we are looking directly at the sa mapper, for 

1380 # all column attrs and then using the prop key. 

1381 

1382 inspector = sa.inspect(self.model_class) 

1383 for prop in inspector.column_attrs: 

1384 

1385 # do not overwrite existing filters 

1386 if prop.key in filters: 

1387 continue 

1388 

1389 # do not create filter for UUID field 

1390 if len(prop.columns) == 1 and isinstance(prop.columns[0].type, UUID): 

1391 continue 

1392 

1393 attr = getattr(self.model_class, prop.key) 

1394 filters[prop.key] = self.make_filter(attr) 

1395 

1396 return filters 

1397 

1398 def make_filter(self, columninfo, **kwargs): 

1399 """ 

1400 Create and return a 

1401 :class:`~wuttaweb.grids.filters.GridFilter` instance suitable 

1402 for use on the given column. 

1403 

1404 Code usually does not need to call this directly. See also 

1405 :meth:`set_filter()`, which calls this method automatically. 

1406 

1407 :param columninfo: Can be either a model property 

1408 (e.g. ``model.User.username``), or a column name 

1409 (e.g. ``"username"``). 

1410 

1411 :returns: :class:`~wuttaweb.grids.filters.GridFilter` 

1412 instance. 

1413 """ 

1414 key = kwargs.pop("key", None) 

1415 

1416 # model_property is required 

1417 model_property = None 

1418 if kwargs.get("model_property"): 

1419 model_property = kwargs["model_property"] 

1420 elif isinstance(columninfo, str): 

1421 key = columninfo 

1422 if self.model_class: 

1423 model_property = getattr(self.model_class, key, None) 

1424 if not model_property: 

1425 raise ValueError(f"cannot locate model property for key: {key}") 

1426 else: 

1427 model_property = columninfo 

1428 

1429 # optional factory override 

1430 factory = kwargs.pop("factory", None) 

1431 if not factory: 

1432 typ = model_property.type 

1433 factory = default_sqlalchemy_filters.get(type(typ)) 

1434 if not factory: 

1435 factory = default_sqlalchemy_filters[None] 

1436 

1437 # make filter 

1438 kwargs["model_property"] = model_property 

1439 return factory(self.request, key or model_property.key, **kwargs) 

1440 

1441 def set_filter(self, key, filterinfo=None, **kwargs): 

1442 """ 

1443 Set/override the backend filter for a column. 

1444 

1445 Only relevant if :attr:`filterable` is true. 

1446 

1447 :param key: Name of column. 

1448 

1449 :param filterinfo: Can be either a filter factory, or else a 

1450 model property (e.g. ``model.User.username``) or column 

1451 name (e.g. ``"username"``). If not specified then the 

1452 ``key`` will be used instead. 

1453 

1454 :param \\**kwargs: Additional kwargs to pass along to the 

1455 filter factory. 

1456 

1457 If ``filterinfo`` is a factory, it will be called with the 

1458 current request, key and kwargs like so:: 

1459 

1460 filtr = factory(self.request, key, **kwargs) 

1461 

1462 Otherwise :meth:`make_filter()` will be called to obtain the 

1463 backend filter. The ``filterinfo`` will be passed along to 

1464 that call; if it is empty then ``key`` will be used instead. 

1465 

1466 See also :meth:`remove_filter()`. Backend filters are tracked 

1467 via :attr:`filters`. 

1468 """ 

1469 filtr = None 

1470 

1471 if filterinfo and callable(filterinfo): 

1472 kwargs.setdefault("label", self.get_filter_label(key)) 

1473 filtr = filterinfo(self.request, key, **kwargs) 

1474 

1475 else: 

1476 kwargs["key"] = key 

1477 kwargs.setdefault("label", self.get_filter_label(key)) 

1478 filtr = self.make_filter(filterinfo or key, **kwargs) 

1479 

1480 self.filters[key] = filtr 

1481 

1482 def remove_filter(self, key): 

1483 """ 

1484 Remove the backend filter for a column. 

1485 

1486 This removes the filter *instance*, so there is no way to 

1487 filter by this column unless another filter is later defined 

1488 for it. 

1489 

1490 See also :meth:`set_filter()`. 

1491 """ 

1492 self.filters.pop(key, None) 

1493 

1494 def set_filter_defaults(self, **defaults): 

1495 """ 

1496 Set default state preferences for the grid filters. 

1497 

1498 These preferences will affect the initial grid display, until 

1499 user requests a different filtering method. 

1500 

1501 Each kwarg should be named by filter key, and the value should 

1502 be a dict of preferences for that filter. For instance:: 

1503 

1504 grid.set_filter_defaults(name={'active': True, 

1505 'verb': 'contains', 

1506 'value': 'foo'}, 

1507 value={'active': True}) 

1508 

1509 Filter defaults are tracked via :attr:`filter_defaults`. 

1510 """ 

1511 filter_defaults = dict(getattr(self, "filter_defaults", {})) 

1512 

1513 for key, values in defaults.items(): 

1514 filtr = filter_defaults.setdefault(key, {}) 

1515 filtr.update(values) 

1516 

1517 self.filter_defaults = filter_defaults 

1518 

1519 ############################## 

1520 # paging methods 

1521 ############################## 

1522 

1523 def get_pagesize_options(self, default=None): 

1524 """ 

1525 Returns a list of default page size options for the grid. 

1526 

1527 It will check config but if no setting exists, will fall 

1528 back to:: 

1529 

1530 [5, 10, 20, 50, 100, 200] 

1531 

1532 :param default: Alternate default value to return if none is 

1533 configured. 

1534 

1535 This method is intended for use in the constructor. Code can 

1536 instead access :attr:`pagesize_options` directly. 

1537 """ 

1538 options = self.config.get_list("wuttaweb.grids.default_pagesize_options") 

1539 if options: 

1540 options = [int(size) for size in options if size.isdigit()] 

1541 if options: 

1542 return options 

1543 

1544 return default or [5, 10, 20, 50, 100, 200] 

1545 

1546 def get_pagesize(self, default=None, configured=True): 

1547 """ 

1548 Returns the page size for the grid. 

1549 

1550 This normally will first check config and return that value if 

1551 present; otherwise falling back to the default. But if you 

1552 really want to know the effective default, then specify 

1553 ``configured=False`` to skip the config check. 

1554 

1555 If you do not specify the ``default`` value then 

1556 :attr:`pagesize_options` will be consulted. If ``20`` is one 

1557 of the options, that is the preferred default. Otherwise the 

1558 first option found is assumed to be default. 

1559 

1560 :param configured: This method first checks for the 

1561 *configured* page size and always returns that if present. 

1562 Set this param to ``False`` in order to "never" return the 

1563 configured value. 

1564 

1565 :param default: Alternate default value to return if none is 

1566 configured. 

1567 

1568 This method is mainly intended for use in the constructor. 

1569 Code can normally access :attr:`pagesize` directly instead. 

1570 """ 

1571 if configured: 

1572 if size := self.config.get_int("wuttaweb.grids.default_pagesize"): 

1573 return size 

1574 

1575 if default: 

1576 return default 

1577 

1578 if 20 in self.pagesize_options: 

1579 return 20 

1580 

1581 return self.pagesize_options[0] 

1582 

1583 ############################## 

1584 # configuration methods 

1585 ############################## 

1586 

1587 def load_settings( # pylint: disable=too-many-branches,too-many-statements 

1588 self, persist=True 

1589 ): 

1590 """ 

1591 Load all effective settings for the grid. 

1592 

1593 If the request GET params (query string) contains grid 

1594 settings, they are used; otherwise the settings are loaded 

1595 from user session. 

1596 

1597 .. note:: 

1598 

1599 As of now, "sorting" and "pagination" settings are the only 

1600 type supported by this logic. Settings for "filtering" 

1601 coming soon... 

1602 

1603 The overall logic for this method is as follows: 

1604 

1605 * collect settings 

1606 * apply settings to current grid 

1607 * optionally save settings to user session 

1608 

1609 Saving the settings to user session will allow the grid to 

1610 remember its current settings when user refreshes the page, or 

1611 navigates away then comes back. Therefore normally, settings 

1612 are saved each time they are loaded. Note that such settings 

1613 are wiped upon user logout. 

1614 

1615 :param persist: Whether the collected settings should be saved 

1616 to the user session. 

1617 """ 

1618 

1619 # initial default settings 

1620 settings = {} 

1621 if self.filterable: 

1622 for filtr in self.filters.values(): 

1623 defaults = self.filter_defaults.get(filtr.key, {}) 

1624 settings[f"filter.{filtr.key}.active"] = defaults.get( 

1625 "active", filtr.default_active 

1626 ) 

1627 settings[f"filter.{filtr.key}.verb"] = defaults.get( 

1628 "verb", filtr.get_default_verb() 

1629 ) 

1630 settings[f"filter.{filtr.key}.value"] = defaults.get( 

1631 "value", filtr.default_value 

1632 ) 

1633 if self.sortable: 

1634 if self.sort_defaults: 

1635 # nb. as of writing neither Buefy nor Oruga support a 

1636 # multi-column *default* sort; so just use first sorter 

1637 sortinfo = self.sort_defaults[0] 

1638 settings["sorters.length"] = 1 

1639 settings["sorters.1.key"] = sortinfo.sortkey 

1640 settings["sorters.1.dir"] = sortinfo.sortdir 

1641 else: 

1642 settings["sorters.length"] = 0 

1643 if self.paginated and self.paginate_on_backend: 

1644 settings["pagesize"] = self.pagesize 

1645 settings["page"] = self.page 

1646 

1647 # update settings dict based on what we find in the request 

1648 # and/or user session. always prioritize the former. 

1649 

1650 # nb. do not read settings if user wants a reset 

1651 if self.request.GET.get("reset-view"): 

1652 # at this point we only have default settings, and we want 

1653 # to keep those *and* persist them for next time, below 

1654 pass 

1655 

1656 elif self.request_has_settings("filter"): 

1657 self.update_filter_settings(settings, src="request") 

1658 if self.request_has_settings("sort"): 

1659 self.update_sort_settings(settings, src="request") 

1660 else: 

1661 self.update_sort_settings(settings, src="session") 

1662 self.update_page_settings(settings) 

1663 

1664 elif self.request_has_settings("sort"): 

1665 self.update_filter_settings(settings, src="session") 

1666 self.update_sort_settings(settings, src="request") 

1667 self.update_page_settings(settings) 

1668 

1669 elif self.request_has_settings("page"): 

1670 self.update_filter_settings(settings, src="session") 

1671 self.update_sort_settings(settings, src="session") 

1672 self.update_page_settings(settings) 

1673 

1674 else: 

1675 # nothing found in request, so nothing new to save 

1676 persist = False 

1677 

1678 # but still should load whatever is in user session 

1679 self.update_filter_settings(settings, src="session") 

1680 self.update_sort_settings(settings, src="session") 

1681 self.update_page_settings(settings) 

1682 

1683 # maybe save settings in user session, for next time 

1684 if persist: 

1685 self.persist_settings(settings, dest="session") 

1686 

1687 # update ourself to reflect settings dict.. 

1688 

1689 # filtering 

1690 if self.filterable: 

1691 for filtr in self.filters.values(): 

1692 filtr.active = settings[f"filter.{filtr.key}.active"] 

1693 filtr.verb = ( 

1694 settings[f"filter.{filtr.key}.verb"] or filtr.get_default_verb() 

1695 ) 

1696 filtr.value = settings[f"filter.{filtr.key}.value"] 

1697 

1698 # sorting 

1699 if self.sortable: 

1700 # nb. doing this for frontend sorting also 

1701 self.active_sorters = [] 

1702 for i in range(1, settings["sorters.length"] + 1): 

1703 self.active_sorters.append( 

1704 { 

1705 "key": settings[f"sorters.{i}.key"], 

1706 "dir": settings[f"sorters.{i}.dir"], 

1707 } 

1708 ) 

1709 # TODO: i thought this was needed, but now idk? 

1710 # # nb. when showing full index page (i.e. not partial) 

1711 # # this implies we must set the default sorter for Vue 

1712 # # component, and only single-column is allowed there. 

1713 # if not self.request.GET.get('partial'): 

1714 # break 

1715 

1716 # paging 

1717 if self.paginated and self.paginate_on_backend: 

1718 self.pagesize = settings["pagesize"] 

1719 self.page = settings["page"] 

1720 

1721 def request_has_settings(self, typ): # pylint: disable=empty-docstring 

1722 """ """ 

1723 

1724 if typ == "filter" and self.filterable: 

1725 for filtr in self.filters.values(): 

1726 if filtr.key in self.request.GET: 

1727 return True 

1728 if "filter" in self.request.GET: # user may be applying empty filters 

1729 return True 

1730 

1731 elif typ == "sort" and self.sortable and self.sort_on_backend: 

1732 if "sort1key" in self.request.GET: 

1733 return True 

1734 

1735 elif typ == "page" and self.paginated and self.paginate_on_backend: 

1736 for key in ["pagesize", "page"]: 

1737 if key in self.request.GET: 

1738 return True 

1739 

1740 return False 

1741 

1742 def get_setting( # pylint: disable=empty-docstring,too-many-arguments,too-many-positional-arguments 

1743 self, settings, key, src="session", default=None, normalize=lambda v: v 

1744 ): 

1745 """ """ 

1746 

1747 if src == "request": 

1748 value = self.request.GET.get(key) 

1749 if value is not None: 

1750 try: 

1751 return normalize(value) 

1752 except ValueError: 

1753 pass 

1754 

1755 elif src == "session": 

1756 value = self.request.session.get(f"grid.{self.key}.{key}") 

1757 if value is not None: 

1758 return normalize(value) 

1759 

1760 # if src had nothing, try default/existing settings 

1761 value = settings.get(key) 

1762 if value is not None: 

1763 return normalize(value) 

1764 

1765 # okay then, default it is 

1766 return default 

1767 

1768 def update_filter_settings( # pylint: disable=empty-docstring 

1769 self, settings, src=None 

1770 ): 

1771 """ """ 

1772 if not self.filterable: 

1773 return 

1774 

1775 for filtr in self.filters.values(): 

1776 prefix = f"filter.{filtr.key}" 

1777 

1778 if src == "request": 

1779 # consider filter active if query string contains a value for it 

1780 settings[f"{prefix}.active"] = filtr.key in self.request.GET 

1781 settings[f"{prefix}.verb"] = self.get_setting( 

1782 settings, f"{filtr.key}.verb", src="request", default="" 

1783 ) 

1784 settings[f"{prefix}.value"] = self.get_setting( 

1785 settings, filtr.key, src="request", default="" 

1786 ) 

1787 

1788 elif src == "session": 

1789 settings[f"{prefix}.active"] = self.get_setting( 

1790 settings, 

1791 f"{prefix}.active", 

1792 src="session", 

1793 normalize=lambda v: str(v).lower() == "true", 

1794 default=False, 

1795 ) 

1796 settings[f"{prefix}.verb"] = self.get_setting( 

1797 settings, f"{prefix}.verb", src="session", default="" 

1798 ) 

1799 settings[f"{prefix}.value"] = self.get_setting( 

1800 settings, f"{prefix}.value", src="session", default="" 

1801 ) 

1802 

1803 def update_sort_settings( # pylint: disable=empty-docstring 

1804 self, settings, src=None 

1805 ): 

1806 """ """ 

1807 if not (self.sortable and self.sort_on_backend): 

1808 return 

1809 

1810 if src == "request": 

1811 i = 1 

1812 while True: 

1813 skey = f"sort{i}key" 

1814 if skey in self.request.GET: 

1815 settings[f"sorters.{i}.key"] = self.get_setting( 

1816 settings, skey, src="request" 

1817 ) 

1818 settings[f"sorters.{i}.dir"] = self.get_setting( 

1819 settings, f"sort{i}dir", src="request", default="asc" 

1820 ) 

1821 else: 

1822 break 

1823 i += 1 

1824 settings["sorters.length"] = i - 1 

1825 

1826 elif src == "session": 

1827 settings["sorters.length"] = self.get_setting( 

1828 settings, "sorters.length", src="session", normalize=int 

1829 ) 

1830 for i in range(1, settings["sorters.length"] + 1): 

1831 for key in ("key", "dir"): 

1832 skey = f"sorters.{i}.{key}" 

1833 settings[skey] = self.get_setting(settings, skey, src="session") 

1834 

1835 def update_page_settings(self, settings): # pylint: disable=empty-docstring 

1836 """ """ 

1837 if not (self.paginated and self.paginate_on_backend): 

1838 return 

1839 

1840 # update the settings dict from request and/or user session 

1841 

1842 # pagesize 

1843 pagesize = self.request.GET.get("pagesize") 

1844 if pagesize is not None: 

1845 if pagesize.isdigit(): 

1846 settings["pagesize"] = int(pagesize) 

1847 else: 

1848 pagesize = self.request.session.get(f"grid.{self.key}.pagesize") 

1849 if pagesize is not None: 

1850 settings["pagesize"] = pagesize 

1851 

1852 # page 

1853 page = self.request.GET.get("page") 

1854 if page is not None: 

1855 if page.isdigit(): 

1856 settings["page"] = int(page) 

1857 else: 

1858 page = self.request.session.get(f"grid.{self.key}.page") 

1859 if page is not None: 

1860 settings["page"] = int(page) 

1861 

1862 def persist_settings(self, settings, dest=None): # pylint: disable=empty-docstring 

1863 """ """ 

1864 if dest not in ("session",): 

1865 raise ValueError(f"invalid dest identifier: {dest}") 

1866 

1867 # func to save a setting value to user session 

1868 def persist(key, value=settings.get): 

1869 assert dest == "session" 

1870 skey = f"grid.{self.key}.{key}" 

1871 self.request.session[skey] = value(key) 

1872 

1873 # filter settings 

1874 if self.filterable: 

1875 

1876 # always save all filters, with status 

1877 for filtr in self.filters.values(): 

1878 persist( 

1879 f"filter.{filtr.key}.active", 

1880 value=lambda k: "true" if settings.get(k) else "false", 

1881 ) 

1882 persist(f"filter.{filtr.key}.verb") 

1883 persist(f"filter.{filtr.key}.value") 

1884 

1885 # sort settings 

1886 if self.sortable and self.sort_on_backend: 

1887 

1888 # first must clear all sort settings from dest. this is 

1889 # because number of sort settings will vary, so we delete 

1890 # all and then write all 

1891 

1892 if dest == "session": 

1893 # remove sort settings from user session 

1894 prefix = f"grid.{self.key}.sorters." 

1895 for key in list(self.request.session): 

1896 if key.startswith(prefix): 

1897 del self.request.session[key] 

1898 

1899 # now save sort settings to dest 

1900 if "sorters.length" in settings: 

1901 persist("sorters.length") 

1902 for i in range(1, settings["sorters.length"] + 1): 

1903 persist(f"sorters.{i}.key") 

1904 persist(f"sorters.{i}.dir") 

1905 

1906 # pagination settings 

1907 if self.paginated and self.paginate_on_backend: 

1908 

1909 # save to dest 

1910 persist("pagesize") 

1911 persist("page") 

1912 

1913 ############################## 

1914 # data methods 

1915 ############################## 

1916 

1917 def get_visible_data(self): 

1918 """ 

1919 Returns the "effective" visible data for the grid. 

1920 

1921 This uses :attr:`data` as the starting point but may morph it 

1922 for pagination etc. per the grid settings. 

1923 

1924 Code can either access :attr:`data` directly, or call this 

1925 method to get only the data for current view (e.g. assuming 

1926 pagination is used), depending on the need. 

1927 

1928 See also these methods which may be called by this one: 

1929 

1930 * :meth:`filter_data()` 

1931 * :meth:`sort_data()` 

1932 * :meth:`paginate_data()` 

1933 """ 

1934 data = self.data or [] 

1935 self.joined = set() 

1936 

1937 if self.filterable: 

1938 data = self.filter_data(data) 

1939 

1940 if self.sortable and self.sort_on_backend: 

1941 data = self.sort_data(data) 

1942 

1943 if self.paginated and self.paginate_on_backend: 

1944 self.pager = self.paginate_data(data) 

1945 data = self.pager 

1946 

1947 return data 

1948 

1949 @property 

1950 def active_filters(self): 

1951 """ 

1952 Returns the list of currently active filters. 

1953 

1954 This inspects each :class:`~wuttaweb.grids.filters.GridFilter` 

1955 in :attr:`filters` and only returns the ones marked active. 

1956 """ 

1957 return [filtr for filtr in self.filters.values() if filtr.active] 

1958 

1959 def filter_data(self, data, filters=None): 

1960 """ 

1961 Filter the given data and return the result. This is called 

1962 by :meth:`get_visible_data()`. 

1963 

1964 :param filters: Optional list of filters to use. If not 

1965 specified, the grid's :attr:`active_filters` are used. 

1966 """ 

1967 if filters is None: 

1968 filters = self.active_filters 

1969 if not filters: 

1970 return data 

1971 

1972 for filtr in filters: 

1973 key = filtr.key 

1974 

1975 if key in self.joiners and key not in self.joined: 

1976 data = self.joiners[key](data) 

1977 self.joined.add(key) 

1978 

1979 try: 

1980 data = filtr.apply_filter(data) 

1981 except VerbNotSupported as error: 

1982 log.warning("verb not supported for '%s' filter: %s", key, error.verb) 

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

1984 log.exception("filtering data by '%s' failed!", key) 

1985 

1986 return data 

1987 

1988 def sort_data(self, data, sorters=None): 

1989 """ 

1990 Sort the given data and return the result. This is called by 

1991 :meth:`get_visible_data()`. 

1992 

1993 :param sorters: Optional list of sorters to use. If not 

1994 specified, the grid's :attr:`active_sorters` are used. 

1995 """ 

1996 if sorters is None: 

1997 sorters = self.active_sorters 

1998 if not sorters: 

1999 return data 

2000 

2001 # nb. when data is a query, we want to apply sorters in the 

2002 # requested order, so the final query has order_by() in the 

2003 # correct "as-is" sequence. however when data is a list we 

2004 # must do the opposite, applying in the reverse order, so the 

2005 # final list has the most "important" sort(s) applied last. 

2006 if not isinstance(data, orm.Query): 

2007 sorters = reversed(sorters) 

2008 

2009 for sorter in sorters: 

2010 sortkey = sorter["key"] 

2011 sortdir = sorter["dir"] 

2012 

2013 # cannot sort unless we have a sorter callable 

2014 sortfunc = self.sorters.get(sortkey) 

2015 if not sortfunc: 

2016 return data 

2017 

2018 # join appropriate model if needed 

2019 if sortkey in self.joiners and sortkey not in self.joined: 

2020 data = self.joiners[sortkey](data) 

2021 self.joined.add(sortkey) 

2022 

2023 # invoke the sorter 

2024 data = sortfunc(data, sortdir) 

2025 

2026 return data 

2027 

2028 def paginate_data(self, data): 

2029 """ 

2030 Apply pagination to the given data set, based on grid settings. 

2031 

2032 This returns a "pager" object which can then be used as a 

2033 "data replacement" in subsequent logic. 

2034 

2035 This method is called by :meth:`get_visible_data()`. 

2036 """ 

2037 if isinstance(data, orm.Query): 

2038 pager = SqlalchemyOrmPage( 

2039 data, items_per_page=self.pagesize, page=self.page 

2040 ) 

2041 

2042 else: 

2043 pager = paginate.Page(data, items_per_page=self.pagesize, page=self.page) 

2044 

2045 # pager may have detected that our current page is outside the 

2046 # valid range. if so we should update ourself to match 

2047 if pager.page != self.page: 

2048 self.page = pager.page 

2049 key = f"grid.{self.key}.page" 

2050 if key in self.request.session: 

2051 self.request.session[key] = self.page 

2052 

2053 # and re-make the pager just to be safe (?) 

2054 pager = self.paginate_data(data) 

2055 

2056 return pager 

2057 

2058 ############################## 

2059 # rendering methods 

2060 ############################## 

2061 

2062 def render_batch_id(self, obj, key, value): # pylint: disable=unused-argument 

2063 """ 

2064 Column renderer for batch ID values. 

2065 

2066 This is not used automatically but you can use it explicitly:: 

2067 

2068 grid.set_renderer('foo', 'batch_id') 

2069 """ 

2070 if value is None: 

2071 return "" 

2072 

2073 batch_id = int(value) 

2074 return f"{batch_id:08d}" 

2075 

2076 def render_boolean(self, obj, key, value): # pylint: disable=unused-argument 

2077 """ 

2078 Column renderer for boolean values. 

2079 

2080 This calls 

2081 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_boolean()` 

2082 for the return value. 

2083 

2084 This may be used automatically per 

2085 :meth:`set_default_renderers()` or you can use it explicitly:: 

2086 

2087 grid.set_renderer('foo', 'boolean') 

2088 """ 

2089 return self.app.render_boolean(value) 

2090 

2091 def render_currency( # pylint: disable=unused-argument 

2092 self, obj, key, value, **kwargs 

2093 ): 

2094 """ 

2095 Column renderer for currency values. 

2096 

2097 This calls 

2098 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()` 

2099 for the return value. 

2100 

2101 This is not used automatically but you can use it explicitly:: 

2102 

2103 grid.set_renderer('foo', 'currency') 

2104 grid.set_renderer('foo', 'currency', scale=4) 

2105 """ 

2106 return self.app.render_currency(value, **kwargs) 

2107 

2108 def render_date(self, obj, key, value): # pylint: disable=unused-argument 

2109 """ 

2110 Column renderer for :class:`python:datetime.date` values. 

2111 

2112 This calls 

2113 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_date()` 

2114 for the return value. 

2115 

2116 This may be used automatically per 

2117 :meth:`set_default_renderers()` or you can use it explicitly:: 

2118 

2119 grid.set_renderer('foo', 'date') 

2120 """ 

2121 try: 

2122 dt = getattr(obj, key) 

2123 except AttributeError: 

2124 dt = obj[key] 

2125 return self.app.render_date(dt) 

2126 

2127 def render_datetime(self, obj, key, value): # pylint: disable=unused-argument 

2128 """ 

2129 Column renderer for :class:`python:datetime.datetime` values. 

2130 

2131 This calls 

2132 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()` 

2133 for the return value. 

2134 

2135 This may be used automatically per 

2136 :meth:`set_default_renderers()` or you can use it explicitly:: 

2137 

2138 grid.set_renderer('foo', 'datetime') 

2139 """ 

2140 try: 

2141 dt = getattr(obj, key) 

2142 except AttributeError: 

2143 dt = obj[key] 

2144 return self.app.render_datetime(dt, html=True) 

2145 

2146 def render_enum(self, obj, key, value, enum=None): 

2147 """ 

2148 Custom grid value renderer for "enum" fields. 

2149 

2150 See also :meth:`set_enum()`. 

2151 

2152 :param enum: Enum class for the field. This should be an 

2153 instance of :class:`~python:enum.Enum` or else a dict. 

2154 

2155 To use this feature for your grid:: 

2156 

2157 from enum import Enum 

2158 

2159 class MyEnum(Enum): 

2160 ONE = 1 

2161 TWO = 2 

2162 THREE = 3 

2163 

2164 grid.set_enum("my_enum_field", MyEnum) 

2165 

2166 Or, perhaps more common:: 

2167 

2168 myenum = { 

2169 1: "ONE", 

2170 2: "TWO", 

2171 3: "THREE", 

2172 } 

2173 

2174 grid.set_enum("my_enum_field", myenum) 

2175 """ 

2176 if enum: 

2177 

2178 if isinstance(enum, EnumType): 

2179 if raw_value := obj[key]: 

2180 return raw_value.value 

2181 

2182 if isinstance(enum, dict): 

2183 return enum.get(value, value) 

2184 

2185 return value 

2186 

2187 def render_percent( # pylint: disable=unused-argument 

2188 self, obj, key, value, **kwargs 

2189 ): 

2190 """ 

2191 Column renderer for percentage values. 

2192 

2193 This calls 

2194 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_percent()` 

2195 for the return value. 

2196 

2197 This is not used automatically but you can use it explicitly:: 

2198 

2199 grid.set_renderer('foo', 'percent') 

2200 """ 

2201 return self.app.render_percent(value, **kwargs) 

2202 

2203 def render_quantity(self, obj, key, value): # pylint: disable=unused-argument 

2204 """ 

2205 Column renderer for quantity values. 

2206 

2207 This calls 

2208 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()` 

2209 for the return value. 

2210 

2211 This is not used automatically but you can use it explicitly:: 

2212 

2213 grid.set_renderer('foo', 'quantity') 

2214 """ 

2215 return self.app.render_quantity(value) 

2216 

2217 def render_table_element( 

2218 self, form=None, template="/grids/table_element.mako", **context 

2219 ): 

2220 """ 

2221 Render a simple Vue table element for the grid. 

2222 

2223 This is what you want for a "simple" grid which does not 

2224 require a unique Vue component, but can instead use the 

2225 standard table component. 

2226 

2227 This returns something like: 

2228 

2229 .. code-block:: html 

2230 

2231 <b-table :data="gridContext['mykey'].data"> 

2232 <!-- columns etc. --> 

2233 </b-table> 

2234 

2235 See :meth:`render_vue_template()` for a more complete variant. 

2236 

2237 Actual output will of course depend on grid attributes, 

2238 :attr:`key`, :attr:`columns` etc. 

2239 

2240 :param form: Reference to the 

2241 :class:`~wuttaweb.forms.base.Form` instance which 

2242 "contains" this grid. This is needed in order to ensure 

2243 the grid data is available to the form Vue component. 

2244 

2245 :param template: Path to Mako template which is used to render 

2246 the output. 

2247 

2248 .. note:: 

2249 

2250 The above example shows ``gridContext['mykey'].data`` as 

2251 the Vue data reference. This should "just work" if you 

2252 provide the correct ``form`` arg and the grid is contained 

2253 directly by that form's Vue component. 

2254 

2255 However, this may not account for all use cases. For now 

2256 we wait and see what comes up, but know the dust may not 

2257 yet be settled here. 

2258 """ 

2259 

2260 # nb. must register data for inclusion on page template 

2261 if form: 

2262 form.add_grid_vue_context(self) 

2263 

2264 # otherwise logic is the same, just different template 

2265 return self.render_vue_template(template=template, **context) 

2266 

2267 def render_vue_tag(self, **kwargs): 

2268 """ 

2269 Render the Vue component tag for the grid. 

2270 

2271 By default this simply returns: 

2272 

2273 .. code-block:: html 

2274 

2275 <wutta-grid></wutta-grid> 

2276 

2277 The actual output will depend on various grid attributes, in 

2278 particular :attr:`vue_tagname`. 

2279 """ 

2280 return HTML.tag(self.vue_tagname, **kwargs) 

2281 

2282 def render_vue_template(self, template="/grids/vue_template.mako", **context): 

2283 """ 

2284 Render the Vue template block for the grid. 

2285 

2286 This is what you want for a "full-featured" grid which will 

2287 exist as its own unique Vue component on the frontend. 

2288 

2289 This returns something like: 

2290 

2291 .. code-block:: none 

2292 

2293 <script type="text/x-template" id="wutta-grid-template"> 

2294 <b-table> 

2295 <!-- columns etc. --> 

2296 </b-table> 

2297 </script> 

2298 

2299 <script> 

2300 WuttaGridData = {} 

2301 WuttaGrid = { 

2302 template: 'wutta-grid-template', 

2303 } 

2304 </script> 

2305 

2306 .. todo:: 

2307 

2308 Why can't Sphinx render the above code block as 'html' ? 

2309 

2310 It acts like it can't handle a ``<script>`` tag at all? 

2311 

2312 See :meth:`render_table_element()` for a simpler variant. 

2313 

2314 Actual output will of course depend on grid attributes, 

2315 :attr:`vue_tagname` and :attr:`columns` etc. 

2316 

2317 :param template: Path to Mako template which is used to render 

2318 the output. 

2319 """ 

2320 context["grid"] = self 

2321 context.setdefault("request", self.request) 

2322 output = render(template, context) 

2323 return HTML.literal(output) 

2324 

2325 def render_vue_finalize(self): 

2326 """ 

2327 Render the Vue "finalize" script for the grid. 

2328 

2329 By default this simply returns: 

2330 

2331 .. code-block:: html 

2332 

2333 <script> 

2334 WuttaGrid.data = function() { return WuttaGridData } 

2335 Vue.component('wutta-grid', WuttaGrid) 

2336 </script> 

2337 

2338 The actual output may depend on various grid attributes, in 

2339 particular :attr:`vue_tagname`. 

2340 """ 

2341 return render_vue_finalize(self.vue_tagname, self.vue_component) 

2342 

2343 def get_vue_columns(self): 

2344 """ 

2345 Returns a list of Vue-compatible column definitions. 

2346 

2347 This uses :attr:`columns` as the basis; each definition 

2348 returned will be a dict in this format:: 

2349 

2350 { 

2351 'field': 'foo', 

2352 'label': "Foo", 

2353 'sortable': True, 

2354 'searchable': False, 

2355 } 

2356 

2357 The full format is determined by Buefy; see the Column section 

2358 in its `Table docs 

2359 <https://buefy.org/documentation/table/#api-view>`_. 

2360 

2361 See also :meth:`get_vue_context()`. 

2362 """ 

2363 if not self.columns: 

2364 raise ValueError(f"you must define columns for the grid! key = {self.key}") 

2365 

2366 columns = [] 

2367 for name in self.columns: 

2368 columns.append( 

2369 { 

2370 "field": name, 

2371 "label": self.get_column_label(name), 

2372 "hidden": self.is_hidden(name), 

2373 "sortable": self.is_sortable(name), 

2374 "searchable": self.is_searchable(name), 

2375 } 

2376 ) 

2377 return columns 

2378 

2379 def get_vue_active_sorters(self): 

2380 """ 

2381 Returns a list of Vue-compatible column sorter definitions. 

2382 

2383 The list returned is the same as :attr:`active_sorters`; 

2384 however the format used in Vue is different. So this method 

2385 just "converts" them to the required format, e.g.:: 

2386 

2387 # active_sorters format 

2388 {'key': 'name', 'dir': 'asc'} 

2389 

2390 # get_vue_active_sorters() format 

2391 {'field': 'name', 'order': 'asc'} 

2392 

2393 :returns: The :attr:`active_sorters` list, converted as 

2394 described above. 

2395 """ 

2396 sorters = [] 

2397 for sorter in self.active_sorters: 

2398 sorters.append({"field": sorter["key"], "order": sorter["dir"]}) 

2399 return sorters 

2400 

2401 def get_vue_first_sorter(self): 

2402 """ 

2403 Returns the first active sorter, if applicable. 

2404 

2405 This method is used to declare the initial sort for a simple 

2406 table component, i.e. for use with the ``table-element.mako`` 

2407 template. It generally is assumed that frontend sorting is in 

2408 use, as opposed to backend sorting, although it should work 

2409 for either scenario. 

2410 

2411 This checks :attr:`active_sorters` and if set, will use the 

2412 first sorter from that. Note that ``active_sorters`` will 

2413 *not* be set unless :meth:`load_settings()` has been called. 

2414 

2415 Otherwise this will use the first sorter from 

2416 :attr:`sort_defaults` which is defined in constructor. 

2417 

2418 :returns: The first sorter in format ``[sortkey, sortdir]``, 

2419 or ``None``. 

2420 """ 

2421 if self.active_sorters: 

2422 sorter = self.active_sorters[0] 

2423 return [sorter["key"], sorter["dir"]] 

2424 

2425 if self.sort_defaults: 

2426 sorter = self.sort_defaults[0] 

2427 return [sorter.sortkey, sorter.sortdir] 

2428 

2429 return None 

2430 

2431 def get_vue_filters(self): 

2432 """ 

2433 Returns a list of Vue-compatible filter definitions. 

2434 

2435 This returns the full set of :attr:`filters` but represents 

2436 each as a simple dict with the filter state. 

2437 """ 

2438 filters = [] 

2439 for filtr in self.filters.values(): 

2440 

2441 choices = [] 

2442 choice_labels = {} 

2443 if filtr.choices: 

2444 choices = list(filtr.choices) 

2445 choice_labels = dict(filtr.choices) 

2446 

2447 filters.append( 

2448 { 

2449 "key": filtr.key, 

2450 "data_type": filtr.data_type, 

2451 "active": filtr.active, 

2452 "visible": filtr.active, 

2453 "verbs": filtr.get_verbs(), 

2454 "verb_labels": filtr.get_verb_labels(), 

2455 "valueless_verbs": filtr.get_valueless_verbs(), 

2456 "verb": filtr.verb, 

2457 "choices": choices, 

2458 "choice_labels": choice_labels, 

2459 "value": filtr.value, 

2460 "label": filtr.label, 

2461 } 

2462 ) 

2463 return filters 

2464 

2465 def object_to_dict(self, obj): # pylint: disable=empty-docstring 

2466 """ """ 

2467 try: 

2468 dct = dict(obj) 

2469 except TypeError: 

2470 dct = dict(obj.__dict__) 

2471 dct.pop("_sa_instance_state", None) 

2472 

2473 # nb. inject association proxy(-like) fields if applicable 

2474 for field in self.columns: 

2475 if field not in dct: 

2476 try: 

2477 dct[field] = getattr(obj, field) 

2478 except AttributeError: 

2479 pass 

2480 

2481 return dct 

2482 

2483 def get_vue_context(self): 

2484 """ 

2485 Returns a dict of context for the grid, for use with the Vue 

2486 component. This contains the following keys: 

2487 

2488 * ``data`` - list of Vue-compatible data records 

2489 * ``row_classes`` - dict of per-row CSS classes 

2490 

2491 This first calls :meth:`get_visible_data()` to get the 

2492 original data set. Each record is converted to a dict. 

2493 

2494 Then it calls :func:`~wuttaweb.util.make_json_safe()` to 

2495 ensure each record can be serialized to JSON. 

2496 

2497 Then it invokes any :attr:`renderers` which are defined, to 

2498 obtain the "final" values for each record. 

2499 

2500 Then it adds a URL key/value for each of the :attr:`actions` 

2501 defined, to each record. 

2502 

2503 Then it calls :meth:`get_row_class()` for each record. If a 

2504 value is returned, it is added to the ``row_classes`` dict. 

2505 Note that this dict is keyed by "zero-based row sequence as 

2506 string" - the Vue component expects that. 

2507 

2508 :returns: Dict of grid data/CSS context as described above. 

2509 """ 

2510 original_data = self.get_visible_data() 

2511 

2512 # loop thru data 

2513 data = [] 

2514 row_classes = {} 

2515 for i, original_record in enumerate(original_data, 1): 

2516 

2517 # convert record to new dict 

2518 record = self.object_to_dict(original_record) 

2519 

2520 # discard non-declared fields (but always keep uuid) 

2521 record = { 

2522 field: record[field] 

2523 for field in record 

2524 if field in self.columns or field == "uuid" 

2525 } 

2526 

2527 # make all values safe for json 

2528 record = make_json_safe(record, warn=False) 

2529 

2530 # customize value rendering where applicable 

2531 for key, renderer in self.renderers.items(): 

2532 # nb. no need to render if column not included 

2533 if key in self.columns: 

2534 value = record.get(key, None) 

2535 record[f"_rendered_{key}"] = renderer(original_record, key, value) 

2536 

2537 # add action urls to each record 

2538 for action in self.actions: 

2539 key = f"_action_url_{action.key}" 

2540 if key not in record: 

2541 if url := action.get_url(original_record, i): 

2542 record[key] = url 

2543 

2544 # set row css class if applicable 

2545 if css_class := self.get_row_class(original_record, record, i): 

2546 # nb. use *string* zero-based index, for js compat 

2547 row_classes[str(i - 1)] = css_class 

2548 

2549 data.append(record) 

2550 

2551 return { 

2552 "data": data, 

2553 "row_classes": row_classes, 

2554 } 

2555 

2556 def get_vue_data(self): # pylint: disable=empty-docstring 

2557 """ """ 

2558 warnings.warn( 

2559 "grid.get_vue_data() is deprecated; " 

2560 "please use grid.get_vue_context() instead", 

2561 DeprecationWarning, 

2562 stacklevel=2, 

2563 ) 

2564 return self.get_vue_context()["data"] 

2565 

2566 def get_row_class(self, obj, data, i): 

2567 """ 

2568 Returns the row CSS ``class`` attribute for the given record. 

2569 This method is called by :meth:`get_vue_context()`. 

2570 

2571 This will inspect/invoke :attr:`row_class` and return the 

2572 value obtained from there. 

2573 

2574 :param obj: Reference to the original model instance. 

2575 

2576 :param data: Dict of record data for the instance; part of the 

2577 Vue grid data set in/from :meth:`get_vue_context()`. 

2578 

2579 :param i: One-based sequence for this object/record (row) 

2580 within the grid. 

2581 

2582 :returns: String of CSS class name(s), or ``None``. 

2583 """ 

2584 if self.row_class: 

2585 if callable(self.row_class): 

2586 return self.row_class(obj, data, i) 

2587 return self.row_class 

2588 return None 

2589 

2590 def get_vue_pager_stats(self): 

2591 """ 

2592 Returns a simple dict with current grid pager stats. 

2593 

2594 This is used when :attr:`paginate_on_backend` is in effect. 

2595 """ 

2596 pager = self.pager 

2597 return { 

2598 "item_count": pager.item_count, 

2599 "items_per_page": pager.items_per_page, 

2600 "page": pager.page, 

2601 "page_count": pager.page_count, 

2602 "first_item": pager.first_item, 

2603 "last_item": pager.last_item, 

2604 } 

2605 

2606 

2607class GridAction: # pylint: disable=too-many-instance-attributes 

2608 """ 

2609 Represents a "row action" hyperlink within a grid context. 

2610 

2611 All such actions are displayed as a group, in a dedicated 

2612 **Actions** column in the grid. So each row in the grid has its 

2613 own set of action links. 

2614 

2615 A :class:`Grid` can have one (or zero) or more of these in its 

2616 :attr:`~Grid.actions` list. You can call 

2617 :meth:`~wuttaweb.views.base.View.make_grid_action()` to add custom 

2618 actions from within a view. 

2619 

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

2621 

2622 .. note:: 

2623 

2624 Some parameters are not explicitly described above. However 

2625 their corresponding attributes are described below. 

2626 

2627 .. attribute:: key 

2628 

2629 String key for the action (e.g. ``'edit'``), unique within the 

2630 grid. 

2631 

2632 .. attribute:: label 

2633 

2634 Label to be displayed for the action link. If not set, will be 

2635 generated from :attr:`key` by calling 

2636 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.make_title()`. 

2637 

2638 See also :meth:`render_label()`. 

2639 

2640 .. attribute:: url 

2641 

2642 URL for the action link, if applicable. This *can* be a simple 

2643 string, however that will cause every row in the grid to have 

2644 the same URL for this action. 

2645 

2646 A better way is to specify a callable which can return a unique 

2647 URL for each record. The callable should expect ``(obj, i)`` 

2648 args, for instance:: 

2649 

2650 def myurl(obj, i): 

2651 return request.route_url('widgets.view', uuid=obj.uuid) 

2652 

2653 action = GridAction(request, 'view', url=myurl) 

2654 

2655 See also :meth:`get_url()`. 

2656 

2657 .. attribute:: target 

2658 

2659 Optional ``target`` attribute for the ``<a>`` tag. 

2660 

2661 .. attribute:: click_handler 

2662 

2663 Optional JS click handler for the action. This value will be 

2664 rendered as-is within the final grid template, hence the JS 

2665 string must be callable code. Note that ``props.row`` will be 

2666 available in the calling context, so a couple of examples: 

2667 

2668 * ``deleteThisThing(props.row)`` 

2669 * ``$emit('do-something', props.row)`` 

2670 

2671 .. attribute:: icon 

2672 

2673 Name of icon to be shown for the action link. 

2674 

2675 See also :meth:`render_icon()`. 

2676 

2677 .. attribute:: link_class 

2678 

2679 Optional HTML class attribute for the action's ``<a>`` tag. 

2680 """ 

2681 

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

2683 self, 

2684 request, 

2685 key, 

2686 label=None, 

2687 url=None, 

2688 target=None, 

2689 click_handler=None, 

2690 icon=None, 

2691 link_class=None, 

2692 ): 

2693 self.request = request 

2694 self.config = self.request.wutta_config 

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

2696 self.key = key 

2697 self.url = url 

2698 self.target = target 

2699 self.click_handler = click_handler 

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

2701 self.icon = icon or key 

2702 self.link_class = link_class or "" 

2703 

2704 def render_icon_and_label(self): 

2705 """ 

2706 Render the HTML snippet for action link icon and label. 

2707 

2708 Default logic returns the output from :meth:`render_icon()` 

2709 and :meth:`render_label()`. 

2710 """ 

2711 html = [ 

2712 self.render_icon(), 

2713 HTML.literal("&nbsp;"), 

2714 self.render_label(), 

2715 ] 

2716 return HTML.tag("span", c=html, style="white-space: nowrap;") 

2717 

2718 def render_icon(self): 

2719 """ 

2720 Render the HTML snippet for the action link icon. 

2721 

2722 This uses :attr:`icon` to identify the named icon to be shown. 

2723 Output is something like (here ``'trash'`` is the icon name): 

2724 

2725 .. code-block:: html 

2726 

2727 <i class="fas fa-trash"></i> 

2728 

2729 See also :meth:`render_icon_and_label()`. 

2730 """ 

2731 if self.request.use_oruga: 

2732 return HTML.tag("o-icon", icon=self.icon) 

2733 

2734 return HTML.tag("i", class_=f"fas fa-{self.icon}") 

2735 

2736 def render_label(self): 

2737 """ 

2738 Render the label text for the action link. 

2739 

2740 Default behavior is to return :attr:`label` as-is. 

2741 

2742 See also :meth:`render_icon_and_label()`. 

2743 """ 

2744 return self.label 

2745 

2746 def get_url(self, obj, i=None): 

2747 """ 

2748 Returns the action link URL for the given object (model 

2749 instance). 

2750 

2751 If :attr:`url` is a simple string, it is returned as-is. 

2752 

2753 But if :attr:`url` is a callable (which is typically the most 

2754 useful), that will be called with the same ``(obj, i)`` args 

2755 passed along. 

2756 

2757 :param obj: Model instance of whatever type the parent grid is 

2758 setup to use. 

2759 

2760 :param i: One-based sequence for the object's row within the 

2761 parent grid. 

2762 

2763 See also :attr:`url`. 

2764 """ 

2765 if callable(self.url): 

2766 return self.url(obj, i) 

2767 

2768 return self.url