Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / grids / base.py: 100%
702 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-20 21:14 -0500
« prev ^ index » next coverage.py v7.13.4, created at 2026-03-20 21:14 -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"""
26# pylint: disable=too-many-lines
28import functools
29import logging
30import warnings
31from collections import namedtuple, OrderedDict
33try:
34 from enum import EnumType
35except ImportError: # pragma: no cover
36 # nb. python < 3.11
37 from enum import EnumMeta as EnumType
39import sqlalchemy as sa
40from sqlalchemy import orm
42import paginate
43from paginate_sqlalchemy import SqlalchemyOrmPage
44from pyramid.renderers import render
45from webhelpers2.html import HTML
47from wuttjamaican.db.util import UUID
48from wuttaweb.util import (
49 FieldList,
50 get_model_fields,
51 make_json_safe,
52 render_vue_finalize,
53)
54from 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):
1547 """
1548 Returns the default page size for the grid.
1550 It will check config but if no setting exists, will fall back
1551 to a value from :attr:`pagesize_options` (will return ``20`` if
1552 that is listed; otherwise the "first" option).
1554 :param default: Alternate default value to return if none is
1555 configured.
1557 This method is intended for use in the constructor. Code can
1558 instead access :attr:`pagesize` directly.
1559 """
1560 size = self.config.get_int("wuttaweb.grids.default_pagesize")
1561 if size:
1562 return size
1564 if default:
1565 return default
1567 if 20 in self.pagesize_options:
1568 return 20
1570 return self.pagesize_options[0]
1572 ##############################
1573 # configuration methods
1574 ##############################
1576 def load_settings( # pylint: disable=too-many-branches,too-many-statements
1577 self, persist=True
1578 ):
1579 """
1580 Load all effective settings for the grid.
1582 If the request GET params (query string) contains grid
1583 settings, they are used; otherwise the settings are loaded
1584 from user session.
1586 .. note::
1588 As of now, "sorting" and "pagination" settings are the only
1589 type supported by this logic. Settings for "filtering"
1590 coming soon...
1592 The overall logic for this method is as follows:
1594 * collect settings
1595 * apply settings to current grid
1596 * optionally save settings to user session
1598 Saving the settings to user session will allow the grid to
1599 remember its current settings when user refreshes the page, or
1600 navigates away then comes back. Therefore normally, settings
1601 are saved each time they are loaded. Note that such settings
1602 are wiped upon user logout.
1604 :param persist: Whether the collected settings should be saved
1605 to the user session.
1606 """
1608 # initial default settings
1609 settings = {}
1610 if self.filterable:
1611 for filtr in self.filters.values():
1612 defaults = self.filter_defaults.get(filtr.key, {})
1613 settings[f"filter.{filtr.key}.active"] = defaults.get(
1614 "active", filtr.default_active
1615 )
1616 settings[f"filter.{filtr.key}.verb"] = defaults.get(
1617 "verb", filtr.get_default_verb()
1618 )
1619 settings[f"filter.{filtr.key}.value"] = defaults.get(
1620 "value", filtr.default_value
1621 )
1622 if self.sortable:
1623 if self.sort_defaults:
1624 # nb. as of writing neither Buefy nor Oruga support a
1625 # multi-column *default* sort; so just use first sorter
1626 sortinfo = self.sort_defaults[0]
1627 settings["sorters.length"] = 1
1628 settings["sorters.1.key"] = sortinfo.sortkey
1629 settings["sorters.1.dir"] = sortinfo.sortdir
1630 else:
1631 settings["sorters.length"] = 0
1632 if self.paginated and self.paginate_on_backend:
1633 settings["pagesize"] = self.pagesize
1634 settings["page"] = self.page
1636 # update settings dict based on what we find in the request
1637 # and/or user session. always prioritize the former.
1639 # nb. do not read settings if user wants a reset
1640 if self.request.GET.get("reset-view"):
1641 # at this point we only have default settings, and we want
1642 # to keep those *and* persist them for next time, below
1643 pass
1645 elif self.request_has_settings("filter"):
1646 self.update_filter_settings(settings, src="request")
1647 if self.request_has_settings("sort"):
1648 self.update_sort_settings(settings, src="request")
1649 else:
1650 self.update_sort_settings(settings, src="session")
1651 self.update_page_settings(settings)
1653 elif self.request_has_settings("sort"):
1654 self.update_filter_settings(settings, src="session")
1655 self.update_sort_settings(settings, src="request")
1656 self.update_page_settings(settings)
1658 elif self.request_has_settings("page"):
1659 self.update_filter_settings(settings, src="session")
1660 self.update_sort_settings(settings, src="session")
1661 self.update_page_settings(settings)
1663 else:
1664 # nothing found in request, so nothing new to save
1665 persist = False
1667 # but still should load whatever is in user session
1668 self.update_filter_settings(settings, src="session")
1669 self.update_sort_settings(settings, src="session")
1670 self.update_page_settings(settings)
1672 # maybe save settings in user session, for next time
1673 if persist:
1674 self.persist_settings(settings, dest="session")
1676 # update ourself to reflect settings dict..
1678 # filtering
1679 if self.filterable:
1680 for filtr in self.filters.values():
1681 filtr.active = settings[f"filter.{filtr.key}.active"]
1682 filtr.verb = (
1683 settings[f"filter.{filtr.key}.verb"] or filtr.get_default_verb()
1684 )
1685 filtr.value = settings[f"filter.{filtr.key}.value"]
1687 # sorting
1688 if self.sortable:
1689 # nb. doing this for frontend sorting also
1690 self.active_sorters = []
1691 for i in range(1, settings["sorters.length"] + 1):
1692 self.active_sorters.append(
1693 {
1694 "key": settings[f"sorters.{i}.key"],
1695 "dir": settings[f"sorters.{i}.dir"],
1696 }
1697 )
1698 # TODO: i thought this was needed, but now idk?
1699 # # nb. when showing full index page (i.e. not partial)
1700 # # this implies we must set the default sorter for Vue
1701 # # component, and only single-column is allowed there.
1702 # if not self.request.GET.get('partial'):
1703 # break
1705 # paging
1706 if self.paginated and self.paginate_on_backend:
1707 self.pagesize = settings["pagesize"]
1708 self.page = settings["page"]
1710 def request_has_settings(self, typ): # pylint: disable=empty-docstring
1711 """ """
1713 if typ == "filter" and self.filterable:
1714 for filtr in self.filters.values():
1715 if filtr.key in self.request.GET:
1716 return True
1717 if "filter" in self.request.GET: # user may be applying empty filters
1718 return True
1720 elif typ == "sort" and self.sortable and self.sort_on_backend:
1721 if "sort1key" in self.request.GET:
1722 return True
1724 elif typ == "page" and self.paginated and self.paginate_on_backend:
1725 for key in ["pagesize", "page"]:
1726 if key in self.request.GET:
1727 return True
1729 return False
1731 def get_setting( # pylint: disable=empty-docstring,too-many-arguments,too-many-positional-arguments
1732 self, settings, key, src="session", default=None, normalize=lambda v: v
1733 ):
1734 """ """
1736 if src == "request":
1737 value = self.request.GET.get(key)
1738 if value is not None:
1739 try:
1740 return normalize(value)
1741 except ValueError:
1742 pass
1744 elif src == "session":
1745 value = self.request.session.get(f"grid.{self.key}.{key}")
1746 if value is not None:
1747 return normalize(value)
1749 # if src had nothing, try default/existing settings
1750 value = settings.get(key)
1751 if value is not None:
1752 return normalize(value)
1754 # okay then, default it is
1755 return default
1757 def update_filter_settings( # pylint: disable=empty-docstring
1758 self, settings, src=None
1759 ):
1760 """ """
1761 if not self.filterable:
1762 return
1764 for filtr in self.filters.values():
1765 prefix = f"filter.{filtr.key}"
1767 if src == "request":
1768 # consider filter active if query string contains a value for it
1769 settings[f"{prefix}.active"] = filtr.key in self.request.GET
1770 settings[f"{prefix}.verb"] = self.get_setting(
1771 settings, f"{filtr.key}.verb", src="request", default=""
1772 )
1773 settings[f"{prefix}.value"] = self.get_setting(
1774 settings, filtr.key, src="request", default=""
1775 )
1777 elif src == "session":
1778 settings[f"{prefix}.active"] = self.get_setting(
1779 settings,
1780 f"{prefix}.active",
1781 src="session",
1782 normalize=lambda v: str(v).lower() == "true",
1783 default=False,
1784 )
1785 settings[f"{prefix}.verb"] = self.get_setting(
1786 settings, f"{prefix}.verb", src="session", default=""
1787 )
1788 settings[f"{prefix}.value"] = self.get_setting(
1789 settings, f"{prefix}.value", src="session", default=""
1790 )
1792 def update_sort_settings( # pylint: disable=empty-docstring
1793 self, settings, src=None
1794 ):
1795 """ """
1796 if not (self.sortable and self.sort_on_backend):
1797 return
1799 if src == "request":
1800 i = 1
1801 while True:
1802 skey = f"sort{i}key"
1803 if skey in self.request.GET:
1804 settings[f"sorters.{i}.key"] = self.get_setting(
1805 settings, skey, src="request"
1806 )
1807 settings[f"sorters.{i}.dir"] = self.get_setting(
1808 settings, f"sort{i}dir", src="request", default="asc"
1809 )
1810 else:
1811 break
1812 i += 1
1813 settings["sorters.length"] = i - 1
1815 elif src == "session":
1816 settings["sorters.length"] = self.get_setting(
1817 settings, "sorters.length", src="session", normalize=int
1818 )
1819 for i in range(1, settings["sorters.length"] + 1):
1820 for key in ("key", "dir"):
1821 skey = f"sorters.{i}.{key}"
1822 settings[skey] = self.get_setting(settings, skey, src="session")
1824 def update_page_settings(self, settings): # pylint: disable=empty-docstring
1825 """ """
1826 if not (self.paginated and self.paginate_on_backend):
1827 return
1829 # update the settings dict from request and/or user session
1831 # pagesize
1832 pagesize = self.request.GET.get("pagesize")
1833 if pagesize is not None:
1834 if pagesize.isdigit():
1835 settings["pagesize"] = int(pagesize)
1836 else:
1837 pagesize = self.request.session.get(f"grid.{self.key}.pagesize")
1838 if pagesize is not None:
1839 settings["pagesize"] = pagesize
1841 # page
1842 page = self.request.GET.get("page")
1843 if page is not None:
1844 if page.isdigit():
1845 settings["page"] = int(page)
1846 else:
1847 page = self.request.session.get(f"grid.{self.key}.page")
1848 if page is not None:
1849 settings["page"] = int(page)
1851 def persist_settings(self, settings, dest=None): # pylint: disable=empty-docstring
1852 """ """
1853 if dest not in ("session",):
1854 raise ValueError(f"invalid dest identifier: {dest}")
1856 # func to save a setting value to user session
1857 def persist(key, value=settings.get):
1858 assert dest == "session"
1859 skey = f"grid.{self.key}.{key}"
1860 self.request.session[skey] = value(key)
1862 # filter settings
1863 if self.filterable:
1865 # always save all filters, with status
1866 for filtr in self.filters.values():
1867 persist(
1868 f"filter.{filtr.key}.active",
1869 value=lambda k: "true" if settings.get(k) else "false",
1870 )
1871 persist(f"filter.{filtr.key}.verb")
1872 persist(f"filter.{filtr.key}.value")
1874 # sort settings
1875 if self.sortable and self.sort_on_backend:
1877 # first must clear all sort settings from dest. this is
1878 # because number of sort settings will vary, so we delete
1879 # all and then write all
1881 if dest == "session":
1882 # remove sort settings from user session
1883 prefix = f"grid.{self.key}.sorters."
1884 for key in list(self.request.session):
1885 if key.startswith(prefix):
1886 del self.request.session[key]
1888 # now save sort settings to dest
1889 if "sorters.length" in settings:
1890 persist("sorters.length")
1891 for i in range(1, settings["sorters.length"] + 1):
1892 persist(f"sorters.{i}.key")
1893 persist(f"sorters.{i}.dir")
1895 # pagination settings
1896 if self.paginated and self.paginate_on_backend:
1898 # save to dest
1899 persist("pagesize")
1900 persist("page")
1902 ##############################
1903 # data methods
1904 ##############################
1906 def get_visible_data(self):
1907 """
1908 Returns the "effective" visible data for the grid.
1910 This uses :attr:`data` as the starting point but may morph it
1911 for pagination etc. per the grid settings.
1913 Code can either access :attr:`data` directly, or call this
1914 method to get only the data for current view (e.g. assuming
1915 pagination is used), depending on the need.
1917 See also these methods which may be called by this one:
1919 * :meth:`filter_data()`
1920 * :meth:`sort_data()`
1921 * :meth:`paginate_data()`
1922 """
1923 data = self.data or []
1924 self.joined = set()
1926 if self.filterable:
1927 data = self.filter_data(data)
1929 if self.sortable and self.sort_on_backend:
1930 data = self.sort_data(data)
1932 if self.paginated and self.paginate_on_backend:
1933 self.pager = self.paginate_data(data)
1934 data = self.pager
1936 return data
1938 @property
1939 def active_filters(self):
1940 """
1941 Returns the list of currently active filters.
1943 This inspects each :class:`~wuttaweb.grids.filters.GridFilter`
1944 in :attr:`filters` and only returns the ones marked active.
1945 """
1946 return [filtr for filtr in self.filters.values() if filtr.active]
1948 def filter_data(self, data, filters=None):
1949 """
1950 Filter the given data and return the result. This is called
1951 by :meth:`get_visible_data()`.
1953 :param filters: Optional list of filters to use. If not
1954 specified, the grid's :attr:`active_filters` are used.
1955 """
1956 if filters is None:
1957 filters = self.active_filters
1958 if not filters:
1959 return data
1961 for filtr in filters:
1962 key = filtr.key
1964 if key in self.joiners and key not in self.joined:
1965 data = self.joiners[key](data)
1966 self.joined.add(key)
1968 try:
1969 data = filtr.apply_filter(data)
1970 except VerbNotSupported as error:
1971 log.warning("verb not supported for '%s' filter: %s", key, error.verb)
1972 except Exception: # pylint: disable=broad-exception-caught
1973 log.exception("filtering data by '%s' failed!", key)
1975 return data
1977 def sort_data(self, data, sorters=None):
1978 """
1979 Sort the given data and return the result. This is called by
1980 :meth:`get_visible_data()`.
1982 :param sorters: Optional list of sorters to use. If not
1983 specified, the grid's :attr:`active_sorters` are used.
1984 """
1985 if sorters is None:
1986 sorters = self.active_sorters
1987 if not sorters:
1988 return data
1990 # nb. when data is a query, we want to apply sorters in the
1991 # requested order, so the final query has order_by() in the
1992 # correct "as-is" sequence. however when data is a list we
1993 # must do the opposite, applying in the reverse order, so the
1994 # final list has the most "important" sort(s) applied last.
1995 if not isinstance(data, orm.Query):
1996 sorters = reversed(sorters)
1998 for sorter in sorters:
1999 sortkey = sorter["key"]
2000 sortdir = sorter["dir"]
2002 # cannot sort unless we have a sorter callable
2003 sortfunc = self.sorters.get(sortkey)
2004 if not sortfunc:
2005 return data
2007 # join appropriate model if needed
2008 if sortkey in self.joiners and sortkey not in self.joined:
2009 data = self.joiners[sortkey](data)
2010 self.joined.add(sortkey)
2012 # invoke the sorter
2013 data = sortfunc(data, sortdir)
2015 return data
2017 def paginate_data(self, data):
2018 """
2019 Apply pagination to the given data set, based on grid settings.
2021 This returns a "pager" object which can then be used as a
2022 "data replacement" in subsequent logic.
2024 This method is called by :meth:`get_visible_data()`.
2025 """
2026 if isinstance(data, orm.Query):
2027 pager = SqlalchemyOrmPage(
2028 data, items_per_page=self.pagesize, page=self.page
2029 )
2031 else:
2032 pager = paginate.Page(data, items_per_page=self.pagesize, page=self.page)
2034 # pager may have detected that our current page is outside the
2035 # valid range. if so we should update ourself to match
2036 if pager.page != self.page:
2037 self.page = pager.page
2038 key = f"grid.{self.key}.page"
2039 if key in self.request.session:
2040 self.request.session[key] = self.page
2042 # and re-make the pager just to be safe (?)
2043 pager = self.paginate_data(data)
2045 return pager
2047 ##############################
2048 # rendering methods
2049 ##############################
2051 def render_batch_id(self, obj, key, value): # pylint: disable=unused-argument
2052 """
2053 Column renderer for batch ID values.
2055 This is not used automatically but you can use it explicitly::
2057 grid.set_renderer('foo', 'batch_id')
2058 """
2059 if value is None:
2060 return ""
2062 batch_id = int(value)
2063 return f"{batch_id:08d}"
2065 def render_boolean(self, obj, key, value): # pylint: disable=unused-argument
2066 """
2067 Column renderer for boolean values.
2069 This calls
2070 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_boolean()`
2071 for the return value.
2073 This may be used automatically per
2074 :meth:`set_default_renderers()` or you can use it explicitly::
2076 grid.set_renderer('foo', 'boolean')
2077 """
2078 return self.app.render_boolean(value)
2080 def render_currency( # pylint: disable=unused-argument
2081 self, obj, key, value, **kwargs
2082 ):
2083 """
2084 Column renderer for currency values.
2086 This calls
2087 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()`
2088 for the return value.
2090 This is not used automatically but you can use it explicitly::
2092 grid.set_renderer('foo', 'currency')
2093 grid.set_renderer('foo', 'currency', scale=4)
2094 """
2095 return self.app.render_currency(value, **kwargs)
2097 def render_date(self, obj, key, value): # pylint: disable=unused-argument
2098 """
2099 Column renderer for :class:`python:datetime.date` values.
2101 This calls
2102 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_date()`
2103 for the return value.
2105 This may be used automatically per
2106 :meth:`set_default_renderers()` or you can use it explicitly::
2108 grid.set_renderer('foo', 'date')
2109 """
2110 try:
2111 dt = getattr(obj, key)
2112 except AttributeError:
2113 dt = obj[key]
2114 return self.app.render_date(dt)
2116 def render_datetime(self, obj, key, value): # pylint: disable=unused-argument
2117 """
2118 Column renderer for :class:`python:datetime.datetime` values.
2120 This calls
2121 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()`
2122 for the return value.
2124 This may be used automatically per
2125 :meth:`set_default_renderers()` or you can use it explicitly::
2127 grid.set_renderer('foo', 'datetime')
2128 """
2129 try:
2130 dt = getattr(obj, key)
2131 except AttributeError:
2132 dt = obj[key]
2133 return self.app.render_datetime(dt, html=True)
2135 def render_enum(self, obj, key, value, enum=None):
2136 """
2137 Custom grid value renderer for "enum" fields.
2139 See also :meth:`set_enum()`.
2141 :param enum: Enum class for the field. This should be an
2142 instance of :class:`~python:enum.Enum` or else a dict.
2144 To use this feature for your grid::
2146 from enum import Enum
2148 class MyEnum(Enum):
2149 ONE = 1
2150 TWO = 2
2151 THREE = 3
2153 grid.set_enum("my_enum_field", MyEnum)
2155 Or, perhaps more common::
2157 myenum = {
2158 1: "ONE",
2159 2: "TWO",
2160 3: "THREE",
2161 }
2163 grid.set_enum("my_enum_field", myenum)
2164 """
2165 if enum:
2167 if isinstance(enum, EnumType):
2168 if raw_value := obj[key]:
2169 return raw_value.value
2171 if isinstance(enum, dict):
2172 return enum.get(value, value)
2174 return value
2176 def render_percent( # pylint: disable=unused-argument
2177 self, obj, key, value, **kwargs
2178 ):
2179 """
2180 Column renderer for percentage values.
2182 This calls
2183 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_percent()`
2184 for the return value.
2186 This is not used automatically but you can use it explicitly::
2188 grid.set_renderer('foo', 'percent')
2189 """
2190 return self.app.render_percent(value, **kwargs)
2192 def render_quantity(self, obj, key, value): # pylint: disable=unused-argument
2193 """
2194 Column renderer for quantity values.
2196 This calls
2197 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()`
2198 for the return value.
2200 This is not used automatically but you can use it explicitly::
2202 grid.set_renderer('foo', 'quantity')
2203 """
2204 return self.app.render_quantity(value)
2206 def render_table_element(
2207 self, form=None, template="/grids/table_element.mako", **context
2208 ):
2209 """
2210 Render a simple Vue table element for the grid.
2212 This is what you want for a "simple" grid which does not
2213 require a unique Vue component, but can instead use the
2214 standard table component.
2216 This returns something like:
2218 .. code-block:: html
2220 <b-table :data="gridContext['mykey'].data">
2221 <!-- columns etc. -->
2222 </b-table>
2224 See :meth:`render_vue_template()` for a more complete variant.
2226 Actual output will of course depend on grid attributes,
2227 :attr:`key`, :attr:`columns` etc.
2229 :param form: Reference to the
2230 :class:`~wuttaweb.forms.base.Form` instance which
2231 "contains" this grid. This is needed in order to ensure
2232 the grid data is available to the form Vue component.
2234 :param template: Path to Mako template which is used to render
2235 the output.
2237 .. note::
2239 The above example shows ``gridContext['mykey'].data`` as
2240 the Vue data reference. This should "just work" if you
2241 provide the correct ``form`` arg and the grid is contained
2242 directly by that form's Vue component.
2244 However, this may not account for all use cases. For now
2245 we wait and see what comes up, but know the dust may not
2246 yet be settled here.
2247 """
2249 # nb. must register data for inclusion on page template
2250 if form:
2251 form.add_grid_vue_context(self)
2253 # otherwise logic is the same, just different template
2254 return self.render_vue_template(template=template, **context)
2256 def render_vue_tag(self, **kwargs):
2257 """
2258 Render the Vue component tag for the grid.
2260 By default this simply returns:
2262 .. code-block:: html
2264 <wutta-grid></wutta-grid>
2266 The actual output will depend on various grid attributes, in
2267 particular :attr:`vue_tagname`.
2268 """
2269 return HTML.tag(self.vue_tagname, **kwargs)
2271 def render_vue_template(self, template="/grids/vue_template.mako", **context):
2272 """
2273 Render the Vue template block for the grid.
2275 This is what you want for a "full-featured" grid which will
2276 exist as its own unique Vue component on the frontend.
2278 This returns something like:
2280 .. code-block:: none
2282 <script type="text/x-template" id="wutta-grid-template">
2283 <b-table>
2284 <!-- columns etc. -->
2285 </b-table>
2286 </script>
2288 <script>
2289 WuttaGridData = {}
2290 WuttaGrid = {
2291 template: 'wutta-grid-template',
2292 }
2293 </script>
2295 .. todo::
2297 Why can't Sphinx render the above code block as 'html' ?
2299 It acts like it can't handle a ``<script>`` tag at all?
2301 See :meth:`render_table_element()` for a simpler variant.
2303 Actual output will of course depend on grid attributes,
2304 :attr:`vue_tagname` and :attr:`columns` etc.
2306 :param template: Path to Mako template which is used to render
2307 the output.
2308 """
2309 context["grid"] = self
2310 context.setdefault("request", self.request)
2311 output = render(template, context)
2312 return HTML.literal(output)
2314 def render_vue_finalize(self):
2315 """
2316 Render the Vue "finalize" script for the grid.
2318 By default this simply returns:
2320 .. code-block:: html
2322 <script>
2323 WuttaGrid.data = function() { return WuttaGridData }
2324 Vue.component('wutta-grid', WuttaGrid)
2325 </script>
2327 The actual output may depend on various grid attributes, in
2328 particular :attr:`vue_tagname`.
2329 """
2330 return render_vue_finalize(self.vue_tagname, self.vue_component)
2332 def get_vue_columns(self):
2333 """
2334 Returns a list of Vue-compatible column definitions.
2336 This uses :attr:`columns` as the basis; each definition
2337 returned will be a dict in this format::
2339 {
2340 'field': 'foo',
2341 'label': "Foo",
2342 'sortable': True,
2343 'searchable': False,
2344 }
2346 The full format is determined by Buefy; see the Column section
2347 in its `Table docs
2348 <https://buefy.org/documentation/table/#api-view>`_.
2350 See also :meth:`get_vue_context()`.
2351 """
2352 if not self.columns:
2353 raise ValueError(f"you must define columns for the grid! key = {self.key}")
2355 columns = []
2356 for name in self.columns:
2357 columns.append(
2358 {
2359 "field": name,
2360 "label": self.get_column_label(name),
2361 "hidden": self.is_hidden(name),
2362 "sortable": self.is_sortable(name),
2363 "searchable": self.is_searchable(name),
2364 }
2365 )
2366 return columns
2368 def get_vue_active_sorters(self):
2369 """
2370 Returns a list of Vue-compatible column sorter definitions.
2372 The list returned is the same as :attr:`active_sorters`;
2373 however the format used in Vue is different. So this method
2374 just "converts" them to the required format, e.g.::
2376 # active_sorters format
2377 {'key': 'name', 'dir': 'asc'}
2379 # get_vue_active_sorters() format
2380 {'field': 'name', 'order': 'asc'}
2382 :returns: The :attr:`active_sorters` list, converted as
2383 described above.
2384 """
2385 sorters = []
2386 for sorter in self.active_sorters:
2387 sorters.append({"field": sorter["key"], "order": sorter["dir"]})
2388 return sorters
2390 def get_vue_first_sorter(self):
2391 """
2392 Returns the first active sorter, if applicable.
2394 This method is used to declare the initial sort for a simple
2395 table component, i.e. for use with the ``table-element.mako``
2396 template. It generally is assumed that frontend sorting is in
2397 use, as opposed to backend sorting, although it should work
2398 for either scenario.
2400 This checks :attr:`active_sorters` and if set, will use the
2401 first sorter from that. Note that ``active_sorters`` will
2402 *not* be set unless :meth:`load_settings()` has been called.
2404 Otherwise this will use the first sorter from
2405 :attr:`sort_defaults` which is defined in constructor.
2407 :returns: The first sorter in format ``[sortkey, sortdir]``,
2408 or ``None``.
2409 """
2410 if self.active_sorters:
2411 sorter = self.active_sorters[0]
2412 return [sorter["key"], sorter["dir"]]
2414 if self.sort_defaults:
2415 sorter = self.sort_defaults[0]
2416 return [sorter.sortkey, sorter.sortdir]
2418 return None
2420 def get_vue_filters(self):
2421 """
2422 Returns a list of Vue-compatible filter definitions.
2424 This returns the full set of :attr:`filters` but represents
2425 each as a simple dict with the filter state.
2426 """
2427 filters = []
2428 for filtr in self.filters.values():
2430 choices = []
2431 choice_labels = {}
2432 if filtr.choices:
2433 choices = list(filtr.choices)
2434 choice_labels = dict(filtr.choices)
2436 filters.append(
2437 {
2438 "key": filtr.key,
2439 "data_type": filtr.data_type,
2440 "active": filtr.active,
2441 "visible": filtr.active,
2442 "verbs": filtr.get_verbs(),
2443 "verb_labels": filtr.get_verb_labels(),
2444 "valueless_verbs": filtr.get_valueless_verbs(),
2445 "verb": filtr.verb,
2446 "choices": choices,
2447 "choice_labels": choice_labels,
2448 "value": filtr.value,
2449 "label": filtr.label,
2450 }
2451 )
2452 return filters
2454 def object_to_dict(self, obj): # pylint: disable=empty-docstring
2455 """ """
2456 try:
2457 dct = dict(obj)
2458 except TypeError:
2459 dct = dict(obj.__dict__)
2460 dct.pop("_sa_instance_state", None)
2462 # nb. inject association proxy(-like) fields if applicable
2463 for field in self.columns:
2464 if field not in dct:
2465 try:
2466 dct[field] = getattr(obj, field)
2467 except AttributeError:
2468 pass
2470 return dct
2472 def get_vue_context(self):
2473 """
2474 Returns a dict of context for the grid, for use with the Vue
2475 component. This contains the following keys:
2477 * ``data`` - list of Vue-compatible data records
2478 * ``row_classes`` - dict of per-row CSS classes
2480 This first calls :meth:`get_visible_data()` to get the
2481 original data set. Each record is converted to a dict.
2483 Then it calls :func:`~wuttaweb.util.make_json_safe()` to
2484 ensure each record can be serialized to JSON.
2486 Then it invokes any :attr:`renderers` which are defined, to
2487 obtain the "final" values for each record.
2489 Then it adds a URL key/value for each of the :attr:`actions`
2490 defined, to each record.
2492 Then it calls :meth:`get_row_class()` for each record. If a
2493 value is returned, it is added to the ``row_classes`` dict.
2494 Note that this dict is keyed by "zero-based row sequence as
2495 string" - the Vue component expects that.
2497 :returns: Dict of grid data/CSS context as described above.
2498 """
2499 original_data = self.get_visible_data()
2501 # loop thru data
2502 data = []
2503 row_classes = {}
2504 for i, original_record in enumerate(original_data, 1):
2506 # convert record to new dict
2507 record = self.object_to_dict(original_record)
2509 # discard non-declared fields (but always keep uuid)
2510 record = {
2511 field: record[field]
2512 for field in record
2513 if field in self.columns or field == "uuid"
2514 }
2516 # make all values safe for json
2517 record = make_json_safe(record, warn=False)
2519 # customize value rendering where applicable
2520 for key, renderer in self.renderers.items():
2521 # nb. no need to render if column not included
2522 if key in self.columns:
2523 value = record.get(key, None)
2524 record[f"_rendered_{key}"] = renderer(original_record, key, value)
2526 # add action urls to each record
2527 for action in self.actions:
2528 key = f"_action_url_{action.key}"
2529 if key not in record:
2530 if url := action.get_url(original_record, i):
2531 record[key] = url
2533 # set row css class if applicable
2534 if css_class := self.get_row_class(original_record, record, i):
2535 # nb. use *string* zero-based index, for js compat
2536 row_classes[str(i - 1)] = css_class
2538 data.append(record)
2540 return {
2541 "data": data,
2542 "row_classes": row_classes,
2543 }
2545 def get_vue_data(self): # pylint: disable=empty-docstring
2546 """ """
2547 warnings.warn(
2548 "grid.get_vue_data() is deprecated; "
2549 "please use grid.get_vue_context() instead",
2550 DeprecationWarning,
2551 stacklevel=2,
2552 )
2553 return self.get_vue_context()["data"]
2555 def get_row_class(self, obj, data, i):
2556 """
2557 Returns the row CSS ``class`` attribute for the given record.
2558 This method is called by :meth:`get_vue_context()`.
2560 This will inspect/invoke :attr:`row_class` and return the
2561 value obtained from there.
2563 :param obj: Reference to the original model instance.
2565 :param data: Dict of record data for the instance; part of the
2566 Vue grid data set in/from :meth:`get_vue_context()`.
2568 :param i: One-based sequence for this object/record (row)
2569 within the grid.
2571 :returns: String of CSS class name(s), or ``None``.
2572 """
2573 if self.row_class:
2574 if callable(self.row_class):
2575 return self.row_class(obj, data, i)
2576 return self.row_class
2577 return None
2579 def get_vue_pager_stats(self):
2580 """
2581 Returns a simple dict with current grid pager stats.
2583 This is used when :attr:`paginate_on_backend` is in effect.
2584 """
2585 pager = self.pager
2586 return {
2587 "item_count": pager.item_count,
2588 "items_per_page": pager.items_per_page,
2589 "page": pager.page,
2590 "page_count": pager.page_count,
2591 "first_item": pager.first_item,
2592 "last_item": pager.last_item,
2593 }
2596class GridAction: # pylint: disable=too-many-instance-attributes
2597 """
2598 Represents a "row action" hyperlink within a grid context.
2600 All such actions are displayed as a group, in a dedicated
2601 **Actions** column in the grid. So each row in the grid has its
2602 own set of action links.
2604 A :class:`Grid` can have one (or zero) or more of these in its
2605 :attr:`~Grid.actions` list. You can call
2606 :meth:`~wuttaweb.views.base.View.make_grid_action()` to add custom
2607 actions from within a view.
2609 :param request: Current :term:`request` object.
2611 .. note::
2613 Some parameters are not explicitly described above. However
2614 their corresponding attributes are described below.
2616 .. attribute:: key
2618 String key for the action (e.g. ``'edit'``), unique within the
2619 grid.
2621 .. attribute:: label
2623 Label to be displayed for the action link. If not set, will be
2624 generated from :attr:`key` by calling
2625 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.make_title()`.
2627 See also :meth:`render_label()`.
2629 .. attribute:: url
2631 URL for the action link, if applicable. This *can* be a simple
2632 string, however that will cause every row in the grid to have
2633 the same URL for this action.
2635 A better way is to specify a callable which can return a unique
2636 URL for each record. The callable should expect ``(obj, i)``
2637 args, for instance::
2639 def myurl(obj, i):
2640 return request.route_url('widgets.view', uuid=obj.uuid)
2642 action = GridAction(request, 'view', url=myurl)
2644 See also :meth:`get_url()`.
2646 .. attribute:: target
2648 Optional ``target`` attribute for the ``<a>`` tag.
2650 .. attribute:: click_handler
2652 Optional JS click handler for the action. This value will be
2653 rendered as-is within the final grid template, hence the JS
2654 string must be callable code. Note that ``props.row`` will be
2655 available in the calling context, so a couple of examples:
2657 * ``deleteThisThing(props.row)``
2658 * ``$emit('do-something', props.row)``
2660 .. attribute:: icon
2662 Name of icon to be shown for the action link.
2664 See also :meth:`render_icon()`.
2666 .. attribute:: link_class
2668 Optional HTML class attribute for the action's ``<a>`` tag.
2669 """
2671 def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
2672 self,
2673 request,
2674 key,
2675 label=None,
2676 url=None,
2677 target=None,
2678 click_handler=None,
2679 icon=None,
2680 link_class=None,
2681 ):
2682 self.request = request
2683 self.config = self.request.wutta_config
2684 self.app = self.config.get_app()
2685 self.key = key
2686 self.url = url
2687 self.target = target
2688 self.click_handler = click_handler
2689 self.label = label or self.app.make_title(key)
2690 self.icon = icon or key
2691 self.link_class = link_class or ""
2693 def render_icon_and_label(self):
2694 """
2695 Render the HTML snippet for action link icon and label.
2697 Default logic returns the output from :meth:`render_icon()`
2698 and :meth:`render_label()`.
2699 """
2700 html = [
2701 self.render_icon(),
2702 HTML.literal(" "),
2703 self.render_label(),
2704 ]
2705 return HTML.tag("span", c=html, style="white-space: nowrap;")
2707 def render_icon(self):
2708 """
2709 Render the HTML snippet for the action link icon.
2711 This uses :attr:`icon` to identify the named icon to be shown.
2712 Output is something like (here ``'trash'`` is the icon name):
2714 .. code-block:: html
2716 <i class="fas fa-trash"></i>
2718 See also :meth:`render_icon_and_label()`.
2719 """
2720 if self.request.use_oruga:
2721 return HTML.tag("o-icon", icon=self.icon)
2723 return HTML.tag("i", class_=f"fas fa-{self.icon}")
2725 def render_label(self):
2726 """
2727 Render the label text for the action link.
2729 Default behavior is to return :attr:`label` as-is.
2731 See also :meth:`render_icon_and_label()`.
2732 """
2733 return self.label
2735 def get_url(self, obj, i=None):
2736 """
2737 Returns the action link URL for the given object (model
2738 instance).
2740 If :attr:`url` is a simple string, it is returned as-is.
2742 But if :attr:`url` is a callable (which is typically the most
2743 useful), that will be called with the same ``(obj, i)`` args
2744 passed along.
2746 :param obj: Model instance of whatever type the parent grid is
2747 setup to use.
2749 :param i: One-based sequence for the object's row within the
2750 parent grid.
2752 See also :attr:`url`.
2753 """
2754 if callable(self.url):
2755 return self.url(obj, i)
2757 return self.url