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
« 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"""
27import functools
28import json
29import logging
30import warnings
31from collections import namedtuple, OrderedDict
33import sqlalchemy as sa
34from sqlalchemy import orm
36import paginate
37from paginate_sqlalchemy import SqlalchemyOrmPage
38from pyramid.renderers import render
39from webhelpers2.html import HTML
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
47log = logging.getLogger(__name__)
50SortInfo = namedtuple('SortInfo', ['sortkey', 'sortdir'])
51SortInfo.__doc__ = """
52Named tuple to track sorting info.
54Elements of :attr:`~Grid.sort_defaults` will be of this type.
55"""
57class Grid:
58 """
59 Base class for all :term:`grids <grid>`.
61 :param request: Reference to current :term:`request` object.
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`.
67 .. note::
69 Some parameters are not explicitly described above. However
70 their corresponding attributes are described below.
72 Grid instances contain the following attributes:
74 .. attribute:: key
76 Presumably unique key for the grid; used to track per-grid
77 sort/filter settings etc.
79 .. attribute:: vue_tagname
81 String name for Vue component tag. By default this is
82 ``'wutta-grid'``. See also :meth:`render_vue_tag()`.
84 .. attribute:: model_class
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.
90 .. attribute:: columns
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.
96 See also :meth:`set_columns()` and :meth:`get_columns()`.
98 .. attribute:: data
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.
105 This is the "full" data set; see also
106 :meth:`get_visible_data()`.
108 .. attribute:: labels
110 Dict of column label overrides.
112 See also :meth:`get_label()` and :meth:`set_label()`.
114 .. attribute:: renderers
116 Dict of column (cell) value renderer overrides.
118 See also :meth:`set_renderer()` and
119 :meth:`set_default_renderers()`.
121 .. attribute:: row_class
123 This represents the CSS ``class`` attribute for a row within
124 the grid. Default is ``None``.
126 This can be a simple string, in which case the same class is
127 applied to all rows.
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::
133 def my_row_class(obj, data, i):
134 if obj.archived:
135 return 'poser-archived'
137 grid = Grid(request, key='foo', row_class=my_row_class)
139 See :meth:`get_row_class()` for more info.
141 .. attribute:: actions
143 List of :class:`GridAction` instances represenging action links
144 to be shown for each record in the grid.
146 .. attribute:: linked_columns
148 List of column names for which auto-link behavior should be
149 applied.
151 See also :meth:`set_link()` and :meth:`is_linked()`.
153 .. attribute:: sortable
155 Boolean indicating whether *any* column sorting is allowed for
156 the grid. Default is ``False``.
158 See also :attr:`sort_multiple` and :attr:`sort_on_backend`.
160 .. attribute:: sort_multiple
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.
166 Only relevant if :attr:`sortable` is true, but applies to both
167 frontend and backend sorting.
169 .. warning::
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).
175 In particular, if the app theme templates use Vue 2 + Buefy,
176 then multi-column sorting should work.
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.
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`.
188 .. attribute:: sort_on_backend
190 Boolean indicating whether the grid data should be sorted on the
191 backend. Default is ``True``.
193 If ``False``, the client-side Vue component will handle the
194 sorting.
196 Only relevant if :attr:`sortable` is also true.
198 .. attribute:: sorters
200 Dict of functions to use for backend sorting.
202 Only relevant if both :attr:`sortable` and
203 :attr:`sort_on_backend` are true.
205 See also :meth:`set_sorter()`, :attr:`sort_defaults` and
206 :attr:`active_sorters`.
208 .. attribute:: sort_defaults
210 List of options to be used for default sorting, until the user
211 requests a different sorting method.
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`.
218 Used with both frontend and backend sorting.
220 See also :meth:`set_sort_defaults()` and
221 :attr:`active_sorters`.
223 .. warning::
225 While the grid logic is built to handle multi-column
226 sorting, this feature is limited by frontend JS
227 capabilities.
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.
235 See also :attr:`sort_multiple` for more details.
237 .. attribute:: active_sorters
239 List of sorters currently in effect for the grid; used by
240 :meth:`sort_data()`.
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.
246 This attribute is set by :meth:`load_settings()`; until that is
247 called it will not exist.
249 This is conceptually a "subset" of :attr:`sorters` although a
250 different format is used here::
252 grid.active_sorters = [
253 {'key': 'name', 'dir': 'asc'},
254 {'key': 'id', 'dir': 'asc'},
255 ]
257 The above is for example only; there is usually no reason to
258 set this attribute directly.
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.
264 .. attribute:: paginated
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.
270 See also :attr:`pagesize` and :attr:`page`, and
271 :attr:`paginate_on_backend`.
273 .. attribute:: paginate_on_backend
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.
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.
283 Only relevant if :attr:`paginated` is also true.
285 .. attribute:: pagesize_options
287 List of "page size" options for the grid. See also
288 :attr:`pagesize`.
290 Only relevant if :attr:`paginated` is true. If not specified,
291 constructor will call :meth:`get_pagesize_options()` to get the
292 value.
294 .. attribute:: pagesize
296 Number of records to show in a data page. See also
297 :attr:`pagesize_options` and :attr:`page`.
299 Only relevant if :attr:`paginated` is true. If not specified,
300 constructor will call :meth:`get_pagesize()` to get the value.
302 .. attribute:: page
304 The current page number (of data) to display in the grid. See
305 also :attr:`pagesize`.
307 Only relevant if :attr:`paginated` is true. If not specified,
308 constructor will assume ``1`` (first page).
310 .. attribute:: searchable_columns
312 Set of columns declared as searchable for the Vue component.
314 See also :meth:`set_searchable()` and :meth:`is_searchable()`.
316 .. attribute:: filterable
318 Boolean indicating whether the grid should show a "filters"
319 section where user can filter data in various ways. Default is
320 ``False``.
322 .. attribute:: filters
324 Dict of :class:`~wuttaweb.grids.filters.GridFilter` instances
325 available for use with backend filtering.
327 Only relevant if :attr:`filterable` is true.
329 See also :meth:`set_filter()`.
331 .. attribute:: filter_defaults
333 Dict containing default state preferences for the filters.
335 See also :meth:`set_filter_defaults()`.
337 .. attribute:: joiners
339 Dict of "joiner" functions for use with backend filtering and
340 sorting.
342 See :meth:`set_joiner()` for more info.
344 .. attribute:: tools
346 Dict of "tool" elements for the grid. Tools are usually buttons
347 (e.g. "Delete Results"), shown on top right of the grid.
349 The keys for this dict are somewhat arbitrary, defined by the
350 caller. Values should be HTML literal elements.
352 See also :meth:`add_tool()` and :meth:`set_tools()`.
353 """
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 {}
396 self.config = self.request.wutta_config
397 self.app = self.config.get_app()
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)
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 [])
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
429 # searching
430 self.searchable_columns = set(searchable_columns or [])
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 {}))
442 def get_columns(self):
443 """
444 Returns the official list of column names for the grid, or
445 ``None``.
447 If :attr:`columns` is set and non-empty, it is returned.
449 Or, if :attr:`model_class` is set, the field list is derived
450 from that, via :meth:`get_model_columns()`.
452 Otherwise ``None`` is returned.
453 """
454 if hasattr(self, 'columns') and self.columns:
455 return self.columns
457 columns = self.get_model_columns()
458 if columns:
459 return columns
461 return []
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()`.
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)
475 @property
476 def vue_component(self):
477 """
478 String name for the Vue component, e.g. ``'WuttaGrid'``.
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])
485 def set_columns(self, columns):
486 """
487 Explicitly set the list of grid columns.
489 This will overwrite :attr:`columns` with a new
490 :class:`~wuttaweb.util.FieldList` instance.
492 :param columns: List of string column names.
493 """
494 self.columns = FieldList(columns)
496 def append(self, *keys):
497 """
498 Add some columns(s) to the grid.
500 This is a convenience to allow adding multiple columns at
501 once::
503 grid.append('first_field',
504 'second_field',
505 'third_field')
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)
513 def remove(self, *keys):
514 """
515 Remove some column(s) from the grid.
517 This is a convenience to allow removal of multiple columns at
518 once::
520 grid.remove('first_field',
521 'second_field',
522 'third_field')
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)
530 def set_label(self, key, label, column_only=False):
531 """
532 Set/override the label for a column.
534 :param key: Name of column.
536 :param label: New label for the column header.
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``).
542 See also :meth:`get_label()`. Label overrides are tracked via
543 :attr:`labels`.
544 """
545 self.labels[key] = label
547 if not column_only and key in self.filters:
548 self.filters[key].label = label
550 def get_label(self, key):
551 """
552 Returns the label text for a given column.
554 If no override is defined, the label is derived from ``key``.
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)
562 def set_renderer(self, key, renderer, **kwargs):
563 """
564 Set/override the value renderer for a column.
566 :param key: Name of column.
568 :param renderer: Callable as described below.
570 Depending on the nature of grid data, sometimes a cell's
571 "as-is" value will be undesirable for display purposes.
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.
577 But then it also will invoke a renderer override (if defined)
578 to obtain the "final" cell value.
580 A renderer must be a callable which accepts 3 args ``(record,
581 key, value)``:
583 * ``record`` is the "original" record from :attr:`data`
584 * ``key`` is the column name
585 * ``value`` is the JSON-safe cell value
587 Whatever the renderer returns, is then used as final cell
588 value. For instance::
590 from webhelpers2.html import HTML
592 def render_foo(record, key, value):
593 return HTML.literal("<p>this is the final cell value</p>")
595 grid = Grid(request, columns=['foo', 'bar'])
596 grid.set_renderer('foo', render_foo)
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:
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()`
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 }
620 if renderer in builtins:
621 renderer = builtins[renderer]
623 if kwargs:
624 renderer = functools.partial(renderer, **kwargs)
625 self.renderers[key] = renderer
627 def set_default_renderers(self):
628 """
629 Set default column value renderers, where applicable.
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.
636 This only looks for a few data types, and configures as
637 follows:
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
649 for key in self.columns:
650 if key in self.renderers:
651 continue
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)
665 def set_link(self, key, link=True):
666 """
667 Explicitly enable or disable auto-link behavior for a given
668 column.
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.
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.
681 See also :meth:`is_linked()`; the list is tracked via
682 :attr:`linked_columns`.
684 :param key: Column key as string.
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)
696 def is_linked(self, key):
697 """
698 Returns boolean indicating if auto-link behavior is enabled
699 for a given column.
701 See also :meth:`set_link()` which describes auto-link behavior.
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
710 def set_searchable(self, key, searchable=True):
711 """
712 (Un)set the given column's searchable flag for the Vue
713 component.
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)
723 def is_searchable(self, key):
724 """
725 Check if the given column is marked as searchable for the Vue
726 component.
728 See also :meth:`set_searchable()`.
729 """
730 return key in self.searchable_columns
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))
739 def set_tools(self, tools):
740 """
741 Set the :attr:`tools` attribute using the given tools collection.
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 [])
750 def add_tool(self, html, key=None):
751 """
752 Add a new HTML snippet to the :attr:`tools` dict.
754 :param html: HTML literal for the tool element.
756 :param key: Optional key to use when adding to the
757 :attr:`tools` dict. If not specified, a random string is
758 generated.
760 See also :meth:`set_tools()`.
761 """
762 if not key:
763 key = self.app.make_uuid()
764 self.tools[key] = html
766 ##############################
767 # joining methods
768 ##############################
770 def set_joiner(self, key, joiner):
771 """
772 Set/override the backend joiner for a column.
774 A "joiner" is sometimes needed when a column with "related but
775 not primary" data is involved in a sort or filter operation.
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.
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.
786 :param key: Name of column.
788 :param joiner: A joiner callable, as described below.
790 A joiner callable must accept just one ``(data)`` arg and
791 return the "joined" data/query, for example::
793 model = app.model
794 grid = Grid(request, model_class=model.Person)
796 def join_external_profile_value(query):
797 return query.join(model.ExternalProfile)
799 def sort_external_profile(query, direction):
800 sortspec = getattr(model.ExternalProfile.description, direction)
801 return query.order_by(sortspec())
803 grid.set_joiner('external_profile', join_external_profile)
804 grid.set_sorter('external_profile', sort_external_profile)
806 See also :meth:`remove_joiner()`. Backend joiners are tracked
807 via :attr:`joiners`.
808 """
809 self.joiners[key] = joiner
811 def remove_joiner(self, key):
812 """
813 Remove the backend joiner for a column.
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.
819 See also :meth:`set_joiner()`.
820 """
821 self.joiners.pop(key, None)
823 ##############################
824 # sorting methods
825 ##############################
827 def make_backend_sorters(self, sorters=None):
828 """
829 Make backend sorters for all columns in the grid.
831 This is called by the constructor, if both :attr:`sortable`
832 and :attr:`sort_on_backend` are true.
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.
838 .. note::
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).
844 :param sorters: Optional dict of initial sorters. Any
845 existing sorters will be left intact, not replaced.
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 {}
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)
862 return sorters
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.
869 Code usually does not need to call this directly. See also
870 :meth:`set_sorter()`, which calls this method automatically.
872 :param columninfo: Can be either a model property (see below),
873 or a column name.
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.
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.
886 The term "model property" is a bit technical, an example
887 should help to clarify::
889 model = app.model
890 grid = Grid(request, model_class=model.Person)
892 # explicit property
893 sorter = grid.make_sorter(model.Person.full_name)
895 # property name works if grid has model class
896 sorter = grid.make_sorter('full_name')
898 # nb. this will *not* work
899 person = model.Person(full_name="John Doe")
900 sorter = grid.make_sorter(person.full_name)
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)::
907 data = [
908 {'foo': 1},
909 {'bar': 2},
910 ]
912 # nb. no model_class, just as an example
913 grid = Grid(request, columns=['foo', 'bar'], data=data)
915 def getkey(obj):
916 if obj.get('foo')
917 return obj['foo']
918 if obj.get('bar'):
919 return obj['bar']
920 return ''
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')
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.
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
946 def sorter(data, direction):
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)())
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]
972 # then sort the data and return
973 return sorted(data, key=kfunc, reverse=direction == 'desc')
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
981 return sorter
983 def set_sorter(self, key, sortinfo=None):
984 """
985 Set/override the backend sorter for a column.
987 Only relevant if both :attr:`sortable` and
988 :attr:`sort_on_backend` are true.
990 :param key: Name of column.
992 :param sortinfo: Can be either a sorter callable, or else a
993 model property (see below).
995 If ``sortinfo`` is a callable, it will be used as-is for the
996 backend sorter.
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.
1002 A backend sorter callable must accept ``(data, direction)``
1003 args and return the sorted data/query, for example::
1005 model = app.model
1006 grid = Grid(request, model_class=model.Person)
1008 def sort_full_name(query, direction):
1009 sortspec = getattr(model.Person.full_name, direction)
1010 return query.order_by(sortspec())
1012 grid.set_sorter('full_name', sort_full_name)
1014 See also :meth:`remove_sorter()` and :meth:`is_sortable()`.
1015 Backend sorters are tracked via :attr:`sorters`.
1016 """
1017 sorter = None
1019 if sortinfo and callable(sortinfo):
1020 sorter = sortinfo
1021 else:
1022 sorter = self.make_sorter(sortinfo or key)
1024 self.sorters[key] = sorter
1026 def remove_sorter(self, key):
1027 """
1028 Remove the backend sorter for a column.
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.
1034 See also :meth:`set_sorter()`.
1035 """
1036 self.sorters.pop(key, None)
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.
1044 ``args`` for this method are interpreted as follows:
1046 If 2 args are received, they should be for ``sortkey`` and
1047 ``sortdir``; for instance::
1049 grid.set_sort_defaults('name', 'asc')
1051 If just one 2-tuple arg is received, it is handled similarly::
1053 grid.set_sort_defaults(('name', 'asc'))
1055 If just one string arg is received, the default ``sortdir`` is
1056 assumed::
1058 grid.set_sort_defaults('name') # assumes 'asc'
1060 Otherwise there should be just one list arg, elements of
1061 which are each 2-tuples of ``(sortkey, sortdir)`` info::
1063 grid.set_sort_defaults([('name', 'asc'),
1064 ('value', 'desc')])
1066 .. note::
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.
1073 Default sorting info is tracked via :attr:`sort_defaults`.
1074 """
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")
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]]
1099 self.sort_defaults = sort_defaults
1101 def is_sortable(self, key):
1102 """
1103 Returns boolean indicating if a given column should allow
1104 sorting.
1106 If :attr:`sortable` is false, this always returns ``False``.
1108 For frontend sorting (i.e. :attr:`sort_on_backend` is false),
1109 this always returns ``True``.
1111 For backend sorting, may return true or false depending on
1112 whether the column is listed in :attr:`sorters`.
1114 :param key: Column key as string.
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
1124 ##############################
1125 # filtering methods
1126 ##############################
1128 def make_backend_filters(self, filters=None):
1129 """
1130 Make backend filters for all columns in the grid.
1132 This is called by the constructor, if :attr:`filterable` is
1133 true.
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.
1139 .. note::
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).
1145 :param filters: Optional dict of initial filters. Any
1146 existing filters will be left intact, not replaced.
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 {}
1154 if self.model_class:
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.
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)
1179 return filters
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.
1187 Code usually does not need to call this directly. See also
1188 :meth:`set_filter()`, which calls this method automatically.
1190 :param columninfo: Can be either a model property (see below),
1191 or a column name.
1193 :returns: A :class:`~wuttaweb.grids.filters.GridFilter`
1194 instance.
1195 """
1196 key = kwargs.pop('key', None)
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
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]
1219 # make filter
1220 kwargs['model_property'] = model_property
1221 return factory(self.request, key or model_property.key, **kwargs)
1223 def set_filter(self, key, filterinfo=None, **kwargs):
1224 """
1225 Set/override the backend filter for a column.
1227 Only relevant if :attr:`filterable` is true.
1229 :param key: Name of column.
1231 :param filterinfo: Can be either a
1232 :class:`~wuttweb.grids.filters.GridFilter` instance, or
1233 else a model property (see below).
1235 If ``filterinfo`` is a ``GridFilter`` instance, it will be
1236 used as-is for the backend filter.
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.
1242 See also :meth:`remove_filter()`. Backend filters are tracked
1243 via :attr:`filters`.
1244 """
1245 filtr = None
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)
1255 self.filters[key] = filtr
1257 def remove_filter(self, key):
1258 """
1259 Remove the backend filter for a column.
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.
1265 See also :meth:`set_filter()`.
1266 """
1267 self.filters.pop(key, None)
1269 def set_filter_defaults(self, **defaults):
1270 """
1271 Set default state preferences for the grid filters.
1273 These preferences will affect the initial grid display, until
1274 user requests a different filtering method.
1276 Each kwarg should be named by filter key, and the value should
1277 be a dict of preferences for that filter. For instance::
1279 grid.set_filter_defaults(name={'active': True,
1280 'verb': 'contains',
1281 'value': 'foo'},
1282 value={'active': True})
1284 Filter defaults are tracked via :attr:`filter_defaults`.
1285 """
1286 filter_defaults = dict(getattr(self, 'filter_defaults', {}))
1288 for key, values in defaults.items():
1289 filtr = filter_defaults.setdefault(key, {})
1290 filtr.update(values)
1292 self.filter_defaults = filter_defaults
1294 ##############################
1295 # paging methods
1296 ##############################
1298 def get_pagesize_options(self, default=None):
1299 """
1300 Returns a list of default page size options for the grid.
1302 It will check config but if no setting exists, will fall
1303 back to::
1305 [5, 10, 20, 50, 100, 200]
1307 :param default: Alternate default value to return if none is
1308 configured.
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
1320 return default or [5, 10, 20, 50, 100, 200]
1322 def get_pagesize(self, default=None):
1323 """
1324 Returns the default page size for the grid.
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).
1330 :param default: Alternate default value to return if none is
1331 configured.
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
1340 if default:
1341 return default
1343 if 20 in self.pagesize_options:
1344 return 20
1346 return self.pagesize_options[0]
1348 ##############################
1349 # configuration methods
1350 ##############################
1352 def load_settings(self, persist=True):
1353 """
1354 Load all effective settings for the grid.
1356 If the request GET params (query string) contains grid
1357 settings, they are used; otherwise the settings are loaded
1358 from user session.
1360 .. note::
1362 As of now, "sorting" and "pagination" settings are the only
1363 type supported by this logic. Settings for "filtering"
1364 coming soon...
1366 The overall logic for this method is as follows:
1368 * collect settings
1369 * apply settings to current grid
1370 * optionally save settings to user session
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.
1378 :param persist: Whether the collected settings should be saved
1379 to the user session.
1380 """
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
1407 # update settings dict based on what we find in the request
1408 # and/or user session. always prioritize the former.
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
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)
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)
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)
1434 else:
1435 # nothing found in request, so nothing new to save
1436 persist = False
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)
1443 # maybe save settings in user session, for next time
1444 if persist:
1445 self.persist_settings(settings, dest='session')
1447 # update ourself to reflect settings dict..
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']
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
1472 # paging
1473 if self.paginated and self.paginate_on_backend:
1474 self.pagesize = settings['pagesize']
1475 self.page = settings['page']
1477 def request_has_settings(self, typ):
1478 """ """
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
1487 elif typ == 'sort' and self.sortable and self.sort_on_backend:
1488 if 'sort1key' in self.request.GET:
1489 return True
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
1496 return False
1498 def get_setting(self, settings, key, src='session', default=None,
1499 normalize=lambda v: v):
1500 """ """
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
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)
1515 # if src had nothing, try default/existing settings
1516 value = settings.get(key)
1517 if value is not None:
1518 return normalize(value)
1520 # okay then, default it is
1521 return default
1523 def update_filter_settings(self, settings, src=None):
1524 """ """
1525 if not self.filterable:
1526 return
1528 for filtr in self.filters.values():
1529 prefix = f'filter.{filtr.key}'
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='')
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='')
1548 def update_sort_settings(self, settings, src=None):
1549 """ """
1550 if not (self.sortable and self.sort_on_backend):
1551 return
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
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')
1576 def update_page_settings(self, settings):
1577 """ """
1578 if not (self.paginated and self.paginate_on_backend):
1579 return
1581 # update the settings dict from request and/or user session
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
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)
1603 def persist_settings(self, settings, dest=None):
1604 """ """
1605 if dest not in ('session',):
1606 raise ValueError(f"invalid dest identifier: {dest}")
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)
1614 # filter settings
1615 if self.filterable:
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')
1624 # sort settings
1625 if self.sortable and self.sort_on_backend:
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
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]
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')
1645 # pagination settings
1646 if self.paginated and self.paginate_on_backend:
1648 # save to dest
1649 persist('pagesize')
1650 persist('page')
1652 ##############################
1653 # data methods
1654 ##############################
1656 def get_visible_data(self):
1657 """
1658 Returns the "effective" visible data for the grid.
1660 This uses :attr:`data` as the starting point but may morph it
1661 for pagination etc. per the grid settings.
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.
1667 See also these methods which may be called by this one:
1669 * :meth:`filter_data()`
1670 * :meth:`sort_data()`
1671 * :meth:`paginate_data()`
1672 """
1673 data = self.data or []
1674 self.joined = set()
1676 if self.filterable:
1677 data = self.filter_data(data)
1679 if self.sortable and self.sort_on_backend:
1680 data = self.sort_data(data)
1682 if self.paginated and self.paginate_on_backend:
1683 self.pager = self.paginate_data(data)
1684 data = self.pager
1686 return data
1688 @property
1689 def active_filters(self):
1690 """
1691 Returns the list of currently active filters.
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]
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()`.
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
1712 for filtr in filters:
1713 key = filtr.key
1715 if key in self.joiners and key not in self.joined:
1716 data = self.joiners[key](data)
1717 self.joined.add(key)
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)
1726 return data
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()`.
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
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)
1749 for sorter in sorters:
1750 sortkey = sorter['key']
1751 sortdir = sorter['dir']
1753 # cannot sort unless we have a sorter callable
1754 sortfunc = self.sorters.get(sortkey)
1755 if not sortfunc:
1756 return data
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)
1763 # invoke the sorter
1764 data = sortfunc(data, sortdir)
1766 return data
1768 def paginate_data(self, data):
1769 """
1770 Apply pagination to the given data set, based on grid settings.
1772 This returns a "pager" object which can then be used as a
1773 "data replacement" in subsequent logic.
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)
1782 else:
1783 pager = paginate.Page(data,
1784 items_per_page=self.pagesize,
1785 page=self.page)
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
1795 # and re-make the pager just to be safe (?)
1796 pager = self.paginate_data(data)
1798 return pager
1800 ##############################
1801 # rendering methods
1802 ##############################
1804 def render_batch_id(self, obj, key, value):
1805 """
1806 Column renderer for batch ID values.
1808 This is not used automatically but you can use it explicitly::
1810 grid.set_renderer('foo', 'batch_id')
1811 """
1812 if value is None:
1813 return ""
1815 batch_id = int(value)
1816 return f'{batch_id:08d}'
1818 def render_boolean(self, obj, key, value):
1819 """
1820 Column renderer for boolean values.
1822 This calls
1823 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_boolean()`
1824 for the return value.
1826 This may be used automatically per
1827 :meth:`set_default_renderers()` or you can use it explicitly::
1829 grid.set_renderer('foo', 'boolean')
1830 """
1831 return self.app.render_boolean(value)
1833 def render_currency(self, obj, key, value, **kwargs):
1834 """
1835 Column renderer for currency values.
1837 This calls
1838 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()`
1839 for the return value.
1841 This is not used automatically but you can use it explicitly::
1843 grid.set_renderer('foo', 'currency')
1844 grid.set_renderer('foo', 'currency', scale=4)
1845 """
1846 return self.app.render_currency(value, **kwargs)
1848 def render_date(self, obj, key, value):
1849 """
1850 Column renderer for :class:`python:datetime.date` values.
1852 This calls
1853 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_date()`
1854 for the return value.
1856 This may be used automatically per
1857 :meth:`set_default_renderers()` or you can use it explicitly::
1859 grid.set_renderer('foo', 'date')
1860 """
1861 dt = getattr(obj, key)
1862 return self.app.render_date(dt)
1864 def render_datetime(self, obj, key, value):
1865 """
1866 Column renderer for :class:`python:datetime.datetime` values.
1868 This calls
1869 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()`
1870 for the return value.
1872 This may be used automatically per
1873 :meth:`set_default_renderers()` or you can use it explicitly::
1875 grid.set_renderer('foo', 'datetime')
1876 """
1877 dt = getattr(obj, key)
1878 return self.app.render_datetime(dt)
1880 def render_quantity(self, obj, key, value):
1881 """
1882 Column renderer for quantity values.
1884 This calls
1885 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()`
1886 for the return value.
1888 This is not used automatically but you can use it explicitly::
1890 grid.set_renderer('foo', 'quantity')
1891 """
1892 return self.app.render_quantity(value)
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.
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.
1906 This returns something like:
1908 .. code-block:: html
1910 <b-table :data="gridContext['mykey'].data">
1911 <!-- columns etc. -->
1912 </b-table>
1914 See :meth:`render_vue_template()` for a more complete variant.
1916 Actual output will of course depend on grid attributes,
1917 :attr:`key`, :attr:`columns` etc.
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.
1924 :param template: Path to Mako template which is used to render
1925 the output.
1927 .. note::
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.
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 """
1939 # nb. must register data for inclusion on page template
1940 if form:
1941 form.add_grid_vue_context(self)
1943 # otherwise logic is the same, just different template
1944 return self.render_vue_template(template=template, **context)
1946 def render_vue_tag(self, **kwargs):
1947 """
1948 Render the Vue component tag for the grid.
1950 By default this simply returns:
1952 .. code-block:: html
1954 <wutta-grid></wutta-grid>
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)
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.
1968 This is what you want for a "full-featured" grid which will
1969 exist as its own unique Vue component on the frontend.
1971 This returns something like:
1973 .. code-block:: none
1975 <script type="text/x-template" id="wutta-grid-template">
1976 <b-table>
1977 <!-- columns etc. -->
1978 </b-table>
1979 </script>
1981 <script>
1982 WuttaGridData = {}
1983 WuttaGrid = {
1984 template: 'wutta-grid-template',
1985 }
1986 </script>
1988 .. todo::
1990 Why can't Sphinx render the above code block as 'html' ?
1992 It acts like it can't handle a ``<script>`` tag at all?
1994 See :meth:`render_table_element()` for a simpler variant.
1996 Actual output will of course depend on grid attributes,
1997 :attr:`vue_tagname` and :attr:`columns` etc.
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)
2007 def render_vue_finalize(self):
2008 """
2009 Render the Vue "finalize" script for the grid.
2011 By default this simply returns:
2013 .. code-block:: html
2015 <script>
2016 WuttaGrid.data = function() { return WuttaGridData }
2017 Vue.component('wutta-grid', WuttaGrid)
2018 </script>
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'])
2031 def get_vue_columns(self):
2032 """
2033 Returns a list of Vue-compatible column definitions.
2035 This uses :attr:`columns` as the basis; each definition
2036 returned will be a dict in this format::
2038 {
2039 'field': 'foo',
2040 'label': "Foo",
2041 'sortable': True,
2042 'searchable': False,
2043 }
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>`_.
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}")
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
2064 def get_vue_active_sorters(self):
2065 """
2066 Returns a list of Vue-compatible column sorter definitions.
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.::
2072 # active_sorters format
2073 {'key': 'name', 'dir': 'asc'}
2075 # get_vue_active_sorters() format
2076 {'field': 'name', 'order': 'asc'}
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
2087 def get_vue_filters(self):
2088 """
2089 Returns a list of Vue-compatible filter definitions.
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
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
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:
2124 * ``data`` - list of Vue-compatible data records
2125 * ``row_classes`` - dict of per-row CSS classes
2127 This first calls :meth:`get_visible_data()` to get the
2128 original data set. Each record is converted to a dict.
2130 Then it calls :func:`~wuttaweb.util.make_json_safe()` to
2131 ensure each record can be serialized to JSON.
2133 Then it invokes any :attr:`renderers` which are defined, to
2134 obtain the "final" values for each record.
2136 Then it adds a URL key/value for each of the :attr:`actions`
2137 defined, to each record.
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.
2144 :returns: Dict of grid data/CSS context as described above.
2145 """
2146 original_data = self.get_visible_data()
2148 # loop thru data
2149 data = []
2150 row_classes = {}
2151 for i, record in enumerate(original_data, 1):
2152 original_record = record
2154 # convert record to new dict
2155 record = self.object_to_dict(record)
2157 # make all values safe for json
2158 record = make_json_safe(record, warn=False)
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)
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
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
2179 data.append(record)
2181 return {
2182 'data': data,
2183 'row_classes': row_classes,
2184 }
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']
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()`.
2198 This will inspect/invoke :attr:`row_class` and return the
2199 value obtained from there.
2201 :param obj: Reference to the original model instance.
2203 :param data: Dict of record data for the instance; part of the
2204 Vue grid data set in/from :meth:`get_vue_context()`.
2206 :param i: One-based sequence for this object/record (row)
2207 within the grid.
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
2216 def get_vue_pager_stats(self):
2217 """
2218 Returns a simple dict with current grid pager stats.
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 }
2233class GridAction:
2234 """
2235 Represents a "row action" hyperlink within a grid context.
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.
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.
2246 :param request: Current :term:`request` object.
2248 .. note::
2250 Some parameters are not explicitly described above. However
2251 their corresponding attributes are described below.
2253 .. attribute:: key
2255 String key for the action (e.g. ``'edit'``), unique within the
2256 grid.
2258 .. attribute:: label
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()`.
2264 See also :meth:`render_label()`.
2266 .. attribute:: url
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.
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::
2276 def myurl(obj, i):
2277 return request.route_url('widgets.view', uuid=obj.uuid)
2279 action = GridAction(request, 'view', url=myurl)
2281 See also :meth:`get_url()`.
2283 .. attribute:: target
2285 Optional ``target`` attribute for the ``<a>`` tag.
2287 .. attribute:: click_handler
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:
2294 * ``deleteThisThing(props.row)``
2295 * ``$emit('do-something', props.row)``
2297 .. attribute:: icon
2299 Name of icon to be shown for the action link.
2301 See also :meth:`render_icon()`.
2303 .. attribute:: link_class
2305 Optional HTML class attribute for the action's ``<a>`` tag.
2306 """
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 ''
2330 def render_icon_and_label(self):
2331 """
2332 Render the HTML snippet for action link icon and label.
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)
2343 def render_icon(self):
2344 """
2345 Render the HTML snippet for the action link icon.
2347 This uses :attr:`icon` to identify the named icon to be shown.
2348 Output is something like (here ``'trash'`` is the icon name):
2350 .. code-block:: html
2352 <i class="fas fa-trash"></i>
2354 See also :meth:`render_icon_and_label()`.
2355 """
2356 if self.request.use_oruga:
2357 return HTML.tag('o-icon', icon=self.icon)
2359 return HTML.tag('i', class_=f'fas fa-{self.icon}')
2361 def render_label(self):
2362 """
2363 Render the label text for the action link.
2365 Default behavior is to return :attr:`label` as-is.
2367 See also :meth:`render_icon_and_label()`.
2368 """
2369 return self.label
2371 def get_url(self, obj, i=None):
2372 """
2373 Returns the action link URL for the given object (model
2374 instance).
2376 If :attr:`url` is a simple string, it is returned as-is.
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.
2382 :param obj: Model instance of whatever type the parent grid is
2383 setup to use.
2385 :param i: One-based sequence for the object's row within the
2386 parent grid.
2388 See also :attr:`url`.
2389 """
2390 if callable(self.url):
2391 return self.url(obj, i)
2393 return self.url