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

682 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-02-04 08:56 -0600

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 label overrides. 

123 

124 See also :meth:`get_label()` and :meth:`set_label()`. 

125 

126 .. attribute:: renderers 

127 

128 Dict of column (cell) value renderer overrides. 

129 

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

131 :meth:`set_default_renderers()`. 

132 

133 .. attribute:: enums 

134 

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

136 

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

138 

139 .. attribute:: checkable 

140 

141 Boolean indicating whether the grid should expose per-row 

142 checkboxes. 

143 

144 .. attribute:: row_class 

145 

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

147 the grid. Default is ``None``. 

148 

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

150 applied to all rows. 

151 

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

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

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

155 

156 def my_row_class(obj, data, i): 

157 if obj.archived: 

158 return 'poser-archived' 

159 

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

161 

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

163 

164 .. attribute:: actions 

165 

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

167 to be shown for each record in the grid. 

168 

169 .. attribute:: linked_columns 

170 

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

172 applied. 

173 

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

175 

176 .. attribute:: hidden_columns 

177 

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

179 

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

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

182 

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

184 

185 .. attribute:: sortable 

186 

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

188 the grid. Default is ``False``. 

189 

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

191 

192 .. attribute:: sort_multiple 

193 

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

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

196 one column may be sorted at a time. 

197 

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

199 frontend and backend sorting. 

200 

201 .. warning:: 

202 

203 This feature is limited by frontend JS capabilities, 

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

205 frontend and backend sorting). 

206 

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

208 then multi-column sorting should work. 

209 

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

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

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

213 Vue 3 + Oruga templates. 

214 

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

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

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

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

219 

220 .. attribute:: sort_on_backend 

221 

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

223 backend. Default is ``True``. 

224 

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

226 sorting. 

227 

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

229 

230 .. attribute:: sorters 

231 

232 Dict of functions to use for backend sorting. 

233 

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

235 :attr:`sort_on_backend` are true. 

236 

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

238 :attr:`active_sorters`. 

239 

240 .. attribute:: sort_defaults 

241 

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

243 requests a different sorting method. 

244 

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

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

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

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

249 

250 Used with both frontend and backend sorting. 

251 

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

253 :attr:`active_sorters`. 

254 

255 .. warning:: 

256 

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

258 sorting, this feature is limited by frontend JS 

259 capabilities. 

260 

261 Even if ``sort_defaults`` contains multiple entries 

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

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

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

265 component. 

266 

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

268 

269 .. attribute:: active_sorters 

270 

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

272 :meth:`sort_data()`. 

273 

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

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

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

277 

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

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

280 

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

282 different format is used here:: 

283 

284 grid.active_sorters = [ 

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

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

287 ] 

288 

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

290 set this attribute directly. 

291 

292 This list may contain multiple elements only if 

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

294 have either zero or one element. 

295 

296 .. attribute:: paginated 

297 

298 Boolean indicating whether the grid data should be paginated, 

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

300 data is shown at once. 

301 

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

303 :attr:`paginate_on_backend`. 

304 

305 .. attribute:: paginate_on_backend 

306 

307 Boolean indicating whether the grid data should be paginated on 

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

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

310 

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

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

313 pagination. 

314 

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

316 

317 .. attribute:: pagesize_options 

318 

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

320 :attr:`pagesize`. 

321 

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

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

324 value. 

325 

326 .. attribute:: pagesize 

327 

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

329 :attr:`pagesize_options` and :attr:`page`. 

330 

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

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

333 

334 .. attribute:: page 

335 

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

337 also :attr:`pagesize`. 

338 

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

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

341 

342 .. attribute:: searchable_columns 

343 

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

345 

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

347 

348 .. attribute:: filterable 

349 

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

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

352 ``False``. 

353 

354 .. attribute:: filters 

355 

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

357 available for use with backend filtering. 

358 

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

360 

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

362 

363 .. attribute:: filter_defaults 

364 

365 Dict containing default state preferences for the filters. 

366 

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

368 

369 .. attribute:: joiners 

370 

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

372 sorting. 

373 

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

375 

376 .. attribute:: tools 

377 

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

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

380 

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

382 caller. Values should be HTML literal elements. 

383 

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

385 """ 

386 

387 active_sorters = None 

388 joined = None 

389 pager = None 

390 

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

392 self, 

393 request, 

394 vue_tagname="wutta-grid", 

395 model_class=None, 

396 key=None, 

397 columns=None, 

398 data=None, 

399 labels=None, 

400 renderers=None, 

401 enums=None, 

402 checkable=False, 

403 row_class=None, 

404 actions=None, 

405 linked_columns=None, 

406 hidden_columns=None, 

407 sortable=False, 

408 sort_multiple=None, 

409 sort_on_backend=True, 

410 sorters=None, 

411 sort_defaults=None, 

412 paginated=False, 

413 paginate_on_backend=True, 

414 pagesize_options=None, 

415 pagesize=None, 

416 page=1, 

417 searchable_columns=None, 

418 filterable=False, 

419 filters=None, 

420 filter_defaults=None, 

421 joiners=None, 

422 tools=None, 

423 ): 

424 self.request = request 

425 self.vue_tagname = vue_tagname 

426 self.model_class = model_class 

427 self.key = key 

428 self.data = data 

429 self.labels = labels or {} 

430 self.checkable = checkable 

431 self.row_class = row_class 

432 self.actions = actions or [] 

433 self.linked_columns = linked_columns or [] 

434 self.hidden_columns = hidden_columns or [] 

435 self.joiners = joiners or {} 

436 

437 self.config = self.request.wutta_config 

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

439 

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

441 self.renderers = {} 

442 if renderers: 

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

444 self.set_renderer(k, val) 

445 self.set_default_renderers() 

446 self.set_tools(tools) 

447 

448 # sorting 

449 self.sortable = sortable 

450 if sort_multiple is not None: 

451 self.sort_multiple = sort_multiple 

452 elif self.request.use_oruga: 

453 self.sort_multiple = False 

454 else: 

455 self.sort_multiple = bool(self.model_class) 

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

457 log.warning( 

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

459 ) 

460 self.sort_multiple = False 

461 self.sort_on_backend = sort_on_backend 

462 if sorters is not None: 

463 self.sorters = sorters 

464 elif self.sortable and self.sort_on_backend: 

465 self.sorters = self.make_backend_sorters() 

466 else: 

467 self.sorters = {} 

468 self.set_sort_defaults(sort_defaults or []) 

469 

470 # paging 

471 self.paginated = paginated 

472 self.paginate_on_backend = paginate_on_backend 

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

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

475 self.page = page 

476 

477 # searching 

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

479 

480 # filtering 

481 self.filterable = filterable 

482 if filters is not None: 

483 self.filters = filters 

484 elif self.filterable: 

485 self.filters = self.make_backend_filters() 

486 else: 

487 self.filters = {} 

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

489 

490 # enums 

491 self.enums = {} 

492 for k in enums or {}: 

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

494 

495 def get_columns(self): 

496 """ 

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

498 ``None``. 

499 

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

501 

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

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

504 

505 Otherwise ``None`` is returned. 

506 """ 

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

508 return self.columns 

509 

510 columns = self.get_model_columns() 

511 if columns: 

512 return columns 

513 

514 return [] 

515 

516 def get_model_columns(self, model_class=None): 

517 """ 

518 This method is a shortcut which calls 

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

520 

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

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

523 assumed. 

524 """ 

525 return get_model_fields( 

526 self.config, model_class=model_class or self.model_class 

527 ) 

528 

529 @property 

530 def vue_component(self): 

531 """ 

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

533 

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

535 """ 

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

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

538 

539 def set_columns(self, columns): 

540 """ 

541 Explicitly set the list of grid columns. 

542 

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

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

545 

546 :param columns: List of string column names. 

547 """ 

548 self.columns = FieldList(columns) 

549 

550 def append(self, *keys): 

551 """ 

552 Add some columns(s) to the grid. 

553 

554 This is a convenience to allow adding multiple columns at 

555 once:: 

556 

557 grid.append('first_field', 

558 'second_field', 

559 'third_field') 

560 

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

562 """ 

563 for key in keys: 

564 if key not in self.columns: 

565 self.columns.append(key) 

566 

567 def remove(self, *keys): 

568 """ 

569 Remove some column(s) from the grid. 

570 

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

572 once:: 

573 

574 grid.remove('first_field', 

575 'second_field', 

576 'third_field') 

577 

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

579 """ 

580 for key in keys: 

581 if key in self.columns: 

582 self.columns.remove(key) 

583 

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

585 """ 

586 Set/override the hidden flag for a column. 

587 

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

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

590 

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

592 :attr:`hidden_columns`. 

593 

594 :param key: Column key as string. 

595 

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

597 (vs. shown). 

598 """ 

599 if hidden: 

600 if key not in self.hidden_columns: 

601 self.hidden_columns.append(key) 

602 else: # un-hide 

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

604 self.hidden_columns.remove(key) 

605 

606 def is_hidden(self, key): 

607 """ 

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

609 

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

611 

612 :param key: Column key as string. 

613 

614 :rtype: bool 

615 """ 

616 if self.hidden_columns: 

617 if key in self.hidden_columns: 

618 return True 

619 return False 

620 

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

622 """ 

623 Set/override the label for a column. 

624 

625 :param key: Name of column. 

626 

627 :param label: New label for the column header. 

628 

629 :param column_only: Boolean indicating whether the label 

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

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

632 

633 See also :meth:`get_label()`. Label overrides are tracked via 

634 :attr:`labels`. 

635 """ 

636 self.labels[key] = label 

637 

638 if not column_only and key in self.filters: 

639 self.filters[key].label = label 

640 

641 def get_label(self, key): 

642 """ 

643 Returns the label text for a given column. 

644 

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

646 

647 See also :meth:`set_label()`. 

648 """ 

649 if key in self.labels: 

650 return self.labels[key] 

651 return self.app.make_title(key) 

652 

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

654 """ 

655 Set/override the value renderer for a column. 

656 

657 :param key: Name of column. 

658 

659 :param renderer: Callable as described below. 

660 

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

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

663 

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

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

666 JSON-compatible. 

667 

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

669 to obtain the "final" cell value. 

670 

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

672 key, value)``: 

673 

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

675 * ``key`` is the column name 

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

677 

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

679 value. For instance:: 

680 

681 from webhelpers2.html import HTML 

682 

683 def render_foo(record, key, value): 

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

685 

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

687 grid.set_renderer('foo', render_foo) 

688 

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

690 specify one of the following strings, which will be 

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

692 

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

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

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

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

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

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

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

700 

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

702 """ 

703 builtins = { 

704 "batch_id": self.render_batch_id, 

705 "boolean": self.render_boolean, 

706 "currency": self.render_currency, 

707 "date": self.render_date, 

708 "datetime": self.render_datetime, 

709 "quantity": self.render_quantity, 

710 "percent": self.render_percent, 

711 } 

712 

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

714 renderer = builtins[renderer] 

715 

716 if kwargs: 

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

718 self.renderers[key] = renderer 

719 

720 def set_default_renderers(self): 

721 """ 

722 Set default column value renderers, where applicable. 

723 

724 This is called automatically from the class constructor. It 

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

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

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

728 

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

730 follows: 

731 

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

733 :meth:`render_boolean()` 

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

735 :meth:`render_date()` 

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

737 :meth:`render_datetime()` 

738 """ 

739 if not self.model_class: 

740 return 

741 

742 for key in self.columns: 

743 if key in self.renderers: 

744 continue 

745 

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

747 if attr: 

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

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

750 column = prop.columns[0] 

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

752 self.set_renderer(key, self.render_date) 

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

754 self.set_renderer(key, self.render_datetime) 

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

756 self.set_renderer(key, self.render_boolean) 

757 

758 def set_enum(self, key, enum): 

759 """ 

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

761 

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

763 value for each row in the grid. See also 

764 :meth:`render_enum()`. 

765 

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

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

768 enum. 

769 

770 :param key: Name of column. 

771 

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

773 """ 

774 self.enums[key] = enum 

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

776 if key in self.filters: 

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

778 

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

780 """ 

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

782 column. 

783 

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

785 contents will automatically be wrapped with a hyperlink. The 

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

787 :class:`GridAction` 

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

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

790 link depending on which data record it points to. 

791 

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

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

794 

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

796 :attr:`linked_columns`. 

797 

798 :param key: Column key as string. 

799 

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

801 should be auto-linked. 

802 """ 

803 if link: 

804 if key not in self.linked_columns: 

805 self.linked_columns.append(key) 

806 else: # unlink 

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

808 self.linked_columns.remove(key) 

809 

810 def is_linked(self, key): 

811 """ 

812 Returns boolean indicating if auto-link behavior is enabled 

813 for a given column. 

814 

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

816 

817 :param key: Column key as string. 

818 """ 

819 if self.linked_columns: 

820 if key in self.linked_columns: 

821 return True 

822 return False 

823 

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

825 """ 

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

827 component. 

828 

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

830 :attr:`searchable_columns`. 

831 """ 

832 if searchable: 

833 self.searchable_columns.add(key) 

834 elif key in self.searchable_columns: 

835 self.searchable_columns.remove(key) 

836 

837 def is_searchable(self, key): 

838 """ 

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

840 component. 

841 

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

843 """ 

844 return key in self.searchable_columns 

845 

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

847 """ 

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

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

850 """ 

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

852 

853 def set_tools(self, tools): 

854 """ 

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

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

857 

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

859 """ 

860 if tools and isinstance(tools, list): 

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

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

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

864 

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

866 """ 

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

868 

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

870 

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

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

873 generated. 

874 

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

876 """ 

877 if not key: 

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

879 self.tools[key] = html 

880 

881 ############################## 

882 # joining methods 

883 ############################## 

884 

885 def set_joiner(self, key, joiner): 

886 """ 

887 Set/override the backend joiner for a column. 

888 

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

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

891 

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

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

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

895 don't want the join to happen twice. 

896 

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

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

899 filter is needed, the joiner will be invoked. 

900 

901 :param key: Name of column. 

902 

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

904 

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

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

907 

908 model = app.model 

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

910 

911 def join_external_profile_value(query): 

912 return query.join(model.ExternalProfile) 

913 

914 def sort_external_profile(query, direction): 

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

916 return query.order_by(sortspec()) 

917 

918 grid.set_joiner('external_profile', join_external_profile) 

919 grid.set_sorter('external_profile', sort_external_profile) 

920 

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

922 via :attr:`joiners`. 

923 """ 

924 self.joiners[key] = joiner 

925 

926 def remove_joiner(self, key): 

927 """ 

928 Remove the backend joiner for a column. 

929 

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

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

932 later defined for it. 

933 

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

935 """ 

936 self.joiners.pop(key, None) 

937 

938 ############################## 

939 # sorting methods 

940 ############################## 

941 

942 def make_backend_sorters(self, sorters=None): 

943 """ 

944 Make backend sorters for all columns in the grid. 

945 

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

947 and :attr:`sort_on_backend` are true. 

948 

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

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

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

952 

953 .. note:: 

954 

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

956 this method just returns the initial sorters (or empty 

957 dict). 

958 

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

960 existing sorters will be left intact, not replaced. 

961 

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

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

964 created. 

965 """ 

966 sorters = sorters or {} 

967 

968 if self.model_class: 

969 for key in self.columns: 

970 if key in sorters: 

971 continue 

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

973 if ( 

974 prop 

975 and hasattr(prop, "property") 

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

977 ): 

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

979 

980 return sorters 

981 

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

983 """ 

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

985 given column. 

986 

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

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

989 

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

991 or a column name. 

992 

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

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

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

996 a default function is used. 

997 

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

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

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

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

1002 may be disabled if needed. 

1003 

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

1005 should help to clarify:: 

1006 

1007 model = app.model 

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

1009 

1010 # explicit property 

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

1012 

1013 # property name works if grid has model class 

1014 sorter = grid.make_sorter('full_name') 

1015 

1016 # nb. this will *not* work 

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

1018 sorter = grid.make_sorter(person.full_name) 

1019 

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

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

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

1023 query):: 

1024 

1025 data = [ 

1026 {'foo': 1}, 

1027 {'bar': 2}, 

1028 ] 

1029 

1030 # nb. no model_class, just as an example 

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

1032 

1033 def getkey(obj): 

1034 if obj.get('foo') 

1035 return obj['foo'] 

1036 if obj.get('bar'): 

1037 return obj['bar'] 

1038 return '' 

1039 

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

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

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

1043 sorted_data = sortfunc(data, 'asc') 

1044 

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

1046 function will behave differently when it is given a 

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

1048 will return the sorted result. 

1049 

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

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

1052 """ 

1053 model_class = None 

1054 model_property = None 

1055 if isinstance(columninfo, str): 

1056 key = columninfo 

1057 model_class = self.model_class 

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

1059 else: 

1060 model_property = columninfo 

1061 model_class = model_property.class_ 

1062 key = model_property.key 

1063 

1064 def sorter(data, direction): 

1065 

1066 # query is sorted with order_by() 

1067 if isinstance(data, orm.Query): 

1068 if not model_property: 

1069 raise TypeError( 

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

1071 ) 

1072 query = data 

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

1074 

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

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

1077 # each record 

1078 kfunc = keyfunc 

1079 if not kfunc: 

1080 if model_property: 

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

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

1083 if foldcase: 

1084 

1085 def kfunc_folded(obj): 

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

1087 

1088 kfunc = kfunc_folded 

1089 

1090 else: 

1091 

1092 def kfunc_standard(obj): 

1093 return obj[key] or "" 

1094 

1095 kfunc = kfunc_standard 

1096 

1097 if not kfunc: 

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

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

1100 

1101 def kfunc_fallback(obj): 

1102 return obj[key] 

1103 

1104 kfunc = kfunc_fallback 

1105 

1106 # then sort the data and return 

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

1108 

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

1110 # multi-column sorting with sqlalchemy queries 

1111 if model_property: 

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

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

1114 

1115 return sorter 

1116 

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

1118 """ 

1119 Set/override the backend sorter for a column. 

1120 

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

1122 :attr:`sort_on_backend` are true. 

1123 

1124 :param key: Name of column. 

1125 

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

1127 model property (see below). 

1128 

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

1130 backend sorter. 

1131 

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

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

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

1135 

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

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

1138 

1139 model = app.model 

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

1141 

1142 def sort_full_name(query, direction): 

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

1144 return query.order_by(sortspec()) 

1145 

1146 grid.set_sorter('full_name', sort_full_name) 

1147 

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

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

1150 """ 

1151 sorter = None 

1152 

1153 if sortinfo and callable(sortinfo): 

1154 sorter = sortinfo 

1155 else: 

1156 sorter = self.make_sorter(sortinfo or key) 

1157 

1158 self.sorters[key] = sorter 

1159 

1160 def remove_sorter(self, key): 

1161 """ 

1162 Remove the backend sorter for a column. 

1163 

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

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

1166 later defined for it. 

1167 

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

1169 """ 

1170 self.sorters.pop(key, None) 

1171 

1172 def set_sort_defaults(self, *args): 

1173 """ 

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

1175 used unless/until the user requests a different sorting 

1176 method. 

1177 

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

1179 

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

1181 ``sortdir``; for instance:: 

1182 

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

1184 

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

1186 

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

1188 

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

1190 assumed:: 

1191 

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

1193 

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

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

1196 

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

1198 ('value', 'desc')]) 

1199 

1200 .. note:: 

1201 

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

1203 is actually allowed to have multiple sort defaults. The 

1204 defaults requested by the method call may be pruned if 

1205 necessary to accommodate that. 

1206 

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

1208 """ 

1209 

1210 # convert args to sort defaults 

1211 sort_defaults = [] 

1212 if len(args) == 1: 

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

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

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

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

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

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

1219 else: 

1220 raise ValueError( 

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

1222 ) 

1223 elif len(args) == 2: 

1224 sort_defaults = [SortInfo(*args)] 

1225 else: 

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

1227 

1228 # prune if multi-column requested but not supported 

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

1230 log.warning( 

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

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

1233 self.key, 

1234 sort_defaults, 

1235 ) 

1236 sort_defaults = [sort_defaults[0]] 

1237 

1238 self.sort_defaults = sort_defaults 

1239 

1240 def is_sortable(self, key): 

1241 """ 

1242 Returns boolean indicating if a given column should allow 

1243 sorting. 

1244 

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

1246 

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

1248 this always returns ``True``. 

1249 

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

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

1252 

1253 :param key: Column key as string. 

1254 

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

1256 """ 

1257 if not self.sortable: 

1258 return False 

1259 if self.sort_on_backend: 

1260 return key in self.sorters 

1261 return True 

1262 

1263 ############################## 

1264 # filtering methods 

1265 ############################## 

1266 

1267 def make_backend_filters(self, filters=None): 

1268 """ 

1269 Make "automatic" backend filters for the grid. 

1270 

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

1272 true. 

1273 

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

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

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

1277 any of those. 

1278 

1279 .. note:: 

1280 

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

1282 this method just returns the initial filters (or empty 

1283 dict). 

1284 

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

1286 existing filters will be left intact, not replaced. 

1287 

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

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

1290 created. 

1291 """ 

1292 filters = filters or {} 

1293 

1294 if self.model_class: 

1295 

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

1297 # things i've tried so far include: 

1298 # 

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

1300 # that was too aggressive in many cases. 

1301 # 

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

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

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

1305 # 

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

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

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

1309 # 

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

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

1312 

1313 inspector = sa.inspect(self.model_class) 

1314 for prop in inspector.column_attrs: 

1315 

1316 # do not overwrite existing filters 

1317 if prop.key in filters: 

1318 continue 

1319 

1320 # do not create filter for UUID field 

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

1322 continue 

1323 

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

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

1326 

1327 return filters 

1328 

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

1330 """ 

1331 Create and return a 

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

1333 for use on the given column. 

1334 

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

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

1337 

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

1339 or a column name. 

1340 

1341 :returns: A :class:`~wuttaweb.grids.filters.GridFilter` 

1342 instance. 

1343 """ 

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

1345 

1346 # model_property is required 

1347 model_property = None 

1348 if kwargs.get("model_property"): 

1349 model_property = kwargs["model_property"] 

1350 elif isinstance(columninfo, str): 

1351 key = columninfo 

1352 if self.model_class: 

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

1354 if not model_property: 

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

1356 else: 

1357 model_property = columninfo 

1358 

1359 # optional factory override 

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

1361 if not factory: 

1362 typ = model_property.type 

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

1364 if not factory: 

1365 factory = default_sqlalchemy_filters[None] 

1366 

1367 # make filter 

1368 kwargs["model_property"] = model_property 

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

1370 

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

1372 """ 

1373 Set/override the backend filter for a column. 

1374 

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

1376 

1377 :param key: Name of column. 

1378 

1379 :param filterinfo: Can be either a 

1380 :class:`~wuttweb.grids.filters.GridFilter` instance, or 

1381 else a model property (see below). 

1382 

1383 If ``filterinfo`` is a ``GridFilter`` instance, it will be 

1384 used as-is for the backend filter. 

1385 

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

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

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

1389 

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

1391 via :attr:`filters`. 

1392 """ 

1393 filtr = None 

1394 

1395 if filterinfo and callable(filterinfo): 

1396 # filtr = filterinfo 

1397 raise NotImplementedError 

1398 

1399 kwargs["key"] = key 

1400 kwargs.setdefault("label", self.get_label(key)) 

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

1402 

1403 self.filters[key] = filtr 

1404 

1405 def remove_filter(self, key): 

1406 """ 

1407 Remove the backend filter for a column. 

1408 

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

1410 filter by this column unless another filter is later defined 

1411 for it. 

1412 

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

1414 """ 

1415 self.filters.pop(key, None) 

1416 

1417 def set_filter_defaults(self, **defaults): 

1418 """ 

1419 Set default state preferences for the grid filters. 

1420 

1421 These preferences will affect the initial grid display, until 

1422 user requests a different filtering method. 

1423 

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

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

1426 

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

1428 'verb': 'contains', 

1429 'value': 'foo'}, 

1430 value={'active': True}) 

1431 

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

1433 """ 

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

1435 

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

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

1438 filtr.update(values) 

1439 

1440 self.filter_defaults = filter_defaults 

1441 

1442 ############################## 

1443 # paging methods 

1444 ############################## 

1445 

1446 def get_pagesize_options(self, default=None): 

1447 """ 

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

1449 

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

1451 back to:: 

1452 

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

1454 

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

1456 configured. 

1457 

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

1459 instead access :attr:`pagesize_options` directly. 

1460 """ 

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

1462 if options: 

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

1464 if options: 

1465 return options 

1466 

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

1468 

1469 def get_pagesize(self, default=None): 

1470 """ 

1471 Returns the default page size for the grid. 

1472 

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

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

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

1476 

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

1478 configured. 

1479 

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

1481 instead access :attr:`pagesize` directly. 

1482 """ 

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

1484 if size: 

1485 return size 

1486 

1487 if default: 

1488 return default 

1489 

1490 if 20 in self.pagesize_options: 

1491 return 20 

1492 

1493 return self.pagesize_options[0] 

1494 

1495 ############################## 

1496 # configuration methods 

1497 ############################## 

1498 

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

1500 self, persist=True 

1501 ): 

1502 """ 

1503 Load all effective settings for the grid. 

1504 

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

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

1507 from user session. 

1508 

1509 .. note:: 

1510 

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

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

1513 coming soon... 

1514 

1515 The overall logic for this method is as follows: 

1516 

1517 * collect settings 

1518 * apply settings to current grid 

1519 * optionally save settings to user session 

1520 

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

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

1523 navigates away then comes back. Therefore normally, settings 

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

1525 are wiped upon user logout. 

1526 

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

1528 to the user session. 

1529 """ 

1530 

1531 # initial default settings 

1532 settings = {} 

1533 if self.filterable: 

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

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

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

1537 "active", filtr.default_active 

1538 ) 

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

1540 "verb", filtr.get_default_verb() 

1541 ) 

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

1543 "value", filtr.default_value 

1544 ) 

1545 if self.sortable: 

1546 if self.sort_defaults: 

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

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

1549 sortinfo = self.sort_defaults[0] 

1550 settings["sorters.length"] = 1 

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

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

1553 else: 

1554 settings["sorters.length"] = 0 

1555 if self.paginated and self.paginate_on_backend: 

1556 settings["pagesize"] = self.pagesize 

1557 settings["page"] = self.page 

1558 

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

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

1561 

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

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

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

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

1566 pass 

1567 

1568 elif self.request_has_settings("filter"): 

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

1570 if self.request_has_settings("sort"): 

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

1572 else: 

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

1574 self.update_page_settings(settings) 

1575 

1576 elif self.request_has_settings("sort"): 

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

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

1579 self.update_page_settings(settings) 

1580 

1581 elif self.request_has_settings("page"): 

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

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

1584 self.update_page_settings(settings) 

1585 

1586 else: 

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

1588 persist = False 

1589 

1590 # but still should load whatever is in user session 

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

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

1593 self.update_page_settings(settings) 

1594 

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

1596 if persist: 

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

1598 

1599 # update ourself to reflect settings dict.. 

1600 

1601 # filtering 

1602 if self.filterable: 

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

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

1605 filtr.verb = ( 

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

1607 ) 

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

1609 

1610 # sorting 

1611 if self.sortable: 

1612 # nb. doing this for frontend sorting also 

1613 self.active_sorters = [] 

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

1615 self.active_sorters.append( 

1616 { 

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

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

1619 } 

1620 ) 

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

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

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

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

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

1626 # break 

1627 

1628 # paging 

1629 if self.paginated and self.paginate_on_backend: 

1630 self.pagesize = settings["pagesize"] 

1631 self.page = settings["page"] 

1632 

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

1634 """ """ 

1635 

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

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

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

1639 return True 

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

1641 return True 

1642 

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

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

1645 return True 

1646 

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

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

1649 if key in self.request.GET: 

1650 return True 

1651 

1652 return False 

1653 

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

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

1656 ): 

1657 """ """ 

1658 

1659 if src == "request": 

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

1661 if value is not None: 

1662 try: 

1663 return normalize(value) 

1664 except ValueError: 

1665 pass 

1666 

1667 elif src == "session": 

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

1669 if value is not None: 

1670 return normalize(value) 

1671 

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

1673 value = settings.get(key) 

1674 if value is not None: 

1675 return normalize(value) 

1676 

1677 # okay then, default it is 

1678 return default 

1679 

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

1681 self, settings, src=None 

1682 ): 

1683 """ """ 

1684 if not self.filterable: 

1685 return 

1686 

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

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

1689 

1690 if src == "request": 

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

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

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

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

1695 ) 

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

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

1698 ) 

1699 

1700 elif src == "session": 

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

1702 settings, 

1703 f"{prefix}.active", 

1704 src="session", 

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

1706 default=False, 

1707 ) 

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

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

1710 ) 

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

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

1713 ) 

1714 

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

1716 self, settings, src=None 

1717 ): 

1718 """ """ 

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

1720 return 

1721 

1722 if src == "request": 

1723 i = 1 

1724 while True: 

1725 skey = f"sort{i}key" 

1726 if skey in self.request.GET: 

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

1728 settings, skey, src="request" 

1729 ) 

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

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

1732 ) 

1733 else: 

1734 break 

1735 i += 1 

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

1737 

1738 elif src == "session": 

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

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

1741 ) 

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

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

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

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

1746 

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

1748 """ """ 

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

1750 return 

1751 

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

1753 

1754 # pagesize 

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

1756 if pagesize is not None: 

1757 if pagesize.isdigit(): 

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

1759 else: 

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

1761 if pagesize is not None: 

1762 settings["pagesize"] = pagesize 

1763 

1764 # page 

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

1766 if page is not None: 

1767 if page.isdigit(): 

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

1769 else: 

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

1771 if page is not None: 

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

1773 

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

1775 """ """ 

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

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

1778 

1779 # func to save a setting value to user session 

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

1781 assert dest == "session" 

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

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

1784 

1785 # filter settings 

1786 if self.filterable: 

1787 

1788 # always save all filters, with status 

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

1790 persist( 

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

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

1793 ) 

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

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

1796 

1797 # sort settings 

1798 if self.sortable and self.sort_on_backend: 

1799 

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

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

1802 # all and then write all 

1803 

1804 if dest == "session": 

1805 # remove sort settings from user session 

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

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

1808 if key.startswith(prefix): 

1809 del self.request.session[key] 

1810 

1811 # now save sort settings to dest 

1812 if "sorters.length" in settings: 

1813 persist("sorters.length") 

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

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

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

1817 

1818 # pagination settings 

1819 if self.paginated and self.paginate_on_backend: 

1820 

1821 # save to dest 

1822 persist("pagesize") 

1823 persist("page") 

1824 

1825 ############################## 

1826 # data methods 

1827 ############################## 

1828 

1829 def get_visible_data(self): 

1830 """ 

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

1832 

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

1834 for pagination etc. per the grid settings. 

1835 

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

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

1838 pagination is used), depending on the need. 

1839 

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

1841 

1842 * :meth:`filter_data()` 

1843 * :meth:`sort_data()` 

1844 * :meth:`paginate_data()` 

1845 """ 

1846 data = self.data or [] 

1847 self.joined = set() 

1848 

1849 if self.filterable: 

1850 data = self.filter_data(data) 

1851 

1852 if self.sortable and self.sort_on_backend: 

1853 data = self.sort_data(data) 

1854 

1855 if self.paginated and self.paginate_on_backend: 

1856 self.pager = self.paginate_data(data) 

1857 data = self.pager 

1858 

1859 return data 

1860 

1861 @property 

1862 def active_filters(self): 

1863 """ 

1864 Returns the list of currently active filters. 

1865 

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

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

1868 """ 

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

1870 

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

1872 """ 

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

1874 by :meth:`get_visible_data()`. 

1875 

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

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

1878 """ 

1879 if filters is None: 

1880 filters = self.active_filters 

1881 if not filters: 

1882 return data 

1883 

1884 for filtr in filters: 

1885 key = filtr.key 

1886 

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

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

1889 self.joined.add(key) 

1890 

1891 try: 

1892 data = filtr.apply_filter(data) 

1893 except VerbNotSupported as error: 

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

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

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

1897 

1898 return data 

1899 

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

1901 """ 

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

1903 :meth:`get_visible_data()`. 

1904 

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

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

1907 """ 

1908 if sorters is None: 

1909 sorters = self.active_sorters 

1910 if not sorters: 

1911 return data 

1912 

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

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

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

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

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

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

1919 sorters = reversed(sorters) 

1920 

1921 for sorter in sorters: 

1922 sortkey = sorter["key"] 

1923 sortdir = sorter["dir"] 

1924 

1925 # cannot sort unless we have a sorter callable 

1926 sortfunc = self.sorters.get(sortkey) 

1927 if not sortfunc: 

1928 return data 

1929 

1930 # join appropriate model if needed 

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

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

1933 self.joined.add(sortkey) 

1934 

1935 # invoke the sorter 

1936 data = sortfunc(data, sortdir) 

1937 

1938 return data 

1939 

1940 def paginate_data(self, data): 

1941 """ 

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

1943 

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

1945 "data replacement" in subsequent logic. 

1946 

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

1948 """ 

1949 if isinstance(data, orm.Query): 

1950 pager = SqlalchemyOrmPage( 

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

1952 ) 

1953 

1954 else: 

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

1956 

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

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

1959 if pager.page != self.page: 

1960 self.page = pager.page 

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

1962 if key in self.request.session: 

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

1964 

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

1966 pager = self.paginate_data(data) 

1967 

1968 return pager 

1969 

1970 ############################## 

1971 # rendering methods 

1972 ############################## 

1973 

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

1975 """ 

1976 Column renderer for batch ID values. 

1977 

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

1979 

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

1981 """ 

1982 if value is None: 

1983 return "" 

1984 

1985 batch_id = int(value) 

1986 return f"{batch_id:08d}" 

1987 

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

1989 """ 

1990 Column renderer for boolean values. 

1991 

1992 This calls 

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

1994 for the return value. 

1995 

1996 This may be used automatically per 

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

1998 

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

2000 """ 

2001 return self.app.render_boolean(value) 

2002 

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

2004 self, obj, key, value, **kwargs 

2005 ): 

2006 """ 

2007 Column renderer for currency values. 

2008 

2009 This calls 

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

2011 for the return value. 

2012 

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

2014 

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

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

2017 """ 

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

2019 

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

2021 """ 

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

2023 

2024 This calls 

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

2026 for the return value. 

2027 

2028 This may be used automatically per 

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

2030 

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

2032 """ 

2033 try: 

2034 dt = getattr(obj, key) 

2035 except AttributeError: 

2036 dt = obj[key] 

2037 return self.app.render_date(dt) 

2038 

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

2040 """ 

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

2042 

2043 This calls 

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

2045 for the return value. 

2046 

2047 This may be used automatically per 

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

2049 

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

2051 """ 

2052 try: 

2053 dt = getattr(obj, key) 

2054 except AttributeError: 

2055 dt = obj[key] 

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

2057 

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

2059 """ 

2060 Custom grid value renderer for "enum" fields. 

2061 

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

2063 

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

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

2066 

2067 To use this feature for your grid:: 

2068 

2069 from enum import Enum 

2070 

2071 class MyEnum(Enum): 

2072 ONE = 1 

2073 TWO = 2 

2074 THREE = 3 

2075 

2076 grid.set_enum("my_enum_field", MyEnum) 

2077 

2078 Or, perhaps more common:: 

2079 

2080 myenum = { 

2081 1: "ONE", 

2082 2: "TWO", 

2083 3: "THREE", 

2084 } 

2085 

2086 grid.set_enum("my_enum_field", myenum) 

2087 """ 

2088 if enum: 

2089 

2090 if isinstance(enum, EnumType): 

2091 if raw_value := obj[key]: 

2092 return raw_value.value 

2093 

2094 if isinstance(enum, dict): 

2095 return enum.get(value, value) 

2096 

2097 return value 

2098 

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

2100 self, obj, key, value, **kwargs 

2101 ): 

2102 """ 

2103 Column renderer for percentage values. 

2104 

2105 This calls 

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

2107 for the return value. 

2108 

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

2110 

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

2112 """ 

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

2114 

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

2116 """ 

2117 Column renderer for quantity values. 

2118 

2119 This calls 

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

2121 for the return value. 

2122 

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

2124 

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

2126 """ 

2127 return self.app.render_quantity(value) 

2128 

2129 def render_table_element( 

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

2131 ): 

2132 """ 

2133 Render a simple Vue table element for the grid. 

2134 

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

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

2137 standard table component. 

2138 

2139 This returns something like: 

2140 

2141 .. code-block:: html 

2142 

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

2144 <!-- columns etc. --> 

2145 </b-table> 

2146 

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

2148 

2149 Actual output will of course depend on grid attributes, 

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

2151 

2152 :param form: Reference to the 

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

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

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

2156 

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

2158 the output. 

2159 

2160 .. note:: 

2161 

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

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

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

2165 directly by that form's Vue component. 

2166 

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

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

2169 yet be settled here. 

2170 """ 

2171 

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

2173 if form: 

2174 form.add_grid_vue_context(self) 

2175 

2176 # otherwise logic is the same, just different template 

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

2178 

2179 def render_vue_tag(self, **kwargs): 

2180 """ 

2181 Render the Vue component tag for the grid. 

2182 

2183 By default this simply returns: 

2184 

2185 .. code-block:: html 

2186 

2187 <wutta-grid></wutta-grid> 

2188 

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

2190 particular :attr:`vue_tagname`. 

2191 """ 

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

2193 

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

2195 """ 

2196 Render the Vue template block for the grid. 

2197 

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

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

2200 

2201 This returns something like: 

2202 

2203 .. code-block:: none 

2204 

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

2206 <b-table> 

2207 <!-- columns etc. --> 

2208 </b-table> 

2209 </script> 

2210 

2211 <script> 

2212 WuttaGridData = {} 

2213 WuttaGrid = { 

2214 template: 'wutta-grid-template', 

2215 } 

2216 </script> 

2217 

2218 .. todo:: 

2219 

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

2221 

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

2223 

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

2225 

2226 Actual output will of course depend on grid attributes, 

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

2228 

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

2230 the output. 

2231 """ 

2232 context["grid"] = self 

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

2234 output = render(template, context) 

2235 return HTML.literal(output) 

2236 

2237 def render_vue_finalize(self): 

2238 """ 

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

2240 

2241 By default this simply returns: 

2242 

2243 .. code-block:: html 

2244 

2245 <script> 

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

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

2248 </script> 

2249 

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

2251 particular :attr:`vue_tagname`. 

2252 """ 

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

2254 

2255 def get_vue_columns(self): 

2256 """ 

2257 Returns a list of Vue-compatible column definitions. 

2258 

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

2260 returned will be a dict in this format:: 

2261 

2262 { 

2263 'field': 'foo', 

2264 'label': "Foo", 

2265 'sortable': True, 

2266 'searchable': False, 

2267 } 

2268 

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

2270 in its `Table docs 

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

2272 

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

2274 """ 

2275 if not self.columns: 

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

2277 

2278 columns = [] 

2279 for name in self.columns: 

2280 columns.append( 

2281 { 

2282 "field": name, 

2283 "label": self.get_label(name), 

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

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

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

2287 } 

2288 ) 

2289 return columns 

2290 

2291 def get_vue_active_sorters(self): 

2292 """ 

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

2294 

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

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

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

2298 

2299 # active_sorters format 

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

2301 

2302 # get_vue_active_sorters() format 

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

2304 

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

2306 described above. 

2307 """ 

2308 sorters = [] 

2309 for sorter in self.active_sorters: 

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

2311 return sorters 

2312 

2313 def get_vue_first_sorter(self): 

2314 """ 

2315 Returns the first active sorter, if applicable. 

2316 

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

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

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

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

2321 for either scenario. 

2322 

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

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

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

2326 

2327 Otherwise this will use the first sorter from 

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

2329 

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

2331 or ``None``. 

2332 """ 

2333 if self.active_sorters: 

2334 sorter = self.active_sorters[0] 

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

2336 

2337 if self.sort_defaults: 

2338 sorter = self.sort_defaults[0] 

2339 return [sorter.sortkey, sorter.sortdir] 

2340 

2341 return None 

2342 

2343 def get_vue_filters(self): 

2344 """ 

2345 Returns a list of Vue-compatible filter definitions. 

2346 

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

2348 each as a simple dict with the filter state. 

2349 """ 

2350 filters = [] 

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

2352 

2353 choices = [] 

2354 choice_labels = {} 

2355 if filtr.choices: 

2356 choices = list(filtr.choices) 

2357 choice_labels = dict(filtr.choices) 

2358 

2359 filters.append( 

2360 { 

2361 "key": filtr.key, 

2362 "data_type": filtr.data_type, 

2363 "active": filtr.active, 

2364 "visible": filtr.active, 

2365 "verbs": filtr.get_verbs(), 

2366 "verb_labels": filtr.get_verb_labels(), 

2367 "valueless_verbs": filtr.get_valueless_verbs(), 

2368 "verb": filtr.verb, 

2369 "choices": choices, 

2370 "choice_labels": choice_labels, 

2371 "value": filtr.value, 

2372 "label": filtr.label, 

2373 } 

2374 ) 

2375 return filters 

2376 

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

2378 """ """ 

2379 try: 

2380 dct = dict(obj) 

2381 except TypeError: 

2382 dct = dict(obj.__dict__) 

2383 dct.pop("_sa_instance_state", None) 

2384 return dct 

2385 

2386 def get_vue_context(self): 

2387 """ 

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

2389 component. This contains the following keys: 

2390 

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

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

2393 

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

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

2396 

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

2398 ensure each record can be serialized to JSON. 

2399 

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

2401 obtain the "final" values for each record. 

2402 

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

2404 defined, to each record. 

2405 

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

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

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

2409 string" - the Vue component expects that. 

2410 

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

2412 """ 

2413 original_data = self.get_visible_data() 

2414 

2415 # loop thru data 

2416 data = [] 

2417 row_classes = {} 

2418 for i, record in enumerate(original_data, 1): 

2419 original_record = record 

2420 

2421 # convert record to new dict 

2422 record = self.object_to_dict(record) 

2423 

2424 # discard non-declared fields 

2425 record = {field: record[field] for field in record if field in self.columns} 

2426 

2427 # make all values safe for json 

2428 record = make_json_safe(record, warn=False) 

2429 

2430 # customize value rendering where applicable 

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

2432 value = record.get(key, None) 

2433 record[key] = renderer(original_record, key, value) 

2434 

2435 # add action urls to each record 

2436 for action in self.actions: 

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

2438 if key not in record: 

2439 url = action.get_url(original_record, i) 

2440 if url: 

2441 record[key] = url 

2442 

2443 # set row css class if applicable 

2444 css_class = self.get_row_class(original_record, record, i) 

2445 if css_class: 

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

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

2448 

2449 data.append(record) 

2450 

2451 return { 

2452 "data": data, 

2453 "row_classes": row_classes, 

2454 } 

2455 

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

2457 """ """ 

2458 warnings.warn( 

2459 "grid.get_vue_data() is deprecated; " 

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

2461 DeprecationWarning, 

2462 stacklevel=2, 

2463 ) 

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

2465 

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

2467 """ 

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

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

2470 

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

2472 value obtained from there. 

2473 

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

2475 

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

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

2478 

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

2480 within the grid. 

2481 

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

2483 """ 

2484 if self.row_class: 

2485 if callable(self.row_class): 

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

2487 return self.row_class 

2488 return None 

2489 

2490 def get_vue_pager_stats(self): 

2491 """ 

2492 Returns a simple dict with current grid pager stats. 

2493 

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

2495 """ 

2496 pager = self.pager 

2497 return { 

2498 "item_count": pager.item_count, 

2499 "items_per_page": pager.items_per_page, 

2500 "page": pager.page, 

2501 "page_count": pager.page_count, 

2502 "first_item": pager.first_item, 

2503 "last_item": pager.last_item, 

2504 } 

2505 

2506 

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

2508 """ 

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

2510 

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

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

2513 own set of action links. 

2514 

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

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

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

2518 actions from within a view. 

2519 

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

2521 

2522 .. note:: 

2523 

2524 Some parameters are not explicitly described above. However 

2525 their corresponding attributes are described below. 

2526 

2527 .. attribute:: key 

2528 

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

2530 grid. 

2531 

2532 .. attribute:: label 

2533 

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

2535 generated from :attr:`key` by calling 

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

2537 

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

2539 

2540 .. attribute:: url 

2541 

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

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

2544 the same URL for this action. 

2545 

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

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

2548 args, for instance:: 

2549 

2550 def myurl(obj, i): 

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

2552 

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

2554 

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

2556 

2557 .. attribute:: target 

2558 

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

2560 

2561 .. attribute:: click_handler 

2562 

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

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

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

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

2567 

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

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

2570 

2571 .. attribute:: icon 

2572 

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

2574 

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

2576 

2577 .. attribute:: link_class 

2578 

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

2580 """ 

2581 

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

2583 self, 

2584 request, 

2585 key, 

2586 label=None, 

2587 url=None, 

2588 target=None, 

2589 click_handler=None, 

2590 icon=None, 

2591 link_class=None, 

2592 ): 

2593 self.request = request 

2594 self.config = self.request.wutta_config 

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

2596 self.key = key 

2597 self.url = url 

2598 self.target = target 

2599 self.click_handler = click_handler 

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

2601 self.icon = icon or key 

2602 self.link_class = link_class or "" 

2603 

2604 def render_icon_and_label(self): 

2605 """ 

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

2607 

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

2609 and :meth:`render_label()`. 

2610 """ 

2611 html = [ 

2612 self.render_icon(), 

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

2614 self.render_label(), 

2615 ] 

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

2617 

2618 def render_icon(self): 

2619 """ 

2620 Render the HTML snippet for the action link icon. 

2621 

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

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

2624 

2625 .. code-block:: html 

2626 

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

2628 

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

2630 """ 

2631 if self.request.use_oruga: 

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

2633 

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

2635 

2636 def render_label(self): 

2637 """ 

2638 Render the label text for the action link. 

2639 

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

2641 

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

2643 """ 

2644 return self.label 

2645 

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

2647 """ 

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

2649 instance). 

2650 

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

2652 

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

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

2655 passed along. 

2656 

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

2658 setup to use. 

2659 

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

2661 parent grid. 

2662 

2663 See also :attr:`url`. 

2664 """ 

2665 if callable(self.url): 

2666 return self.url(obj, i) 

2667 

2668 return self.url