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

616 statements  

« prev     ^ index     » next       coverage.py v7.6.10, created at 2025-01-14 18:23 -0600

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 :term:`grids <grid>`. 

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()` and 

119 :meth:`set_default_renderers()`. 

120 

121 .. attribute:: row_class 

122 

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

124 the grid. Default is ``None``. 

125 

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

127 applied to all rows. 

128 

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

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

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

132 

133 def my_row_class(obj, data, i): 

134 if obj.archived: 

135 return 'poser-archived' 

136 

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

138 

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

140 

141 .. attribute:: actions 

142 

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

144 to be shown for each record in the grid. 

145 

146 .. attribute:: linked_columns 

147 

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

149 applied. 

150 

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

152 

153 .. attribute:: sortable 

154 

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

156 the grid. Default is ``False``. 

157 

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

159 

160 .. attribute:: sort_multiple 

161 

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

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

164 may be sorted at a time. 

165 

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

167 frontend and backend sorting. 

168 

169 .. warning:: 

170 

171 This feature is limited by frontend JS capabilities, 

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

173 frontend and backend sorting). 

174 

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

176 then multi-column sorting should work. 

177 

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

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

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

181 Vue 3 + Oruga templates. 

182 

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

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

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

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

187 

188 .. attribute:: sort_on_backend 

189 

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

191 backend. Default is ``True``. 

192 

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

194 sorting. 

195 

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

197 

198 .. attribute:: sorters 

199 

200 Dict of functions to use for backend sorting. 

201 

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

203 :attr:`sort_on_backend` are true. 

204 

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

206 :attr:`active_sorters`. 

207 

208 .. attribute:: sort_defaults 

209 

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

211 requests a different sorting method. 

212 

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

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

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

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

217 

218 Used with both frontend and backend sorting. 

219 

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

221 :attr:`active_sorters`. 

222 

223 .. warning:: 

224 

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

226 sorting, this feature is limited by frontend JS 

227 capabilities. 

228 

229 Even if ``sort_defaults`` contains multiple entries 

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

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

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

233 component. 

234 

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

236 

237 .. attribute:: active_sorters 

238 

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

240 :meth:`sort_data()`. 

241 

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

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

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

245 

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

247 called it will not exist. 

248 

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

250 different format is used here:: 

251 

252 grid.active_sorters = [ 

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

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

255 ] 

256 

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

258 set this attribute directly. 

259 

260 This list may contain multiple elements only if 

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

262 have either zero or one element. 

263 

264 .. attribute:: paginated 

265 

266 Boolean indicating whether the grid data should be paginated, 

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

268 data is shown at once. 

269 

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

271 :attr:`paginate_on_backend`. 

272 

273 .. attribute:: paginate_on_backend 

274 

275 Boolean indicating whether the grid data should be paginated on 

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

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

278 

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

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

281 pagination. 

282 

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

284 

285 .. attribute:: pagesize_options 

286 

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

288 :attr:`pagesize`. 

289 

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

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

292 value. 

293 

294 .. attribute:: pagesize 

295 

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

297 :attr:`pagesize_options` and :attr:`page`. 

298 

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

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

301 

302 .. attribute:: page 

303 

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

305 also :attr:`pagesize`. 

306 

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

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

309 

310 .. attribute:: searchable_columns 

311 

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

313 

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

315 

316 .. attribute:: filterable 

317 

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

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

320 ``False``. 

321 

322 .. attribute:: filters 

323 

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

325 available for use with backend filtering. 

326 

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

328 

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

330 

331 .. attribute:: filter_defaults 

332 

333 Dict containing default state preferences for the filters. 

334 

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

336 

337 .. attribute:: joiners 

338 

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

340 sorting. 

341 

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

343 

344 .. attribute:: tools 

345 

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

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

348 

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

350 caller. Values should be HTML literal elements. 

351 

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

353 """ 

354 

355 def __init__( 

356 self, 

357 request, 

358 vue_tagname='wutta-grid', 

359 model_class=None, 

360 key=None, 

361 columns=None, 

362 data=None, 

363 labels={}, 

364 renderers={}, 

365 row_class=None, 

366 actions=[], 

367 linked_columns=[], 

368 sortable=False, 

369 sort_multiple=True, 

370 sort_on_backend=True, 

371 sorters=None, 

372 sort_defaults=None, 

373 paginated=False, 

374 paginate_on_backend=True, 

375 pagesize_options=None, 

376 pagesize=None, 

377 page=1, 

378 searchable_columns=None, 

379 filterable=False, 

380 filters=None, 

381 filter_defaults=None, 

382 joiners=None, 

383 tools=None, 

384 ): 

385 self.request = request 

386 self.vue_tagname = vue_tagname 

387 self.model_class = model_class 

388 self.key = key 

389 self.data = data 

390 self.labels = labels 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.renderers = {} 

401 if renderers: 

402 for key, val in renderers.items(): 

403 self.set_renderer(key, val) 

404 self.set_default_renderers() 

405 self.set_tools(tools) 

406 

407 # sorting 

408 self.sortable = sortable 

409 self.sort_multiple = sort_multiple 

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

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

412 self.sort_multiple = False 

413 self.sort_on_backend = sort_on_backend 

414 if sorters is not None: 

415 self.sorters = sorters 

416 elif self.sortable and self.sort_on_backend: 

417 self.sorters = self.make_backend_sorters() 

418 else: 

419 self.sorters = {} 

420 self.set_sort_defaults(sort_defaults or []) 

421 

422 # paging 

423 self.paginated = paginated 

424 self.paginate_on_backend = paginate_on_backend 

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

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

427 self.page = page 

428 

429 # searching 

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

431 

432 # filtering 

433 self.filterable = filterable 

434 if filters is not None: 

435 self.filters = filters 

436 elif self.filterable: 

437 self.filters = self.make_backend_filters() 

438 else: 

439 self.filters = {} 

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

441 

442 def get_columns(self): 

443 """ 

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

445 ``None``. 

446 

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

448 

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

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

451 

452 Otherwise ``None`` is returned. 

453 """ 

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

455 return self.columns 

456 

457 columns = self.get_model_columns() 

458 if columns: 

459 return columns 

460 

461 return [] 

462 

463 def get_model_columns(self, model_class=None): 

464 """ 

465 This method is a shortcut which calls 

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

467 

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

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

470 assumed. 

471 """ 

472 return get_model_fields(self.config, 

473 model_class=model_class or self.model_class) 

474 

475 @property 

476 def vue_component(self): 

477 """ 

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

479 

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

481 """ 

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

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

484 

485 def set_columns(self, columns): 

486 """ 

487 Explicitly set the list of grid columns. 

488 

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

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

491 

492 :param columns: List of string column names. 

493 """ 

494 self.columns = FieldList(columns) 

495 

496 def append(self, *keys): 

497 """ 

498 Add some columns(s) to the grid. 

499 

500 This is a convenience to allow adding multiple columns at 

501 once:: 

502 

503 grid.append('first_field', 

504 'second_field', 

505 'third_field') 

506 

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

508 """ 

509 for key in keys: 

510 if key not in self.columns: 

511 self.columns.append(key) 

512 

513 def remove(self, *keys): 

514 """ 

515 Remove some column(s) from the grid. 

516 

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

518 once:: 

519 

520 grid.remove('first_field', 

521 'second_field', 

522 'third_field') 

523 

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

525 """ 

526 for key in keys: 

527 if key in self.columns: 

528 self.columns.remove(key) 

529 

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

531 """ 

532 Set/override the label for a column. 

533 

534 :param key: Name of column. 

535 

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

537 

538 :param column_only: Boolean indicating whether the label 

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

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

541 

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

543 :attr:`labels`. 

544 """ 

545 self.labels[key] = label 

546 

547 if not column_only and key in self.filters: 

548 self.filters[key].label = label 

549 

550 def get_label(self, key): 

551 """ 

552 Returns the label text for a given column. 

553 

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

555 

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

557 """ 

558 if key in self.labels: 

559 return self.labels[key] 

560 return self.app.make_title(key) 

561 

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

563 """ 

564 Set/override the value renderer for a column. 

565 

566 :param key: Name of column. 

567 

568 :param renderer: Callable as described below. 

569 

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

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

572 

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

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

575 JSON-compatible. 

576 

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

578 to obtain the "final" cell value. 

579 

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

581 key, value)``: 

582 

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

584 * ``key`` is the column name 

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

586 

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

588 value. For instance:: 

589 

590 from webhelpers2.html import HTML 

591 

592 def render_foo(record, key, value): 

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

594 

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

596 grid.set_renderer('foo', render_foo) 

597 

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

599 specify one of the following strings, which will be 

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

601 

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

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

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

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

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

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

608 

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

610 """ 

611 builtins = { 

612 'batch_id': self.render_batch_id, 

613 'boolean': self.render_boolean, 

614 'currency': self.render_currency, 

615 'date': self.render_date, 

616 'datetime': self.render_datetime, 

617 'quantity': self.render_quantity, 

618 } 

619 

620 if renderer in builtins: 

621 renderer = builtins[renderer] 

622 

623 if kwargs: 

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

625 self.renderers[key] = renderer 

626 

627 def set_default_renderers(self): 

628 """ 

629 Set default column value renderers, where applicable. 

630 

631 This is called automatically from the class constructor. It 

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

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

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

635 

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

637 follows: 

638 

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

640 :meth:`render_boolean()` 

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

642 :meth:`render_date()` 

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

644 :meth:`render_datetime()` 

645 """ 

646 if not self.model_class: 

647 return 

648 

649 for key in self.columns: 

650 if key in self.renderers: 

651 continue 

652 

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

654 if attr: 

655 prop = getattr(attr, 'prop', None) 

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

657 column = prop.columns[0] 

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

659 self.set_renderer(key, self.render_date) 

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

661 self.set_renderer(key, self.render_datetime) 

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

663 self.set_renderer(key, self.render_boolean) 

664 

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

666 """ 

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

668 column. 

669 

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

671 contents will automatically be wrapped with a hyperlink. The 

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

673 :class:`GridAction` 

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

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

676 link depending on which data record it points to. 

677 

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

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

680 

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

682 :attr:`linked_columns`. 

683 

684 :param key: Column key as string. 

685 

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

687 should be auto-linked. 

688 """ 

689 if link: 

690 if key not in self.linked_columns: 

691 self.linked_columns.append(key) 

692 else: # unlink 

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

694 self.linked_columns.remove(key) 

695 

696 def is_linked(self, key): 

697 """ 

698 Returns boolean indicating if auto-link behavior is enabled 

699 for a given column. 

700 

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

702 

703 :param key: Column key as string. 

704 """ 

705 if self.linked_columns: 

706 if key in self.linked_columns: 

707 return True 

708 return False 

709 

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

711 """ 

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

713 component. 

714 

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

716 :attr:`searchable_columns`. 

717 """ 

718 if searchable: 

719 self.searchable_columns.add(key) 

720 elif key in self.searchable_columns: 

721 self.searchable_columns.remove(key) 

722 

723 def is_searchable(self, key): 

724 """ 

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

726 component. 

727 

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

729 """ 

730 return key in self.searchable_columns 

731 

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

733 """ 

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

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

736 """ 

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

738 

739 def set_tools(self, tools): 

740 """ 

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

742 

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

744 """ 

745 if tools and isinstance(tools, list): 

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

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

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

749 

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

751 """ 

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

753 

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

755 

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

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

758 generated. 

759 

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

761 """ 

762 if not key: 

763 key = self.app.make_uuid() 

764 self.tools[key] = html 

765 

766 ############################## 

767 # joining methods 

768 ############################## 

769 

770 def set_joiner(self, key, joiner): 

771 """ 

772 Set/override the backend joiner for a column. 

773 

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

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

776 

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

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

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

780 don't want the join to happen twice. 

781 

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

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

784 filter is needed, the joiner will be invoked. 

785 

786 :param key: Name of column. 

787 

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

789 

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

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

792 

793 model = app.model 

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

795 

796 def join_external_profile_value(query): 

797 return query.join(model.ExternalProfile) 

798 

799 def sort_external_profile(query, direction): 

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

801 return query.order_by(sortspec()) 

802 

803 grid.set_joiner('external_profile', join_external_profile) 

804 grid.set_sorter('external_profile', sort_external_profile) 

805 

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

807 via :attr:`joiners`. 

808 """ 

809 self.joiners[key] = joiner 

810 

811 def remove_joiner(self, key): 

812 """ 

813 Remove the backend joiner for a column. 

814 

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

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

817 later defined for it. 

818 

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

820 """ 

821 self.joiners.pop(key, None) 

822 

823 ############################## 

824 # sorting methods 

825 ############################## 

826 

827 def make_backend_sorters(self, sorters=None): 

828 """ 

829 Make backend sorters for all columns in the grid. 

830 

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

832 and :attr:`sort_on_backend` are true. 

833 

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

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

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

837 

838 .. note:: 

839 

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

841 this method just returns the initial sorters (or empty 

842 dict). 

843 

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

845 existing sorters will be left intact, not replaced. 

846 

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

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

849 created. 

850 """ 

851 sorters = sorters or {} 

852 

853 if self.model_class: 

854 for key in self.columns: 

855 if key in sorters: 

856 continue 

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

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

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

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

861 

862 return sorters 

863 

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

865 """ 

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

867 given column. 

868 

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

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

871 

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

873 or a column name. 

874 

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

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

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

878 a default function is used. 

879 

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

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

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

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

884 may be disabled if needed. 

885 

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

887 should help to clarify:: 

888 

889 model = app.model 

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

891 

892 # explicit property 

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

894 

895 # property name works if grid has model class 

896 sorter = grid.make_sorter('full_name') 

897 

898 # nb. this will *not* work 

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

900 sorter = grid.make_sorter(person.full_name) 

901 

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

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

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

905 query):: 

906 

907 data = [ 

908 {'foo': 1}, 

909 {'bar': 2}, 

910 ] 

911 

912 # nb. no model_class, just as an example 

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

914 

915 def getkey(obj): 

916 if obj.get('foo') 

917 return obj['foo'] 

918 if obj.get('bar'): 

919 return obj['bar'] 

920 return '' 

921 

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

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

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

925 sorted_data = sortfunc(data, 'asc') 

926 

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

928 function will behave differently when it is given a 

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

930 will return the sorted result. 

931 

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

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

934 """ 

935 model_class = None 

936 model_property = None 

937 if isinstance(columninfo, str): 

938 key = columninfo 

939 model_class = self.model_class 

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

941 else: 

942 model_property = columninfo 

943 model_class = model_property.class_ 

944 key = model_property.key 

945 

946 def sorter(data, direction): 

947 

948 # query is sorted with order_by() 

949 if isinstance(data, orm.Query): 

950 if not model_property: 

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

952 query = data 

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

954 

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

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

957 # each record 

958 kfunc = keyfunc 

959 if not kfunc: 

960 if model_property: 

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

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

963 if foldcase: 

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

965 else: 

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

967 if not kfunc: 

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

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

970 kfunc = lambda obj: obj[key] 

971 

972 # then sort the data and return 

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

974 

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

976 # multi-column sorting with sqlalchemy queries 

977 if model_property: 

978 sorter._class = model_class 

979 sorter._column = model_property 

980 

981 return sorter 

982 

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

984 """ 

985 Set/override the backend sorter for a column. 

986 

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

988 :attr:`sort_on_backend` are true. 

989 

990 :param key: Name of column. 

991 

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

993 model property (see below). 

994 

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

996 backend sorter. 

997 

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

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

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

1001 

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

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

1004 

1005 model = app.model 

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

1007 

1008 def sort_full_name(query, direction): 

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

1010 return query.order_by(sortspec()) 

1011 

1012 grid.set_sorter('full_name', sort_full_name) 

1013 

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

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

1016 """ 

1017 sorter = None 

1018 

1019 if sortinfo and callable(sortinfo): 

1020 sorter = sortinfo 

1021 else: 

1022 sorter = self.make_sorter(sortinfo or key) 

1023 

1024 self.sorters[key] = sorter 

1025 

1026 def remove_sorter(self, key): 

1027 """ 

1028 Remove the backend sorter for a column. 

1029 

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

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

1032 later defined for it. 

1033 

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

1035 """ 

1036 self.sorters.pop(key, None) 

1037 

1038 def set_sort_defaults(self, *args): 

1039 """ 

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

1041 used unless/until the user requests a different sorting 

1042 method. 

1043 

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

1045 

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

1047 ``sortdir``; for instance:: 

1048 

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

1050 

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

1052 

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

1054 

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

1056 assumed:: 

1057 

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

1059 

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

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

1062 

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

1064 ('value', 'desc')]) 

1065 

1066 .. note:: 

1067 

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

1069 is actually allowed to have multiple sort defaults. The 

1070 defaults requested by the method call may be pruned if 

1071 necessary to accommodate that. 

1072 

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

1074 """ 

1075 

1076 # convert args to sort defaults 

1077 sort_defaults = [] 

1078 if len(args) == 1: 

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

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

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

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

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

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

1085 else: 

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

1087 elif len(args) == 2: 

1088 sort_defaults = [SortInfo(*args)] 

1089 else: 

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

1091 

1092 # prune if multi-column requested but not supported 

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

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

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

1096 self.key, sort_defaults) 

1097 sort_defaults = [sort_defaults[0]] 

1098 

1099 self.sort_defaults = sort_defaults 

1100 

1101 def is_sortable(self, key): 

1102 """ 

1103 Returns boolean indicating if a given column should allow 

1104 sorting. 

1105 

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

1107 

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

1109 this always returns ``True``. 

1110 

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

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

1113 

1114 :param key: Column key as string. 

1115 

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

1117 """ 

1118 if not self.sortable: 

1119 return False 

1120 if self.sort_on_backend: 

1121 return key in self.sorters 

1122 return True 

1123 

1124 ############################## 

1125 # filtering methods 

1126 ############################## 

1127 

1128 def make_backend_filters(self, filters=None): 

1129 """ 

1130 Make backend filters for all columns in the grid. 

1131 

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

1133 true. 

1134 

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

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

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

1138 

1139 .. note:: 

1140 

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

1142 this method just returns the initial filters (or empty 

1143 dict). 

1144 

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

1146 existing filters will be left intact, not replaced. 

1147 

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

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

1150 created. 

1151 """ 

1152 filters = filters or {} 

1153 

1154 if self.model_class: 

1155 

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

1157 # things i've tried so far include: 

1158 # 

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

1160 # that was too aggressive in many cases. 

1161 # 

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

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

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

1165 # 

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

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

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

1169 # 

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

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

1172 

1173 inspector = sa.inspect(self.model_class) 

1174 for prop in inspector.column_attrs: 

1175 if prop.key not in filters: 

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

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

1178 

1179 return filters 

1180 

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

1182 """ 

1183 Create and return a 

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

1185 for use on the given column. 

1186 

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

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

1189 

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

1191 or a column name. 

1192 

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

1194 instance. 

1195 """ 

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

1197 

1198 # model_property is required 

1199 model_property = None 

1200 if kwargs.get('model_property'): 

1201 model_property = kwargs['model_property'] 

1202 elif isinstance(columninfo, str): 

1203 key = columninfo 

1204 if self.model_class: 

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

1206 if not model_property: 

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

1208 else: 

1209 model_property = columninfo 

1210 

1211 # optional factory override 

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

1213 if not factory: 

1214 typ = model_property.type 

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

1216 if not factory: 

1217 factory = default_sqlalchemy_filters[None] 

1218 

1219 # make filter 

1220 kwargs['model_property'] = model_property 

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

1222 

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

1224 """ 

1225 Set/override the backend filter for a column. 

1226 

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

1228 

1229 :param key: Name of column. 

1230 

1231 :param filterinfo: Can be either a 

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

1233 else a model property (see below). 

1234 

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

1236 used as-is for the backend filter. 

1237 

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

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

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

1241 

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

1243 via :attr:`filters`. 

1244 """ 

1245 filtr = None 

1246 

1247 if filterinfo and callable(filterinfo): 

1248 # filtr = filterinfo 

1249 raise NotImplementedError 

1250 else: 

1251 kwargs['key'] = key 

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

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

1254 

1255 self.filters[key] = filtr 

1256 

1257 def remove_filter(self, key): 

1258 """ 

1259 Remove the backend filter for a column. 

1260 

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

1262 filter by this column unless another filter is later defined 

1263 for it. 

1264 

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

1266 """ 

1267 self.filters.pop(key, None) 

1268 

1269 def set_filter_defaults(self, **defaults): 

1270 """ 

1271 Set default state preferences for the grid filters. 

1272 

1273 These preferences will affect the initial grid display, until 

1274 user requests a different filtering method. 

1275 

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

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

1278 

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

1280 'verb': 'contains', 

1281 'value': 'foo'}, 

1282 value={'active': True}) 

1283 

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

1285 """ 

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

1287 

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

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

1290 filtr.update(values) 

1291 

1292 self.filter_defaults = filter_defaults 

1293 

1294 ############################## 

1295 # paging methods 

1296 ############################## 

1297 

1298 def get_pagesize_options(self, default=None): 

1299 """ 

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

1301 

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

1303 back to:: 

1304 

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

1306 

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

1308 configured. 

1309 

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

1311 instead access :attr:`pagesize_options` directly. 

1312 """ 

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

1314 if options: 

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

1316 if size.isdigit()] 

1317 if options: 

1318 return options 

1319 

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

1321 

1322 def get_pagesize(self, default=None): 

1323 """ 

1324 Returns the default page size for the grid. 

1325 

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

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

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

1329 

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

1331 configured. 

1332 

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

1334 instead access :attr:`pagesize` directly. 

1335 """ 

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

1337 if size: 

1338 return size 

1339 

1340 if default: 

1341 return default 

1342 

1343 if 20 in self.pagesize_options: 

1344 return 20 

1345 

1346 return self.pagesize_options[0] 

1347 

1348 ############################## 

1349 # configuration methods 

1350 ############################## 

1351 

1352 def load_settings(self, persist=True): 

1353 """ 

1354 Load all effective settings for the grid. 

1355 

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

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

1358 from user session. 

1359 

1360 .. note:: 

1361 

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

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

1364 coming soon... 

1365 

1366 The overall logic for this method is as follows: 

1367 

1368 * collect settings 

1369 * apply settings to current grid 

1370 * optionally save settings to user session 

1371 

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

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

1374 navigates away then comes back. Therefore normally, settings 

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

1376 are wiped upon user logout. 

1377 

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

1379 to the user session. 

1380 """ 

1381 

1382 # initial default settings 

1383 settings = {} 

1384 if self.filterable: 

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

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

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

1388 filtr.default_active) 

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

1390 filtr.get_default_verb()) 

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

1392 filtr.default_value) 

1393 if self.sortable: 

1394 if self.sort_defaults: 

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

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

1397 sortinfo = self.sort_defaults[0] 

1398 settings['sorters.length'] = 1 

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

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

1401 else: 

1402 settings['sorters.length'] = 0 

1403 if self.paginated and self.paginate_on_backend: 

1404 settings['pagesize'] = self.pagesize 

1405 settings['page'] = self.page 

1406 

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

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

1409 

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

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

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

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

1414 pass 

1415 

1416 elif self.request_has_settings('filter'): 

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

1418 if self.request_has_settings('sort'): 

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

1420 else: 

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

1422 self.update_page_settings(settings) 

1423 

1424 elif self.request_has_settings('sort'): 

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

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

1427 self.update_page_settings(settings) 

1428 

1429 elif self.request_has_settings('page'): 

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

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

1432 self.update_page_settings(settings) 

1433 

1434 else: 

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

1436 persist = False 

1437 

1438 # but still should load whatever is in user session 

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

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

1441 self.update_page_settings(settings) 

1442 

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

1444 if persist: 

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

1446 

1447 # update ourself to reflect settings dict.. 

1448 

1449 # filtering 

1450 if self.filterable: 

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

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

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

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

1455 

1456 # sorting 

1457 if self.sortable: 

1458 # nb. doing this for frontend sorting also 

1459 self.active_sorters = [] 

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

1461 self.active_sorters.append({ 

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

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

1464 }) 

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

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

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

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

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

1470 # break 

1471 

1472 # paging 

1473 if self.paginated and self.paginate_on_backend: 

1474 self.pagesize = settings['pagesize'] 

1475 self.page = settings['page'] 

1476 

1477 def request_has_settings(self, typ): 

1478 """ """ 

1479 

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

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

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

1483 return True 

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

1485 return True 

1486 

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

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

1489 return True 

1490 

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

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

1493 if key in self.request.GET: 

1494 return True 

1495 

1496 return False 

1497 

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

1499 normalize=lambda v: v): 

1500 """ """ 

1501 

1502 if src == 'request': 

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

1504 if value is not None: 

1505 try: 

1506 return normalize(value) 

1507 except ValueError: 

1508 pass 

1509 

1510 elif src == 'session': 

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

1512 if value is not None: 

1513 return normalize(value) 

1514 

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

1516 value = settings.get(key) 

1517 if value is not None: 

1518 return normalize(value) 

1519 

1520 # okay then, default it is 

1521 return default 

1522 

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

1524 """ """ 

1525 if not self.filterable: 

1526 return 

1527 

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

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

1530 

1531 if src == 'request': 

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

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

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

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

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

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

1538 

1539 elif src == 'session': 

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

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

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

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

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

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

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

1547 

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

1549 """ """ 

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

1551 return 

1552 

1553 if src == 'request': 

1554 i = 1 

1555 while True: 

1556 skey = f'sort{i}key' 

1557 if skey in self.request.GET: 

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

1559 src='request') 

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

1561 src='request', 

1562 default='asc') 

1563 else: 

1564 break 

1565 i += 1 

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

1567 

1568 elif src == 'session': 

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

1570 src='session', normalize=int) 

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

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

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

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

1575 

1576 def update_page_settings(self, settings): 

1577 """ """ 

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

1579 return 

1580 

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

1582 

1583 # pagesize 

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

1585 if pagesize is not None: 

1586 if pagesize.isdigit(): 

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

1588 else: 

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

1590 if pagesize is not None: 

1591 settings['pagesize'] = pagesize 

1592 

1593 # page 

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

1595 if page is not None: 

1596 if page.isdigit(): 

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

1598 else: 

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

1600 if page is not None: 

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

1602 

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

1604 """ """ 

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

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

1607 

1608 # func to save a setting value to user session 

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

1610 assert dest == 'session' 

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

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

1613 

1614 # filter settings 

1615 if self.filterable: 

1616 

1617 # always save all filters, with status 

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

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

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

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

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

1623 

1624 # sort settings 

1625 if self.sortable and self.sort_on_backend: 

1626 

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

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

1629 # all and then write all 

1630 

1631 if dest == 'session': 

1632 # remove sort settings from user session 

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

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

1635 if key.startswith(prefix): 

1636 del self.request.session[key] 

1637 

1638 # now save sort settings to dest 

1639 if 'sorters.length' in settings: 

1640 persist('sorters.length') 

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

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

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

1644 

1645 # pagination settings 

1646 if self.paginated and self.paginate_on_backend: 

1647 

1648 # save to dest 

1649 persist('pagesize') 

1650 persist('page') 

1651 

1652 ############################## 

1653 # data methods 

1654 ############################## 

1655 

1656 def get_visible_data(self): 

1657 """ 

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

1659 

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

1661 for pagination etc. per the grid settings. 

1662 

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

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

1665 pagination is used), depending on the need. 

1666 

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

1668 

1669 * :meth:`filter_data()` 

1670 * :meth:`sort_data()` 

1671 * :meth:`paginate_data()` 

1672 """ 

1673 data = self.data or [] 

1674 self.joined = set() 

1675 

1676 if self.filterable: 

1677 data = self.filter_data(data) 

1678 

1679 if self.sortable and self.sort_on_backend: 

1680 data = self.sort_data(data) 

1681 

1682 if self.paginated and self.paginate_on_backend: 

1683 self.pager = self.paginate_data(data) 

1684 data = self.pager 

1685 

1686 return data 

1687 

1688 @property 

1689 def active_filters(self): 

1690 """ 

1691 Returns the list of currently active filters. 

1692 

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

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

1695 """ 

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

1697 if filtr.active] 

1698 

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

1700 """ 

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

1702 by :meth:`get_visible_data()`. 

1703 

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

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

1706 """ 

1707 if filters is None: 

1708 filters = self.active_filters 

1709 if not filters: 

1710 return data 

1711 

1712 for filtr in filters: 

1713 key = filtr.key 

1714 

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

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

1717 self.joined.add(key) 

1718 

1719 try: 

1720 data = filtr.apply_filter(data) 

1721 except VerbNotSupported as error: 

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

1723 except: 

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

1725 

1726 return data 

1727 

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

1729 """ 

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

1731 :meth:`get_visible_data()`. 

1732 

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

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

1735 """ 

1736 if sorters is None: 

1737 sorters = self.active_sorters 

1738 if not sorters: 

1739 return data 

1740 

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

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

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

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

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

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

1747 sorters = reversed(sorters) 

1748 

1749 for sorter in sorters: 

1750 sortkey = sorter['key'] 

1751 sortdir = sorter['dir'] 

1752 

1753 # cannot sort unless we have a sorter callable 

1754 sortfunc = self.sorters.get(sortkey) 

1755 if not sortfunc: 

1756 return data 

1757 

1758 # join appropriate model if needed 

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

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

1761 self.joined.add(sortkey) 

1762 

1763 # invoke the sorter 

1764 data = sortfunc(data, sortdir) 

1765 

1766 return data 

1767 

1768 def paginate_data(self, data): 

1769 """ 

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

1771 

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

1773 "data replacement" in subsequent logic. 

1774 

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

1776 """ 

1777 if isinstance(data, orm.Query): 

1778 pager = SqlalchemyOrmPage(data, 

1779 items_per_page=self.pagesize, 

1780 page=self.page) 

1781 

1782 else: 

1783 pager = paginate.Page(data, 

1784 items_per_page=self.pagesize, 

1785 page=self.page) 

1786 

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

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

1789 if pager.page != self.page: 

1790 self.page = pager.page 

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

1792 if key in self.request.session: 

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

1794 

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

1796 pager = self.paginate_data(data) 

1797 

1798 return pager 

1799 

1800 ############################## 

1801 # rendering methods 

1802 ############################## 

1803 

1804 def render_batch_id(self, obj, key, value): 

1805 """ 

1806 Column renderer for batch ID values. 

1807 

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

1809 

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

1811 """ 

1812 if value is None: 

1813 return "" 

1814 

1815 batch_id = int(value) 

1816 return f'{batch_id:08d}' 

1817 

1818 def render_boolean(self, obj, key, value): 

1819 """ 

1820 Column renderer for boolean values. 

1821 

1822 This calls 

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

1824 for the return value. 

1825 

1826 This may be used automatically per 

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

1828 

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

1830 """ 

1831 return self.app.render_boolean(value) 

1832 

1833 def render_currency(self, obj, key, value, **kwargs): 

1834 """ 

1835 Column renderer for currency values. 

1836 

1837 This calls 

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

1839 for the return value. 

1840 

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

1842 

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

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

1845 """ 

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

1847 

1848 def render_date(self, obj, key, value): 

1849 """ 

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

1851 

1852 This calls 

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

1854 for the return value. 

1855 

1856 This may be used automatically per 

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

1858 

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

1860 """ 

1861 dt = getattr(obj, key) 

1862 return self.app.render_date(dt) 

1863 

1864 def render_datetime(self, obj, key, value): 

1865 """ 

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

1867 

1868 This calls 

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

1870 for the return value. 

1871 

1872 This may be used automatically per 

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

1874 

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

1876 """ 

1877 dt = getattr(obj, key) 

1878 return self.app.render_datetime(dt) 

1879 

1880 def render_quantity(self, obj, key, value): 

1881 """ 

1882 Column renderer for quantity values. 

1883 

1884 This calls 

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

1886 for the return value. 

1887 

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

1889 

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

1891 """ 

1892 return self.app.render_quantity(value) 

1893 

1894 def render_table_element( 

1895 self, 

1896 form=None, 

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

1898 **context): 

1899 """ 

1900 Render a simple Vue table element for the grid. 

1901 

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

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

1904 component. 

1905 

1906 This returns something like: 

1907 

1908 .. code-block:: html 

1909 

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

1911 <!-- columns etc. --> 

1912 </b-table> 

1913 

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

1915 

1916 Actual output will of course depend on grid attributes, 

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

1918 

1919 :param form: Reference to the 

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

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

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

1923 

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

1925 the output. 

1926 

1927 .. note:: 

1928 

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

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

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

1932 directly by that form's Vue component. 

1933 

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

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

1936 yet be settled here. 

1937 """ 

1938 

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

1940 if form: 

1941 form.add_grid_vue_context(self) 

1942 

1943 # otherwise logic is the same, just different template 

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

1945 

1946 def render_vue_tag(self, **kwargs): 

1947 """ 

1948 Render the Vue component tag for the grid. 

1949 

1950 By default this simply returns: 

1951 

1952 .. code-block:: html 

1953 

1954 <wutta-grid></wutta-grid> 

1955 

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

1957 particular :attr:`vue_tagname`. 

1958 """ 

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

1960 

1961 def render_vue_template( 

1962 self, 

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

1964 **context): 

1965 """ 

1966 Render the Vue template block for the grid. 

1967 

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

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

1970 

1971 This returns something like: 

1972 

1973 .. code-block:: none 

1974 

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

1976 <b-table> 

1977 <!-- columns etc. --> 

1978 </b-table> 

1979 </script> 

1980 

1981 <script> 

1982 WuttaGridData = {} 

1983 WuttaGrid = { 

1984 template: 'wutta-grid-template', 

1985 } 

1986 </script> 

1987 

1988 .. todo:: 

1989 

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

1991 

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

1993 

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

1995 

1996 Actual output will of course depend on grid attributes, 

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

1998 

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

2000 the output. 

2001 """ 

2002 context['grid'] = self 

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

2004 output = render(template, context) 

2005 return HTML.literal(output) 

2006 

2007 def render_vue_finalize(self): 

2008 """ 

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

2010 

2011 By default this simply returns: 

2012 

2013 .. code-block:: html 

2014 

2015 <script> 

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

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

2018 </script> 

2019 

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

2021 particular :attr:`vue_tagname`. 

2022 """ 

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

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

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

2026 HTML.literal(set_data), 

2027 '\n', 

2028 HTML.literal(make_component), 

2029 '\n']) 

2030 

2031 def get_vue_columns(self): 

2032 """ 

2033 Returns a list of Vue-compatible column definitions. 

2034 

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

2036 returned will be a dict in this format:: 

2037 

2038 { 

2039 'field': 'foo', 

2040 'label': "Foo", 

2041 'sortable': True, 

2042 'searchable': False, 

2043 } 

2044 

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

2046 in its `Table docs 

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

2048 

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

2050 """ 

2051 if not self.columns: 

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

2053 

2054 columns = [] 

2055 for name in self.columns: 

2056 columns.append({ 

2057 'field': name, 

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

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

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

2061 }) 

2062 return columns 

2063 

2064 def get_vue_active_sorters(self): 

2065 """ 

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

2067 

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

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

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

2071 

2072 # active_sorters format 

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

2074 

2075 # get_vue_active_sorters() format 

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

2077 

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

2079 described above. 

2080 """ 

2081 sorters = [] 

2082 for sorter in self.active_sorters: 

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

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

2085 return sorters 

2086 

2087 def get_vue_filters(self): 

2088 """ 

2089 Returns a list of Vue-compatible filter definitions. 

2090 

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

2092 each as a simple dict with the filter state. 

2093 """ 

2094 filters = [] 

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

2096 filters.append({ 

2097 'key': filtr.key, 

2098 'data_type': filtr.data_type, 

2099 'active': filtr.active, 

2100 'visible': filtr.active, 

2101 'verbs': filtr.get_verbs(), 

2102 'verb_labels': filtr.get_verb_labels(), 

2103 'valueless_verbs': filtr.get_valueless_verbs(), 

2104 'verb': filtr.verb, 

2105 'value': filtr.value, 

2106 'label': filtr.label, 

2107 }) 

2108 return filters 

2109 

2110 def object_to_dict(self, obj): 

2111 """ """ 

2112 try: 

2113 dct = dict(obj) 

2114 except TypeError: 

2115 dct = dict(obj.__dict__) 

2116 dct.pop('_sa_instance_state', None) 

2117 return dct 

2118 

2119 def get_vue_context(self): 

2120 """ 

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

2122 component. This contains the following keys: 

2123 

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

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

2126 

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

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

2129 

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

2131 ensure each record can be serialized to JSON. 

2132 

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

2134 obtain the "final" values for each record. 

2135 

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

2137 defined, to each record. 

2138 

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

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

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

2142 string" - the Vue component expects that. 

2143 

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

2145 """ 

2146 original_data = self.get_visible_data() 

2147 

2148 # loop thru data 

2149 data = [] 

2150 row_classes = {} 

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

2152 original_record = record 

2153 

2154 # convert record to new dict 

2155 record = self.object_to_dict(record) 

2156 

2157 # make all values safe for json 

2158 record = make_json_safe(record, warn=False) 

2159 

2160 # customize value rendering where applicable 

2161 for key in self.renderers: 

2162 value = record.get(key, None) 

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

2164 

2165 # add action urls to each record 

2166 for action in self.actions: 

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

2168 if key not in record: 

2169 url = action.get_url(original_record, i) 

2170 if url: 

2171 record[key] = url 

2172 

2173 # set row css class if applicable 

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

2175 if css_class: 

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

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

2178 

2179 data.append(record) 

2180 

2181 return { 

2182 'data': data, 

2183 'row_classes': row_classes, 

2184 } 

2185 

2186 def get_vue_data(self): 

2187 """ """ 

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

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

2190 DeprecationWarning, stacklevel=2) 

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

2192 

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

2194 """ 

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

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

2197 

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

2199 value obtained from there. 

2200 

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

2202 

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

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

2205 

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

2207 within the grid. 

2208 

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

2210 """ 

2211 if self.row_class: 

2212 if callable(self.row_class): 

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

2214 return self.row_class 

2215 

2216 def get_vue_pager_stats(self): 

2217 """ 

2218 Returns a simple dict with current grid pager stats. 

2219 

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

2221 """ 

2222 pager = self.pager 

2223 return { 

2224 'item_count': pager.item_count, 

2225 'items_per_page': pager.items_per_page, 

2226 'page': pager.page, 

2227 'page_count': pager.page_count, 

2228 'first_item': pager.first_item, 

2229 'last_item': pager.last_item, 

2230 } 

2231 

2232 

2233class GridAction: 

2234 """ 

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

2236 

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

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

2239 own set of action links. 

2240 

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

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

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

2244 actions from within a view. 

2245 

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

2247 

2248 .. note:: 

2249 

2250 Some parameters are not explicitly described above. However 

2251 their corresponding attributes are described below. 

2252 

2253 .. attribute:: key 

2254 

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

2256 grid. 

2257 

2258 .. attribute:: label 

2259 

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

2261 generated from :attr:`key` by calling 

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

2263 

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

2265 

2266 .. attribute:: url 

2267 

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

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

2270 the same URL for this action. 

2271 

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

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

2274 args, for instance:: 

2275 

2276 def myurl(obj, i): 

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

2278 

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

2280 

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

2282 

2283 .. attribute:: target 

2284 

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

2286 

2287 .. attribute:: click_handler 

2288 

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

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

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

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

2293 

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

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

2296 

2297 .. attribute:: icon 

2298 

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

2300 

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

2302 

2303 .. attribute:: link_class 

2304 

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

2306 """ 

2307 

2308 def __init__( 

2309 self, 

2310 request, 

2311 key, 

2312 label=None, 

2313 url=None, 

2314 target=None, 

2315 click_handler=None, 

2316 icon=None, 

2317 link_class=None, 

2318 ): 

2319 self.request = request 

2320 self.config = self.request.wutta_config 

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

2322 self.key = key 

2323 self.url = url 

2324 self.target = target 

2325 self.click_handler = click_handler 

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

2327 self.icon = icon or key 

2328 self.link_class = link_class or '' 

2329 

2330 def render_icon_and_label(self): 

2331 """ 

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

2333 

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

2335 and :meth:`render_label()`. 

2336 """ 

2337 html = [ 

2338 self.render_icon(), 

2339 self.render_label(), 

2340 ] 

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

2342 

2343 def render_icon(self): 

2344 """ 

2345 Render the HTML snippet for the action link icon. 

2346 

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

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

2349 

2350 .. code-block:: html 

2351 

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

2353 

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

2355 """ 

2356 if self.request.use_oruga: 

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

2358 

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

2360 

2361 def render_label(self): 

2362 """ 

2363 Render the label text for the action link. 

2364 

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

2366 

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

2368 """ 

2369 return self.label 

2370 

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

2372 """ 

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

2374 instance). 

2375 

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

2377 

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

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

2380 passed along. 

2381 

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

2383 setup to use. 

2384 

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

2386 parent grid. 

2387 

2388 See also :attr:`url`. 

2389 """ 

2390 if callable(self.url): 

2391 return self.url(obj, i) 

2392 

2393 return self.url