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

567 statements  

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

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

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

3# 

4# wuttaweb -- Web App for Wutta Framework 

5# Copyright © 2024 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

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

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

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

12# later version. 

13# 

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

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

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

17# more details. 

18# 

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

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

21# 

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

23""" 

24Base grid classes 

25""" 

26 

27import functools 

28import json 

29import logging 

30import warnings 

31from collections import namedtuple, OrderedDict 

32 

33import sqlalchemy as sa 

34from sqlalchemy import orm 

35 

36import paginate 

37from paginate_sqlalchemy import SqlalchemyOrmPage 

38from pyramid.renderers import render 

39from webhelpers2.html import HTML 

40 

41from wuttaweb.db import Session 

42from wuttaweb.util import FieldList, get_model_fields, make_json_safe 

43from wuttjamaican.util import UNSPECIFIED 

44from wuttaweb.grids.filters import default_sqlalchemy_filters, VerbNotSupported 

45 

46 

47log = logging.getLogger(__name__) 

48 

49 

50SortInfo = namedtuple('SortInfo', ['sortkey', 'sortdir']) 

51SortInfo.__doc__ = """ 

52Named tuple to track sorting info. 

53 

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

55""" 

56 

57class Grid: 

58 """ 

59 Base class for all grids. 

60 

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

62 

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

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

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

66 

67 .. note:: 

68 

69 Some parameters are not explicitly described above. However 

70 their corresponding attributes are described below. 

71 

72 Grid instances contain the following attributes: 

73 

74 .. attribute:: key 

75 

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

77 sort/filter settings etc. 

78 

79 .. attribute:: vue_tagname 

80 

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

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

83 

84 .. attribute:: model_class 

85 

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

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

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

89 

90 .. attribute:: columns 

91 

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

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

94 order as they are in this list. 

95 

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

97 

98 .. attribute:: data 

99 

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

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

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

103 list, e.g. SQLAlchemy query. 

104 

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

106 :meth:`get_visible_data()`. 

107 

108 .. attribute:: labels 

109 

110 Dict of column label overrides. 

111 

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

113 

114 .. attribute:: renderers 

115 

116 Dict of column (cell) value renderer overrides. 

117 

118 See also :meth:`set_renderer()`. 

119 

120 .. attribute:: row_class 

121 

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

123 the grid. Default is ``None``. 

124 

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

126 applied to all rows. 

127 

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

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

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

131 

132 def my_row_class(obj, data, i): 

133 if obj.archived: 

134 return 'poser-archived' 

135 

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

137 

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

139 

140 .. attribute:: actions 

141 

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

143 to be shown for each record in the grid. 

144 

145 .. attribute:: linked_columns 

146 

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

148 applied. 

149 

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

151 

152 .. attribute:: sortable 

153 

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

155 the grid. Default is ``False``. 

156 

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

158 

159 .. attribute:: sort_multiple 

160 

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

162 Default is ``True``; if this is ``False`` then only one column 

163 may be sorted at a time. 

164 

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

166 frontend and backend sorting. 

167 

168 .. warning:: 

169 

170 This feature is limited by frontend JS capabilities, 

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

172 frontend and backend sorting). 

173 

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

175 then multi-column sorting should work. 

176 

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

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

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

180 Vue 3 + Oruga templates. 

181 

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

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

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

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

186 

187 .. attribute:: sort_on_backend 

188 

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

190 backend. Default is ``True``. 

191 

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

193 sorting. 

194 

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

196 

197 .. attribute:: sorters 

198 

199 Dict of functions to use for backend sorting. 

200 

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

202 :attr:`sort_on_backend` are true. 

203 

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

205 :attr:`active_sorters`. 

206 

207 .. attribute:: sort_defaults 

208 

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

210 requests a different sorting method. 

211 

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

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

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

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

216 

217 Used with both frontend and backend sorting. 

218 

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

220 :attr:`active_sorters`. 

221 

222 .. warning:: 

223 

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

225 sorting, this feature is limited by frontend JS 

226 capabilities. 

227 

228 Even if ``sort_defaults`` contains multiple entries 

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

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

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

232 component. 

233 

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

235 

236 .. attribute:: active_sorters 

237 

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

239 :meth:`sort_data()`. 

240 

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

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

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

244 

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

246 called it will not exist. 

247 

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

249 different format is used here:: 

250 

251 grid.active_sorters = [ 

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

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

254 ] 

255 

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

257 set this attribute directly. 

258 

259 This list may contain multiple elements only if 

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

261 have either zero or one element. 

262 

263 .. attribute:: paginated 

264 

265 Boolean indicating whether the grid data should be paginated, 

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

267 data is shown at once. 

268 

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

270 :attr:`paginate_on_backend`. 

271 

272 .. attribute:: paginate_on_backend 

273 

274 Boolean indicating whether the grid data should be paginated on 

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

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

277 

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

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

280 pagination. 

281 

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

283 

284 .. attribute:: pagesize_options 

285 

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

287 :attr:`pagesize`. 

288 

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

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

291 value. 

292 

293 .. attribute:: pagesize 

294 

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

296 :attr:`pagesize_options` and :attr:`page`. 

297 

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

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

300 

301 .. attribute:: page 

302 

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

304 also :attr:`pagesize`. 

305 

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

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

308 

309 .. attribute:: searchable_columns 

310 

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

312 

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

314 

315 .. attribute:: filterable 

316 

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

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

319 ``False``. 

320 

321 .. attribute:: filters 

322 

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

324 available for use with backend filtering. 

325 

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

327 

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

329 

330 .. attribute:: filter_defaults 

331 

332 Dict containing default state preferences for the filters. 

333 

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

335 

336 .. attribute:: joiners 

337 

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

339 sorting. 

340 

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

342 

343 .. attribute:: tools 

344 

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

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

347 

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

349 caller. Values should be HTML literal elements. 

350 

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

352 """ 

353 

354 def __init__( 

355 self, 

356 request, 

357 vue_tagname='wutta-grid', 

358 model_class=None, 

359 key=None, 

360 columns=None, 

361 data=None, 

362 labels={}, 

363 renderers={}, 

364 row_class=None, 

365 actions=[], 

366 linked_columns=[], 

367 sortable=False, 

368 sort_multiple=True, 

369 sort_on_backend=True, 

370 sorters=None, 

371 sort_defaults=None, 

372 paginated=False, 

373 paginate_on_backend=True, 

374 pagesize_options=None, 

375 pagesize=None, 

376 page=1, 

377 searchable_columns=None, 

378 filterable=False, 

379 filters=None, 

380 filter_defaults=None, 

381 joiners=None, 

382 tools=None, 

383 ): 

384 self.request = request 

385 self.vue_tagname = vue_tagname 

386 self.model_class = model_class 

387 self.key = key 

388 self.data = data 

389 self.labels = labels or {} 

390 self.renderers = renderers or {} 

391 self.row_class = row_class 

392 self.actions = actions or [] 

393 self.linked_columns = linked_columns or [] 

394 self.joiners = joiners or {} 

395 

396 self.config = self.request.wutta_config 

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

398 

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

400 self.set_tools(tools) 

401 

402 # sorting 

403 self.sortable = sortable 

404 self.sort_multiple = sort_multiple 

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

406 log.warning("grid.sort_multiple is not implemented for Oruga-based templates") 

407 self.sort_multiple = False 

408 self.sort_on_backend = sort_on_backend 

409 if sorters is not None: 

410 self.sorters = sorters 

411 elif self.sortable and self.sort_on_backend: 

412 self.sorters = self.make_backend_sorters() 

413 else: 

414 self.sorters = {} 

415 self.set_sort_defaults(sort_defaults or []) 

416 

417 # paging 

418 self.paginated = paginated 

419 self.paginate_on_backend = paginate_on_backend 

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

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

422 self.page = page 

423 

424 # searching 

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

426 

427 # filtering 

428 self.filterable = filterable 

429 if filters is not None: 

430 self.filters = filters 

431 elif self.filterable: 

432 self.filters = self.make_backend_filters() 

433 else: 

434 self.filters = {} 

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

436 

437 def get_columns(self): 

438 """ 

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

440 ``None``. 

441 

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

443 

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

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

446 

447 Otherwise ``None`` is returned. 

448 """ 

449 if hasattr(self, 'columns') and self.columns: 

450 return self.columns 

451 

452 columns = self.get_model_columns() 

453 if columns: 

454 return columns 

455 

456 return [] 

457 

458 def get_model_columns(self, model_class=None): 

459 """ 

460 This method is a shortcut which calls 

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

462 

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

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

465 assumed. 

466 """ 

467 return get_model_fields(self.config, 

468 model_class=model_class or self.model_class) 

469 

470 @property 

471 def vue_component(self): 

472 """ 

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

474 

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

476 """ 

477 words = self.vue_tagname.split('-') 

478 return ''.join([word.capitalize() for word in words]) 

479 

480 def set_columns(self, columns): 

481 """ 

482 Explicitly set the list of grid columns. 

483 

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

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

486 

487 :param columns: List of string column names. 

488 """ 

489 self.columns = FieldList(columns) 

490 

491 def append(self, *keys): 

492 """ 

493 Add some columns(s) to the grid. 

494 

495 This is a convenience to allow adding multiple columns at 

496 once:: 

497 

498 grid.append('first_field', 

499 'second_field', 

500 'third_field') 

501 

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

503 """ 

504 for key in keys: 

505 if key not in self.columns: 

506 self.columns.append(key) 

507 

508 def remove(self, *keys): 

509 """ 

510 Remove some column(s) from the grid. 

511 

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

513 once:: 

514 

515 grid.remove('first_field', 

516 'second_field', 

517 'third_field') 

518 

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

520 """ 

521 for key in keys: 

522 if key in self.columns: 

523 self.columns.remove(key) 

524 

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

526 """ 

527 Set/override the label for a column. 

528 

529 :param key: Name of column. 

530 

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

532 

533 :param column_only: Boolean indicating whether the label 

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

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

536 

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

538 :attr:`labels`. 

539 """ 

540 self.labels[key] = label 

541 

542 if not column_only and key in self.filters: 

543 self.filters[key].label = label 

544 

545 def get_label(self, key): 

546 """ 

547 Returns the label text for a given column. 

548 

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

550 

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

552 """ 

553 if key in self.labels: 

554 return self.labels[key] 

555 return self.app.make_title(key) 

556 

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

558 """ 

559 Set/override the value renderer for a column. 

560 

561 :param key: Name of column. 

562 

563 :param renderer: Callable as described below. 

564 

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

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

567 

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

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

570 JSON-compatible. 

571 

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

573 to obtain the "final" cell value. 

574 

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

576 key, value)``: 

577 

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

579 * ``key`` is the column name 

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

581 

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

583 value. For instance:: 

584 

585 from webhelpers2.html import HTML 

586 

587 def render_foo(record, key, value): 

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

589 

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

591 grid.set_renderer('foo', render_foo) 

592 

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

594 """ 

595 if kwargs: 

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

597 self.renderers[key] = renderer 

598 

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

600 """ 

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

602 column. 

603 

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

605 contents will automatically be wrapped with a hyperlink. The 

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

607 :class:`GridAction` 

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

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

610 link depending on which data record it points to. 

611 

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

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

614 

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

616 :attr:`linked_columns`. 

617 

618 :param key: Column key as string. 

619 

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

621 should be auto-linked. 

622 """ 

623 if link: 

624 if key not in self.linked_columns: 

625 self.linked_columns.append(key) 

626 else: # unlink 

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

628 self.linked_columns.remove(key) 

629 

630 def is_linked(self, key): 

631 """ 

632 Returns boolean indicating if auto-link behavior is enabled 

633 for a given column. 

634 

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

636 

637 :param key: Column key as string. 

638 """ 

639 if self.linked_columns: 

640 if key in self.linked_columns: 

641 return True 

642 return False 

643 

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

645 """ 

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

647 component. 

648 

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

650 :attr:`searchable_columns`. 

651 """ 

652 if searchable: 

653 self.searchable_columns.add(key) 

654 elif key in self.searchable_columns: 

655 self.searchable_columns.remove(key) 

656 

657 def is_searchable(self, key): 

658 """ 

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

660 component. 

661 

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

663 """ 

664 return key in self.searchable_columns 

665 

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

667 """ 

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

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

670 """ 

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

672 

673 def set_tools(self, tools): 

674 """ 

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

676 

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

678 """ 

679 if tools and isinstance(tools, list): 

680 if not any([isinstance(t, (tuple, list)) for t in tools]): 

681 tools = [(self.app.make_uuid(), t) for t in tools] 

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

683 

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

685 """ 

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

687 

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

689 

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

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

692 generated. 

693 

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

695 """ 

696 if not key: 

697 key = self.app.make_uuid() 

698 self.tools[key] = html 

699 

700 ############################## 

701 # joining methods 

702 ############################## 

703 

704 def set_joiner(self, key, joiner): 

705 """ 

706 Set/override the backend joiner for a column. 

707 

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

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

710 

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

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

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

714 don't want the join to happen twice. 

715 

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

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

718 filter is needed, the joiner will be invoked. 

719 

720 :param key: Name of column. 

721 

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

723 

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

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

726 

727 model = app.model 

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

729 

730 def join_external_profile_value(query): 

731 return query.join(model.ExternalProfile) 

732 

733 def sort_external_profile(query, direction): 

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

735 return query.order_by(sortspec()) 

736 

737 grid.set_joiner('external_profile', join_external_profile) 

738 grid.set_sorter('external_profile', sort_external_profile) 

739 

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

741 via :attr:`joiners`. 

742 """ 

743 self.joiners[key] = joiner 

744 

745 def remove_joiner(self, key): 

746 """ 

747 Remove the backend joiner for a column. 

748 

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

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

751 later defined for it. 

752 

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

754 """ 

755 self.joiners.pop(key, None) 

756 

757 ############################## 

758 # sorting methods 

759 ############################## 

760 

761 def make_backend_sorters(self, sorters=None): 

762 """ 

763 Make backend sorters for all columns in the grid. 

764 

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

766 and :attr:`sort_on_backend` are true. 

767 

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

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

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

771 

772 .. note:: 

773 

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

775 this method just returns the initial sorters (or empty 

776 dict). 

777 

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

779 existing sorters will be left intact, not replaced. 

780 

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

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

783 created. 

784 """ 

785 sorters = sorters or {} 

786 

787 if self.model_class: 

788 for key in self.columns: 

789 if key in sorters: 

790 continue 

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

792 if (prop and hasattr(prop, 'property') 

793 and isinstance(prop.property, orm.ColumnProperty)): 

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

795 

796 return sorters 

797 

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

799 """ 

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

801 given column. 

802 

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

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

805 

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

807 or a column name. 

808 

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

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

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

812 a default function is used. 

813 

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

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

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

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

818 may be disabled if needed. 

819 

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

821 should help to clarify:: 

822 

823 model = app.model 

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

825 

826 # explicit property 

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

828 

829 # property name works if grid has model class 

830 sorter = grid.make_sorter('full_name') 

831 

832 # nb. this will *not* work 

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

834 sorter = grid.make_sorter(person.full_name) 

835 

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

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

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

839 query):: 

840 

841 data = [ 

842 {'foo': 1}, 

843 {'bar': 2}, 

844 ] 

845 

846 # nb. no model_class, just as an example 

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

848 

849 def getkey(obj): 

850 if obj.get('foo') 

851 return obj['foo'] 

852 if obj.get('bar'): 

853 return obj['bar'] 

854 return '' 

855 

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

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

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

859 sorted_data = sortfunc(data, 'asc') 

860 

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

862 function will behave differently when it is given a 

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

864 will return the sorted result. 

865 

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

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

868 """ 

869 model_class = None 

870 model_property = None 

871 if isinstance(columninfo, str): 

872 key = columninfo 

873 model_class = self.model_class 

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

875 else: 

876 model_property = columninfo 

877 model_class = model_property.class_ 

878 key = model_property.key 

879 

880 def sorter(data, direction): 

881 

882 # query is sorted with order_by() 

883 if isinstance(data, orm.Query): 

884 if not model_property: 

885 raise TypeError(f"grid sorter for '{key}' does not map to a model property") 

886 query = data 

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

888 

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

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

891 # each record 

892 kfunc = keyfunc 

893 if not kfunc: 

894 if model_property: 

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

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

897 if foldcase: 

898 kfunc = lambda obj: (obj[key] or '').lower() 

899 else: 

900 kfunc = lambda obj: obj[key] or '' 

901 if not kfunc: 

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

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

904 kfunc = lambda obj: obj[key] 

905 

906 # then sort the data and return 

907 return sorted(data, key=kfunc, reverse=direction == 'desc') 

908 

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

910 # multi-column sorting with sqlalchemy queries 

911 if model_property: 

912 sorter._class = model_class 

913 sorter._column = model_property 

914 

915 return sorter 

916 

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

918 """ 

919 Set/override the backend sorter for a column. 

920 

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

922 :attr:`sort_on_backend` are true. 

923 

924 :param key: Name of column. 

925 

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

927 model property (see below). 

928 

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

930 backend sorter. 

931 

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

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

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

935 

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

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

938 

939 model = app.model 

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

941 

942 def sort_full_name(query, direction): 

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

944 return query.order_by(sortspec()) 

945 

946 grid.set_sorter('full_name', sort_full_name) 

947 

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

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

950 """ 

951 sorter = None 

952 

953 if sortinfo and callable(sortinfo): 

954 sorter = sortinfo 

955 else: 

956 sorter = self.make_sorter(sortinfo or key) 

957 

958 self.sorters[key] = sorter 

959 

960 def remove_sorter(self, key): 

961 """ 

962 Remove the backend sorter for a column. 

963 

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

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

966 later defined for it. 

967 

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

969 """ 

970 self.sorters.pop(key, None) 

971 

972 def set_sort_defaults(self, *args): 

973 """ 

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

975 used unless/until the user requests a different sorting 

976 method. 

977 

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

979 

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

981 ``sortdir``; for instance:: 

982 

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

984 

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

986 

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

988 

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

990 assumed:: 

991 

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

993 

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

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

996 

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

998 ('value', 'desc')]) 

999 

1000 .. note:: 

1001 

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

1003 is actually allowed to have multiple sort defaults. The 

1004 defaults requested by the method call may be pruned if 

1005 necessary to accommodate that. 

1006 

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

1008 """ 

1009 

1010 # convert args to sort defaults 

1011 sort_defaults = [] 

1012 if len(args) == 1: 

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

1014 sort_defaults = [SortInfo(args[0], 'asc')] 

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

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

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

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

1019 else: 

1020 raise ValueError("for just one positional arg, must pass string, 2-tuple or list") 

1021 elif len(args) == 2: 

1022 sort_defaults = [SortInfo(*args)] 

1023 else: 

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

1025 

1026 # prune if multi-column requested but not supported 

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

1028 log.warning("multi-column sorting is not enabled for the instance; " 

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

1030 self.key, sort_defaults) 

1031 sort_defaults = [sort_defaults[0]] 

1032 

1033 self.sort_defaults = sort_defaults 

1034 

1035 def is_sortable(self, key): 

1036 """ 

1037 Returns boolean indicating if a given column should allow 

1038 sorting. 

1039 

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

1041 

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

1043 this always returns ``True``. 

1044 

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

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

1047 

1048 :param key: Column key as string. 

1049 

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

1051 """ 

1052 if not self.sortable: 

1053 return False 

1054 if self.sort_on_backend: 

1055 return key in self.sorters 

1056 return True 

1057 

1058 ############################## 

1059 # filtering methods 

1060 ############################## 

1061 

1062 def make_backend_filters(self, filters=None): 

1063 """ 

1064 Make backend filters for all columns in the grid. 

1065 

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

1067 true. 

1068 

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

1070 ``filters`` and if the column is not yet in there, will call 

1071 :meth:`make_filter()` to add it. 

1072 

1073 .. note:: 

1074 

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

1076 this method just returns the initial filters (or empty 

1077 dict). 

1078 

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

1080 existing filters will be left intact, not replaced. 

1081 

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

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

1084 created. 

1085 """ 

1086 filters = filters or {} 

1087 

1088 if self.model_class: 

1089 # TODO: i tried using self.get_model_columns() here but in 

1090 # many cases that will be too aggressive. however it is 

1091 # often the case that the *grid* columns are a subset of 

1092 # the unerlying *table* columns. so until a better way 

1093 # is found, we choose "too few" instead of "too many" 

1094 # filters here. surely must improve it at some point. 

1095 for key in self.columns: 

1096 if key in filters: 

1097 continue 

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

1099 if (prop and hasattr(prop, 'property') 

1100 and isinstance(prop.property, orm.ColumnProperty)): 

1101 filters[prop.key] = self.make_filter(prop) 

1102 

1103 return filters 

1104 

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

1106 """ 

1107 Create and return a 

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

1109 for use on the given column. 

1110 

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

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

1113 

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

1115 or a column name. 

1116 

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

1118 instance. 

1119 """ 

1120 key = kwargs.pop('key', None) 

1121 

1122 # model_property is required 

1123 model_property = None 

1124 if kwargs.get('model_property'): 

1125 model_property = kwargs['model_property'] 

1126 elif isinstance(columninfo, str): 

1127 key = columninfo 

1128 if self.model_class: 

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

1130 if not model_property: 

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

1132 else: 

1133 model_property = columninfo 

1134 

1135 # optional factory override 

1136 factory = kwargs.pop('factory', None) 

1137 if not factory: 

1138 typ = model_property.type 

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

1140 if not factory: 

1141 factory = default_sqlalchemy_filters[None] 

1142 

1143 # make filter 

1144 kwargs['model_property'] = model_property 

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

1146 

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

1148 """ 

1149 Set/override the backend filter for a column. 

1150 

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

1152 

1153 :param key: Name of column. 

1154 

1155 :param filterinfo: Can be either a 

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

1157 else a model property (see below). 

1158 

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

1160 used as-is for the backend filter. 

1161 

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

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

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

1165 

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

1167 via :attr:`filters`. 

1168 """ 

1169 filtr = None 

1170 

1171 if filterinfo and callable(filterinfo): 

1172 # filtr = filterinfo 

1173 raise NotImplementedError 

1174 else: 

1175 kwargs['key'] = key 

1176 kwargs.setdefault('label', self.get_label(key)) 

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

1178 

1179 self.filters[key] = filtr 

1180 

1181 def remove_filter(self, key): 

1182 """ 

1183 Remove the backend filter for a column. 

1184 

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

1186 filter by this column unless another filter is later defined 

1187 for it. 

1188 

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

1190 """ 

1191 self.filters.pop(key, None) 

1192 

1193 def set_filter_defaults(self, **defaults): 

1194 """ 

1195 Set default state preferences for the grid filters. 

1196 

1197 These preferences will affect the initial grid display, until 

1198 user requests a different filtering method. 

1199 

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

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

1202 

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

1204 'verb': 'contains', 

1205 'value': 'foo'}, 

1206 value={'active': True}) 

1207 

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

1209 """ 

1210 filter_defaults = dict(getattr(self, 'filter_defaults', {})) 

1211 

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

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

1214 filtr.update(values) 

1215 

1216 self.filter_defaults = filter_defaults 

1217 

1218 ############################## 

1219 # paging methods 

1220 ############################## 

1221 

1222 def get_pagesize_options(self, default=None): 

1223 """ 

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

1225 

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

1227 back to:: 

1228 

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

1230 

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

1232 configured. 

1233 

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

1235 instead access :attr:`pagesize_options` directly. 

1236 """ 

1237 options = self.config.get_list('wuttaweb.grids.default_pagesize_options') 

1238 if options: 

1239 options = [int(size) for size in options 

1240 if size.isdigit()] 

1241 if options: 

1242 return options 

1243 

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

1245 

1246 def get_pagesize(self, default=None): 

1247 """ 

1248 Returns the default page size for the grid. 

1249 

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

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

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

1253 

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

1255 configured. 

1256 

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

1258 instead access :attr:`pagesize` directly. 

1259 """ 

1260 size = self.config.get_int('wuttaweb.grids.default_pagesize') 

1261 if size: 

1262 return size 

1263 

1264 if default: 

1265 return default 

1266 

1267 if 20 in self.pagesize_options: 

1268 return 20 

1269 

1270 return self.pagesize_options[0] 

1271 

1272 ############################## 

1273 # configuration methods 

1274 ############################## 

1275 

1276 def load_settings(self, persist=True): 

1277 """ 

1278 Load all effective settings for the grid. 

1279 

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

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

1282 from user session. 

1283 

1284 .. note:: 

1285 

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

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

1288 coming soon... 

1289 

1290 The overall logic for this method is as follows: 

1291 

1292 * collect settings 

1293 * apply settings to current grid 

1294 * optionally save settings to user session 

1295 

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

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

1298 navigates away then comes back. Therefore normally, settings 

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

1300 are wiped upon user logout. 

1301 

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

1303 to the user session. 

1304 """ 

1305 

1306 # initial default settings 

1307 settings = {} 

1308 if self.filterable: 

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

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

1311 settings[f'filter.{filtr.key}.active'] = defaults.get('active', 

1312 filtr.default_active) 

1313 settings[f'filter.{filtr.key}.verb'] = defaults.get('verb', 

1314 filtr.get_default_verb()) 

1315 settings[f'filter.{filtr.key}.value'] = defaults.get('value', 

1316 filtr.default_value) 

1317 if self.sortable: 

1318 if self.sort_defaults: 

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

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

1321 sortinfo = self.sort_defaults[0] 

1322 settings['sorters.length'] = 1 

1323 settings['sorters.1.key'] = sortinfo.sortkey 

1324 settings['sorters.1.dir'] = sortinfo.sortdir 

1325 else: 

1326 settings['sorters.length'] = 0 

1327 if self.paginated and self.paginate_on_backend: 

1328 settings['pagesize'] = self.pagesize 

1329 settings['page'] = self.page 

1330 

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

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

1333 

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

1335 if self.request.GET.get('reset-view'): 

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

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

1338 pass 

1339 

1340 elif self.request_has_settings('filter'): 

1341 self.update_filter_settings(settings, src='request') 

1342 if self.request_has_settings('sort'): 

1343 self.update_sort_settings(settings, src='request') 

1344 else: 

1345 self.update_sort_settings(settings, src='session') 

1346 self.update_page_settings(settings) 

1347 

1348 elif self.request_has_settings('sort'): 

1349 self.update_filter_settings(settings, src='session') 

1350 self.update_sort_settings(settings, src='request') 

1351 self.update_page_settings(settings) 

1352 

1353 elif self.request_has_settings('page'): 

1354 self.update_filter_settings(settings, src='session') 

1355 self.update_sort_settings(settings, src='session') 

1356 self.update_page_settings(settings) 

1357 

1358 else: 

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

1360 persist = False 

1361 

1362 # but still should load whatever is in user session 

1363 self.update_filter_settings(settings, src='session') 

1364 self.update_sort_settings(settings, src='session') 

1365 self.update_page_settings(settings) 

1366 

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

1368 if persist: 

1369 self.persist_settings(settings, dest='session') 

1370 

1371 # update ourself to reflect settings dict.. 

1372 

1373 # filtering 

1374 if self.filterable: 

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

1376 filtr.active = settings[f'filter.{filtr.key}.active'] 

1377 filtr.verb = settings[f'filter.{filtr.key}.verb'] or filtr.get_default_verb() 

1378 filtr.value = settings[f'filter.{filtr.key}.value'] 

1379 

1380 # sorting 

1381 if self.sortable: 

1382 # nb. doing this for frontend sorting also 

1383 self.active_sorters = [] 

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

1385 self.active_sorters.append({ 

1386 'key': settings[f'sorters.{i}.key'], 

1387 'dir': settings[f'sorters.{i}.dir'], 

1388 }) 

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

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

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

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

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

1394 # break 

1395 

1396 # paging 

1397 if self.paginated and self.paginate_on_backend: 

1398 self.pagesize = settings['pagesize'] 

1399 self.page = settings['page'] 

1400 

1401 def request_has_settings(self, typ): 

1402 """ """ 

1403 

1404 if typ == 'filter' and self.filterable: 

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

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

1407 return True 

1408 if 'filter' in self.request.GET: # user may be applying empty filters 

1409 return True 

1410 

1411 elif typ == 'sort' and self.sortable and self.sort_on_backend: 

1412 if 'sort1key' in self.request.GET: 

1413 return True 

1414 

1415 elif typ == 'page' and self.paginated and self.paginate_on_backend: 

1416 for key in ['pagesize', 'page']: 

1417 if key in self.request.GET: 

1418 return True 

1419 

1420 return False 

1421 

1422 def get_setting(self, settings, key, src='session', default=None, 

1423 normalize=lambda v: v): 

1424 """ """ 

1425 

1426 if src == 'request': 

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

1428 if value is not None: 

1429 try: 

1430 return normalize(value) 

1431 except ValueError: 

1432 pass 

1433 

1434 elif src == 'session': 

1435 value = self.request.session.get(f'grid.{self.key}.{key}') 

1436 if value is not None: 

1437 return normalize(value) 

1438 

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

1440 value = settings.get(key) 

1441 if value is not None: 

1442 return normalize(value) 

1443 

1444 # okay then, default it is 

1445 return default 

1446 

1447 def update_filter_settings(self, settings, src=None): 

1448 """ """ 

1449 if not self.filterable: 

1450 return 

1451 

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

1453 prefix = f'filter.{filtr.key}' 

1454 

1455 if src == 'request': 

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

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

1458 settings[f'{prefix}.verb'] = self.get_setting( 

1459 settings, f'{filtr.key}.verb', src='request', default='') 

1460 settings[f'{prefix}.value'] = self.get_setting( 

1461 settings, filtr.key, src='request', default='') 

1462 

1463 elif src == 'session': 

1464 settings[f'{prefix}.active'] = self.get_setting( 

1465 settings, f'{prefix}.active', src='session', 

1466 normalize=lambda v: str(v).lower() == 'true', default=False) 

1467 settings[f'{prefix}.verb'] = self.get_setting( 

1468 settings, f'{prefix}.verb', src='session', default='') 

1469 settings[f'{prefix}.value'] = self.get_setting( 

1470 settings, f'{prefix}.value', src='session', default='') 

1471 

1472 def update_sort_settings(self, settings, src=None): 

1473 """ """ 

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

1475 return 

1476 

1477 if src == 'request': 

1478 i = 1 

1479 while True: 

1480 skey = f'sort{i}key' 

1481 if skey in self.request.GET: 

1482 settings[f'sorters.{i}.key'] = self.get_setting(settings, skey, 

1483 src='request') 

1484 settings[f'sorters.{i}.dir'] = self.get_setting(settings, f'sort{i}dir', 

1485 src='request', 

1486 default='asc') 

1487 else: 

1488 break 

1489 i += 1 

1490 settings['sorters.length'] = i - 1 

1491 

1492 elif src == 'session': 

1493 settings['sorters.length'] = self.get_setting(settings, 'sorters.length', 

1494 src='session', normalize=int) 

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

1496 for key in ('key', 'dir'): 

1497 skey = f'sorters.{i}.{key}' 

1498 settings[skey] = self.get_setting(settings, skey, src='session') 

1499 

1500 def update_page_settings(self, settings): 

1501 """ """ 

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

1503 return 

1504 

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

1506 

1507 # pagesize 

1508 pagesize = self.request.GET.get('pagesize') 

1509 if pagesize is not None: 

1510 if pagesize.isdigit(): 

1511 settings['pagesize'] = int(pagesize) 

1512 else: 

1513 pagesize = self.request.session.get(f'grid.{self.key}.pagesize') 

1514 if pagesize is not None: 

1515 settings['pagesize'] = pagesize 

1516 

1517 # page 

1518 page = self.request.GET.get('page') 

1519 if page is not None: 

1520 if page.isdigit(): 

1521 settings['page'] = int(page) 

1522 else: 

1523 page = self.request.session.get(f'grid.{self.key}.page') 

1524 if page is not None: 

1525 settings['page'] = int(page) 

1526 

1527 def persist_settings(self, settings, dest=None): 

1528 """ """ 

1529 if dest not in ('session',): 

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

1531 

1532 # func to save a setting value to user session 

1533 def persist(key, value=lambda k: settings.get(k)): 

1534 assert dest == 'session' 

1535 skey = f'grid.{self.key}.{key}' 

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

1537 

1538 # filter settings 

1539 if self.filterable: 

1540 

1541 # always save all filters, with status 

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

1543 persist(f'filter.{filtr.key}.active', 

1544 value=lambda k: 'true' if settings.get(k) else 'false') 

1545 persist(f'filter.{filtr.key}.verb') 

1546 persist(f'filter.{filtr.key}.value') 

1547 

1548 # sort settings 

1549 if self.sortable and self.sort_on_backend: 

1550 

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

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

1553 # all and then write all 

1554 

1555 if dest == 'session': 

1556 # remove sort settings from user session 

1557 prefix = f'grid.{self.key}.sorters.' 

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

1559 if key.startswith(prefix): 

1560 del self.request.session[key] 

1561 

1562 # now save sort settings to dest 

1563 if 'sorters.length' in settings: 

1564 persist('sorters.length') 

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

1566 persist(f'sorters.{i}.key') 

1567 persist(f'sorters.{i}.dir') 

1568 

1569 # pagination settings 

1570 if self.paginated and self.paginate_on_backend: 

1571 

1572 # save to dest 

1573 persist('pagesize') 

1574 persist('page') 

1575 

1576 ############################## 

1577 # data methods 

1578 ############################## 

1579 

1580 def get_visible_data(self): 

1581 """ 

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

1583 

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

1585 for pagination etc. per the grid settings. 

1586 

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

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

1589 pagination is used), depending on the need. 

1590 

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

1592 

1593 * :meth:`filter_data()` 

1594 * :meth:`sort_data()` 

1595 * :meth:`paginate_data()` 

1596 """ 

1597 data = self.data or [] 

1598 self.joined = set() 

1599 

1600 if self.filterable: 

1601 data = self.filter_data(data) 

1602 

1603 if self.sortable and self.sort_on_backend: 

1604 data = self.sort_data(data) 

1605 

1606 if self.paginated and self.paginate_on_backend: 

1607 self.pager = self.paginate_data(data) 

1608 data = self.pager 

1609 

1610 return data 

1611 

1612 @property 

1613 def active_filters(self): 

1614 """ 

1615 Returns the list of currently active filters. 

1616 

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

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

1619 """ 

1620 return [filtr for filtr in self.filters.values() 

1621 if filtr.active] 

1622 

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

1624 """ 

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

1626 by :meth:`get_visible_data()`. 

1627 

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

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

1630 """ 

1631 if filters is None: 

1632 filters = self.active_filters 

1633 if not filters: 

1634 return data 

1635 

1636 for filtr in filters: 

1637 key = filtr.key 

1638 

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

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

1641 self.joined.add(key) 

1642 

1643 try: 

1644 data = filtr.apply_filter(data) 

1645 except VerbNotSupported as error: 

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

1647 except: 

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

1649 

1650 return data 

1651 

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

1653 """ 

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

1655 :meth:`get_visible_data()`. 

1656 

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

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

1659 """ 

1660 if sorters is None: 

1661 sorters = self.active_sorters 

1662 if not sorters: 

1663 return data 

1664 

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

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

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

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

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

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

1671 sorters = reversed(sorters) 

1672 

1673 for sorter in sorters: 

1674 sortkey = sorter['key'] 

1675 sortdir = sorter['dir'] 

1676 

1677 # cannot sort unless we have a sorter callable 

1678 sortfunc = self.sorters.get(sortkey) 

1679 if not sortfunc: 

1680 return data 

1681 

1682 # join appropriate model if needed 

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

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

1685 self.joined.add(sortkey) 

1686 

1687 # invoke the sorter 

1688 data = sortfunc(data, sortdir) 

1689 

1690 return data 

1691 

1692 def paginate_data(self, data): 

1693 """ 

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

1695 

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

1697 "data replacement" in subsequent logic. 

1698 

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

1700 """ 

1701 if isinstance(data, orm.Query): 

1702 pager = SqlalchemyOrmPage(data, 

1703 items_per_page=self.pagesize, 

1704 page=self.page) 

1705 

1706 else: 

1707 pager = paginate.Page(data, 

1708 items_per_page=self.pagesize, 

1709 page=self.page) 

1710 

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

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

1713 if pager.page != self.page: 

1714 self.page = pager.page 

1715 key = f'grid.{self.key}.page' 

1716 if key in self.request.session: 

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

1718 

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

1720 pager = self.paginate_data(data) 

1721 

1722 return pager 

1723 

1724 ############################## 

1725 # rendering methods 

1726 ############################## 

1727 

1728 def render_table_element( 

1729 self, 

1730 form=None, 

1731 template='/grids/table_element.mako', 

1732 **context): 

1733 """ 

1734 Render a simple Vue table element for the grid. 

1735 

1736 This is what you want for a "simple" grid which does require a 

1737 unique Vue component, but can instead use the standard table 

1738 component. 

1739 

1740 This returns something like: 

1741 

1742 .. code-block:: html 

1743 

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

1745 <!-- columns etc. --> 

1746 </b-table> 

1747 

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

1749 

1750 Actual output will of course depend on grid attributes, 

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

1752 

1753 :param form: Reference to the 

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

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

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

1757 

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

1759 the output. 

1760 

1761 .. note:: 

1762 

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

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

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

1766 directly by that form's Vue component. 

1767 

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

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

1770 yet be settled here. 

1771 """ 

1772 

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

1774 if form: 

1775 form.add_grid_vue_context(self) 

1776 

1777 # otherwise logic is the same, just different template 

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

1779 

1780 def render_vue_tag(self, **kwargs): 

1781 """ 

1782 Render the Vue component tag for the grid. 

1783 

1784 By default this simply returns: 

1785 

1786 .. code-block:: html 

1787 

1788 <wutta-grid></wutta-grid> 

1789 

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

1791 particular :attr:`vue_tagname`. 

1792 """ 

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

1794 

1795 def render_vue_template( 

1796 self, 

1797 template='/grids/vue_template.mako', 

1798 **context): 

1799 """ 

1800 Render the Vue template block for the grid. 

1801 

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

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

1804 

1805 This returns something like: 

1806 

1807 .. code-block:: none 

1808 

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

1810 <b-table> 

1811 <!-- columns etc. --> 

1812 </b-table> 

1813 </script> 

1814 

1815 <script> 

1816 WuttaGridData = {} 

1817 WuttaGrid = { 

1818 template: 'wutta-grid-template', 

1819 } 

1820 </script> 

1821 

1822 .. todo:: 

1823 

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

1825 

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

1827 

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

1829 

1830 Actual output will of course depend on grid attributes, 

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

1832 

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

1834 the output. 

1835 """ 

1836 context['grid'] = self 

1837 context.setdefault('request', self.request) 

1838 output = render(template, context) 

1839 return HTML.literal(output) 

1840 

1841 def render_vue_finalize(self): 

1842 """ 

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

1844 

1845 By default this simply returns: 

1846 

1847 .. code-block:: html 

1848 

1849 <script> 

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

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

1852 </script> 

1853 

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

1855 particular :attr:`vue_tagname`. 

1856 """ 

1857 set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}" 

1858 make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})" 

1859 return HTML.tag('script', c=['\n', 

1860 HTML.literal(set_data), 

1861 '\n', 

1862 HTML.literal(make_component), 

1863 '\n']) 

1864 

1865 def get_vue_columns(self): 

1866 """ 

1867 Returns a list of Vue-compatible column definitions. 

1868 

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

1870 returned will be a dict in this format:: 

1871 

1872 { 

1873 'field': 'foo', 

1874 'label': "Foo", 

1875 'sortable': True, 

1876 'searchable': False, 

1877 } 

1878 

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

1880 in its `Table docs 

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

1882 

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

1884 """ 

1885 if not self.columns: 

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

1887 

1888 columns = [] 

1889 for name in self.columns: 

1890 columns.append({ 

1891 'field': name, 

1892 'label': self.get_label(name), 

1893 'sortable': self.is_sortable(name), 

1894 'searchable': self.is_searchable(name), 

1895 }) 

1896 return columns 

1897 

1898 def get_vue_active_sorters(self): 

1899 """ 

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

1901 

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

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

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

1905 

1906 # active_sorters format 

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

1908 

1909 # get_vue_active_sorters() format 

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

1911 

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

1913 described above. 

1914 """ 

1915 sorters = [] 

1916 for sorter in self.active_sorters: 

1917 sorters.append({'field': sorter['key'], 

1918 'order': sorter['dir']}) 

1919 return sorters 

1920 

1921 def get_vue_filters(self): 

1922 """ 

1923 Returns a list of Vue-compatible filter definitions. 

1924 

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

1926 each as a simple dict with the filter state. 

1927 """ 

1928 filters = [] 

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

1930 filters.append({ 

1931 'key': filtr.key, 

1932 'active': filtr.active, 

1933 'visible': filtr.active, 

1934 'verbs': filtr.get_verbs(), 

1935 'verb_labels': filtr.get_verb_labels(), 

1936 'valueless_verbs': filtr.get_valueless_verbs(), 

1937 'verb': filtr.verb, 

1938 'value': filtr.value, 

1939 'label': filtr.label, 

1940 }) 

1941 return filters 

1942 

1943 def get_vue_context(self): 

1944 """ 

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

1946 component. This contains the following keys: 

1947 

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

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

1950 

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

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

1953 

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

1955 ensure each record can be serialized to JSON. 

1956 

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

1958 obtain the "final" values for each record. 

1959 

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

1961 defined, to each record. 

1962 

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

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

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

1966 string" - the Vue component expects that. 

1967 

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

1969 """ 

1970 original_data = self.get_visible_data() 

1971 

1972 # loop thru data 

1973 data = [] 

1974 row_classes = {} 

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

1976 original_record = record 

1977 

1978 # convert record to new dict 

1979 record = dict(record) 

1980 

1981 # make all values safe for json 

1982 record = make_json_safe(record, warn=False) 

1983 

1984 # customize value rendering where applicable 

1985 for key in self.renderers: 

1986 value = record.get(key, None) 

1987 record[key] = self.renderers[key](original_record, key, value) 

1988 

1989 # add action urls to each record 

1990 for action in self.actions: 

1991 key = f'_action_url_{action.key}' 

1992 if key not in record: 

1993 url = action.get_url(original_record, i) 

1994 if url: 

1995 record[key] = url 

1996 

1997 # set row css class if applicable 

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

1999 if css_class: 

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

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

2002 

2003 data.append(record) 

2004 

2005 return { 

2006 'data': data, 

2007 'row_classes': row_classes, 

2008 } 

2009 

2010 def get_vue_data(self): 

2011 """ """ 

2012 warnings.warn("grid.get_vue_data() is deprecated; " 

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

2014 DeprecationWarning, stacklevel=2) 

2015 return self.get_vue_context()['data'] 

2016 

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

2018 """ 

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

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

2021 

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

2023 value obtained from there. 

2024 

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

2026 

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

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

2029 

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

2031 within the grid. 

2032 

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

2034 """ 

2035 if self.row_class: 

2036 if callable(self.row_class): 

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

2038 return self.row_class 

2039 

2040 def get_vue_pager_stats(self): 

2041 """ 

2042 Returns a simple dict with current grid pager stats. 

2043 

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

2045 """ 

2046 pager = self.pager 

2047 return { 

2048 'item_count': pager.item_count, 

2049 'items_per_page': pager.items_per_page, 

2050 'page': pager.page, 

2051 'page_count': pager.page_count, 

2052 'first_item': pager.first_item, 

2053 'last_item': pager.last_item, 

2054 } 

2055 

2056 

2057class GridAction: 

2058 """ 

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

2060 

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

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

2063 own set of action links. 

2064 

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

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

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

2068 actions from within a view. 

2069 

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

2071 

2072 .. note:: 

2073 

2074 Some parameters are not explicitly described above. However 

2075 their corresponding attributes are described below. 

2076 

2077 .. attribute:: key 

2078 

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

2080 grid. 

2081 

2082 .. attribute:: label 

2083 

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

2085 generated from :attr:`key` by calling 

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

2087 

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

2089 

2090 .. attribute:: url 

2091 

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

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

2094 the same URL for this action. 

2095 

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

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

2098 args, for instance:: 

2099 

2100 def myurl(obj, i): 

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

2102 

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

2104 

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

2106 

2107 .. attribute:: icon 

2108 

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

2110 

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

2112 

2113 .. attribute:: link_class 

2114 

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

2116 """ 

2117 

2118 def __init__( 

2119 self, 

2120 request, 

2121 key, 

2122 label=None, 

2123 url=None, 

2124 icon=None, 

2125 link_class=None, 

2126 ): 

2127 self.request = request 

2128 self.config = self.request.wutta_config 

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

2130 self.key = key 

2131 self.url = url 

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

2133 self.icon = icon or key 

2134 self.link_class = link_class or '' 

2135 

2136 def render_icon_and_label(self): 

2137 """ 

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

2139 

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

2141 and :meth:`render_label()`. 

2142 """ 

2143 html = [ 

2144 self.render_icon(), 

2145 self.render_label(), 

2146 ] 

2147 return HTML.literal(' ').join(html) 

2148 

2149 def render_icon(self): 

2150 """ 

2151 Render the HTML snippet for the action link icon. 

2152 

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

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

2155 

2156 .. code-block:: html 

2157 

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

2159 

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

2161 """ 

2162 if self.request.use_oruga: 

2163 return HTML.tag('o-icon', icon=self.icon) 

2164 

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

2166 

2167 def render_label(self): 

2168 """ 

2169 Render the label text for the action link. 

2170 

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

2172 

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

2174 """ 

2175 return self.label 

2176 

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

2178 """ 

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

2180 instance). 

2181 

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

2183 

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

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

2186 passed along. 

2187 

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

2189 setup to use. 

2190 

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

2192 parent grid. 

2193 

2194 See also :attr:`url`. 

2195 """ 

2196 if callable(self.url): 

2197 return self.url(obj, i) 

2198 

2199 return self.url