Coverage for .tox/coverage/lib/python3.11/site-packages/wuttaweb/grids/base.py: 100%
567 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-26 14:40 -0500
« prev ^ index » next coverage.py v7.6.1, created at 2024-08-26 14:40 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Base grid classes
25"""
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 grids.
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()`.
120 .. attribute:: row_class
122 This represents the CSS ``class`` attribute for a row within
123 the grid. Default is ``None``.
125 This can be a simple string, in which case the same class is
126 applied to all rows.
128 Or it can be a callable, which can then return different
129 class(es) depending on each row. The callable must take three
130 args: ``(obj, data, i)`` - for example::
132 def my_row_class(obj, data, i):
133 if obj.archived:
134 return 'poser-archived'
136 grid = Grid(request, key='foo', row_class=my_row_class)
138 See :meth:`get_row_class()` for more info.
140 .. attribute:: actions
142 List of :class:`GridAction` instances represenging action links
143 to be shown for each record in the grid.
145 .. attribute:: linked_columns
147 List of column names for which auto-link behavior should be
148 applied.
150 See also :meth:`set_link()` and :meth:`is_linked()`.
152 .. attribute:: sortable
154 Boolean indicating whether *any* column sorting is allowed for
155 the grid. Default is ``False``.
157 See also :attr:`sort_multiple` and :attr:`sort_on_backend`.
159 .. attribute:: sort_multiple
161 Boolean indicating whether "multi-column" sorting is allowed.
162 Default is ``True``; if this is ``False`` then only one column
163 may be sorted at a time.
165 Only relevant if :attr:`sortable` is true, but applies to both
166 frontend and backend sorting.
168 .. warning::
170 This feature is limited by frontend JS capabilities,
171 regardless of :attr:`sort_on_backend` value (i.e. for both
172 frontend and backend sorting).
174 In particular, if the app theme templates use Vue 2 + Buefy,
175 then multi-column sorting should work.
177 But not so with Vue 3 + Oruga, *yet* - see also the `open
178 issue <https://github.com/oruga-ui/oruga/issues/962>`_
179 regarding that. For now this flag is simply ignored for
180 Vue 3 + Oruga templates.
182 Additionally, even with Vue 2 + Buefy this flag can only
183 allow the user to *request* a multi-column sort. Whereas
184 the "default sort" in the Vue component can only ever be
185 single-column, regardless of :attr:`sort_defaults`.
187 .. attribute:: sort_on_backend
189 Boolean indicating whether the grid data should be sorted on the
190 backend. Default is ``True``.
192 If ``False``, the client-side Vue component will handle the
193 sorting.
195 Only relevant if :attr:`sortable` is also true.
197 .. attribute:: sorters
199 Dict of functions to use for backend sorting.
201 Only relevant if both :attr:`sortable` and
202 :attr:`sort_on_backend` are true.
204 See also :meth:`set_sorter()`, :attr:`sort_defaults` and
205 :attr:`active_sorters`.
207 .. attribute:: sort_defaults
209 List of options to be used for default sorting, until the user
210 requests a different sorting method.
212 This list usually contains either zero or one elements. (More
213 are allowed if :attr:`sort_multiple` is true, but see note
214 below.) Each list element is a :class:`SortInfo` tuple and
215 must correspond to an entry in :attr:`sorters`.
217 Used with both frontend and backend sorting.
219 See also :meth:`set_sort_defaults()` and
220 :attr:`active_sorters`.
222 .. warning::
224 While the grid logic is built to handle multi-column
225 sorting, this feature is limited by frontend JS
226 capabilities.
228 Even if ``sort_defaults`` contains multiple entries
229 (i.e. for multi-column sorting to be used "by default" for
230 the grid), only the *first* entry (i.e. single-column
231 sorting) will actually be used as the default for the Vue
232 component.
234 See also :attr:`sort_multiple` for more details.
236 .. attribute:: active_sorters
238 List of sorters currently in effect for the grid; used by
239 :meth:`sort_data()`.
241 Whereas :attr:`sorters` defines all "available" sorters, and
242 :attr:`sort_defaults` defines the "default" sorters,
243 ``active_sorters`` defines the "current/effective" sorters.
245 This attribute is set by :meth:`load_settings()`; until that is
246 called it will not exist.
248 This is conceptually a "subset" of :attr:`sorters` although a
249 different format is used here::
251 grid.active_sorters = [
252 {'key': 'name', 'dir': 'asc'},
253 {'key': 'id', 'dir': 'asc'},
254 ]
256 The above is for example only; there is usually no reason to
257 set this attribute directly.
259 This list may contain multiple elements only if
260 :attr:`sort_multiple` is true. Otherewise it should always
261 have either zero or one element.
263 .. attribute:: paginated
265 Boolean indicating whether the grid data should be paginated,
266 i.e. split up into pages. Default is ``False`` which means all
267 data is shown at once.
269 See also :attr:`pagesize` and :attr:`page`, and
270 :attr:`paginate_on_backend`.
272 .. attribute:: paginate_on_backend
274 Boolean indicating whether the grid data should be paginated on
275 the backend. Default is ``True`` which means only one "page"
276 of data is sent to the client-side component.
278 If this is ``False``, the full set of grid data is sent for
279 each request, and the client-side Vue component will handle the
280 pagination.
282 Only relevant if :attr:`paginated` is also true.
284 .. attribute:: pagesize_options
286 List of "page size" options for the grid. See also
287 :attr:`pagesize`.
289 Only relevant if :attr:`paginated` is true. If not specified,
290 constructor will call :meth:`get_pagesize_options()` to get the
291 value.
293 .. attribute:: pagesize
295 Number of records to show in a data page. See also
296 :attr:`pagesize_options` and :attr:`page`.
298 Only relevant if :attr:`paginated` is true. If not specified,
299 constructor will call :meth:`get_pagesize()` to get the value.
301 .. attribute:: page
303 The current page number (of data) to display in the grid. See
304 also :attr:`pagesize`.
306 Only relevant if :attr:`paginated` is true. If not specified,
307 constructor will assume ``1`` (first page).
309 .. attribute:: searchable_columns
311 Set of columns declared as searchable for the Vue component.
313 See also :meth:`set_searchable()` and :meth:`is_searchable()`.
315 .. attribute:: filterable
317 Boolean indicating whether the grid should show a "filters"
318 section where user can filter data in various ways. Default is
319 ``False``.
321 .. attribute:: filters
323 Dict of :class:`~wuttaweb.grids.filters.GridFilter` instances
324 available for use with backend filtering.
326 Only relevant if :attr:`filterable` is true.
328 See also :meth:`set_filter()`.
330 .. attribute:: filter_defaults
332 Dict containing default state preferences for the filters.
334 See also :meth:`set_filter_defaults()`.
336 .. attribute:: joiners
338 Dict of "joiner" functions for use with backend filtering and
339 sorting.
341 See :meth:`set_joiner()` for more info.
343 .. attribute:: tools
345 Dict of "tool" elements for the grid. Tools are usually buttons
346 (e.g. "Delete Results"), shown on top right of the grid.
348 The keys for this dict are somewhat arbitrary, defined by the
349 caller. Values should be HTML literal elements.
351 See also :meth:`add_tool()` and :meth:`set_tools()`.
352 """
354 def __init__(
355 self,
356 request,
357 vue_tagname='wutta-grid',
358 model_class=None,
359 key=None,
360 columns=None,
361 data=None,
362 labels={},
363 renderers={},
364 row_class=None,
365 actions=[],
366 linked_columns=[],
367 sortable=False,
368 sort_multiple=True,
369 sort_on_backend=True,
370 sorters=None,
371 sort_defaults=None,
372 paginated=False,
373 paginate_on_backend=True,
374 pagesize_options=None,
375 pagesize=None,
376 page=1,
377 searchable_columns=None,
378 filterable=False,
379 filters=None,
380 filter_defaults=None,
381 joiners=None,
382 tools=None,
383 ):
384 self.request = request
385 self.vue_tagname = vue_tagname
386 self.model_class = model_class
387 self.key = key
388 self.data = data
389 self.labels = labels or {}
390 self.renderers = renderers or {}
391 self.row_class = row_class
392 self.actions = actions or []
393 self.linked_columns = linked_columns or []
394 self.joiners = joiners or {}
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.set_tools(tools)
402 # sorting
403 self.sortable = sortable
404 self.sort_multiple = sort_multiple
405 if self.sort_multiple and self.request.use_oruga:
406 log.warning("grid.sort_multiple is not implemented for Oruga-based templates")
407 self.sort_multiple = False
408 self.sort_on_backend = sort_on_backend
409 if sorters is not None:
410 self.sorters = sorters
411 elif self.sortable and self.sort_on_backend:
412 self.sorters = self.make_backend_sorters()
413 else:
414 self.sorters = {}
415 self.set_sort_defaults(sort_defaults or [])
417 # paging
418 self.paginated = paginated
419 self.paginate_on_backend = paginate_on_backend
420 self.pagesize_options = pagesize_options or self.get_pagesize_options()
421 self.pagesize = pagesize or self.get_pagesize()
422 self.page = page
424 # searching
425 self.searchable_columns = set(searchable_columns or [])
427 # filtering
428 self.filterable = filterable
429 if filters is not None:
430 self.filters = filters
431 elif self.filterable:
432 self.filters = self.make_backend_filters()
433 else:
434 self.filters = {}
435 self.set_filter_defaults(**(filter_defaults or {}))
437 def get_columns(self):
438 """
439 Returns the official list of column names for the grid, or
440 ``None``.
442 If :attr:`columns` is set and non-empty, it is returned.
444 Or, if :attr:`model_class` is set, the field list is derived
445 from that, via :meth:`get_model_columns()`.
447 Otherwise ``None`` is returned.
448 """
449 if hasattr(self, 'columns') and self.columns:
450 return self.columns
452 columns = self.get_model_columns()
453 if columns:
454 return columns
456 return []
458 def get_model_columns(self, model_class=None):
459 """
460 This method is a shortcut which calls
461 :func:`~wuttaweb.util.get_model_fields()`.
463 :param model_class: Optional model class for which to return
464 fields. If not set, the grid's :attr:`model_class` is
465 assumed.
466 """
467 return get_model_fields(self.config,
468 model_class=model_class or self.model_class)
470 @property
471 def vue_component(self):
472 """
473 String name for the Vue component, e.g. ``'WuttaGrid'``.
475 This is a generated value based on :attr:`vue_tagname`.
476 """
477 words = self.vue_tagname.split('-')
478 return ''.join([word.capitalize() for word in words])
480 def set_columns(self, columns):
481 """
482 Explicitly set the list of grid columns.
484 This will overwrite :attr:`columns` with a new
485 :class:`~wuttaweb.util.FieldList` instance.
487 :param columns: List of string column names.
488 """
489 self.columns = FieldList(columns)
491 def append(self, *keys):
492 """
493 Add some columns(s) to the grid.
495 This is a convenience to allow adding multiple columns at
496 once::
498 grid.append('first_field',
499 'second_field',
500 'third_field')
502 It will add each column to :attr:`columns`.
503 """
504 for key in keys:
505 if key not in self.columns:
506 self.columns.append(key)
508 def remove(self, *keys):
509 """
510 Remove some column(s) from the grid.
512 This is a convenience to allow removal of multiple columns at
513 once::
515 grid.remove('first_field',
516 'second_field',
517 'third_field')
519 It will remove each column from :attr:`columns`.
520 """
521 for key in keys:
522 if key in self.columns:
523 self.columns.remove(key)
525 def set_label(self, key, label, column_only=False):
526 """
527 Set/override the label for a column.
529 :param key: Name of column.
531 :param label: New label for the column header.
533 :param column_only: Boolean indicating whether the label
534 should be applied *only* to the column header (if
535 ``True``), vs. applying also to the filter (if ``False``).
537 See also :meth:`get_label()`. Label overrides are tracked via
538 :attr:`labels`.
539 """
540 self.labels[key] = label
542 if not column_only and key in self.filters:
543 self.filters[key].label = label
545 def get_label(self, key):
546 """
547 Returns the label text for a given column.
549 If no override is defined, the label is derived from ``key``.
551 See also :meth:`set_label()`.
552 """
553 if key in self.labels:
554 return self.labels[key]
555 return self.app.make_title(key)
557 def set_renderer(self, key, renderer, **kwargs):
558 """
559 Set/override the value renderer for a column.
561 :param key: Name of column.
563 :param renderer: Callable as described below.
565 Depending on the nature of grid data, sometimes a cell's
566 "as-is" value will be undesirable for display purposes.
568 The logic in :meth:`get_vue_context()` will first "convert"
569 all grid data as necessary so that it is at least
570 JSON-compatible.
572 But then it also will invoke a renderer override (if defined)
573 to obtain the "final" cell value.
575 A renderer must be a callable which accepts 3 args ``(record,
576 key, value)``:
578 * ``record`` is the "original" record from :attr:`data`
579 * ``key`` is the column name
580 * ``value`` is the JSON-safe cell value
582 Whatever the renderer returns, is then used as final cell
583 value. For instance::
585 from webhelpers2.html import HTML
587 def render_foo(record, key, value):
588 return HTML.literal("<p>this is the final cell value</p>")
590 grid = Grid(request, columns=['foo', 'bar'])
591 grid.set_renderer('foo', render_foo)
593 Renderer overrides are tracked via :attr:`renderers`.
594 """
595 if kwargs:
596 renderer = functools.partial(renderer, **kwargs)
597 self.renderers[key] = renderer
599 def set_link(self, key, link=True):
600 """
601 Explicitly enable or disable auto-link behavior for a given
602 column.
604 If a column has auto-link enabled, then each of its cell
605 contents will automatically be wrapped with a hyperlink. The
606 URL for this will be the same as for the "View"
607 :class:`GridAction`
608 (aka. :meth:`~wuttaweb.views.master.MasterView.view()`).
609 Although of course each cell in the column gets a different
610 link depending on which data record it points to.
612 It is typical to enable auto-link for fields relating to ID,
613 description etc. or some may prefer to auto-link all columns.
615 See also :meth:`is_linked()`; the list is tracked via
616 :attr:`linked_columns`.
618 :param key: Column key as string.
620 :param link: Boolean indicating whether column's cell contents
621 should be auto-linked.
622 """
623 if link:
624 if key not in self.linked_columns:
625 self.linked_columns.append(key)
626 else: # unlink
627 if self.linked_columns and key in self.linked_columns:
628 self.linked_columns.remove(key)
630 def is_linked(self, key):
631 """
632 Returns boolean indicating if auto-link behavior is enabled
633 for a given column.
635 See also :meth:`set_link()` which describes auto-link behavior.
637 :param key: Column key as string.
638 """
639 if self.linked_columns:
640 if key in self.linked_columns:
641 return True
642 return False
644 def set_searchable(self, key, searchable=True):
645 """
646 (Un)set the given column's searchable flag for the Vue
647 component.
649 See also :meth:`is_searchable()`. Flags are tracked via
650 :attr:`searchable_columns`.
651 """
652 if searchable:
653 self.searchable_columns.add(key)
654 elif key in self.searchable_columns:
655 self.searchable_columns.remove(key)
657 def is_searchable(self, key):
658 """
659 Check if the given column is marked as searchable for the Vue
660 component.
662 See also :meth:`set_searchable()`.
663 """
664 return key in self.searchable_columns
666 def add_action(self, key, **kwargs):
667 """
668 Convenience to add a new :class:`GridAction` instance to the
669 grid's :attr:`actions` list.
670 """
671 self.actions.append(GridAction(self.request, key, **kwargs))
673 def set_tools(self, tools):
674 """
675 Set the :attr:`tools` attribute using the given tools collection.
677 This will normalize the list/dict to desired internal format.
678 """
679 if tools and isinstance(tools, list):
680 if not any([isinstance(t, (tuple, list)) for t in tools]):
681 tools = [(self.app.make_uuid(), t) for t in tools]
682 self.tools = OrderedDict(tools or [])
684 def add_tool(self, html, key=None):
685 """
686 Add a new HTML snippet to the :attr:`tools` dict.
688 :param html: HTML literal for the tool element.
690 :param key: Optional key to use when adding to the
691 :attr:`tools` dict. If not specified, a random string is
692 generated.
694 See also :meth:`set_tools()`.
695 """
696 if not key:
697 key = self.app.make_uuid()
698 self.tools[key] = html
700 ##############################
701 # joining methods
702 ##############################
704 def set_joiner(self, key, joiner):
705 """
706 Set/override the backend joiner for a column.
708 A "joiner" is sometimes needed when a column with "related but
709 not primary" data is involved in a sort or filter operation.
711 A sorter or filter may need to "join" other table(s) to get at
712 the appropriate data. But if a given column has both a sorter
713 and filter defined, and both are used at the same time, we
714 don't want the join to happen twice.
716 Hence we track joiners separately, also keyed by column name
717 (as are sorters and filters). When a column's sorter **and/or**
718 filter is needed, the joiner will be invoked.
720 :param key: Name of column.
722 :param joiner: A joiner callable, as described below.
724 A joiner callable must accept just one ``(data)`` arg and
725 return the "joined" data/query, for example::
727 model = app.model
728 grid = Grid(request, model_class=model.Person)
730 def join_external_profile_value(query):
731 return query.join(model.ExternalProfile)
733 def sort_external_profile(query, direction):
734 sortspec = getattr(model.ExternalProfile.description, direction)
735 return query.order_by(sortspec())
737 grid.set_joiner('external_profile', join_external_profile)
738 grid.set_sorter('external_profile', sort_external_profile)
740 See also :meth:`remove_joiner()`. Backend joiners are tracked
741 via :attr:`joiners`.
742 """
743 self.joiners[key] = joiner
745 def remove_joiner(self, key):
746 """
747 Remove the backend joiner for a column.
749 Note that this removes the joiner *function*, so there is no
750 way to apply joins for this column unless another joiner is
751 later defined for it.
753 See also :meth:`set_joiner()`.
754 """
755 self.joiners.pop(key, None)
757 ##############################
758 # sorting methods
759 ##############################
761 def make_backend_sorters(self, sorters=None):
762 """
763 Make backend sorters for all columns in the grid.
765 This is called by the constructor, if both :attr:`sortable`
766 and :attr:`sort_on_backend` are true.
768 For each column in the grid, this checks the provided
769 ``sorters`` and if the column is not yet in there, will call
770 :meth:`make_sorter()` to add it.
772 .. note::
774 This only works if grid has a :attr:`model_class`. If not,
775 this method just returns the initial sorters (or empty
776 dict).
778 :param sorters: Optional dict of initial sorters. Any
779 existing sorters will be left intact, not replaced.
781 :returns: Final dict of all sorters. Includes any from the
782 initial ``sorters`` param as well as any which were
783 created.
784 """
785 sorters = sorters or {}
787 if self.model_class:
788 for key in self.columns:
789 if key in sorters:
790 continue
791 prop = getattr(self.model_class, key, None)
792 if (prop and hasattr(prop, 'property')
793 and isinstance(prop.property, orm.ColumnProperty)):
794 sorters[prop.key] = self.make_sorter(prop)
796 return sorters
798 def make_sorter(self, columninfo, keyfunc=None, foldcase=True):
799 """
800 Returns a function suitable for use as a backend sorter on the
801 given column.
803 Code usually does not need to call this directly. See also
804 :meth:`set_sorter()`, which calls this method automatically.
806 :param columninfo: Can be either a model property (see below),
807 or a column name.
809 :param keyfunc: Optional function to use as the "sort key
810 getter" callable, if the sorter is manual (as opposed to
811 SQLAlchemy query). More on this below. If not specified,
812 a default function is used.
814 :param foldcase: If the sorter is manual (not SQLAlchemy), and
815 the column data is of text type, this may be used to
816 automatically "fold case" for the sorting. Defaults to
817 ``True`` since this behavior is presumably expected, but
818 may be disabled if needed.
820 The term "model property" is a bit technical, an example
821 should help to clarify::
823 model = app.model
824 grid = Grid(request, model_class=model.Person)
826 # explicit property
827 sorter = grid.make_sorter(model.Person.full_name)
829 # property name works if grid has model class
830 sorter = grid.make_sorter('full_name')
832 # nb. this will *not* work
833 person = model.Person(full_name="John Doe")
834 sorter = grid.make_sorter(person.full_name)
836 The ``keyfunc`` param allows you to override the way sort keys
837 are obtained from data records (this only applies for a
838 "manual" sort, where data is a list and not a SQLAlchemy
839 query)::
841 data = [
842 {'foo': 1},
843 {'bar': 2},
844 ]
846 # nb. no model_class, just as an example
847 grid = Grid(request, columns=['foo', 'bar'], data=data)
849 def getkey(obj):
850 if obj.get('foo')
851 return obj['foo']
852 if obj.get('bar'):
853 return obj['bar']
854 return ''
856 # nb. sortfunc will ostensibly sort by 'foo' column, but in
857 # practice it is sorted per value from getkey() above
858 sortfunc = grid.make_sorter('foo', keyfunc=getkey)
859 sorted_data = sortfunc(data, 'asc')
861 :returns: A function suitable for backend sorting. This
862 function will behave differently when it is given a
863 SQLAlchemy query vs. a "list" of data. In either case it
864 will return the sorted result.
866 This function may be called as shown above. It expects 2
867 args: ``(data, direction)``
868 """
869 model_class = None
870 model_property = None
871 if isinstance(columninfo, str):
872 key = columninfo
873 model_class = self.model_class
874 model_property = getattr(self.model_class, key, None)
875 else:
876 model_property = columninfo
877 model_class = model_property.class_
878 key = model_property.key
880 def sorter(data, direction):
882 # query is sorted with order_by()
883 if isinstance(data, orm.Query):
884 if not model_property:
885 raise TypeError(f"grid sorter for '{key}' does not map to a model property")
886 query = data
887 return query.order_by(getattr(model_property, direction)())
889 # other data is sorted manually. first step is to
890 # identify the function used to produce a sort key for
891 # each record
892 kfunc = keyfunc
893 if not kfunc:
894 if model_property:
895 # TODO: may need this for String etc. as well?
896 if isinstance(model_property.type, sa.Text):
897 if foldcase:
898 kfunc = lambda obj: (obj[key] or '').lower()
899 else:
900 kfunc = lambda obj: obj[key] or ''
901 if not kfunc:
902 # nb. sorting with this can raise error if data
903 # contains varying types, e.g. str and None
904 kfunc = lambda obj: obj[key]
906 # then sort the data and return
907 return sorted(data, key=kfunc, reverse=direction == 'desc')
909 # TODO: this should be improved; is needed in tailbone for
910 # multi-column sorting with sqlalchemy queries
911 if model_property:
912 sorter._class = model_class
913 sorter._column = model_property
915 return sorter
917 def set_sorter(self, key, sortinfo=None):
918 """
919 Set/override the backend sorter for a column.
921 Only relevant if both :attr:`sortable` and
922 :attr:`sort_on_backend` are true.
924 :param key: Name of column.
926 :param sortinfo: Can be either a sorter callable, or else a
927 model property (see below).
929 If ``sortinfo`` is a callable, it will be used as-is for the
930 backend sorter.
932 Otherwise :meth:`make_sorter()` will be called to obtain the
933 backend sorter. The ``sortinfo`` will be passed along to that
934 call; if it is empty then ``key`` will be used instead.
936 A backend sorter callable must accept ``(data, direction)``
937 args and return the sorted data/query, for example::
939 model = app.model
940 grid = Grid(request, model_class=model.Person)
942 def sort_full_name(query, direction):
943 sortspec = getattr(model.Person.full_name, direction)
944 return query.order_by(sortspec())
946 grid.set_sorter('full_name', sort_full_name)
948 See also :meth:`remove_sorter()` and :meth:`is_sortable()`.
949 Backend sorters are tracked via :attr:`sorters`.
950 """
951 sorter = None
953 if sortinfo and callable(sortinfo):
954 sorter = sortinfo
955 else:
956 sorter = self.make_sorter(sortinfo or key)
958 self.sorters[key] = sorter
960 def remove_sorter(self, key):
961 """
962 Remove the backend sorter for a column.
964 Note that this removes the sorter *function*, so there is
965 no way to sort by this column unless another sorter is
966 later defined for it.
968 See also :meth:`set_sorter()`.
969 """
970 self.sorters.pop(key, None)
972 def set_sort_defaults(self, *args):
973 """
974 Set the default sorting method for the grid. This sorting is
975 used unless/until the user requests a different sorting
976 method.
978 ``args`` for this method are interpreted as follows:
980 If 2 args are received, they should be for ``sortkey`` and
981 ``sortdir``; for instance::
983 grid.set_sort_defaults('name', 'asc')
985 If just one 2-tuple arg is received, it is handled similarly::
987 grid.set_sort_defaults(('name', 'asc'))
989 If just one string arg is received, the default ``sortdir`` is
990 assumed::
992 grid.set_sort_defaults('name') # assumes 'asc'
994 Otherwise there should be just one list arg, elements of
995 which are each 2-tuples of ``(sortkey, sortdir)`` info::
997 grid.set_sort_defaults([('name', 'asc'),
998 ('value', 'desc')])
1000 .. note::
1002 Note that :attr:`sort_multiple` determines whether the grid
1003 is actually allowed to have multiple sort defaults. The
1004 defaults requested by the method call may be pruned if
1005 necessary to accommodate that.
1007 Default sorting info is tracked via :attr:`sort_defaults`.
1008 """
1010 # convert args to sort defaults
1011 sort_defaults = []
1012 if len(args) == 1:
1013 if isinstance(args[0], str):
1014 sort_defaults = [SortInfo(args[0], 'asc')]
1015 elif isinstance(args[0], tuple) and len(args[0]) == 2:
1016 sort_defaults = [SortInfo(*args[0])]
1017 elif isinstance(args[0], list):
1018 sort_defaults = [SortInfo(*tup) for tup in args[0]]
1019 else:
1020 raise ValueError("for just one positional arg, must pass string, 2-tuple or list")
1021 elif len(args) == 2:
1022 sort_defaults = [SortInfo(*args)]
1023 else:
1024 raise ValueError("must pass just one or two positional args")
1026 # prune if multi-column requested but not supported
1027 if len(sort_defaults) > 1 and not self.sort_multiple:
1028 log.warning("multi-column sorting is not enabled for the instance; "
1029 "list will be pruned to first element for '%s' grid: %s",
1030 self.key, sort_defaults)
1031 sort_defaults = [sort_defaults[0]]
1033 self.sort_defaults = sort_defaults
1035 def is_sortable(self, key):
1036 """
1037 Returns boolean indicating if a given column should allow
1038 sorting.
1040 If :attr:`sortable` is false, this always returns ``False``.
1042 For frontend sorting (i.e. :attr:`sort_on_backend` is false),
1043 this always returns ``True``.
1045 For backend sorting, may return true or false depending on
1046 whether the column is listed in :attr:`sorters`.
1048 :param key: Column key as string.
1050 See also :meth:`set_sorter()`.
1051 """
1052 if not self.sortable:
1053 return False
1054 if self.sort_on_backend:
1055 return key in self.sorters
1056 return True
1058 ##############################
1059 # filtering methods
1060 ##############################
1062 def make_backend_filters(self, filters=None):
1063 """
1064 Make backend filters for all columns in the grid.
1066 This is called by the constructor, if :attr:`filterable` is
1067 true.
1069 For each column in the grid, this checks the provided
1070 ``filters`` and if the column is not yet in there, will call
1071 :meth:`make_filter()` to add it.
1073 .. note::
1075 This only works if grid has a :attr:`model_class`. If not,
1076 this method just returns the initial filters (or empty
1077 dict).
1079 :param filters: Optional dict of initial filters. Any
1080 existing filters will be left intact, not replaced.
1082 :returns: Final dict of all filters. Includes any from the
1083 initial ``filters`` param as well as any which were
1084 created.
1085 """
1086 filters = filters or {}
1088 if self.model_class:
1089 # TODO: i tried using self.get_model_columns() here but in
1090 # many cases that will be too aggressive. however it is
1091 # often the case that the *grid* columns are a subset of
1092 # the unerlying *table* columns. so until a better way
1093 # is found, we choose "too few" instead of "too many"
1094 # filters here. surely must improve it at some point.
1095 for key in self.columns:
1096 if key in filters:
1097 continue
1098 prop = getattr(self.model_class, key, None)
1099 if (prop and hasattr(prop, 'property')
1100 and isinstance(prop.property, orm.ColumnProperty)):
1101 filters[prop.key] = self.make_filter(prop)
1103 return filters
1105 def make_filter(self, columninfo, **kwargs):
1106 """
1107 Create and return a
1108 :class:`~wuttaweb.grids.filters.GridFilter` instance suitable
1109 for use on the given column.
1111 Code usually does not need to call this directly. See also
1112 :meth:`set_filter()`, which calls this method automatically.
1114 :param columninfo: Can be either a model property (see below),
1115 or a column name.
1117 :returns: A :class:`~wuttaweb.grids.filters.GridFilter`
1118 instance.
1119 """
1120 key = kwargs.pop('key', None)
1122 # model_property is required
1123 model_property = None
1124 if kwargs.get('model_property'):
1125 model_property = kwargs['model_property']
1126 elif isinstance(columninfo, str):
1127 key = columninfo
1128 if self.model_class:
1129 model_property = getattr(self.model_class, key, None)
1130 if not model_property:
1131 raise ValueError(f"cannot locate model property for key: {key}")
1132 else:
1133 model_property = columninfo
1135 # optional factory override
1136 factory = kwargs.pop('factory', None)
1137 if not factory:
1138 typ = model_property.type
1139 factory = default_sqlalchemy_filters.get(type(typ))
1140 if not factory:
1141 factory = default_sqlalchemy_filters[None]
1143 # make filter
1144 kwargs['model_property'] = model_property
1145 return factory(self.request, key or model_property.key, **kwargs)
1147 def set_filter(self, key, filterinfo=None, **kwargs):
1148 """
1149 Set/override the backend filter for a column.
1151 Only relevant if :attr:`filterable` is true.
1153 :param key: Name of column.
1155 :param filterinfo: Can be either a
1156 :class:`~wuttweb.grids.filters.GridFilter` instance, or
1157 else a model property (see below).
1159 If ``filterinfo`` is a ``GridFilter`` instance, it will be
1160 used as-is for the backend filter.
1162 Otherwise :meth:`make_filter()` will be called to obtain the
1163 backend filter. The ``filterinfo`` will be passed along to
1164 that call; if it is empty then ``key`` will be used instead.
1166 See also :meth:`remove_filter()`. Backend filters are tracked
1167 via :attr:`filters`.
1168 """
1169 filtr = None
1171 if filterinfo and callable(filterinfo):
1172 # filtr = filterinfo
1173 raise NotImplementedError
1174 else:
1175 kwargs['key'] = key
1176 kwargs.setdefault('label', self.get_label(key))
1177 filtr = self.make_filter(filterinfo or key, **kwargs)
1179 self.filters[key] = filtr
1181 def remove_filter(self, key):
1182 """
1183 Remove the backend filter for a column.
1185 This removes the filter *instance*, so there is no way to
1186 filter by this column unless another filter is later defined
1187 for it.
1189 See also :meth:`set_filter()`.
1190 """
1191 self.filters.pop(key, None)
1193 def set_filter_defaults(self, **defaults):
1194 """
1195 Set default state preferences for the grid filters.
1197 These preferences will affect the initial grid display, until
1198 user requests a different filtering method.
1200 Each kwarg should be named by filter key, and the value should
1201 be a dict of preferences for that filter. For instance::
1203 grid.set_filter_defaults(name={'active': True,
1204 'verb': 'contains',
1205 'value': 'foo'},
1206 value={'active': True})
1208 Filter defaults are tracked via :attr:`filter_defaults`.
1209 """
1210 filter_defaults = dict(getattr(self, 'filter_defaults', {}))
1212 for key, values in defaults.items():
1213 filtr = filter_defaults.setdefault(key, {})
1214 filtr.update(values)
1216 self.filter_defaults = filter_defaults
1218 ##############################
1219 # paging methods
1220 ##############################
1222 def get_pagesize_options(self, default=None):
1223 """
1224 Returns a list of default page size options for the grid.
1226 It will check config but if no setting exists, will fall
1227 back to::
1229 [5, 10, 20, 50, 100, 200]
1231 :param default: Alternate default value to return if none is
1232 configured.
1234 This method is intended for use in the constructor. Code can
1235 instead access :attr:`pagesize_options` directly.
1236 """
1237 options = self.config.get_list('wuttaweb.grids.default_pagesize_options')
1238 if options:
1239 options = [int(size) for size in options
1240 if size.isdigit()]
1241 if options:
1242 return options
1244 return default or [5, 10, 20, 50, 100, 200]
1246 def get_pagesize(self, default=None):
1247 """
1248 Returns the default page size for the grid.
1250 It will check config but if no setting exists, will fall back
1251 to a value from :attr:`pagesize_options` (will return ``20`` if
1252 that is listed; otherwise the "first" option).
1254 :param default: Alternate default value to return if none is
1255 configured.
1257 This method is intended for use in the constructor. Code can
1258 instead access :attr:`pagesize` directly.
1259 """
1260 size = self.config.get_int('wuttaweb.grids.default_pagesize')
1261 if size:
1262 return size
1264 if default:
1265 return default
1267 if 20 in self.pagesize_options:
1268 return 20
1270 return self.pagesize_options[0]
1272 ##############################
1273 # configuration methods
1274 ##############################
1276 def load_settings(self, persist=True):
1277 """
1278 Load all effective settings for the grid.
1280 If the request GET params (query string) contains grid
1281 settings, they are used; otherwise the settings are loaded
1282 from user session.
1284 .. note::
1286 As of now, "sorting" and "pagination" settings are the only
1287 type supported by this logic. Settings for "filtering"
1288 coming soon...
1290 The overall logic for this method is as follows:
1292 * collect settings
1293 * apply settings to current grid
1294 * optionally save settings to user session
1296 Saving the settings to user session will allow the grid to
1297 remember its current settings when user refreshes the page, or
1298 navigates away then comes back. Therefore normally, settings
1299 are saved each time they are loaded. Note that such settings
1300 are wiped upon user logout.
1302 :param persist: Whether the collected settings should be saved
1303 to the user session.
1304 """
1306 # initial default settings
1307 settings = {}
1308 if self.filterable:
1309 for filtr in self.filters.values():
1310 defaults = self.filter_defaults.get(filtr.key, {})
1311 settings[f'filter.{filtr.key}.active'] = defaults.get('active',
1312 filtr.default_active)
1313 settings[f'filter.{filtr.key}.verb'] = defaults.get('verb',
1314 filtr.get_default_verb())
1315 settings[f'filter.{filtr.key}.value'] = defaults.get('value',
1316 filtr.default_value)
1317 if self.sortable:
1318 if self.sort_defaults:
1319 # nb. as of writing neither Buefy nor Oruga support a
1320 # multi-column *default* sort; so just use first sorter
1321 sortinfo = self.sort_defaults[0]
1322 settings['sorters.length'] = 1
1323 settings['sorters.1.key'] = sortinfo.sortkey
1324 settings['sorters.1.dir'] = sortinfo.sortdir
1325 else:
1326 settings['sorters.length'] = 0
1327 if self.paginated and self.paginate_on_backend:
1328 settings['pagesize'] = self.pagesize
1329 settings['page'] = self.page
1331 # update settings dict based on what we find in the request
1332 # and/or user session. always prioritize the former.
1334 # nb. do not read settings if user wants a reset
1335 if self.request.GET.get('reset-view'):
1336 # at this point we only have default settings, and we want
1337 # to keep those *and* persist them for next time, below
1338 pass
1340 elif self.request_has_settings('filter'):
1341 self.update_filter_settings(settings, src='request')
1342 if self.request_has_settings('sort'):
1343 self.update_sort_settings(settings, src='request')
1344 else:
1345 self.update_sort_settings(settings, src='session')
1346 self.update_page_settings(settings)
1348 elif self.request_has_settings('sort'):
1349 self.update_filter_settings(settings, src='session')
1350 self.update_sort_settings(settings, src='request')
1351 self.update_page_settings(settings)
1353 elif self.request_has_settings('page'):
1354 self.update_filter_settings(settings, src='session')
1355 self.update_sort_settings(settings, src='session')
1356 self.update_page_settings(settings)
1358 else:
1359 # nothing found in request, so nothing new to save
1360 persist = False
1362 # but still should load whatever is in user session
1363 self.update_filter_settings(settings, src='session')
1364 self.update_sort_settings(settings, src='session')
1365 self.update_page_settings(settings)
1367 # maybe save settings in user session, for next time
1368 if persist:
1369 self.persist_settings(settings, dest='session')
1371 # update ourself to reflect settings dict..
1373 # filtering
1374 if self.filterable:
1375 for filtr in self.filters.values():
1376 filtr.active = settings[f'filter.{filtr.key}.active']
1377 filtr.verb = settings[f'filter.{filtr.key}.verb'] or filtr.get_default_verb()
1378 filtr.value = settings[f'filter.{filtr.key}.value']
1380 # sorting
1381 if self.sortable:
1382 # nb. doing this for frontend sorting also
1383 self.active_sorters = []
1384 for i in range(1, settings['sorters.length'] + 1):
1385 self.active_sorters.append({
1386 'key': settings[f'sorters.{i}.key'],
1387 'dir': settings[f'sorters.{i}.dir'],
1388 })
1389 # TODO: i thought this was needed, but now idk?
1390 # # nb. when showing full index page (i.e. not partial)
1391 # # this implies we must set the default sorter for Vue
1392 # # component, and only single-column is allowed there.
1393 # if not self.request.GET.get('partial'):
1394 # break
1396 # paging
1397 if self.paginated and self.paginate_on_backend:
1398 self.pagesize = settings['pagesize']
1399 self.page = settings['page']
1401 def request_has_settings(self, typ):
1402 """ """
1404 if typ == 'filter' and self.filterable:
1405 for filtr in self.filters.values():
1406 if filtr.key in self.request.GET:
1407 return True
1408 if 'filter' in self.request.GET: # user may be applying empty filters
1409 return True
1411 elif typ == 'sort' and self.sortable and self.sort_on_backend:
1412 if 'sort1key' in self.request.GET:
1413 return True
1415 elif typ == 'page' and self.paginated and self.paginate_on_backend:
1416 for key in ['pagesize', 'page']:
1417 if key in self.request.GET:
1418 return True
1420 return False
1422 def get_setting(self, settings, key, src='session', default=None,
1423 normalize=lambda v: v):
1424 """ """
1426 if src == 'request':
1427 value = self.request.GET.get(key)
1428 if value is not None:
1429 try:
1430 return normalize(value)
1431 except ValueError:
1432 pass
1434 elif src == 'session':
1435 value = self.request.session.get(f'grid.{self.key}.{key}')
1436 if value is not None:
1437 return normalize(value)
1439 # if src had nothing, try default/existing settings
1440 value = settings.get(key)
1441 if value is not None:
1442 return normalize(value)
1444 # okay then, default it is
1445 return default
1447 def update_filter_settings(self, settings, src=None):
1448 """ """
1449 if not self.filterable:
1450 return
1452 for filtr in self.filters.values():
1453 prefix = f'filter.{filtr.key}'
1455 if src == 'request':
1456 # consider filter active if query string contains a value for it
1457 settings[f'{prefix}.active'] = filtr.key in self.request.GET
1458 settings[f'{prefix}.verb'] = self.get_setting(
1459 settings, f'{filtr.key}.verb', src='request', default='')
1460 settings[f'{prefix}.value'] = self.get_setting(
1461 settings, filtr.key, src='request', default='')
1463 elif src == 'session':
1464 settings[f'{prefix}.active'] = self.get_setting(
1465 settings, f'{prefix}.active', src='session',
1466 normalize=lambda v: str(v).lower() == 'true', default=False)
1467 settings[f'{prefix}.verb'] = self.get_setting(
1468 settings, f'{prefix}.verb', src='session', default='')
1469 settings[f'{prefix}.value'] = self.get_setting(
1470 settings, f'{prefix}.value', src='session', default='')
1472 def update_sort_settings(self, settings, src=None):
1473 """ """
1474 if not (self.sortable and self.sort_on_backend):
1475 return
1477 if src == 'request':
1478 i = 1
1479 while True:
1480 skey = f'sort{i}key'
1481 if skey in self.request.GET:
1482 settings[f'sorters.{i}.key'] = self.get_setting(settings, skey,
1483 src='request')
1484 settings[f'sorters.{i}.dir'] = self.get_setting(settings, f'sort{i}dir',
1485 src='request',
1486 default='asc')
1487 else:
1488 break
1489 i += 1
1490 settings['sorters.length'] = i - 1
1492 elif src == 'session':
1493 settings['sorters.length'] = self.get_setting(settings, 'sorters.length',
1494 src='session', normalize=int)
1495 for i in range(1, settings['sorters.length'] + 1):
1496 for key in ('key', 'dir'):
1497 skey = f'sorters.{i}.{key}'
1498 settings[skey] = self.get_setting(settings, skey, src='session')
1500 def update_page_settings(self, settings):
1501 """ """
1502 if not (self.paginated and self.paginate_on_backend):
1503 return
1505 # update the settings dict from request and/or user session
1507 # pagesize
1508 pagesize = self.request.GET.get('pagesize')
1509 if pagesize is not None:
1510 if pagesize.isdigit():
1511 settings['pagesize'] = int(pagesize)
1512 else:
1513 pagesize = self.request.session.get(f'grid.{self.key}.pagesize')
1514 if pagesize is not None:
1515 settings['pagesize'] = pagesize
1517 # page
1518 page = self.request.GET.get('page')
1519 if page is not None:
1520 if page.isdigit():
1521 settings['page'] = int(page)
1522 else:
1523 page = self.request.session.get(f'grid.{self.key}.page')
1524 if page is not None:
1525 settings['page'] = int(page)
1527 def persist_settings(self, settings, dest=None):
1528 """ """
1529 if dest not in ('session',):
1530 raise ValueError(f"invalid dest identifier: {dest}")
1532 # func to save a setting value to user session
1533 def persist(key, value=lambda k: settings.get(k)):
1534 assert dest == 'session'
1535 skey = f'grid.{self.key}.{key}'
1536 self.request.session[skey] = value(key)
1538 # filter settings
1539 if self.filterable:
1541 # always save all filters, with status
1542 for filtr in self.filters.values():
1543 persist(f'filter.{filtr.key}.active',
1544 value=lambda k: 'true' if settings.get(k) else 'false')
1545 persist(f'filter.{filtr.key}.verb')
1546 persist(f'filter.{filtr.key}.value')
1548 # sort settings
1549 if self.sortable and self.sort_on_backend:
1551 # first must clear all sort settings from dest. this is
1552 # because number of sort settings will vary, so we delete
1553 # all and then write all
1555 if dest == 'session':
1556 # remove sort settings from user session
1557 prefix = f'grid.{self.key}.sorters.'
1558 for key in list(self.request.session):
1559 if key.startswith(prefix):
1560 del self.request.session[key]
1562 # now save sort settings to dest
1563 if 'sorters.length' in settings:
1564 persist('sorters.length')
1565 for i in range(1, settings['sorters.length'] + 1):
1566 persist(f'sorters.{i}.key')
1567 persist(f'sorters.{i}.dir')
1569 # pagination settings
1570 if self.paginated and self.paginate_on_backend:
1572 # save to dest
1573 persist('pagesize')
1574 persist('page')
1576 ##############################
1577 # data methods
1578 ##############################
1580 def get_visible_data(self):
1581 """
1582 Returns the "effective" visible data for the grid.
1584 This uses :attr:`data` as the starting point but may morph it
1585 for pagination etc. per the grid settings.
1587 Code can either access :attr:`data` directly, or call this
1588 method to get only the data for current view (e.g. assuming
1589 pagination is used), depending on the need.
1591 See also these methods which may be called by this one:
1593 * :meth:`filter_data()`
1594 * :meth:`sort_data()`
1595 * :meth:`paginate_data()`
1596 """
1597 data = self.data or []
1598 self.joined = set()
1600 if self.filterable:
1601 data = self.filter_data(data)
1603 if self.sortable and self.sort_on_backend:
1604 data = self.sort_data(data)
1606 if self.paginated and self.paginate_on_backend:
1607 self.pager = self.paginate_data(data)
1608 data = self.pager
1610 return data
1612 @property
1613 def active_filters(self):
1614 """
1615 Returns the list of currently active filters.
1617 This inspects each :class:`~wuttaweb.grids.filters.GridFilter`
1618 in :attr:`filters` and only returns the ones marked active.
1619 """
1620 return [filtr for filtr in self.filters.values()
1621 if filtr.active]
1623 def filter_data(self, data, filters=None):
1624 """
1625 Filter the given data and return the result. This is called
1626 by :meth:`get_visible_data()`.
1628 :param filters: Optional list of filters to use. If not
1629 specified, the grid's :attr:`active_filters` are used.
1630 """
1631 if filters is None:
1632 filters = self.active_filters
1633 if not filters:
1634 return data
1636 for filtr in filters:
1637 key = filtr.key
1639 if key in self.joiners and key not in self.joined:
1640 data = self.joiners[key](data)
1641 self.joined.add(key)
1643 try:
1644 data = filtr.apply_filter(data)
1645 except VerbNotSupported as error:
1646 log.warning("verb not supported for '%s' filter: %s", key, error.verb)
1647 except:
1648 log.exception("filtering data by '%s' failed!", key)
1650 return data
1652 def sort_data(self, data, sorters=None):
1653 """
1654 Sort the given data and return the result. This is called by
1655 :meth:`get_visible_data()`.
1657 :param sorters: Optional list of sorters to use. If not
1658 specified, the grid's :attr:`active_sorters` are used.
1659 """
1660 if sorters is None:
1661 sorters = self.active_sorters
1662 if not sorters:
1663 return data
1665 # nb. when data is a query, we want to apply sorters in the
1666 # requested order, so the final query has order_by() in the
1667 # correct "as-is" sequence. however when data is a list we
1668 # must do the opposite, applying in the reverse order, so the
1669 # final list has the most "important" sort(s) applied last.
1670 if not isinstance(data, orm.Query):
1671 sorters = reversed(sorters)
1673 for sorter in sorters:
1674 sortkey = sorter['key']
1675 sortdir = sorter['dir']
1677 # cannot sort unless we have a sorter callable
1678 sortfunc = self.sorters.get(sortkey)
1679 if not sortfunc:
1680 return data
1682 # join appropriate model if needed
1683 if sortkey in self.joiners and sortkey not in self.joined:
1684 data = self.joiners[sortkey](data)
1685 self.joined.add(sortkey)
1687 # invoke the sorter
1688 data = sortfunc(data, sortdir)
1690 return data
1692 def paginate_data(self, data):
1693 """
1694 Apply pagination to the given data set, based on grid settings.
1696 This returns a "pager" object which can then be used as a
1697 "data replacement" in subsequent logic.
1699 This method is called by :meth:`get_visible_data()`.
1700 """
1701 if isinstance(data, orm.Query):
1702 pager = SqlalchemyOrmPage(data,
1703 items_per_page=self.pagesize,
1704 page=self.page)
1706 else:
1707 pager = paginate.Page(data,
1708 items_per_page=self.pagesize,
1709 page=self.page)
1711 # pager may have detected that our current page is outside the
1712 # valid range. if so we should update ourself to match
1713 if pager.page != self.page:
1714 self.page = pager.page
1715 key = f'grid.{self.key}.page'
1716 if key in self.request.session:
1717 self.request.session[key] = self.page
1719 # and re-make the pager just to be safe (?)
1720 pager = self.paginate_data(data)
1722 return pager
1724 ##############################
1725 # rendering methods
1726 ##############################
1728 def render_table_element(
1729 self,
1730 form=None,
1731 template='/grids/table_element.mako',
1732 **context):
1733 """
1734 Render a simple Vue table element for the grid.
1736 This is what you want for a "simple" grid which does require a
1737 unique Vue component, but can instead use the standard table
1738 component.
1740 This returns something like:
1742 .. code-block:: html
1744 <b-table :data="gridContext['mykey'].data">
1745 <!-- columns etc. -->
1746 </b-table>
1748 See :meth:`render_vue_template()` for a more complete variant.
1750 Actual output will of course depend on grid attributes,
1751 :attr:`key`, :attr:`columns` etc.
1753 :param form: Reference to the
1754 :class:`~wuttaweb.forms.base.Form` instance which
1755 "contains" this grid. This is needed in order to ensure
1756 the grid data is available to the form Vue component.
1758 :param template: Path to Mako template which is used to render
1759 the output.
1761 .. note::
1763 The above example shows ``gridContext['mykey'].data`` as
1764 the Vue data reference. This should "just work" if you
1765 provide the correct ``form`` arg and the grid is contained
1766 directly by that form's Vue component.
1768 However, this may not account for all use cases. For now
1769 we wait and see what comes up, but know the dust may not
1770 yet be settled here.
1771 """
1773 # nb. must register data for inclusion on page template
1774 if form:
1775 form.add_grid_vue_context(self)
1777 # otherwise logic is the same, just different template
1778 return self.render_vue_template(template=template, **context)
1780 def render_vue_tag(self, **kwargs):
1781 """
1782 Render the Vue component tag for the grid.
1784 By default this simply returns:
1786 .. code-block:: html
1788 <wutta-grid></wutta-grid>
1790 The actual output will depend on various grid attributes, in
1791 particular :attr:`vue_tagname`.
1792 """
1793 return HTML.tag(self.vue_tagname, **kwargs)
1795 def render_vue_template(
1796 self,
1797 template='/grids/vue_template.mako',
1798 **context):
1799 """
1800 Render the Vue template block for the grid.
1802 This is what you want for a "full-featured" grid which will
1803 exist as its own unique Vue component on the frontend.
1805 This returns something like:
1807 .. code-block:: none
1809 <script type="text/x-template" id="wutta-grid-template">
1810 <b-table>
1811 <!-- columns etc. -->
1812 </b-table>
1813 </script>
1815 <script>
1816 WuttaGridData = {}
1817 WuttaGrid = {
1818 template: 'wutta-grid-template',
1819 }
1820 </script>
1822 .. todo::
1824 Why can't Sphinx render the above code block as 'html' ?
1826 It acts like it can't handle a ``<script>`` tag at all?
1828 See :meth:`render_table_element()` for a simpler variant.
1830 Actual output will of course depend on grid attributes,
1831 :attr:`vue_tagname` and :attr:`columns` etc.
1833 :param template: Path to Mako template which is used to render
1834 the output.
1835 """
1836 context['grid'] = self
1837 context.setdefault('request', self.request)
1838 output = render(template, context)
1839 return HTML.literal(output)
1841 def render_vue_finalize(self):
1842 """
1843 Render the Vue "finalize" script for the grid.
1845 By default this simply returns:
1847 .. code-block:: html
1849 <script>
1850 WuttaGrid.data = function() { return WuttaGridData }
1851 Vue.component('wutta-grid', WuttaGrid)
1852 </script>
1854 The actual output may depend on various grid attributes, in
1855 particular :attr:`vue_tagname`.
1856 """
1857 set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}"
1858 make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})"
1859 return HTML.tag('script', c=['\n',
1860 HTML.literal(set_data),
1861 '\n',
1862 HTML.literal(make_component),
1863 '\n'])
1865 def get_vue_columns(self):
1866 """
1867 Returns a list of Vue-compatible column definitions.
1869 This uses :attr:`columns` as the basis; each definition
1870 returned will be a dict in this format::
1872 {
1873 'field': 'foo',
1874 'label': "Foo",
1875 'sortable': True,
1876 'searchable': False,
1877 }
1879 The full format is determined by Buefy; see the Column section
1880 in its `Table docs
1881 <https://buefy.org/documentation/table/#api-view>`_.
1883 See also :meth:`get_vue_context()`.
1884 """
1885 if not self.columns:
1886 raise ValueError(f"you must define columns for the grid! key = {self.key}")
1888 columns = []
1889 for name in self.columns:
1890 columns.append({
1891 'field': name,
1892 'label': self.get_label(name),
1893 'sortable': self.is_sortable(name),
1894 'searchable': self.is_searchable(name),
1895 })
1896 return columns
1898 def get_vue_active_sorters(self):
1899 """
1900 Returns a list of Vue-compatible column sorter definitions.
1902 The list returned is the same as :attr:`active_sorters`;
1903 however the format used in Vue is different. So this method
1904 just "converts" them to the required format, e.g.::
1906 # active_sorters format
1907 {'key': 'name', 'dir': 'asc'}
1909 # get_vue_active_sorters() format
1910 {'field': 'name', 'order': 'asc'}
1912 :returns: The :attr:`active_sorters` list, converted as
1913 described above.
1914 """
1915 sorters = []
1916 for sorter in self.active_sorters:
1917 sorters.append({'field': sorter['key'],
1918 'order': sorter['dir']})
1919 return sorters
1921 def get_vue_filters(self):
1922 """
1923 Returns a list of Vue-compatible filter definitions.
1925 This returns the full set of :attr:`filters` but represents
1926 each as a simple dict with the filter state.
1927 """
1928 filters = []
1929 for filtr in self.filters.values():
1930 filters.append({
1931 'key': filtr.key,
1932 'active': filtr.active,
1933 'visible': filtr.active,
1934 'verbs': filtr.get_verbs(),
1935 'verb_labels': filtr.get_verb_labels(),
1936 'valueless_verbs': filtr.get_valueless_verbs(),
1937 'verb': filtr.verb,
1938 'value': filtr.value,
1939 'label': filtr.label,
1940 })
1941 return filters
1943 def get_vue_context(self):
1944 """
1945 Returns a dict of context for the grid, for use with the Vue
1946 component. This contains the following keys:
1948 * ``data`` - list of Vue-compatible data records
1949 * ``row_classes`` - dict of per-row CSS classes
1951 This first calls :meth:`get_visible_data()` to get the
1952 original data set. Each record is converted to a dict.
1954 Then it calls :func:`~wuttaweb.util.make_json_safe()` to
1955 ensure each record can be serialized to JSON.
1957 Then it invokes any :attr:`renderers` which are defined, to
1958 obtain the "final" values for each record.
1960 Then it adds a URL key/value for each of the :attr:`actions`
1961 defined, to each record.
1963 Then it calls :meth:`get_row_class()` for each record. If a
1964 value is returned, it is added to the ``row_classes`` dict.
1965 Note that this dict is keyed by "zero-based row sequence as
1966 string" - the Vue component expects that.
1968 :returns: Dict of grid data/CSS context as described above.
1969 """
1970 original_data = self.get_visible_data()
1972 # loop thru data
1973 data = []
1974 row_classes = {}
1975 for i, record in enumerate(original_data, 1):
1976 original_record = record
1978 # convert record to new dict
1979 record = dict(record)
1981 # make all values safe for json
1982 record = make_json_safe(record, warn=False)
1984 # customize value rendering where applicable
1985 for key in self.renderers:
1986 value = record.get(key, None)
1987 record[key] = self.renderers[key](original_record, key, value)
1989 # add action urls to each record
1990 for action in self.actions:
1991 key = f'_action_url_{action.key}'
1992 if key not in record:
1993 url = action.get_url(original_record, i)
1994 if url:
1995 record[key] = url
1997 # set row css class if applicable
1998 css_class = self.get_row_class(original_record, record, i)
1999 if css_class:
2000 # nb. use *string* zero-based index, for js compat
2001 row_classes[str(i-1)] = css_class
2003 data.append(record)
2005 return {
2006 'data': data,
2007 'row_classes': row_classes,
2008 }
2010 def get_vue_data(self):
2011 """ """
2012 warnings.warn("grid.get_vue_data() is deprecated; "
2013 "please use grid.get_vue_context() instead",
2014 DeprecationWarning, stacklevel=2)
2015 return self.get_vue_context()['data']
2017 def get_row_class(self, obj, data, i):
2018 """
2019 Returns the row CSS ``class`` attribute for the given record.
2020 This method is called by :meth:`get_vue_context()`.
2022 This will inspect/invoke :attr:`row_class` and return the
2023 value obtained from there.
2025 :param obj: Reference to the original model instance.
2027 :param data: Dict of record data for the instance; part of the
2028 Vue grid data set in/from :meth:`get_vue_context()`.
2030 :param i: One-based sequence for this object/record (row)
2031 within the grid.
2033 :returns: String of CSS class name(s), or ``None``.
2034 """
2035 if self.row_class:
2036 if callable(self.row_class):
2037 return self.row_class(obj, data, i)
2038 return self.row_class
2040 def get_vue_pager_stats(self):
2041 """
2042 Returns a simple dict with current grid pager stats.
2044 This is used when :attr:`paginate_on_backend` is in effect.
2045 """
2046 pager = self.pager
2047 return {
2048 'item_count': pager.item_count,
2049 'items_per_page': pager.items_per_page,
2050 'page': pager.page,
2051 'page_count': pager.page_count,
2052 'first_item': pager.first_item,
2053 'last_item': pager.last_item,
2054 }
2057class GridAction:
2058 """
2059 Represents a "row action" hyperlink within a grid context.
2061 All such actions are displayed as a group, in a dedicated
2062 **Actions** column in the grid. So each row in the grid has its
2063 own set of action links.
2065 A :class:`Grid` can have one (or zero) or more of these in its
2066 :attr:`~Grid.actions` list. You can call
2067 :meth:`~wuttaweb.views.base.View.make_grid_action()` to add custom
2068 actions from within a view.
2070 :param request: Current :term:`request` object.
2072 .. note::
2074 Some parameters are not explicitly described above. However
2075 their corresponding attributes are described below.
2077 .. attribute:: key
2079 String key for the action (e.g. ``'edit'``), unique within the
2080 grid.
2082 .. attribute:: label
2084 Label to be displayed for the action link. If not set, will be
2085 generated from :attr:`key` by calling
2086 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.make_title()`.
2088 See also :meth:`render_label()`.
2090 .. attribute:: url
2092 URL for the action link, if applicable. This *can* be a simple
2093 string, however that will cause every row in the grid to have
2094 the same URL for this action.
2096 A better way is to specify a callable which can return a unique
2097 URL for each record. The callable should expect ``(obj, i)``
2098 args, for instance::
2100 def myurl(obj, i):
2101 return request.route_url('widgets.view', uuid=obj.uuid)
2103 action = GridAction(request, 'view', url=myurl)
2105 See also :meth:`get_url()`.
2107 .. attribute:: icon
2109 Name of icon to be shown for the action link.
2111 See also :meth:`render_icon()`.
2113 .. attribute:: link_class
2115 Optional HTML class attribute for the action's ``<a>`` tag.
2116 """
2118 def __init__(
2119 self,
2120 request,
2121 key,
2122 label=None,
2123 url=None,
2124 icon=None,
2125 link_class=None,
2126 ):
2127 self.request = request
2128 self.config = self.request.wutta_config
2129 self.app = self.config.get_app()
2130 self.key = key
2131 self.url = url
2132 self.label = label or self.app.make_title(key)
2133 self.icon = icon or key
2134 self.link_class = link_class or ''
2136 def render_icon_and_label(self):
2137 """
2138 Render the HTML snippet for action link icon and label.
2140 Default logic returns the output from :meth:`render_icon()`
2141 and :meth:`render_label()`.
2142 """
2143 html = [
2144 self.render_icon(),
2145 self.render_label(),
2146 ]
2147 return HTML.literal(' ').join(html)
2149 def render_icon(self):
2150 """
2151 Render the HTML snippet for the action link icon.
2153 This uses :attr:`icon` to identify the named icon to be shown.
2154 Output is something like (here ``'trash'`` is the icon name):
2156 .. code-block:: html
2158 <i class="fas fa-trash"></i>
2160 See also :meth:`render_icon_and_label()`.
2161 """
2162 if self.request.use_oruga:
2163 return HTML.tag('o-icon', icon=self.icon)
2165 return HTML.tag('i', class_=f'fas fa-{self.icon}')
2167 def render_label(self):
2168 """
2169 Render the label text for the action link.
2171 Default behavior is to return :attr:`label` as-is.
2173 See also :meth:`render_icon_and_label()`.
2174 """
2175 return self.label
2177 def get_url(self, obj, i=None):
2178 """
2179 Returns the action link URL for the given object (model
2180 instance).
2182 If :attr:`url` is a simple string, it is returned as-is.
2184 But if :attr:`url` is a callable (which is typically the most
2185 useful), that will be called with the same ``(obj, i)`` args
2186 passed along.
2188 :param obj: Model instance of whatever type the parent grid is
2189 setup to use.
2191 :param i: One-based sequence for the object's row within the
2192 parent grid.
2194 See also :attr:`url`.
2195 """
2196 if callable(self.url):
2197 return self.url(obj, i)
2199 return self.url