Coverage for .tox/coverage/lib/python3.13/site-packages/wuttaweb/grids/base.py: 100%
702 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -0500
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-24 16:36 -0500
1# -*- coding: utf-8; -*-
2################################################################################
3#
4# wuttaweb -- Web App for Wutta Framework
5# Copyright © 2024-2026 Lance Edgar
6#
7# This file is part of Wutta Framework.
8#
9# Wutta Framework is free software: you can redistribute it and/or modify it
10# under the terms of the GNU General Public License as published by the Free
11# Software Foundation, either version 3 of the License, or (at your option) any
12# later version.
13#
14# Wutta Framework is distributed in the hope that it will be useful, but
15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
17# more details.
18#
19# You should have received a copy of the GNU General Public License along with
20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
21#
22################################################################################
23"""
24Base grid classes
25"""
27# pylint: disable=too-many-lines
29import functools
30import logging
31import warnings
32from collections import namedtuple, OrderedDict
34try:
35 from enum import EnumType
36except ImportError: # pragma: no cover
37 # nb. python < 3.11
38 from enum import EnumMeta as EnumType
40import sqlalchemy as sa
41from sqlalchemy import orm
43import paginate
44from paginate_sqlalchemy import SqlalchemyOrmPage
45from pyramid.renderers import render
46from webhelpers2.html import HTML
48from wuttjamaican.db.util import UUID
49from wuttaweb.util import (
50 FieldList,
51 get_model_fields,
52 make_json_safe,
53 render_vue_finalize,
54)
55from wuttaweb.grids.filters import default_sqlalchemy_filters, VerbNotSupported
57log = logging.getLogger(__name__)
60SortInfo = namedtuple("SortInfo", ["sortkey", "sortdir"])
61SortInfo.__doc__ = """
62Named tuple to track sorting info.
64Elements of :attr:`~Grid.sort_defaults` will be of this type.
65"""
68class Grid: # pylint: disable=too-many-instance-attributes,too-many-public-methods
69 """
70 Base class for all :term:`grids <grid>`.
72 :param request: Reference to current :term:`request` object.
74 :param columns: List of column names for the grid. This is
75 optional; if not specified an attempt will be made to deduce
76 the list automatically. See also :attr:`columns`.
78 .. note::
80 Some parameters are not explicitly described above. However
81 their corresponding attributes are described below.
83 Grid instances contain the following attributes:
85 .. attribute:: key
87 Presumably unique key for the grid; used to track per-grid
88 sort/filter settings etc.
90 .. attribute:: vue_tagname
92 String name for Vue component tag. By default this is
93 ``'wutta-grid'``. See also :meth:`render_vue_tag()`
94 and :attr:`vue_component`.
96 .. attribute:: model_class
98 Model class for the grid, if applicable. When set, this is
99 usually a SQLAlchemy mapped class. This may be used for
100 deriving the default :attr:`columns` among other things.
102 .. attribute:: columns
104 :class:`~wuttaweb.util.FieldList` instance containing string
105 column names for the grid. Columns will appear in the same
106 order as they are in this list.
108 See also :meth:`set_columns()` and :meth:`get_columns()`.
110 .. attribute:: data
112 Data set for the grid. This should either be a list of dicts
113 (or objects with dict-like access to fields, corresponding to
114 model records) or else an object capable of producing such a
115 list, e.g. SQLAlchemy query.
117 This is the "full" data set; see also
118 :meth:`get_visible_data()`.
120 .. attribute:: labels
122 Dict of column and/or filter label overrides.
124 See also :attr:`column_labels`, :meth:`set_label()`,
125 :meth:`get_column_label()` and :meth:`get_filter_label()`.
127 .. attribute:: column_labels
129 Dict of label overrides for column only.
131 See also :attr:`labels`, :meth:`set_label()` and
132 :meth:`get_column_label()`.
134 .. attribute:: centered
136 Dict of column "centered" flags.
138 See also :meth:`is_centered()` and :meth:`set_centered()`.
140 .. attribute:: renderers
142 Dict of column (cell) value renderer overrides.
144 See also :meth:`set_renderer()` and
145 :meth:`set_default_renderers()`.
147 .. attribute:: enums
149 Dict of "enum" collections, for supported columns.
151 See also :meth:`set_enum()`.
153 .. attribute:: checkable
155 Boolean indicating whether the grid should expose per-row
156 checkboxes.
158 .. attribute:: row_class
160 This represents the CSS ``class`` attribute for a row within
161 the grid. Default is ``None``.
163 This can be a simple string, in which case the same class is
164 applied to all rows.
166 Or it can be a callable, which can then return different
167 class(es) depending on each row. The callable must take three
168 args: ``(obj, data, i)`` - for example::
170 def my_row_class(obj, data, i):
171 if obj.archived:
172 return 'poser-archived'
174 grid = Grid(request, key='foo', row_class=my_row_class)
176 See :meth:`get_row_class()` for more info.
178 .. attribute:: actions
180 List of :class:`GridAction` instances represenging action links
181 to be shown for each record in the grid.
183 .. attribute:: linked_columns
185 List of column names for which auto-link behavior should be
186 applied.
188 See also :meth:`set_link()` and :meth:`is_linked()`.
190 .. attribute:: hidden_columns
192 List of column names which should be hidden from view.
194 Hidden columns are sometimes useful to pass "extra" data into
195 the grid, for use by other component logic etc.
197 See also :meth:`set_hidden()` and :meth:`is_hidden()`.
199 .. attribute:: sortable
201 Boolean indicating whether *any* column sorting is allowed for
202 the grid. Default is ``False``.
204 See also :attr:`sort_multiple` and :attr:`sort_on_backend`.
206 .. attribute:: sort_multiple
208 Boolean indicating whether "multi-column" sorting is allowed.
209 This is true by default, where possible. If false then only
210 one column may be sorted at a time.
212 Only relevant if :attr:`sortable` is true, but applies to both
213 frontend and backend sorting.
215 .. warning::
217 This feature is limited by frontend JS capabilities,
218 regardless of :attr:`sort_on_backend` value (i.e. for both
219 frontend and backend sorting).
221 In particular, if the app theme templates use Vue 2 + Buefy,
222 then multi-column sorting should work.
224 But not so with Vue 3 + Oruga, *yet* - see also the `open
225 issue <https://github.com/oruga-ui/oruga/issues/962>`_
226 regarding that. For now this flag is simply ignored for
227 Vue 3 + Oruga templates.
229 Additionally, even with Vue 2 + Buefy this flag can only
230 allow the user to *request* a multi-column sort. Whereas
231 the "default sort" in the Vue component can only ever be
232 single-column, regardless of :attr:`sort_defaults`.
234 .. attribute:: sort_on_backend
236 Boolean indicating whether the grid data should be sorted on the
237 backend. Default is ``True``.
239 If ``False``, the client-side Vue component will handle the
240 sorting.
242 Only relevant if :attr:`sortable` is also true.
244 .. attribute:: sorters
246 Dict of functions to use for backend sorting.
248 Only relevant if both :attr:`sortable` and
249 :attr:`sort_on_backend` are true.
251 See also :meth:`set_sorter()`, :attr:`sort_defaults` and
252 :attr:`active_sorters`.
254 .. attribute:: sort_defaults
256 List of options to be used for default sorting, until the user
257 requests a different sorting method.
259 This list usually contains either zero or one elements. (More
260 are allowed if :attr:`sort_multiple` is true, but see note
261 below.) Each list element is a :class:`SortInfo` tuple and
262 must correspond to an entry in :attr:`sorters`.
264 Used with both frontend and backend sorting.
266 See also :meth:`set_sort_defaults()` and
267 :attr:`active_sorters`.
269 .. warning::
271 While the grid logic is built to handle multi-column
272 sorting, this feature is limited by frontend JS
273 capabilities.
275 Even if ``sort_defaults`` contains multiple entries
276 (i.e. for multi-column sorting to be used "by default" for
277 the grid), only the *first* entry (i.e. single-column
278 sorting) will actually be used as the default for the Vue
279 component.
281 See also :attr:`sort_multiple` for more details.
283 .. attribute:: active_sorters
285 List of sorters currently in effect for the grid; used by
286 :meth:`sort_data()`.
288 Whereas :attr:`sorters` defines all "available" sorters, and
289 :attr:`sort_defaults` defines the "default" sorters,
290 ``active_sorters`` defines the "current/effective" sorters.
292 This attribute is set by :meth:`load_settings()`; until that is
293 called its value will be ``None``.
295 This is conceptually a "subset" of :attr:`sorters` although a
296 different format is used here::
298 grid.active_sorters = [
299 {'key': 'name', 'dir': 'asc'},
300 {'key': 'id', 'dir': 'asc'},
301 ]
303 The above is for example only; there is usually no reason to
304 set this attribute directly.
306 This list may contain multiple elements only if
307 :attr:`sort_multiple` is true. Otherewise it should always
308 have either zero or one element.
310 .. attribute:: paginated
312 Boolean indicating whether the grid data should be paginated,
313 i.e. split up into pages. Default is ``False`` which means all
314 data is shown at once.
316 See also :attr:`pagesize` and :attr:`page`, and
317 :attr:`paginate_on_backend`.
319 .. attribute:: paginate_on_backend
321 Boolean indicating whether the grid data should be paginated on
322 the backend. Default is ``True`` which means only one "page"
323 of data is sent to the client-side component.
325 If this is ``False``, the full set of grid data is sent for
326 each request, and the client-side Vue component will handle the
327 pagination.
329 Only relevant if :attr:`paginated` is also true.
331 .. attribute:: pagesize_options
333 List of "page size" options for the grid. See also
334 :attr:`pagesize`.
336 Only relevant if :attr:`paginated` is true. If not specified,
337 constructor will call :meth:`get_pagesize_options()` to get the
338 value.
340 .. attribute:: pagesize
342 Number of records to show in a data page. See also
343 :attr:`pagesize_options` and :attr:`page`.
345 Only relevant if :attr:`paginated` is true. If not specified,
346 constructor will call :meth:`get_pagesize()` to get the value.
348 .. attribute:: page
350 The current page number (of data) to display in the grid. See
351 also :attr:`pagesize`.
353 Only relevant if :attr:`paginated` is true. If not specified,
354 constructor will assume ``1`` (first page).
356 .. attribute:: searchable_columns
358 Set of columns declared as searchable for the Vue component.
360 See also :meth:`set_searchable()` and :meth:`is_searchable()`.
362 .. attribute:: filterable
364 Boolean indicating whether the grid should show a "filters"
365 section where user can filter data in various ways. Default is
366 ``False``.
368 .. attribute:: filters
370 Dict of :class:`~wuttaweb.grids.filters.GridFilter` instances
371 available for use with backend filtering.
373 Only relevant if :attr:`filterable` is true.
375 See also :meth:`set_filter()`.
377 .. attribute:: filter_defaults
379 Dict containing default state preferences for the filters.
381 See also :meth:`set_filter_defaults()`.
383 .. attribute:: joiners
385 Dict of "joiner" functions for use with backend filtering and
386 sorting.
388 See :meth:`set_joiner()` for more info.
390 .. attribute:: tools
392 Dict of "tool" elements for the grid. Tools are usually buttons
393 (e.g. "Delete Results"), shown on top right of the grid.
395 The keys for this dict are somewhat arbitrary, defined by the
396 caller. Values should be HTML literal elements.
398 See also :meth:`add_tool()` and :meth:`set_tools()`.
399 """
401 active_sorters = None
402 joined = None
403 pager = None
405 def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-branches,too-many-statements
406 self,
407 request,
408 vue_tagname="wutta-grid",
409 model_class=None,
410 key=None,
411 columns=None,
412 data=None,
413 labels=None,
414 centered=None,
415 renderers=None,
416 enums=None,
417 checkable=False,
418 row_class=None,
419 actions=None,
420 linked_columns=None,
421 hidden_columns=None,
422 sortable=False,
423 sort_multiple=None,
424 sort_on_backend=True,
425 sorters=None,
426 sort_defaults=None,
427 paginated=False,
428 paginate_on_backend=True,
429 pagesize_options=None,
430 pagesize=None,
431 page=1,
432 searchable_columns=None,
433 filterable=False,
434 filters=None,
435 filter_defaults=None,
436 joiners=None,
437 tools=None,
438 ):
439 self.request = request
440 self.vue_tagname = vue_tagname
441 self.model_class = model_class
442 self.key = key
443 self.data = data
444 self.labels = labels or {}
445 self.column_labels = {}
446 self.checkable = checkable
447 self.row_class = row_class
448 self.actions = actions or []
449 self.linked_columns = linked_columns or []
450 self.hidden_columns = hidden_columns or []
451 self.joiners = joiners or {}
453 self.config = self.request.wutta_config
454 self.app = self.config.get_app()
456 self.set_columns(columns or self.get_columns())
457 self.centered = centered or {}
458 self.renderers = {}
459 if renderers:
460 for k, val in renderers.items():
461 self.set_renderer(k, val)
462 self.set_default_renderers()
463 self.set_tools(tools)
465 # sorting
466 self.sortable = sortable
467 if sort_multiple is not None:
468 self.sort_multiple = sort_multiple
469 elif self.request.use_oruga:
470 self.sort_multiple = False
471 else:
472 self.sort_multiple = bool(self.model_class)
473 if self.sort_multiple and self.request.use_oruga:
474 log.warning(
475 "grid.sort_multiple is not implemented for Oruga-based templates"
476 )
477 self.sort_multiple = False
478 self.sort_on_backend = sort_on_backend
479 if sorters is not None:
480 self.sorters = sorters
481 elif self.sortable and self.sort_on_backend:
482 self.sorters = self.make_backend_sorters()
483 else:
484 self.sorters = {}
485 self.set_sort_defaults(sort_defaults or [])
487 # paging
488 self.paginated = paginated
489 self.paginate_on_backend = paginate_on_backend
490 self.pagesize_options = pagesize_options or self.get_pagesize_options()
491 self.pagesize = pagesize or self.get_pagesize()
492 self.page = page
494 # searching
495 self.searchable_columns = set(searchable_columns or [])
497 # filtering
498 self.filterable = filterable
499 if filters is not None:
500 self.filters = filters
501 elif self.filterable:
502 self.filters = self.make_backend_filters()
503 else:
504 self.filters = {}
505 self.set_filter_defaults(**(filter_defaults or {}))
507 # enums
508 self.enums = {}
509 for k in enums or {}:
510 self.set_enum(k, enums[k])
512 def get_columns(self):
513 """
514 Returns the official list of column names for the grid, or
515 ``None``.
517 If :attr:`columns` is set and non-empty, it is returned.
519 Or, if :attr:`model_class` is set, the field list is derived
520 from that, via :meth:`get_model_columns()`.
522 Otherwise ``None`` is returned.
523 """
524 if hasattr(self, "columns") and self.columns:
525 return self.columns
527 columns = self.get_model_columns()
528 if columns:
529 return columns
531 return []
533 def get_model_columns(self, model_class=None):
534 """
535 This method is a shortcut which calls
536 :func:`~wuttaweb.util.get_model_fields()`.
538 :param model_class: Optional model class for which to return
539 fields. If not set, the grid's :attr:`model_class` is
540 assumed.
541 """
542 return get_model_fields(
543 self.config, model_class=model_class or self.model_class
544 )
546 @property
547 def vue_component(self):
548 """
549 String name for the Vue component, e.g. ``'WuttaGrid'``.
551 This is a generated value based on :attr:`vue_tagname`.
552 """
553 words = self.vue_tagname.split("-")
554 return "".join([word.capitalize() for word in words])
556 def set_columns(self, columns):
557 """
558 Explicitly set the list of grid columns.
560 This will overwrite :attr:`columns` with a new
561 :class:`~wuttaweb.util.FieldList` instance.
563 :param columns: List of string column names.
564 """
565 self.columns = FieldList(columns)
567 def append(self, *keys):
568 """
569 Add some columns(s) to the grid.
571 This is a convenience to allow adding multiple columns at
572 once::
574 grid.append('first_field',
575 'second_field',
576 'third_field')
578 It will add each column to :attr:`columns`.
579 """
580 for key in keys:
581 if key not in self.columns:
582 self.columns.append(key)
584 def remove(self, *keys):
585 """
586 Remove some column(s) from the grid.
588 This is a convenience to allow removal of multiple columns at
589 once::
591 grid.remove('first_field',
592 'second_field',
593 'third_field')
595 It will remove each column from :attr:`columns`.
596 """
597 for key in keys:
598 if key in self.columns:
599 self.columns.remove(key)
601 def set_hidden(self, key, hidden=True):
602 """
603 Set/override the hidden flag for a column.
605 Hidden columns are sometimes useful to pass "extra" data into
606 the grid, for use by other component logic etc.
608 See also :meth:`is_hidden()`; the list is tracked via
609 :attr:`hidden_columns`.
611 :param key: Column key as string.
613 :param hidden: Flag indicating whether column should be hidden
614 (vs. shown).
615 """
616 if hidden:
617 if key not in self.hidden_columns:
618 self.hidden_columns.append(key)
619 else: # un-hide
620 if self.hidden_columns and key in self.hidden_columns:
621 self.hidden_columns.remove(key)
623 def is_hidden(self, key):
624 """
625 Returns boolean indicating if the column is hidden from view.
627 See also :meth:`set_hidden()` and :attr:`hidden_columns`.
629 :param key: Column key as string.
631 :rtype: bool
632 """
633 if self.hidden_columns:
634 if key in self.hidden_columns:
635 return True
636 return False
638 def set_label(self, key, label, column_only=False):
639 """
640 Set/override the label for a column and/or filter.
642 :param key: Key for the column/filter.
644 :param label: New label for the column and/or filter.
646 :param column_only: Boolean indicating whether the label
647 should be applied *only* to the column header (if
648 ``True``), vs. applying also to the filter (if ``False``).
650 See also :meth:`get_column_label()` and
651 :meth:`get_filter_label()`. Label overrides are tracked via
652 :attr:`labels` and :attr:`column_labels`.
653 """
654 if column_only:
655 self.column_labels[key] = label
656 else:
657 self.labels[key] = label
658 if key in self.filters:
659 self.filters[key].label = label
661 def get_label(self, key): # pylint: disable=missing-function-docstring
662 warnings.warn(
663 "Grid.get_label() is deprecated; please use "
664 "get_filter_label() or get_column_label() instead",
665 DeprecationWarning,
666 stacklevel=2,
667 )
668 return self.get_filter_label(key)
670 def get_filter_label(self, key):
671 """
672 Returns the label text for a given filter.
674 If no override is defined, the label is derived from ``key``.
676 See also :meth:`set_label()` and :meth:`get_column_label()`.
677 """
678 if key in self.labels:
679 return self.labels[key]
681 return self.app.make_title(key)
683 def get_column_label(self, key):
684 """
685 Returns the label text for a given column.
687 If no override is defined, the label is derived from ``key``.
689 See also :meth:`set_label()` and :meth:`get_filter_label()`.
690 """
691 if key in self.column_labels:
692 return self.column_labels[key]
694 return self.get_filter_label(key)
696 def set_centered(self, key, centered=True):
697 """
698 Set/override the "centered" flag for a column.
700 :param key: Name of column.
702 :param centered: Whether the column data should be centered.
704 See also :meth:`is_centered()`. Column flags are tracked via
705 :attr:`centered`.
706 """
707 self.centered[key] = centered
709 def is_centered(self, key):
710 """
711 Check if the given column should be centered.
713 :param key: Name of column.
715 :rtype: boolean
717 See also :meth:`set_centered()`. Column flags are tracked via
718 :attr:`centered`.
719 """
720 return self.centered.get(key, False)
722 def set_renderer(self, key, renderer, **kwargs):
723 """
724 Set/override the value renderer for a column.
726 :param key: Name of column.
728 :param renderer: Callable as described below.
730 Depending on the nature of grid data, sometimes a cell's
731 "as-is" value will be undesirable for display purposes.
733 The logic in :meth:`get_vue_context()` will first "convert"
734 all grid data as necessary so that it is at least
735 JSON-compatible.
737 But then it also will invoke a renderer override (if defined)
738 to obtain the "final" cell value.
740 A renderer must be a callable which accepts 3 args ``(record,
741 key, value)``:
743 * ``record`` is the "original" record from :attr:`data`
744 * ``key`` is the column name
745 * ``value`` is the JSON-safe cell value
747 Whatever the renderer returns, is then used as final cell
748 value. For instance::
750 from webhelpers2.html import HTML
752 def render_foo(record, key, value):
753 return HTML.literal("<p>this is the final cell value</p>")
755 grid = Grid(request, columns=['foo', 'bar'])
756 grid.set_renderer('foo', render_foo)
758 For convenience, in lieu of a renderer callable, you may
759 specify one of the following strings, which will be
760 interpreted as a built-in renderer callable, as shown below:
762 * ``'batch_id'`` -> :meth:`render_batch_id()`
763 * ``'boolean'`` -> :meth:`render_boolean()`
764 * ``'currency'`` -> :meth:`render_currency()`
765 * ``'date'`` -> :meth:`render_date()`
766 * ``'datetime'`` -> :meth:`render_datetime()`
767 * ``'quantity'`` -> :meth:`render_quantity()`
768 * ``'percent'`` -> :meth:`render_percent()`
770 Renderer overrides are tracked via :attr:`renderers`.
771 """
772 builtins = {
773 "batch_id": self.render_batch_id,
774 "boolean": self.render_boolean,
775 "currency": self.render_currency,
776 "date": self.render_date,
777 "datetime": self.render_datetime,
778 "quantity": self.render_quantity,
779 "percent": self.render_percent,
780 }
782 if renderer in builtins: # pylint: disable=consider-using-get
783 renderer = builtins[renderer]
785 if kwargs:
786 renderer = functools.partial(renderer, **kwargs)
787 self.renderers[key] = renderer
789 def set_default_renderers(self):
790 """
791 Set default column value renderers, where applicable.
793 This is called automatically from the class constructor. It
794 will add new entries to :attr:`renderers` for columns whose
795 data type implies a default renderer. This is only possible
796 if :attr:`model_class` is set to a SQLAlchemy mapped class.
798 This only looks for a few data types, and configures as
799 follows:
801 * :class:`sqlalchemy:sqlalchemy.types.Boolean` ->
802 :meth:`render_boolean()`
803 * :class:`sqlalchemy:sqlalchemy.types.Date` ->
804 :meth:`render_date()`
805 * :class:`sqlalchemy:sqlalchemy.types.DateTime` ->
806 :meth:`render_datetime()`
807 """
808 if not self.model_class:
809 return
811 for key in self.columns:
812 if key in self.renderers:
813 continue
815 attr = getattr(self.model_class, key, None)
816 if attr:
817 prop = getattr(attr, "prop", None)
818 if prop and isinstance(prop, orm.ColumnProperty):
819 column = prop.columns[0]
820 if isinstance(column.type, sa.Date):
821 self.set_renderer(key, self.render_date)
822 elif isinstance(column.type, sa.DateTime):
823 self.set_renderer(key, self.render_datetime)
824 elif isinstance(column.type, sa.Boolean):
825 self.set_renderer(key, self.render_boolean)
827 def set_enum(self, key, enum):
828 """
829 Set the "enum" collection for a given column.
831 This will set the column renderer to show the appropriate enum
832 value for each row in the grid. See also
833 :meth:`render_enum()`.
835 If the grid has a corresponding filter for the column, it will
836 be modified to show "choices" for values contained in the
837 enum.
839 :param key: Name of column.
841 :param enum: Instance of :class:`python:enum.Enum`, or a dict.
842 """
843 self.enums[key] = enum
844 self.set_renderer(key, self.render_enum, enum=enum)
845 if key in self.filters:
846 self.filters[key].set_choices(enum)
848 def set_link(self, key, link=True):
849 """
850 Explicitly enable or disable auto-link behavior for a given
851 column.
853 If a column has auto-link enabled, then each of its cell
854 contents will automatically be wrapped with a hyperlink. The
855 URL for this will be the same as for the "View"
856 :class:`GridAction`
857 (aka. :meth:`~wuttaweb.views.master.MasterView.view()`).
858 Although of course each cell in the column gets a different
859 link depending on which data record it points to.
861 It is typical to enable auto-link for fields relating to ID,
862 description etc. or some may prefer to auto-link all columns.
864 See also :meth:`is_linked()`; the list is tracked via
865 :attr:`linked_columns`.
867 :param key: Column key as string.
869 :param link: Boolean indicating whether column's cell contents
870 should be auto-linked.
871 """
872 if link:
873 if key not in self.linked_columns:
874 self.linked_columns.append(key)
875 else: # unlink
876 if self.linked_columns and key in self.linked_columns:
877 self.linked_columns.remove(key)
879 def is_linked(self, key):
880 """
881 Returns boolean indicating if auto-link behavior is enabled
882 for a given column.
884 See also :meth:`set_link()` which describes auto-link behavior.
886 :param key: Column key as string.
887 """
888 if self.linked_columns:
889 if key in self.linked_columns:
890 return True
891 return False
893 def set_searchable(self, key, searchable=True):
894 """
895 (Un)set the given column's searchable flag for the Vue
896 component.
898 See also :meth:`is_searchable()`. Flags are tracked via
899 :attr:`searchable_columns`.
900 """
901 if searchable:
902 self.searchable_columns.add(key)
903 elif key in self.searchable_columns:
904 self.searchable_columns.remove(key)
906 def is_searchable(self, key):
907 """
908 Check if the given column is marked as searchable for the Vue
909 component.
911 See also :meth:`set_searchable()`.
912 """
913 return key in self.searchable_columns
915 def add_action(self, key, **kwargs):
916 """
917 Convenience to add a new :class:`GridAction` instance to the
918 grid's :attr:`actions` list.
919 """
920 self.actions.append(GridAction(self.request, key, **kwargs))
922 def set_tools(self, tools):
923 """
924 Set the :attr:`tools` attribute using the given tools collection.
925 This will normalize the list/dict to desired internal format.
927 See also :meth:`add_tool()`.
928 """
929 if tools and isinstance(tools, list):
930 if not any(isinstance(t, (tuple, list)) for t in tools):
931 tools = [(self.app.make_true_uuid().hex, t) for t in tools]
932 self.tools = OrderedDict(tools or [])
934 def add_tool(self, html, key=None):
935 """
936 Add a new HTML snippet to the :attr:`tools` dict.
938 :param html: HTML literal for the tool element.
940 :param key: Optional key to use when adding to the
941 :attr:`tools` dict. If not specified, a random string is
942 generated.
944 See also :meth:`set_tools()`.
945 """
946 if not key:
947 key = self.app.make_true_uuid().hex
948 self.tools[key] = html
950 ##############################
951 # joining methods
952 ##############################
954 def set_joiner(self, key, joiner):
955 """
956 Set/override the backend joiner for a column.
958 A "joiner" is sometimes needed when a column with "related but
959 not primary" data is involved in a sort or filter operation.
961 A sorter or filter may need to "join" other table(s) to get at
962 the appropriate data. But if a given column has both a sorter
963 and filter defined, and both are used at the same time, we
964 don't want the join to happen twice.
966 Hence we track joiners separately, also keyed by column name
967 (as are sorters and filters). When a column's sorter **and/or**
968 filter is needed, the joiner will be invoked.
970 :param key: Name of column.
972 :param joiner: A joiner callable, as described below.
974 A joiner callable must accept just one ``(data)`` arg and
975 return the "joined" data/query, for example::
977 model = app.model
978 grid = Grid(request, model_class=model.Person)
980 def join_external_profile_value(query):
981 return query.join(model.ExternalProfile)
983 def sort_external_profile(query, direction):
984 sortspec = getattr(model.ExternalProfile.description, direction)
985 return query.order_by(sortspec())
987 grid.set_joiner('external_profile', join_external_profile)
988 grid.set_sorter('external_profile', sort_external_profile)
990 See also :meth:`remove_joiner()`. Backend joiners are tracked
991 via :attr:`joiners`.
992 """
993 self.joiners[key] = joiner
995 def remove_joiner(self, key):
996 """
997 Remove the backend joiner for a column.
999 Note that this removes the joiner *function*, so there is no
1000 way to apply joins for this column unless another joiner is
1001 later defined for it.
1003 See also :meth:`set_joiner()`.
1004 """
1005 self.joiners.pop(key, None)
1007 ##############################
1008 # sorting methods
1009 ##############################
1011 def make_backend_sorters(self, sorters=None):
1012 """
1013 Make backend sorters for all columns in the grid.
1015 This is called by the constructor, if both :attr:`sortable`
1016 and :attr:`sort_on_backend` are true.
1018 For each column in the grid, this checks the provided
1019 ``sorters`` and if the column is not yet in there, will call
1020 :meth:`make_sorter()` to add it.
1022 .. note::
1024 This only works if grid has a :attr:`model_class`. If not,
1025 this method just returns the initial sorters (or empty
1026 dict).
1028 :param sorters: Optional dict of initial sorters. Any
1029 existing sorters will be left intact, not replaced.
1031 :returns: Final dict of all sorters. Includes any from the
1032 initial ``sorters`` param as well as any which were
1033 created.
1034 """
1035 sorters = sorters or {}
1037 if self.model_class:
1038 for key in self.columns:
1039 if key in sorters:
1040 continue
1041 prop = getattr(self.model_class, key, None)
1042 if (
1043 prop
1044 and hasattr(prop, "property")
1045 and isinstance(prop.property, orm.ColumnProperty)
1046 ):
1047 sorters[prop.key] = self.make_sorter(prop)
1049 return sorters
1051 def make_sorter(self, columninfo, keyfunc=None, foldcase=True):
1052 """
1053 Returns a function suitable for use as a backend sorter on the
1054 given column.
1056 Code usually does not need to call this directly. See also
1057 :meth:`set_sorter()`, which calls this method automatically.
1059 :param columninfo: Can be either a model property (see below),
1060 or a column name.
1062 :param keyfunc: Optional function to use as the "sort key
1063 getter" callable, if the sorter is manual (as opposed to
1064 SQLAlchemy query). More on this below. If not specified,
1065 a default function is used.
1067 :param foldcase: If the sorter is manual (not SQLAlchemy), and
1068 the column data is of text type, this may be used to
1069 automatically "fold case" for the sorting. Defaults to
1070 ``True`` since this behavior is presumably expected, but
1071 may be disabled if needed.
1073 The term "model property" is a bit technical, an example
1074 should help to clarify::
1076 model = app.model
1077 grid = Grid(request, model_class=model.Person)
1079 # explicit property
1080 sorter = grid.make_sorter(model.Person.full_name)
1082 # property name works if grid has model class
1083 sorter = grid.make_sorter('full_name')
1085 # nb. this will *not* work
1086 person = model.Person(full_name="John Doe")
1087 sorter = grid.make_sorter(person.full_name)
1089 The ``keyfunc`` param allows you to override the way sort keys
1090 are obtained from data records (this only applies for a
1091 "manual" sort, where data is a list and not a SQLAlchemy
1092 query)::
1094 data = [
1095 {'foo': 1},
1096 {'bar': 2},
1097 ]
1099 # nb. no model_class, just as an example
1100 grid = Grid(request, columns=['foo', 'bar'], data=data)
1102 def getkey(obj):
1103 if obj.get('foo')
1104 return obj['foo']
1105 if obj.get('bar'):
1106 return obj['bar']
1107 return ''
1109 # nb. sortfunc will ostensibly sort by 'foo' column, but in
1110 # practice it is sorted per value from getkey() above
1111 sortfunc = grid.make_sorter('foo', keyfunc=getkey)
1112 sorted_data = sortfunc(data, 'asc')
1114 :returns: A function suitable for backend sorting. This
1115 function will behave differently when it is given a
1116 SQLAlchemy query vs. a "list" of data. In either case it
1117 will return the sorted result.
1119 This function may be called as shown above. It expects 2
1120 args: ``(data, direction)``
1121 """
1122 model_class = None
1123 model_property = None
1124 if isinstance(columninfo, str):
1125 key = columninfo
1126 model_class = self.model_class
1127 model_property = getattr(self.model_class, key, None)
1128 else:
1129 model_property = columninfo
1130 model_class = model_property.class_
1131 key = model_property.key
1133 def sorter(data, direction):
1135 # query is sorted with order_by()
1136 if isinstance(data, orm.Query):
1137 if not model_property:
1138 raise TypeError(
1139 f"grid sorter for '{key}' does not map to a model property"
1140 )
1141 query = data
1142 return query.order_by(getattr(model_property, direction)())
1144 # other data is sorted manually. first step is to
1145 # identify the function used to produce a sort key for
1146 # each record
1147 kfunc = keyfunc
1148 if not kfunc:
1149 if model_property:
1150 # TODO: may need this for String etc. as well?
1151 if isinstance(model_property.type, sa.Text):
1152 if foldcase:
1154 def kfunc_folded(obj):
1155 return (obj[key] or "").lower()
1157 kfunc = kfunc_folded
1159 else:
1161 def kfunc_standard(obj):
1162 return obj[key] or ""
1164 kfunc = kfunc_standard
1166 if not kfunc:
1167 # nb. sorting with this can raise error if data
1168 # contains varying types, e.g. str and None
1170 def kfunc_fallback(obj):
1171 return obj[key]
1173 kfunc = kfunc_fallback
1175 # then sort the data and return
1176 return sorted(data, key=kfunc, reverse=direction == "desc")
1178 # TODO: this should be improved; is needed in tailbone for
1179 # multi-column sorting with sqlalchemy queries
1180 if model_property:
1181 sorter._class = model_class # pylint: disable=protected-access
1182 sorter._column = model_property # pylint: disable=protected-access
1184 return sorter
1186 def set_sorter(self, key, sortinfo=None):
1187 """
1188 Set/override the backend sorter for a column.
1190 Only relevant if both :attr:`sortable` and
1191 :attr:`sort_on_backend` are true.
1193 :param key: Name of column.
1195 :param sortinfo: Can be either a sorter callable, or else a
1196 model property (see below).
1198 If ``sortinfo`` is a callable, it will be used as-is for the
1199 backend sorter.
1201 Otherwise :meth:`make_sorter()` will be called to obtain the
1202 backend sorter. The ``sortinfo`` will be passed along to that
1203 call; if it is empty then ``key`` will be used instead.
1205 A backend sorter callable must accept ``(data, direction)``
1206 args and return the sorted data/query, for example::
1208 model = app.model
1209 grid = Grid(request, model_class=model.Person)
1211 def sort_full_name(query, direction):
1212 sortspec = getattr(model.Person.full_name, direction)
1213 return query.order_by(sortspec())
1215 grid.set_sorter('full_name', sort_full_name)
1217 See also :meth:`remove_sorter()` and :meth:`is_sortable()`.
1218 Backend sorters are tracked via :attr:`sorters`.
1219 """
1220 sorter = None
1222 if sortinfo and callable(sortinfo):
1223 sorter = sortinfo
1224 else:
1225 sorter = self.make_sorter(sortinfo or key)
1227 self.sorters[key] = sorter
1229 def remove_sorter(self, key):
1230 """
1231 Remove the backend sorter for a column.
1233 Note that this removes the sorter *function*, so there is
1234 no way to sort by this column unless another sorter is
1235 later defined for it.
1237 See also :meth:`set_sorter()`.
1238 """
1239 self.sorters.pop(key, None)
1241 def set_sort_defaults(self, *args):
1242 """
1243 Set the default sorting method for the grid. This sorting is
1244 used unless/until the user requests a different sorting
1245 method.
1247 ``args`` for this method are interpreted as follows:
1249 If 2 args are received, they should be for ``sortkey`` and
1250 ``sortdir``; for instance::
1252 grid.set_sort_defaults('name', 'asc')
1254 If just one 2-tuple arg is received, it is handled similarly::
1256 grid.set_sort_defaults(('name', 'asc'))
1258 If just one string arg is received, the default ``sortdir`` is
1259 assumed::
1261 grid.set_sort_defaults('name') # assumes 'asc'
1263 Otherwise there should be just one list arg, elements of
1264 which are each 2-tuples of ``(sortkey, sortdir)`` info::
1266 grid.set_sort_defaults([('name', 'asc'),
1267 ('value', 'desc')])
1269 .. note::
1271 Note that :attr:`sort_multiple` determines whether the grid
1272 is actually allowed to have multiple sort defaults. The
1273 defaults requested by the method call may be pruned if
1274 necessary to accommodate that.
1276 Default sorting info is tracked via :attr:`sort_defaults`.
1277 """
1279 # convert args to sort defaults
1280 sort_defaults = []
1281 if len(args) == 1:
1282 if isinstance(args[0], str):
1283 sort_defaults = [SortInfo(args[0], "asc")]
1284 elif isinstance(args[0], tuple) and len(args[0]) == 2:
1285 sort_defaults = [SortInfo(*args[0])]
1286 elif isinstance(args[0], list):
1287 sort_defaults = [SortInfo(*tup) for tup in args[0]]
1288 else:
1289 raise ValueError(
1290 "for just one positional arg, must pass string, 2-tuple or list"
1291 )
1292 elif len(args) == 2:
1293 sort_defaults = [SortInfo(*args)]
1294 else:
1295 raise ValueError("must pass just one or two positional args")
1297 # prune if multi-column requested but not supported
1298 if len(sort_defaults) > 1 and not self.sort_multiple:
1299 log.warning(
1300 "multi-column sorting is not enabled for the instance; "
1301 "list will be pruned to first element for '%s' grid: %s",
1302 self.key,
1303 sort_defaults,
1304 )
1305 sort_defaults = [sort_defaults[0]]
1307 self.sort_defaults = sort_defaults
1309 def is_sortable(self, key):
1310 """
1311 Returns boolean indicating if a given column should allow
1312 sorting.
1314 If :attr:`sortable` is false, this always returns ``False``.
1316 For frontend sorting (i.e. :attr:`sort_on_backend` is false),
1317 this always returns ``True``.
1319 For backend sorting, may return true or false depending on
1320 whether the column is listed in :attr:`sorters`.
1322 :param key: Column key as string.
1324 See also :meth:`set_sorter()`.
1325 """
1326 if not self.sortable:
1327 return False
1328 if self.sort_on_backend:
1329 return key in self.sorters
1330 return True
1332 ##############################
1333 # filtering methods
1334 ##############################
1336 def make_backend_filters(self, filters=None):
1337 """
1338 Make "automatic" backend filters for the grid.
1340 This is called by the constructor, if :attr:`filterable` is
1341 true.
1343 For each "column" in the model class, this will call
1344 :meth:`make_filter()` to add an automatic filter. However it
1345 first checks the provided ``filters`` and will not override
1346 any of those.
1348 .. note::
1350 This only works if grid has a :attr:`model_class`. If not,
1351 this method just returns the initial filters (or empty
1352 dict).
1354 :param filters: Optional dict of initial filters. Any
1355 existing filters will be left intact, not replaced.
1357 :returns: Final dict of all filters. Includes any from the
1358 initial ``filters`` param as well as any which were
1359 created.
1360 """
1361 filters = filters or {}
1363 if self.model_class:
1365 # nb. i have found this confusing for some reason. some
1366 # things i've tried so far include:
1367 #
1368 # i first tried self.get_model_columns() but my notes say
1369 # that was too aggressive in many cases.
1370 #
1371 # then i tried using the *subset* of self.columns, just
1372 # the ones which correspond to a property on the model
1373 # class. but sometimes that skips filters we need.
1374 #
1375 # then i tried get_columns() from sa-utils to give the
1376 # "true" column list, but that fails when the underlying
1377 # column has different name than the prop/attr key.
1378 #
1379 # so now, we are looking directly at the sa mapper, for
1380 # all column attrs and then using the prop key.
1382 inspector = sa.inspect(self.model_class)
1383 for prop in inspector.column_attrs:
1385 # do not overwrite existing filters
1386 if prop.key in filters:
1387 continue
1389 # do not create filter for UUID field
1390 if len(prop.columns) == 1 and isinstance(prop.columns[0].type, UUID):
1391 continue
1393 attr = getattr(self.model_class, prop.key)
1394 filters[prop.key] = self.make_filter(attr)
1396 return filters
1398 def make_filter(self, columninfo, **kwargs):
1399 """
1400 Create and return a
1401 :class:`~wuttaweb.grids.filters.GridFilter` instance suitable
1402 for use on the given column.
1404 Code usually does not need to call this directly. See also
1405 :meth:`set_filter()`, which calls this method automatically.
1407 :param columninfo: Can be either a model property
1408 (e.g. ``model.User.username``), or a column name
1409 (e.g. ``"username"``).
1411 :returns: :class:`~wuttaweb.grids.filters.GridFilter`
1412 instance.
1413 """
1414 key = kwargs.pop("key", None)
1416 # model_property is required
1417 model_property = None
1418 if kwargs.get("model_property"):
1419 model_property = kwargs["model_property"]
1420 elif isinstance(columninfo, str):
1421 key = columninfo
1422 if self.model_class:
1423 model_property = getattr(self.model_class, key, None)
1424 if not model_property:
1425 raise ValueError(f"cannot locate model property for key: {key}")
1426 else:
1427 model_property = columninfo
1429 # optional factory override
1430 factory = kwargs.pop("factory", None)
1431 if not factory:
1432 typ = model_property.type
1433 factory = default_sqlalchemy_filters.get(type(typ))
1434 if not factory:
1435 factory = default_sqlalchemy_filters[None]
1437 # make filter
1438 kwargs["model_property"] = model_property
1439 return factory(self.request, key or model_property.key, **kwargs)
1441 def set_filter(self, key, filterinfo=None, **kwargs):
1442 """
1443 Set/override the backend filter for a column.
1445 Only relevant if :attr:`filterable` is true.
1447 :param key: Name of column.
1449 :param filterinfo: Can be either a filter factory, or else a
1450 model property (e.g. ``model.User.username``) or column
1451 name (e.g. ``"username"``). If not specified then the
1452 ``key`` will be used instead.
1454 :param \\**kwargs: Additional kwargs to pass along to the
1455 filter factory.
1457 If ``filterinfo`` is a factory, it will be called with the
1458 current request, key and kwargs like so::
1460 filtr = factory(self.request, key, **kwargs)
1462 Otherwise :meth:`make_filter()` will be called to obtain the
1463 backend filter. The ``filterinfo`` will be passed along to
1464 that call; if it is empty then ``key`` will be used instead.
1466 See also :meth:`remove_filter()`. Backend filters are tracked
1467 via :attr:`filters`.
1468 """
1469 filtr = None
1471 if filterinfo and callable(filterinfo):
1472 kwargs.setdefault("label", self.get_filter_label(key))
1473 filtr = filterinfo(self.request, key, **kwargs)
1475 else:
1476 kwargs["key"] = key
1477 kwargs.setdefault("label", self.get_filter_label(key))
1478 filtr = self.make_filter(filterinfo or key, **kwargs)
1480 self.filters[key] = filtr
1482 def remove_filter(self, key):
1483 """
1484 Remove the backend filter for a column.
1486 This removes the filter *instance*, so there is no way to
1487 filter by this column unless another filter is later defined
1488 for it.
1490 See also :meth:`set_filter()`.
1491 """
1492 self.filters.pop(key, None)
1494 def set_filter_defaults(self, **defaults):
1495 """
1496 Set default state preferences for the grid filters.
1498 These preferences will affect the initial grid display, until
1499 user requests a different filtering method.
1501 Each kwarg should be named by filter key, and the value should
1502 be a dict of preferences for that filter. For instance::
1504 grid.set_filter_defaults(name={'active': True,
1505 'verb': 'contains',
1506 'value': 'foo'},
1507 value={'active': True})
1509 Filter defaults are tracked via :attr:`filter_defaults`.
1510 """
1511 filter_defaults = dict(getattr(self, "filter_defaults", {}))
1513 for key, values in defaults.items():
1514 filtr = filter_defaults.setdefault(key, {})
1515 filtr.update(values)
1517 self.filter_defaults = filter_defaults
1519 ##############################
1520 # paging methods
1521 ##############################
1523 def get_pagesize_options(self, default=None):
1524 """
1525 Returns a list of default page size options for the grid.
1527 It will check config but if no setting exists, will fall
1528 back to::
1530 [5, 10, 20, 50, 100, 200]
1532 :param default: Alternate default value to return if none is
1533 configured.
1535 This method is intended for use in the constructor. Code can
1536 instead access :attr:`pagesize_options` directly.
1537 """
1538 options = self.config.get_list("wuttaweb.grids.default_pagesize_options")
1539 if options:
1540 options = [int(size) for size in options if size.isdigit()]
1541 if options:
1542 return options
1544 return default or [5, 10, 20, 50, 100, 200]
1546 def get_pagesize(self, default=None, configured=True):
1547 """
1548 Returns the page size for the grid.
1550 This normally will first check config and return that value if
1551 present; otherwise falling back to the default. But if you
1552 really want to know the effective default, then specify
1553 ``configured=False`` to skip the config check.
1555 If you do not specify the ``default`` value then
1556 :attr:`pagesize_options` will be consulted. If ``20`` is one
1557 of the options, that is the preferred default. Otherwise the
1558 first option found is assumed to be default.
1560 :param configured: This method first checks for the
1561 *configured* page size and always returns that if present.
1562 Set this param to ``False`` in order to "never" return the
1563 configured value.
1565 :param default: Alternate default value to return if none is
1566 configured.
1568 This method is mainly intended for use in the constructor.
1569 Code can normally access :attr:`pagesize` directly instead.
1570 """
1571 if configured:
1572 if size := self.config.get_int("wuttaweb.grids.default_pagesize"):
1573 return size
1575 if default:
1576 return default
1578 if 20 in self.pagesize_options:
1579 return 20
1581 return self.pagesize_options[0]
1583 ##############################
1584 # configuration methods
1585 ##############################
1587 def load_settings( # pylint: disable=too-many-branches,too-many-statements
1588 self, persist=True
1589 ):
1590 """
1591 Load all effective settings for the grid.
1593 If the request GET params (query string) contains grid
1594 settings, they are used; otherwise the settings are loaded
1595 from user session.
1597 .. note::
1599 As of now, "sorting" and "pagination" settings are the only
1600 type supported by this logic. Settings for "filtering"
1601 coming soon...
1603 The overall logic for this method is as follows:
1605 * collect settings
1606 * apply settings to current grid
1607 * optionally save settings to user session
1609 Saving the settings to user session will allow the grid to
1610 remember its current settings when user refreshes the page, or
1611 navigates away then comes back. Therefore normally, settings
1612 are saved each time they are loaded. Note that such settings
1613 are wiped upon user logout.
1615 :param persist: Whether the collected settings should be saved
1616 to the user session.
1617 """
1619 # initial default settings
1620 settings = {}
1621 if self.filterable:
1622 for filtr in self.filters.values():
1623 defaults = self.filter_defaults.get(filtr.key, {})
1624 settings[f"filter.{filtr.key}.active"] = defaults.get(
1625 "active", filtr.default_active
1626 )
1627 settings[f"filter.{filtr.key}.verb"] = defaults.get(
1628 "verb", filtr.get_default_verb()
1629 )
1630 settings[f"filter.{filtr.key}.value"] = defaults.get(
1631 "value", filtr.default_value
1632 )
1633 if self.sortable:
1634 if self.sort_defaults:
1635 # nb. as of writing neither Buefy nor Oruga support a
1636 # multi-column *default* sort; so just use first sorter
1637 sortinfo = self.sort_defaults[0]
1638 settings["sorters.length"] = 1
1639 settings["sorters.1.key"] = sortinfo.sortkey
1640 settings["sorters.1.dir"] = sortinfo.sortdir
1641 else:
1642 settings["sorters.length"] = 0
1643 if self.paginated and self.paginate_on_backend:
1644 settings["pagesize"] = self.pagesize
1645 settings["page"] = self.page
1647 # update settings dict based on what we find in the request
1648 # and/or user session. always prioritize the former.
1650 # nb. do not read settings if user wants a reset
1651 if self.request.GET.get("reset-view"):
1652 # at this point we only have default settings, and we want
1653 # to keep those *and* persist them for next time, below
1654 pass
1656 elif self.request_has_settings("filter"):
1657 self.update_filter_settings(settings, src="request")
1658 if self.request_has_settings("sort"):
1659 self.update_sort_settings(settings, src="request")
1660 else:
1661 self.update_sort_settings(settings, src="session")
1662 self.update_page_settings(settings)
1664 elif self.request_has_settings("sort"):
1665 self.update_filter_settings(settings, src="session")
1666 self.update_sort_settings(settings, src="request")
1667 self.update_page_settings(settings)
1669 elif self.request_has_settings("page"):
1670 self.update_filter_settings(settings, src="session")
1671 self.update_sort_settings(settings, src="session")
1672 self.update_page_settings(settings)
1674 else:
1675 # nothing found in request, so nothing new to save
1676 persist = False
1678 # but still should load whatever is in user session
1679 self.update_filter_settings(settings, src="session")
1680 self.update_sort_settings(settings, src="session")
1681 self.update_page_settings(settings)
1683 # maybe save settings in user session, for next time
1684 if persist:
1685 self.persist_settings(settings, dest="session")
1687 # update ourself to reflect settings dict..
1689 # filtering
1690 if self.filterable:
1691 for filtr in self.filters.values():
1692 filtr.active = settings[f"filter.{filtr.key}.active"]
1693 filtr.verb = (
1694 settings[f"filter.{filtr.key}.verb"] or filtr.get_default_verb()
1695 )
1696 filtr.value = settings[f"filter.{filtr.key}.value"]
1698 # sorting
1699 if self.sortable:
1700 # nb. doing this for frontend sorting also
1701 self.active_sorters = []
1702 for i in range(1, settings["sorters.length"] + 1):
1703 self.active_sorters.append(
1704 {
1705 "key": settings[f"sorters.{i}.key"],
1706 "dir": settings[f"sorters.{i}.dir"],
1707 }
1708 )
1709 # TODO: i thought this was needed, but now idk?
1710 # # nb. when showing full index page (i.e. not partial)
1711 # # this implies we must set the default sorter for Vue
1712 # # component, and only single-column is allowed there.
1713 # if not self.request.GET.get('partial'):
1714 # break
1716 # paging
1717 if self.paginated and self.paginate_on_backend:
1718 self.pagesize = settings["pagesize"]
1719 self.page = settings["page"]
1721 def request_has_settings(self, typ): # pylint: disable=empty-docstring
1722 """ """
1724 if typ == "filter" and self.filterable:
1725 for filtr in self.filters.values():
1726 if filtr.key in self.request.GET:
1727 return True
1728 if "filter" in self.request.GET: # user may be applying empty filters
1729 return True
1731 elif typ == "sort" and self.sortable and self.sort_on_backend:
1732 if "sort1key" in self.request.GET:
1733 return True
1735 elif typ == "page" and self.paginated and self.paginate_on_backend:
1736 for key in ["pagesize", "page"]:
1737 if key in self.request.GET:
1738 return True
1740 return False
1742 def get_setting( # pylint: disable=empty-docstring,too-many-arguments,too-many-positional-arguments
1743 self, settings, key, src="session", default=None, normalize=lambda v: v
1744 ):
1745 """ """
1747 if src == "request":
1748 value = self.request.GET.get(key)
1749 if value is not None:
1750 try:
1751 return normalize(value)
1752 except ValueError:
1753 pass
1755 elif src == "session":
1756 value = self.request.session.get(f"grid.{self.key}.{key}")
1757 if value is not None:
1758 return normalize(value)
1760 # if src had nothing, try default/existing settings
1761 value = settings.get(key)
1762 if value is not None:
1763 return normalize(value)
1765 # okay then, default it is
1766 return default
1768 def update_filter_settings( # pylint: disable=empty-docstring
1769 self, settings, src=None
1770 ):
1771 """ """
1772 if not self.filterable:
1773 return
1775 for filtr in self.filters.values():
1776 prefix = f"filter.{filtr.key}"
1778 if src == "request":
1779 # consider filter active if query string contains a value for it
1780 settings[f"{prefix}.active"] = filtr.key in self.request.GET
1781 settings[f"{prefix}.verb"] = self.get_setting(
1782 settings, f"{filtr.key}.verb", src="request", default=""
1783 )
1784 settings[f"{prefix}.value"] = self.get_setting(
1785 settings, filtr.key, src="request", default=""
1786 )
1788 elif src == "session":
1789 settings[f"{prefix}.active"] = self.get_setting(
1790 settings,
1791 f"{prefix}.active",
1792 src="session",
1793 normalize=lambda v: str(v).lower() == "true",
1794 default=False,
1795 )
1796 settings[f"{prefix}.verb"] = self.get_setting(
1797 settings, f"{prefix}.verb", src="session", default=""
1798 )
1799 settings[f"{prefix}.value"] = self.get_setting(
1800 settings, f"{prefix}.value", src="session", default=""
1801 )
1803 def update_sort_settings( # pylint: disable=empty-docstring
1804 self, settings, src=None
1805 ):
1806 """ """
1807 if not (self.sortable and self.sort_on_backend):
1808 return
1810 if src == "request":
1811 i = 1
1812 while True:
1813 skey = f"sort{i}key"
1814 if skey in self.request.GET:
1815 settings[f"sorters.{i}.key"] = self.get_setting(
1816 settings, skey, src="request"
1817 )
1818 settings[f"sorters.{i}.dir"] = self.get_setting(
1819 settings, f"sort{i}dir", src="request", default="asc"
1820 )
1821 else:
1822 break
1823 i += 1
1824 settings["sorters.length"] = i - 1
1826 elif src == "session":
1827 settings["sorters.length"] = self.get_setting(
1828 settings, "sorters.length", src="session", normalize=int
1829 )
1830 for i in range(1, settings["sorters.length"] + 1):
1831 for key in ("key", "dir"):
1832 skey = f"sorters.{i}.{key}"
1833 settings[skey] = self.get_setting(settings, skey, src="session")
1835 def update_page_settings(self, settings): # pylint: disable=empty-docstring
1836 """ """
1837 if not (self.paginated and self.paginate_on_backend):
1838 return
1840 # update the settings dict from request and/or user session
1842 # pagesize
1843 pagesize = self.request.GET.get("pagesize")
1844 if pagesize is not None:
1845 if pagesize.isdigit():
1846 settings["pagesize"] = int(pagesize)
1847 else:
1848 pagesize = self.request.session.get(f"grid.{self.key}.pagesize")
1849 if pagesize is not None:
1850 settings["pagesize"] = pagesize
1852 # page
1853 page = self.request.GET.get("page")
1854 if page is not None:
1855 if page.isdigit():
1856 settings["page"] = int(page)
1857 else:
1858 page = self.request.session.get(f"grid.{self.key}.page")
1859 if page is not None:
1860 settings["page"] = int(page)
1862 def persist_settings(self, settings, dest=None): # pylint: disable=empty-docstring
1863 """ """
1864 if dest not in ("session",):
1865 raise ValueError(f"invalid dest identifier: {dest}")
1867 # func to save a setting value to user session
1868 def persist(key, value=settings.get):
1869 assert dest == "session"
1870 skey = f"grid.{self.key}.{key}"
1871 self.request.session[skey] = value(key)
1873 # filter settings
1874 if self.filterable:
1876 # always save all filters, with status
1877 for filtr in self.filters.values():
1878 persist(
1879 f"filter.{filtr.key}.active",
1880 value=lambda k: "true" if settings.get(k) else "false",
1881 )
1882 persist(f"filter.{filtr.key}.verb")
1883 persist(f"filter.{filtr.key}.value")
1885 # sort settings
1886 if self.sortable and self.sort_on_backend:
1888 # first must clear all sort settings from dest. this is
1889 # because number of sort settings will vary, so we delete
1890 # all and then write all
1892 if dest == "session":
1893 # remove sort settings from user session
1894 prefix = f"grid.{self.key}.sorters."
1895 for key in list(self.request.session):
1896 if key.startswith(prefix):
1897 del self.request.session[key]
1899 # now save sort settings to dest
1900 if "sorters.length" in settings:
1901 persist("sorters.length")
1902 for i in range(1, settings["sorters.length"] + 1):
1903 persist(f"sorters.{i}.key")
1904 persist(f"sorters.{i}.dir")
1906 # pagination settings
1907 if self.paginated and self.paginate_on_backend:
1909 # save to dest
1910 persist("pagesize")
1911 persist("page")
1913 ##############################
1914 # data methods
1915 ##############################
1917 def get_visible_data(self):
1918 """
1919 Returns the "effective" visible data for the grid.
1921 This uses :attr:`data` as the starting point but may morph it
1922 for pagination etc. per the grid settings.
1924 Code can either access :attr:`data` directly, or call this
1925 method to get only the data for current view (e.g. assuming
1926 pagination is used), depending on the need.
1928 See also these methods which may be called by this one:
1930 * :meth:`filter_data()`
1931 * :meth:`sort_data()`
1932 * :meth:`paginate_data()`
1933 """
1934 data = self.data or []
1935 self.joined = set()
1937 if self.filterable:
1938 data = self.filter_data(data)
1940 if self.sortable and self.sort_on_backend:
1941 data = self.sort_data(data)
1943 if self.paginated and self.paginate_on_backend:
1944 self.pager = self.paginate_data(data)
1945 data = self.pager
1947 return data
1949 @property
1950 def active_filters(self):
1951 """
1952 Returns the list of currently active filters.
1954 This inspects each :class:`~wuttaweb.grids.filters.GridFilter`
1955 in :attr:`filters` and only returns the ones marked active.
1956 """
1957 return [filtr for filtr in self.filters.values() if filtr.active]
1959 def filter_data(self, data, filters=None):
1960 """
1961 Filter the given data and return the result. This is called
1962 by :meth:`get_visible_data()`.
1964 :param filters: Optional list of filters to use. If not
1965 specified, the grid's :attr:`active_filters` are used.
1966 """
1967 if filters is None:
1968 filters = self.active_filters
1969 if not filters:
1970 return data
1972 for filtr in filters:
1973 key = filtr.key
1975 if key in self.joiners and key not in self.joined:
1976 data = self.joiners[key](data)
1977 self.joined.add(key)
1979 try:
1980 data = filtr.apply_filter(data)
1981 except VerbNotSupported as error:
1982 log.warning("verb not supported for '%s' filter: %s", key, error.verb)
1983 except Exception: # pylint: disable=broad-exception-caught
1984 log.exception("filtering data by '%s' failed!", key)
1986 return data
1988 def sort_data(self, data, sorters=None):
1989 """
1990 Sort the given data and return the result. This is called by
1991 :meth:`get_visible_data()`.
1993 :param sorters: Optional list of sorters to use. If not
1994 specified, the grid's :attr:`active_sorters` are used.
1995 """
1996 if sorters is None:
1997 sorters = self.active_sorters
1998 if not sorters:
1999 return data
2001 # nb. when data is a query, we want to apply sorters in the
2002 # requested order, so the final query has order_by() in the
2003 # correct "as-is" sequence. however when data is a list we
2004 # must do the opposite, applying in the reverse order, so the
2005 # final list has the most "important" sort(s) applied last.
2006 if not isinstance(data, orm.Query):
2007 sorters = reversed(sorters)
2009 for sorter in sorters:
2010 sortkey = sorter["key"]
2011 sortdir = sorter["dir"]
2013 # cannot sort unless we have a sorter callable
2014 sortfunc = self.sorters.get(sortkey)
2015 if not sortfunc:
2016 return data
2018 # join appropriate model if needed
2019 if sortkey in self.joiners and sortkey not in self.joined:
2020 data = self.joiners[sortkey](data)
2021 self.joined.add(sortkey)
2023 # invoke the sorter
2024 data = sortfunc(data, sortdir)
2026 return data
2028 def paginate_data(self, data):
2029 """
2030 Apply pagination to the given data set, based on grid settings.
2032 This returns a "pager" object which can then be used as a
2033 "data replacement" in subsequent logic.
2035 This method is called by :meth:`get_visible_data()`.
2036 """
2037 if isinstance(data, orm.Query):
2038 pager = SqlalchemyOrmPage(
2039 data, items_per_page=self.pagesize, page=self.page
2040 )
2042 else:
2043 pager = paginate.Page(data, items_per_page=self.pagesize, page=self.page)
2045 # pager may have detected that our current page is outside the
2046 # valid range. if so we should update ourself to match
2047 if pager.page != self.page:
2048 self.page = pager.page
2049 key = f"grid.{self.key}.page"
2050 if key in self.request.session:
2051 self.request.session[key] = self.page
2053 # and re-make the pager just to be safe (?)
2054 pager = self.paginate_data(data)
2056 return pager
2058 ##############################
2059 # rendering methods
2060 ##############################
2062 def render_batch_id(self, obj, key, value): # pylint: disable=unused-argument
2063 """
2064 Column renderer for batch ID values.
2066 This is not used automatically but you can use it explicitly::
2068 grid.set_renderer('foo', 'batch_id')
2069 """
2070 if value is None:
2071 return ""
2073 batch_id = int(value)
2074 return f"{batch_id:08d}"
2076 def render_boolean(self, obj, key, value): # pylint: disable=unused-argument
2077 """
2078 Column renderer for boolean values.
2080 This calls
2081 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_boolean()`
2082 for the return value.
2084 This may be used automatically per
2085 :meth:`set_default_renderers()` or you can use it explicitly::
2087 grid.set_renderer('foo', 'boolean')
2088 """
2089 return self.app.render_boolean(value)
2091 def render_currency( # pylint: disable=unused-argument
2092 self, obj, key, value, **kwargs
2093 ):
2094 """
2095 Column renderer for currency values.
2097 This calls
2098 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()`
2099 for the return value.
2101 This is not used automatically but you can use it explicitly::
2103 grid.set_renderer('foo', 'currency')
2104 grid.set_renderer('foo', 'currency', scale=4)
2105 """
2106 return self.app.render_currency(value, **kwargs)
2108 def render_date(self, obj, key, value): # pylint: disable=unused-argument
2109 """
2110 Column renderer for :class:`python:datetime.date` values.
2112 This calls
2113 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_date()`
2114 for the return value.
2116 This may be used automatically per
2117 :meth:`set_default_renderers()` or you can use it explicitly::
2119 grid.set_renderer('foo', 'date')
2120 """
2121 try:
2122 dt = getattr(obj, key)
2123 except AttributeError:
2124 dt = obj[key]
2125 return self.app.render_date(dt)
2127 def render_datetime(self, obj, key, value): # pylint: disable=unused-argument
2128 """
2129 Column renderer for :class:`python:datetime.datetime` values.
2131 This calls
2132 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()`
2133 for the return value.
2135 This may be used automatically per
2136 :meth:`set_default_renderers()` or you can use it explicitly::
2138 grid.set_renderer('foo', 'datetime')
2139 """
2140 try:
2141 dt = getattr(obj, key)
2142 except AttributeError:
2143 dt = obj[key]
2144 return self.app.render_datetime(dt, html=True)
2146 def render_enum(self, obj, key, value, enum=None):
2147 """
2148 Custom grid value renderer for "enum" fields.
2150 See also :meth:`set_enum()`.
2152 :param enum: Enum class for the field. This should be an
2153 instance of :class:`~python:enum.Enum` or else a dict.
2155 To use this feature for your grid::
2157 from enum import Enum
2159 class MyEnum(Enum):
2160 ONE = 1
2161 TWO = 2
2162 THREE = 3
2164 grid.set_enum("my_enum_field", MyEnum)
2166 Or, perhaps more common::
2168 myenum = {
2169 1: "ONE",
2170 2: "TWO",
2171 3: "THREE",
2172 }
2174 grid.set_enum("my_enum_field", myenum)
2175 """
2176 if enum:
2178 if isinstance(enum, EnumType):
2179 if raw_value := obj[key]:
2180 return raw_value.value
2182 if isinstance(enum, dict):
2183 return enum.get(value, value)
2185 return value
2187 def render_percent( # pylint: disable=unused-argument
2188 self, obj, key, value, **kwargs
2189 ):
2190 """
2191 Column renderer for percentage values.
2193 This calls
2194 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_percent()`
2195 for the return value.
2197 This is not used automatically but you can use it explicitly::
2199 grid.set_renderer('foo', 'percent')
2200 """
2201 return self.app.render_percent(value, **kwargs)
2203 def render_quantity(self, obj, key, value): # pylint: disable=unused-argument
2204 """
2205 Column renderer for quantity values.
2207 This calls
2208 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()`
2209 for the return value.
2211 This is not used automatically but you can use it explicitly::
2213 grid.set_renderer('foo', 'quantity')
2214 """
2215 return self.app.render_quantity(value)
2217 def render_table_element(
2218 self, form=None, template="/grids/table_element.mako", **context
2219 ):
2220 """
2221 Render a simple Vue table element for the grid.
2223 This is what you want for a "simple" grid which does not
2224 require a unique Vue component, but can instead use the
2225 standard table component.
2227 This returns something like:
2229 .. code-block:: html
2231 <b-table :data="gridContext['mykey'].data">
2232 <!-- columns etc. -->
2233 </b-table>
2235 See :meth:`render_vue_template()` for a more complete variant.
2237 Actual output will of course depend on grid attributes,
2238 :attr:`key`, :attr:`columns` etc.
2240 :param form: Reference to the
2241 :class:`~wuttaweb.forms.base.Form` instance which
2242 "contains" this grid. This is needed in order to ensure
2243 the grid data is available to the form Vue component.
2245 :param template: Path to Mako template which is used to render
2246 the output.
2248 .. note::
2250 The above example shows ``gridContext['mykey'].data`` as
2251 the Vue data reference. This should "just work" if you
2252 provide the correct ``form`` arg and the grid is contained
2253 directly by that form's Vue component.
2255 However, this may not account for all use cases. For now
2256 we wait and see what comes up, but know the dust may not
2257 yet be settled here.
2258 """
2260 # nb. must register data for inclusion on page template
2261 if form:
2262 form.add_grid_vue_context(self)
2264 # otherwise logic is the same, just different template
2265 return self.render_vue_template(template=template, **context)
2267 def render_vue_tag(self, **kwargs):
2268 """
2269 Render the Vue component tag for the grid.
2271 By default this simply returns:
2273 .. code-block:: html
2275 <wutta-grid></wutta-grid>
2277 The actual output will depend on various grid attributes, in
2278 particular :attr:`vue_tagname`.
2279 """
2280 return HTML.tag(self.vue_tagname, **kwargs)
2282 def render_vue_template(self, template="/grids/vue_template.mako", **context):
2283 """
2284 Render the Vue template block for the grid.
2286 This is what you want for a "full-featured" grid which will
2287 exist as its own unique Vue component on the frontend.
2289 This returns something like:
2291 .. code-block:: none
2293 <script type="text/x-template" id="wutta-grid-template">
2294 <b-table>
2295 <!-- columns etc. -->
2296 </b-table>
2297 </script>
2299 <script>
2300 WuttaGridData = {}
2301 WuttaGrid = {
2302 template: 'wutta-grid-template',
2303 }
2304 </script>
2306 .. todo::
2308 Why can't Sphinx render the above code block as 'html' ?
2310 It acts like it can't handle a ``<script>`` tag at all?
2312 See :meth:`render_table_element()` for a simpler variant.
2314 Actual output will of course depend on grid attributes,
2315 :attr:`vue_tagname` and :attr:`columns` etc.
2317 :param template: Path to Mako template which is used to render
2318 the output.
2319 """
2320 context["grid"] = self
2321 context.setdefault("request", self.request)
2322 output = render(template, context)
2323 return HTML.literal(output)
2325 def render_vue_finalize(self):
2326 """
2327 Render the Vue "finalize" script for the grid.
2329 By default this simply returns:
2331 .. code-block:: html
2333 <script>
2334 WuttaGrid.data = function() { return WuttaGridData }
2335 Vue.component('wutta-grid', WuttaGrid)
2336 </script>
2338 The actual output may depend on various grid attributes, in
2339 particular :attr:`vue_tagname`.
2340 """
2341 return render_vue_finalize(self.vue_tagname, self.vue_component)
2343 def get_vue_columns(self):
2344 """
2345 Returns a list of Vue-compatible column definitions.
2347 This uses :attr:`columns` as the basis; each definition
2348 returned will be a dict in this format::
2350 {
2351 'field': 'foo',
2352 'label': "Foo",
2353 'sortable': True,
2354 'searchable': False,
2355 }
2357 The full format is determined by Buefy; see the Column section
2358 in its `Table docs
2359 <https://buefy.org/documentation/table/#api-view>`_.
2361 See also :meth:`get_vue_context()`.
2362 """
2363 if not self.columns:
2364 raise ValueError(f"you must define columns for the grid! key = {self.key}")
2366 columns = []
2367 for name in self.columns:
2368 columns.append(
2369 {
2370 "field": name,
2371 "label": self.get_column_label(name),
2372 "hidden": self.is_hidden(name),
2373 "sortable": self.is_sortable(name),
2374 "searchable": self.is_searchable(name),
2375 }
2376 )
2377 return columns
2379 def get_vue_active_sorters(self):
2380 """
2381 Returns a list of Vue-compatible column sorter definitions.
2383 The list returned is the same as :attr:`active_sorters`;
2384 however the format used in Vue is different. So this method
2385 just "converts" them to the required format, e.g.::
2387 # active_sorters format
2388 {'key': 'name', 'dir': 'asc'}
2390 # get_vue_active_sorters() format
2391 {'field': 'name', 'order': 'asc'}
2393 :returns: The :attr:`active_sorters` list, converted as
2394 described above.
2395 """
2396 sorters = []
2397 for sorter in self.active_sorters:
2398 sorters.append({"field": sorter["key"], "order": sorter["dir"]})
2399 return sorters
2401 def get_vue_first_sorter(self):
2402 """
2403 Returns the first active sorter, if applicable.
2405 This method is used to declare the initial sort for a simple
2406 table component, i.e. for use with the ``table-element.mako``
2407 template. It generally is assumed that frontend sorting is in
2408 use, as opposed to backend sorting, although it should work
2409 for either scenario.
2411 This checks :attr:`active_sorters` and if set, will use the
2412 first sorter from that. Note that ``active_sorters`` will
2413 *not* be set unless :meth:`load_settings()` has been called.
2415 Otherwise this will use the first sorter from
2416 :attr:`sort_defaults` which is defined in constructor.
2418 :returns: The first sorter in format ``[sortkey, sortdir]``,
2419 or ``None``.
2420 """
2421 if self.active_sorters:
2422 sorter = self.active_sorters[0]
2423 return [sorter["key"], sorter["dir"]]
2425 if self.sort_defaults:
2426 sorter = self.sort_defaults[0]
2427 return [sorter.sortkey, sorter.sortdir]
2429 return None
2431 def get_vue_filters(self):
2432 """
2433 Returns a list of Vue-compatible filter definitions.
2435 This returns the full set of :attr:`filters` but represents
2436 each as a simple dict with the filter state.
2437 """
2438 filters = []
2439 for filtr in self.filters.values():
2441 choices = []
2442 choice_labels = {}
2443 if filtr.choices:
2444 choices = list(filtr.choices)
2445 choice_labels = dict(filtr.choices)
2447 filters.append(
2448 {
2449 "key": filtr.key,
2450 "data_type": filtr.data_type,
2451 "active": filtr.active,
2452 "visible": filtr.active,
2453 "verbs": filtr.get_verbs(),
2454 "verb_labels": filtr.get_verb_labels(),
2455 "valueless_verbs": filtr.get_valueless_verbs(),
2456 "verb": filtr.verb,
2457 "choices": choices,
2458 "choice_labels": choice_labels,
2459 "value": filtr.value,
2460 "label": filtr.label,
2461 }
2462 )
2463 return filters
2465 def object_to_dict(self, obj): # pylint: disable=empty-docstring
2466 """ """
2467 try:
2468 dct = dict(obj)
2469 except TypeError:
2470 dct = dict(obj.__dict__)
2471 dct.pop("_sa_instance_state", None)
2473 # nb. inject association proxy(-like) fields if applicable
2474 for field in self.columns:
2475 if field not in dct:
2476 try:
2477 dct[field] = getattr(obj, field)
2478 except AttributeError:
2479 pass
2481 return dct
2483 def get_vue_context(self):
2484 """
2485 Returns a dict of context for the grid, for use with the Vue
2486 component. This contains the following keys:
2488 * ``data`` - list of Vue-compatible data records
2489 * ``row_classes`` - dict of per-row CSS classes
2491 This first calls :meth:`get_visible_data()` to get the
2492 original data set. Each record is converted to a dict.
2494 Then it calls :func:`~wuttaweb.util.make_json_safe()` to
2495 ensure each record can be serialized to JSON.
2497 Then it invokes any :attr:`renderers` which are defined, to
2498 obtain the "final" values for each record.
2500 Then it adds a URL key/value for each of the :attr:`actions`
2501 defined, to each record.
2503 Then it calls :meth:`get_row_class()` for each record. If a
2504 value is returned, it is added to the ``row_classes`` dict.
2505 Note that this dict is keyed by "zero-based row sequence as
2506 string" - the Vue component expects that.
2508 :returns: Dict of grid data/CSS context as described above.
2509 """
2510 original_data = self.get_visible_data()
2512 # loop thru data
2513 data = []
2514 row_classes = {}
2515 for i, original_record in enumerate(original_data, 1):
2517 # convert record to new dict
2518 record = self.object_to_dict(original_record)
2520 # discard non-declared fields (but always keep uuid)
2521 record = {
2522 field: record[field]
2523 for field in record
2524 if field in self.columns or field == "uuid"
2525 }
2527 # make all values safe for json
2528 record = make_json_safe(record, warn=False)
2530 # customize value rendering where applicable
2531 for key, renderer in self.renderers.items():
2532 # nb. no need to render if column not included
2533 if key in self.columns:
2534 value = record.get(key, None)
2535 record[f"_rendered_{key}"] = renderer(original_record, key, value)
2537 # add action urls to each record
2538 for action in self.actions:
2539 key = f"_action_url_{action.key}"
2540 if key not in record:
2541 if url := action.get_url(original_record, i):
2542 record[key] = url
2544 # set row css class if applicable
2545 if css_class := self.get_row_class(original_record, record, i):
2546 # nb. use *string* zero-based index, for js compat
2547 row_classes[str(i - 1)] = css_class
2549 data.append(record)
2551 return {
2552 "data": data,
2553 "row_classes": row_classes,
2554 }
2556 def get_vue_data(self): # pylint: disable=empty-docstring
2557 """ """
2558 warnings.warn(
2559 "grid.get_vue_data() is deprecated; "
2560 "please use grid.get_vue_context() instead",
2561 DeprecationWarning,
2562 stacklevel=2,
2563 )
2564 return self.get_vue_context()["data"]
2566 def get_row_class(self, obj, data, i):
2567 """
2568 Returns the row CSS ``class`` attribute for the given record.
2569 This method is called by :meth:`get_vue_context()`.
2571 This will inspect/invoke :attr:`row_class` and return the
2572 value obtained from there.
2574 :param obj: Reference to the original model instance.
2576 :param data: Dict of record data for the instance; part of the
2577 Vue grid data set in/from :meth:`get_vue_context()`.
2579 :param i: One-based sequence for this object/record (row)
2580 within the grid.
2582 :returns: String of CSS class name(s), or ``None``.
2583 """
2584 if self.row_class:
2585 if callable(self.row_class):
2586 return self.row_class(obj, data, i)
2587 return self.row_class
2588 return None
2590 def get_vue_pager_stats(self):
2591 """
2592 Returns a simple dict with current grid pager stats.
2594 This is used when :attr:`paginate_on_backend` is in effect.
2595 """
2596 pager = self.pager
2597 return {
2598 "item_count": pager.item_count,
2599 "items_per_page": pager.items_per_page,
2600 "page": pager.page,
2601 "page_count": pager.page_count,
2602 "first_item": pager.first_item,
2603 "last_item": pager.last_item,
2604 }
2607class GridAction: # pylint: disable=too-many-instance-attributes
2608 """
2609 Represents a "row action" hyperlink within a grid context.
2611 All such actions are displayed as a group, in a dedicated
2612 **Actions** column in the grid. So each row in the grid has its
2613 own set of action links.
2615 A :class:`Grid` can have one (or zero) or more of these in its
2616 :attr:`~Grid.actions` list. You can call
2617 :meth:`~wuttaweb.views.base.View.make_grid_action()` to add custom
2618 actions from within a view.
2620 :param request: Current :term:`request` object.
2622 .. note::
2624 Some parameters are not explicitly described above. However
2625 their corresponding attributes are described below.
2627 .. attribute:: key
2629 String key for the action (e.g. ``'edit'``), unique within the
2630 grid.
2632 .. attribute:: label
2634 Label to be displayed for the action link. If not set, will be
2635 generated from :attr:`key` by calling
2636 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.make_title()`.
2638 See also :meth:`render_label()`.
2640 .. attribute:: url
2642 URL for the action link, if applicable. This *can* be a simple
2643 string, however that will cause every row in the grid to have
2644 the same URL for this action.
2646 A better way is to specify a callable which can return a unique
2647 URL for each record. The callable should expect ``(obj, i)``
2648 args, for instance::
2650 def myurl(obj, i):
2651 return request.route_url('widgets.view', uuid=obj.uuid)
2653 action = GridAction(request, 'view', url=myurl)
2655 See also :meth:`get_url()`.
2657 .. attribute:: target
2659 Optional ``target`` attribute for the ``<a>`` tag.
2661 .. attribute:: click_handler
2663 Optional JS click handler for the action. This value will be
2664 rendered as-is within the final grid template, hence the JS
2665 string must be callable code. Note that ``props.row`` will be
2666 available in the calling context, so a couple of examples:
2668 * ``deleteThisThing(props.row)``
2669 * ``$emit('do-something', props.row)``
2671 .. attribute:: icon
2673 Name of icon to be shown for the action link.
2675 See also :meth:`render_icon()`.
2677 .. attribute:: link_class
2679 Optional HTML class attribute for the action's ``<a>`` tag.
2680 """
2682 def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
2683 self,
2684 request,
2685 key,
2686 label=None,
2687 url=None,
2688 target=None,
2689 click_handler=None,
2690 icon=None,
2691 link_class=None,
2692 ):
2693 self.request = request
2694 self.config = self.request.wutta_config
2695 self.app = self.config.get_app()
2696 self.key = key
2697 self.url = url
2698 self.target = target
2699 self.click_handler = click_handler
2700 self.label = label or self.app.make_title(key)
2701 self.icon = icon or key
2702 self.link_class = link_class or ""
2704 def render_icon_and_label(self):
2705 """
2706 Render the HTML snippet for action link icon and label.
2708 Default logic returns the output from :meth:`render_icon()`
2709 and :meth:`render_label()`.
2710 """
2711 html = [
2712 self.render_icon(),
2713 HTML.literal(" "),
2714 self.render_label(),
2715 ]
2716 return HTML.tag("span", c=html, style="white-space: nowrap;")
2718 def render_icon(self):
2719 """
2720 Render the HTML snippet for the action link icon.
2722 This uses :attr:`icon` to identify the named icon to be shown.
2723 Output is something like (here ``'trash'`` is the icon name):
2725 .. code-block:: html
2727 <i class="fas fa-trash"></i>
2729 See also :meth:`render_icon_and_label()`.
2730 """
2731 if self.request.use_oruga:
2732 return HTML.tag("o-icon", icon=self.icon)
2734 return HTML.tag("i", class_=f"fas fa-{self.icon}")
2736 def render_label(self):
2737 """
2738 Render the label text for the action link.
2740 Default behavior is to return :attr:`label` as-is.
2742 See also :meth:`render_icon_and_label()`.
2743 """
2744 return self.label
2746 def get_url(self, obj, i=None):
2747 """
2748 Returns the action link URL for the given object (model
2749 instance).
2751 If :attr:`url` is a simple string, it is returned as-is.
2753 But if :attr:`url` is a callable (which is typically the most
2754 useful), that will be called with the same ``(obj, i)`` args
2755 passed along.
2757 :param obj: Model instance of whatever type the parent grid is
2758 setup to use.
2760 :param i: One-based sequence for the object's row within the
2761 parent grid.
2763 See also :attr:`url`.
2764 """
2765 if callable(self.url):
2766 return self.url(obj, i)
2768 return self.url