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

702 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-20 21:14 -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# pylint: disable=too-many-lines 

27 

28import functools 

29import logging 

30import warnings 

31from collections import namedtuple, OrderedDict 

32 

33try: 

34 from enum import EnumType 

35except ImportError: # pragma: no cover 

36 # nb. python < 3.11 

37 from enum import EnumMeta as EnumType 

38 

39import sqlalchemy as sa 

40from sqlalchemy import orm 

41 

42import paginate 

43from paginate_sqlalchemy import SqlalchemyOrmPage 

44from pyramid.renderers import render 

45from webhelpers2.html import HTML 

46 

47from wuttjamaican.db.util import UUID 

48from wuttaweb.util import ( 

49 FieldList, 

50 get_model_fields, 

51 make_json_safe, 

52 render_vue_finalize, 

53) 

54from wuttaweb.grids.filters import default_sqlalchemy_filters, VerbNotSupported 

55 

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

1547 """ 

1548 Returns the default page size for the grid. 

1549 

1550 It will check config but if no setting exists, will fall back 

1551 to a value from :attr:`pagesize_options` (will return ``20`` if 

1552 that is listed; otherwise the "first" option). 

1553 

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

1555 configured. 

1556 

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

1558 instead access :attr:`pagesize` directly. 

1559 """ 

1560 size = self.config.get_int("wuttaweb.grids.default_pagesize") 

1561 if size: 

1562 return size 

1563 

1564 if default: 

1565 return default 

1566 

1567 if 20 in self.pagesize_options: 

1568 return 20 

1569 

1570 return self.pagesize_options[0] 

1571 

1572 ############################## 

1573 # configuration methods 

1574 ############################## 

1575 

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

1577 self, persist=True 

1578 ): 

1579 """ 

1580 Load all effective settings for the grid. 

1581 

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

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

1584 from user session. 

1585 

1586 .. note:: 

1587 

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

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

1590 coming soon... 

1591 

1592 The overall logic for this method is as follows: 

1593 

1594 * collect settings 

1595 * apply settings to current grid 

1596 * optionally save settings to user session 

1597 

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

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

1600 navigates away then comes back. Therefore normally, settings 

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

1602 are wiped upon user logout. 

1603 

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

1605 to the user session. 

1606 """ 

1607 

1608 # initial default settings 

1609 settings = {} 

1610 if self.filterable: 

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

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

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

1614 "active", filtr.default_active 

1615 ) 

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

1617 "verb", filtr.get_default_verb() 

1618 ) 

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

1620 "value", filtr.default_value 

1621 ) 

1622 if self.sortable: 

1623 if self.sort_defaults: 

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

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

1626 sortinfo = self.sort_defaults[0] 

1627 settings["sorters.length"] = 1 

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

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

1630 else: 

1631 settings["sorters.length"] = 0 

1632 if self.paginated and self.paginate_on_backend: 

1633 settings["pagesize"] = self.pagesize 

1634 settings["page"] = self.page 

1635 

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

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

1638 

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

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

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

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

1643 pass 

1644 

1645 elif self.request_has_settings("filter"): 

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

1647 if self.request_has_settings("sort"): 

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

1649 else: 

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

1651 self.update_page_settings(settings) 

1652 

1653 elif self.request_has_settings("sort"): 

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

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

1656 self.update_page_settings(settings) 

1657 

1658 elif self.request_has_settings("page"): 

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

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

1661 self.update_page_settings(settings) 

1662 

1663 else: 

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

1665 persist = False 

1666 

1667 # but still should load whatever is in user session 

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

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

1670 self.update_page_settings(settings) 

1671 

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

1673 if persist: 

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

1675 

1676 # update ourself to reflect settings dict.. 

1677 

1678 # filtering 

1679 if self.filterable: 

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

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

1682 filtr.verb = ( 

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

1684 ) 

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

1686 

1687 # sorting 

1688 if self.sortable: 

1689 # nb. doing this for frontend sorting also 

1690 self.active_sorters = [] 

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

1692 self.active_sorters.append( 

1693 { 

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

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

1696 } 

1697 ) 

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

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

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

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

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

1703 # break 

1704 

1705 # paging 

1706 if self.paginated and self.paginate_on_backend: 

1707 self.pagesize = settings["pagesize"] 

1708 self.page = settings["page"] 

1709 

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

1711 """ """ 

1712 

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

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

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

1716 return True 

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

1718 return True 

1719 

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

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

1722 return True 

1723 

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

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

1726 if key in self.request.GET: 

1727 return True 

1728 

1729 return False 

1730 

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

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

1733 ): 

1734 """ """ 

1735 

1736 if src == "request": 

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

1738 if value is not None: 

1739 try: 

1740 return normalize(value) 

1741 except ValueError: 

1742 pass 

1743 

1744 elif src == "session": 

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

1746 if value is not None: 

1747 return normalize(value) 

1748 

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

1750 value = settings.get(key) 

1751 if value is not None: 

1752 return normalize(value) 

1753 

1754 # okay then, default it is 

1755 return default 

1756 

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

1758 self, settings, src=None 

1759 ): 

1760 """ """ 

1761 if not self.filterable: 

1762 return 

1763 

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

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

1766 

1767 if src == "request": 

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

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

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

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

1772 ) 

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

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

1775 ) 

1776 

1777 elif src == "session": 

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

1779 settings, 

1780 f"{prefix}.active", 

1781 src="session", 

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

1783 default=False, 

1784 ) 

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

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

1787 ) 

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

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

1790 ) 

1791 

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

1793 self, settings, src=None 

1794 ): 

1795 """ """ 

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

1797 return 

1798 

1799 if src == "request": 

1800 i = 1 

1801 while True: 

1802 skey = f"sort{i}key" 

1803 if skey in self.request.GET: 

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

1805 settings, skey, src="request" 

1806 ) 

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

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

1809 ) 

1810 else: 

1811 break 

1812 i += 1 

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

1814 

1815 elif src == "session": 

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

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

1818 ) 

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

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

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

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

1823 

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

1825 """ """ 

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

1827 return 

1828 

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

1830 

1831 # pagesize 

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

1833 if pagesize is not None: 

1834 if pagesize.isdigit(): 

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

1836 else: 

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

1838 if pagesize is not None: 

1839 settings["pagesize"] = pagesize 

1840 

1841 # page 

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

1843 if page is not None: 

1844 if page.isdigit(): 

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

1846 else: 

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

1848 if page is not None: 

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

1850 

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

1852 """ """ 

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

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

1855 

1856 # func to save a setting value to user session 

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

1858 assert dest == "session" 

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

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

1861 

1862 # filter settings 

1863 if self.filterable: 

1864 

1865 # always save all filters, with status 

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

1867 persist( 

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

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

1870 ) 

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

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

1873 

1874 # sort settings 

1875 if self.sortable and self.sort_on_backend: 

1876 

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

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

1879 # all and then write all 

1880 

1881 if dest == "session": 

1882 # remove sort settings from user session 

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

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

1885 if key.startswith(prefix): 

1886 del self.request.session[key] 

1887 

1888 # now save sort settings to dest 

1889 if "sorters.length" in settings: 

1890 persist("sorters.length") 

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

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

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

1894 

1895 # pagination settings 

1896 if self.paginated and self.paginate_on_backend: 

1897 

1898 # save to dest 

1899 persist("pagesize") 

1900 persist("page") 

1901 

1902 ############################## 

1903 # data methods 

1904 ############################## 

1905 

1906 def get_visible_data(self): 

1907 """ 

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

1909 

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

1911 for pagination etc. per the grid settings. 

1912 

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

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

1915 pagination is used), depending on the need. 

1916 

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

1918 

1919 * :meth:`filter_data()` 

1920 * :meth:`sort_data()` 

1921 * :meth:`paginate_data()` 

1922 """ 

1923 data = self.data or [] 

1924 self.joined = set() 

1925 

1926 if self.filterable: 

1927 data = self.filter_data(data) 

1928 

1929 if self.sortable and self.sort_on_backend: 

1930 data = self.sort_data(data) 

1931 

1932 if self.paginated and self.paginate_on_backend: 

1933 self.pager = self.paginate_data(data) 

1934 data = self.pager 

1935 

1936 return data 

1937 

1938 @property 

1939 def active_filters(self): 

1940 """ 

1941 Returns the list of currently active filters. 

1942 

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

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

1945 """ 

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

1947 

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

1949 """ 

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

1951 by :meth:`get_visible_data()`. 

1952 

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

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

1955 """ 

1956 if filters is None: 

1957 filters = self.active_filters 

1958 if not filters: 

1959 return data 

1960 

1961 for filtr in filters: 

1962 key = filtr.key 

1963 

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

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

1966 self.joined.add(key) 

1967 

1968 try: 

1969 data = filtr.apply_filter(data) 

1970 except VerbNotSupported as error: 

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

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

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

1974 

1975 return data 

1976 

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

1978 """ 

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

1980 :meth:`get_visible_data()`. 

1981 

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

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

1984 """ 

1985 if sorters is None: 

1986 sorters = self.active_sorters 

1987 if not sorters: 

1988 return data 

1989 

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

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

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

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

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

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

1996 sorters = reversed(sorters) 

1997 

1998 for sorter in sorters: 

1999 sortkey = sorter["key"] 

2000 sortdir = sorter["dir"] 

2001 

2002 # cannot sort unless we have a sorter callable 

2003 sortfunc = self.sorters.get(sortkey) 

2004 if not sortfunc: 

2005 return data 

2006 

2007 # join appropriate model if needed 

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

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

2010 self.joined.add(sortkey) 

2011 

2012 # invoke the sorter 

2013 data = sortfunc(data, sortdir) 

2014 

2015 return data 

2016 

2017 def paginate_data(self, data): 

2018 """ 

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

2020 

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

2022 "data replacement" in subsequent logic. 

2023 

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

2025 """ 

2026 if isinstance(data, orm.Query): 

2027 pager = SqlalchemyOrmPage( 

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

2029 ) 

2030 

2031 else: 

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

2033 

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

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

2036 if pager.page != self.page: 

2037 self.page = pager.page 

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

2039 if key in self.request.session: 

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

2041 

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

2043 pager = self.paginate_data(data) 

2044 

2045 return pager 

2046 

2047 ############################## 

2048 # rendering methods 

2049 ############################## 

2050 

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

2052 """ 

2053 Column renderer for batch ID values. 

2054 

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

2056 

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

2058 """ 

2059 if value is None: 

2060 return "" 

2061 

2062 batch_id = int(value) 

2063 return f"{batch_id:08d}" 

2064 

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

2066 """ 

2067 Column renderer for boolean values. 

2068 

2069 This calls 

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

2071 for the return value. 

2072 

2073 This may be used automatically per 

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

2075 

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

2077 """ 

2078 return self.app.render_boolean(value) 

2079 

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

2081 self, obj, key, value, **kwargs 

2082 ): 

2083 """ 

2084 Column renderer for currency values. 

2085 

2086 This calls 

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

2088 for the return value. 

2089 

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

2091 

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

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

2094 """ 

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

2096 

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

2098 """ 

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

2100 

2101 This calls 

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

2103 for the return value. 

2104 

2105 This may be used automatically per 

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

2107 

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

2109 """ 

2110 try: 

2111 dt = getattr(obj, key) 

2112 except AttributeError: 

2113 dt = obj[key] 

2114 return self.app.render_date(dt) 

2115 

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

2117 """ 

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

2119 

2120 This calls 

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

2122 for the return value. 

2123 

2124 This may be used automatically per 

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

2126 

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

2128 """ 

2129 try: 

2130 dt = getattr(obj, key) 

2131 except AttributeError: 

2132 dt = obj[key] 

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

2134 

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

2136 """ 

2137 Custom grid value renderer for "enum" fields. 

2138 

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

2140 

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

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

2143 

2144 To use this feature for your grid:: 

2145 

2146 from enum import Enum 

2147 

2148 class MyEnum(Enum): 

2149 ONE = 1 

2150 TWO = 2 

2151 THREE = 3 

2152 

2153 grid.set_enum("my_enum_field", MyEnum) 

2154 

2155 Or, perhaps more common:: 

2156 

2157 myenum = { 

2158 1: "ONE", 

2159 2: "TWO", 

2160 3: "THREE", 

2161 } 

2162 

2163 grid.set_enum("my_enum_field", myenum) 

2164 """ 

2165 if enum: 

2166 

2167 if isinstance(enum, EnumType): 

2168 if raw_value := obj[key]: 

2169 return raw_value.value 

2170 

2171 if isinstance(enum, dict): 

2172 return enum.get(value, value) 

2173 

2174 return value 

2175 

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

2177 self, obj, key, value, **kwargs 

2178 ): 

2179 """ 

2180 Column renderer for percentage values. 

2181 

2182 This calls 

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

2184 for the return value. 

2185 

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

2187 

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

2189 """ 

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

2191 

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

2193 """ 

2194 Column renderer for quantity values. 

2195 

2196 This calls 

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

2198 for the return value. 

2199 

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

2201 

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

2203 """ 

2204 return self.app.render_quantity(value) 

2205 

2206 def render_table_element( 

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

2208 ): 

2209 """ 

2210 Render a simple Vue table element for the grid. 

2211 

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

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

2214 standard table component. 

2215 

2216 This returns something like: 

2217 

2218 .. code-block:: html 

2219 

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

2221 <!-- columns etc. --> 

2222 </b-table> 

2223 

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

2225 

2226 Actual output will of course depend on grid attributes, 

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

2228 

2229 :param form: Reference to the 

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

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

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

2233 

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

2235 the output. 

2236 

2237 .. note:: 

2238 

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

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

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

2242 directly by that form's Vue component. 

2243 

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

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

2246 yet be settled here. 

2247 """ 

2248 

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

2250 if form: 

2251 form.add_grid_vue_context(self) 

2252 

2253 # otherwise logic is the same, just different template 

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

2255 

2256 def render_vue_tag(self, **kwargs): 

2257 """ 

2258 Render the Vue component tag for the grid. 

2259 

2260 By default this simply returns: 

2261 

2262 .. code-block:: html 

2263 

2264 <wutta-grid></wutta-grid> 

2265 

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

2267 particular :attr:`vue_tagname`. 

2268 """ 

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

2270 

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

2272 """ 

2273 Render the Vue template block for the grid. 

2274 

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

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

2277 

2278 This returns something like: 

2279 

2280 .. code-block:: none 

2281 

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

2283 <b-table> 

2284 <!-- columns etc. --> 

2285 </b-table> 

2286 </script> 

2287 

2288 <script> 

2289 WuttaGridData = {} 

2290 WuttaGrid = { 

2291 template: 'wutta-grid-template', 

2292 } 

2293 </script> 

2294 

2295 .. todo:: 

2296 

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

2298 

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

2300 

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

2302 

2303 Actual output will of course depend on grid attributes, 

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

2305 

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

2307 the output. 

2308 """ 

2309 context["grid"] = self 

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

2311 output = render(template, context) 

2312 return HTML.literal(output) 

2313 

2314 def render_vue_finalize(self): 

2315 """ 

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

2317 

2318 By default this simply returns: 

2319 

2320 .. code-block:: html 

2321 

2322 <script> 

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

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

2325 </script> 

2326 

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

2328 particular :attr:`vue_tagname`. 

2329 """ 

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

2331 

2332 def get_vue_columns(self): 

2333 """ 

2334 Returns a list of Vue-compatible column definitions. 

2335 

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

2337 returned will be a dict in this format:: 

2338 

2339 { 

2340 'field': 'foo', 

2341 'label': "Foo", 

2342 'sortable': True, 

2343 'searchable': False, 

2344 } 

2345 

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

2347 in its `Table docs 

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

2349 

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

2351 """ 

2352 if not self.columns: 

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

2354 

2355 columns = [] 

2356 for name in self.columns: 

2357 columns.append( 

2358 { 

2359 "field": name, 

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

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

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

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

2364 } 

2365 ) 

2366 return columns 

2367 

2368 def get_vue_active_sorters(self): 

2369 """ 

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

2371 

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

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

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

2375 

2376 # active_sorters format 

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

2378 

2379 # get_vue_active_sorters() format 

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

2381 

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

2383 described above. 

2384 """ 

2385 sorters = [] 

2386 for sorter in self.active_sorters: 

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

2388 return sorters 

2389 

2390 def get_vue_first_sorter(self): 

2391 """ 

2392 Returns the first active sorter, if applicable. 

2393 

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

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

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

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

2398 for either scenario. 

2399 

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

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

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

2403 

2404 Otherwise this will use the first sorter from 

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

2406 

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

2408 or ``None``. 

2409 """ 

2410 if self.active_sorters: 

2411 sorter = self.active_sorters[0] 

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

2413 

2414 if self.sort_defaults: 

2415 sorter = self.sort_defaults[0] 

2416 return [sorter.sortkey, sorter.sortdir] 

2417 

2418 return None 

2419 

2420 def get_vue_filters(self): 

2421 """ 

2422 Returns a list of Vue-compatible filter definitions. 

2423 

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

2425 each as a simple dict with the filter state. 

2426 """ 

2427 filters = [] 

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

2429 

2430 choices = [] 

2431 choice_labels = {} 

2432 if filtr.choices: 

2433 choices = list(filtr.choices) 

2434 choice_labels = dict(filtr.choices) 

2435 

2436 filters.append( 

2437 { 

2438 "key": filtr.key, 

2439 "data_type": filtr.data_type, 

2440 "active": filtr.active, 

2441 "visible": filtr.active, 

2442 "verbs": filtr.get_verbs(), 

2443 "verb_labels": filtr.get_verb_labels(), 

2444 "valueless_verbs": filtr.get_valueless_verbs(), 

2445 "verb": filtr.verb, 

2446 "choices": choices, 

2447 "choice_labels": choice_labels, 

2448 "value": filtr.value, 

2449 "label": filtr.label, 

2450 } 

2451 ) 

2452 return filters 

2453 

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

2455 """ """ 

2456 try: 

2457 dct = dict(obj) 

2458 except TypeError: 

2459 dct = dict(obj.__dict__) 

2460 dct.pop("_sa_instance_state", None) 

2461 

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

2463 for field in self.columns: 

2464 if field not in dct: 

2465 try: 

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

2467 except AttributeError: 

2468 pass 

2469 

2470 return dct 

2471 

2472 def get_vue_context(self): 

2473 """ 

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

2475 component. This contains the following keys: 

2476 

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

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

2479 

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

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

2482 

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

2484 ensure each record can be serialized to JSON. 

2485 

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

2487 obtain the "final" values for each record. 

2488 

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

2490 defined, to each record. 

2491 

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

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

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

2495 string" - the Vue component expects that. 

2496 

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

2498 """ 

2499 original_data = self.get_visible_data() 

2500 

2501 # loop thru data 

2502 data = [] 

2503 row_classes = {} 

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

2505 

2506 # convert record to new dict 

2507 record = self.object_to_dict(original_record) 

2508 

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

2510 record = { 

2511 field: record[field] 

2512 for field in record 

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

2514 } 

2515 

2516 # make all values safe for json 

2517 record = make_json_safe(record, warn=False) 

2518 

2519 # customize value rendering where applicable 

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

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

2522 if key in self.columns: 

2523 value = record.get(key, None) 

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

2525 

2526 # add action urls to each record 

2527 for action in self.actions: 

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

2529 if key not in record: 

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

2531 record[key] = url 

2532 

2533 # set row css class if applicable 

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

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

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

2537 

2538 data.append(record) 

2539 

2540 return { 

2541 "data": data, 

2542 "row_classes": row_classes, 

2543 } 

2544 

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

2546 """ """ 

2547 warnings.warn( 

2548 "grid.get_vue_data() is deprecated; " 

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

2550 DeprecationWarning, 

2551 stacklevel=2, 

2552 ) 

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

2554 

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

2556 """ 

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

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

2559 

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

2561 value obtained from there. 

2562 

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

2564 

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

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

2567 

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

2569 within the grid. 

2570 

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

2572 """ 

2573 if self.row_class: 

2574 if callable(self.row_class): 

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

2576 return self.row_class 

2577 return None 

2578 

2579 def get_vue_pager_stats(self): 

2580 """ 

2581 Returns a simple dict with current grid pager stats. 

2582 

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

2584 """ 

2585 pager = self.pager 

2586 return { 

2587 "item_count": pager.item_count, 

2588 "items_per_page": pager.items_per_page, 

2589 "page": pager.page, 

2590 "page_count": pager.page_count, 

2591 "first_item": pager.first_item, 

2592 "last_item": pager.last_item, 

2593 } 

2594 

2595 

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

2597 """ 

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

2599 

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

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

2602 own set of action links. 

2603 

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

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

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

2607 actions from within a view. 

2608 

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

2610 

2611 .. note:: 

2612 

2613 Some parameters are not explicitly described above. However 

2614 their corresponding attributes are described below. 

2615 

2616 .. attribute:: key 

2617 

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

2619 grid. 

2620 

2621 .. attribute:: label 

2622 

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

2624 generated from :attr:`key` by calling 

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

2626 

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

2628 

2629 .. attribute:: url 

2630 

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

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

2633 the same URL for this action. 

2634 

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

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

2637 args, for instance:: 

2638 

2639 def myurl(obj, i): 

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

2641 

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

2643 

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

2645 

2646 .. attribute:: target 

2647 

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

2649 

2650 .. attribute:: click_handler 

2651 

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

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

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

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

2656 

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

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

2659 

2660 .. attribute:: icon 

2661 

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

2663 

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

2665 

2666 .. attribute:: link_class 

2667 

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

2669 """ 

2670 

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

2672 self, 

2673 request, 

2674 key, 

2675 label=None, 

2676 url=None, 

2677 target=None, 

2678 click_handler=None, 

2679 icon=None, 

2680 link_class=None, 

2681 ): 

2682 self.request = request 

2683 self.config = self.request.wutta_config 

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

2685 self.key = key 

2686 self.url = url 

2687 self.target = target 

2688 self.click_handler = click_handler 

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

2690 self.icon = icon or key 

2691 self.link_class = link_class or "" 

2692 

2693 def render_icon_and_label(self): 

2694 """ 

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

2696 

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

2698 and :meth:`render_label()`. 

2699 """ 

2700 html = [ 

2701 self.render_icon(), 

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

2703 self.render_label(), 

2704 ] 

2705 return HTML.tag("span", c=html, style="white-space: nowrap;") 

2706 

2707 def render_icon(self): 

2708 """ 

2709 Render the HTML snippet for the action link icon. 

2710 

2711 This uses :attr:`icon` to identify the named icon to be shown. 

2712 Output is something like (here ``'trash'`` is the icon name): 

2713 

2714 .. code-block:: html 

2715 

2716 <i class="fas fa-trash"></i> 

2717 

2718 See also :meth:`render_icon_and_label()`. 

2719 """ 

2720 if self.request.use_oruga: 

2721 return HTML.tag("o-icon", icon=self.icon) 

2722 

2723 return HTML.tag("i", class_=f"fas fa-{self.icon}") 

2724 

2725 def render_label(self): 

2726 """ 

2727 Render the label text for the action link. 

2728 

2729 Default behavior is to return :attr:`label` as-is. 

2730 

2731 See also :meth:`render_icon_and_label()`. 

2732 """ 

2733 return self.label 

2734 

2735 def get_url(self, obj, i=None): 

2736 """ 

2737 Returns the action link URL for the given object (model 

2738 instance). 

2739 

2740 If :attr:`url` is a simple string, it is returned as-is. 

2741 

2742 But if :attr:`url` is a callable (which is typically the most 

2743 useful), that will be called with the same ``(obj, i)`` args 

2744 passed along. 

2745 

2746 :param obj: Model instance of whatever type the parent grid is 

2747 setup to use. 

2748 

2749 :param i: One-based sequence for the object's row within the 

2750 parent grid. 

2751 

2752 See also :attr:`url`. 

2753 """ 

2754 if callable(self.url): 

2755 return self.url(obj, i) 

2756 

2757 return self.url