Coverage for .tox / coverage / lib / python3.11 / site-packages / wuttaweb / grids / base.py: 100%
682 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-04 08:56 -0600
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-04 08:56 -0600
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 label overrides.
124 See also :meth:`get_label()` and :meth:`set_label()`.
126 .. attribute:: renderers
128 Dict of column (cell) value renderer overrides.
130 See also :meth:`set_renderer()` and
131 :meth:`set_default_renderers()`.
133 .. attribute:: enums
135 Dict of "enum" collections, for supported columns.
137 See also :meth:`set_enum()`.
139 .. attribute:: checkable
141 Boolean indicating whether the grid should expose per-row
142 checkboxes.
144 .. attribute:: row_class
146 This represents the CSS ``class`` attribute for a row within
147 the grid. Default is ``None``.
149 This can be a simple string, in which case the same class is
150 applied to all rows.
152 Or it can be a callable, which can then return different
153 class(es) depending on each row. The callable must take three
154 args: ``(obj, data, i)`` - for example::
156 def my_row_class(obj, data, i):
157 if obj.archived:
158 return 'poser-archived'
160 grid = Grid(request, key='foo', row_class=my_row_class)
162 See :meth:`get_row_class()` for more info.
164 .. attribute:: actions
166 List of :class:`GridAction` instances represenging action links
167 to be shown for each record in the grid.
169 .. attribute:: linked_columns
171 List of column names for which auto-link behavior should be
172 applied.
174 See also :meth:`set_link()` and :meth:`is_linked()`.
176 .. attribute:: hidden_columns
178 List of column names which should be hidden from view.
180 Hidden columns are sometimes useful to pass "extra" data into
181 the grid, for use by other component logic etc.
183 See also :meth:`set_hidden()` and :meth:`is_hidden()`.
185 .. attribute:: sortable
187 Boolean indicating whether *any* column sorting is allowed for
188 the grid. Default is ``False``.
190 See also :attr:`sort_multiple` and :attr:`sort_on_backend`.
192 .. attribute:: sort_multiple
194 Boolean indicating whether "multi-column" sorting is allowed.
195 This is true by default, where possible. If false then only
196 one column may be sorted at a time.
198 Only relevant if :attr:`sortable` is true, but applies to both
199 frontend and backend sorting.
201 .. warning::
203 This feature is limited by frontend JS capabilities,
204 regardless of :attr:`sort_on_backend` value (i.e. for both
205 frontend and backend sorting).
207 In particular, if the app theme templates use Vue 2 + Buefy,
208 then multi-column sorting should work.
210 But not so with Vue 3 + Oruga, *yet* - see also the `open
211 issue <https://github.com/oruga-ui/oruga/issues/962>`_
212 regarding that. For now this flag is simply ignored for
213 Vue 3 + Oruga templates.
215 Additionally, even with Vue 2 + Buefy this flag can only
216 allow the user to *request* a multi-column sort. Whereas
217 the "default sort" in the Vue component can only ever be
218 single-column, regardless of :attr:`sort_defaults`.
220 .. attribute:: sort_on_backend
222 Boolean indicating whether the grid data should be sorted on the
223 backend. Default is ``True``.
225 If ``False``, the client-side Vue component will handle the
226 sorting.
228 Only relevant if :attr:`sortable` is also true.
230 .. attribute:: sorters
232 Dict of functions to use for backend sorting.
234 Only relevant if both :attr:`sortable` and
235 :attr:`sort_on_backend` are true.
237 See also :meth:`set_sorter()`, :attr:`sort_defaults` and
238 :attr:`active_sorters`.
240 .. attribute:: sort_defaults
242 List of options to be used for default sorting, until the user
243 requests a different sorting method.
245 This list usually contains either zero or one elements. (More
246 are allowed if :attr:`sort_multiple` is true, but see note
247 below.) Each list element is a :class:`SortInfo` tuple and
248 must correspond to an entry in :attr:`sorters`.
250 Used with both frontend and backend sorting.
252 See also :meth:`set_sort_defaults()` and
253 :attr:`active_sorters`.
255 .. warning::
257 While the grid logic is built to handle multi-column
258 sorting, this feature is limited by frontend JS
259 capabilities.
261 Even if ``sort_defaults`` contains multiple entries
262 (i.e. for multi-column sorting to be used "by default" for
263 the grid), only the *first* entry (i.e. single-column
264 sorting) will actually be used as the default for the Vue
265 component.
267 See also :attr:`sort_multiple` for more details.
269 .. attribute:: active_sorters
271 List of sorters currently in effect for the grid; used by
272 :meth:`sort_data()`.
274 Whereas :attr:`sorters` defines all "available" sorters, and
275 :attr:`sort_defaults` defines the "default" sorters,
276 ``active_sorters`` defines the "current/effective" sorters.
278 This attribute is set by :meth:`load_settings()`; until that is
279 called its value will be ``None``.
281 This is conceptually a "subset" of :attr:`sorters` although a
282 different format is used here::
284 grid.active_sorters = [
285 {'key': 'name', 'dir': 'asc'},
286 {'key': 'id', 'dir': 'asc'},
287 ]
289 The above is for example only; there is usually no reason to
290 set this attribute directly.
292 This list may contain multiple elements only if
293 :attr:`sort_multiple` is true. Otherewise it should always
294 have either zero or one element.
296 .. attribute:: paginated
298 Boolean indicating whether the grid data should be paginated,
299 i.e. split up into pages. Default is ``False`` which means all
300 data is shown at once.
302 See also :attr:`pagesize` and :attr:`page`, and
303 :attr:`paginate_on_backend`.
305 .. attribute:: paginate_on_backend
307 Boolean indicating whether the grid data should be paginated on
308 the backend. Default is ``True`` which means only one "page"
309 of data is sent to the client-side component.
311 If this is ``False``, the full set of grid data is sent for
312 each request, and the client-side Vue component will handle the
313 pagination.
315 Only relevant if :attr:`paginated` is also true.
317 .. attribute:: pagesize_options
319 List of "page size" options for the grid. See also
320 :attr:`pagesize`.
322 Only relevant if :attr:`paginated` is true. If not specified,
323 constructor will call :meth:`get_pagesize_options()` to get the
324 value.
326 .. attribute:: pagesize
328 Number of records to show in a data page. See also
329 :attr:`pagesize_options` and :attr:`page`.
331 Only relevant if :attr:`paginated` is true. If not specified,
332 constructor will call :meth:`get_pagesize()` to get the value.
334 .. attribute:: page
336 The current page number (of data) to display in the grid. See
337 also :attr:`pagesize`.
339 Only relevant if :attr:`paginated` is true. If not specified,
340 constructor will assume ``1`` (first page).
342 .. attribute:: searchable_columns
344 Set of columns declared as searchable for the Vue component.
346 See also :meth:`set_searchable()` and :meth:`is_searchable()`.
348 .. attribute:: filterable
350 Boolean indicating whether the grid should show a "filters"
351 section where user can filter data in various ways. Default is
352 ``False``.
354 .. attribute:: filters
356 Dict of :class:`~wuttaweb.grids.filters.GridFilter` instances
357 available for use with backend filtering.
359 Only relevant if :attr:`filterable` is true.
361 See also :meth:`set_filter()`.
363 .. attribute:: filter_defaults
365 Dict containing default state preferences for the filters.
367 See also :meth:`set_filter_defaults()`.
369 .. attribute:: joiners
371 Dict of "joiner" functions for use with backend filtering and
372 sorting.
374 See :meth:`set_joiner()` for more info.
376 .. attribute:: tools
378 Dict of "tool" elements for the grid. Tools are usually buttons
379 (e.g. "Delete Results"), shown on top right of the grid.
381 The keys for this dict are somewhat arbitrary, defined by the
382 caller. Values should be HTML literal elements.
384 See also :meth:`add_tool()` and :meth:`set_tools()`.
385 """
387 active_sorters = None
388 joined = None
389 pager = None
391 def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-branches,too-many-statements
392 self,
393 request,
394 vue_tagname="wutta-grid",
395 model_class=None,
396 key=None,
397 columns=None,
398 data=None,
399 labels=None,
400 renderers=None,
401 enums=None,
402 checkable=False,
403 row_class=None,
404 actions=None,
405 linked_columns=None,
406 hidden_columns=None,
407 sortable=False,
408 sort_multiple=None,
409 sort_on_backend=True,
410 sorters=None,
411 sort_defaults=None,
412 paginated=False,
413 paginate_on_backend=True,
414 pagesize_options=None,
415 pagesize=None,
416 page=1,
417 searchable_columns=None,
418 filterable=False,
419 filters=None,
420 filter_defaults=None,
421 joiners=None,
422 tools=None,
423 ):
424 self.request = request
425 self.vue_tagname = vue_tagname
426 self.model_class = model_class
427 self.key = key
428 self.data = data
429 self.labels = labels or {}
430 self.checkable = checkable
431 self.row_class = row_class
432 self.actions = actions or []
433 self.linked_columns = linked_columns or []
434 self.hidden_columns = hidden_columns or []
435 self.joiners = joiners or {}
437 self.config = self.request.wutta_config
438 self.app = self.config.get_app()
440 self.set_columns(columns or self.get_columns())
441 self.renderers = {}
442 if renderers:
443 for k, val in renderers.items():
444 self.set_renderer(k, val)
445 self.set_default_renderers()
446 self.set_tools(tools)
448 # sorting
449 self.sortable = sortable
450 if sort_multiple is not None:
451 self.sort_multiple = sort_multiple
452 elif self.request.use_oruga:
453 self.sort_multiple = False
454 else:
455 self.sort_multiple = bool(self.model_class)
456 if self.sort_multiple and self.request.use_oruga:
457 log.warning(
458 "grid.sort_multiple is not implemented for Oruga-based templates"
459 )
460 self.sort_multiple = False
461 self.sort_on_backend = sort_on_backend
462 if sorters is not None:
463 self.sorters = sorters
464 elif self.sortable and self.sort_on_backend:
465 self.sorters = self.make_backend_sorters()
466 else:
467 self.sorters = {}
468 self.set_sort_defaults(sort_defaults or [])
470 # paging
471 self.paginated = paginated
472 self.paginate_on_backend = paginate_on_backend
473 self.pagesize_options = pagesize_options or self.get_pagesize_options()
474 self.pagesize = pagesize or self.get_pagesize()
475 self.page = page
477 # searching
478 self.searchable_columns = set(searchable_columns or [])
480 # filtering
481 self.filterable = filterable
482 if filters is not None:
483 self.filters = filters
484 elif self.filterable:
485 self.filters = self.make_backend_filters()
486 else:
487 self.filters = {}
488 self.set_filter_defaults(**(filter_defaults or {}))
490 # enums
491 self.enums = {}
492 for k in enums or {}:
493 self.set_enum(k, enums[k])
495 def get_columns(self):
496 """
497 Returns the official list of column names for the grid, or
498 ``None``.
500 If :attr:`columns` is set and non-empty, it is returned.
502 Or, if :attr:`model_class` is set, the field list is derived
503 from that, via :meth:`get_model_columns()`.
505 Otherwise ``None`` is returned.
506 """
507 if hasattr(self, "columns") and self.columns:
508 return self.columns
510 columns = self.get_model_columns()
511 if columns:
512 return columns
514 return []
516 def get_model_columns(self, model_class=None):
517 """
518 This method is a shortcut which calls
519 :func:`~wuttaweb.util.get_model_fields()`.
521 :param model_class: Optional model class for which to return
522 fields. If not set, the grid's :attr:`model_class` is
523 assumed.
524 """
525 return get_model_fields(
526 self.config, model_class=model_class or self.model_class
527 )
529 @property
530 def vue_component(self):
531 """
532 String name for the Vue component, e.g. ``'WuttaGrid'``.
534 This is a generated value based on :attr:`vue_tagname`.
535 """
536 words = self.vue_tagname.split("-")
537 return "".join([word.capitalize() for word in words])
539 def set_columns(self, columns):
540 """
541 Explicitly set the list of grid columns.
543 This will overwrite :attr:`columns` with a new
544 :class:`~wuttaweb.util.FieldList` instance.
546 :param columns: List of string column names.
547 """
548 self.columns = FieldList(columns)
550 def append(self, *keys):
551 """
552 Add some columns(s) to the grid.
554 This is a convenience to allow adding multiple columns at
555 once::
557 grid.append('first_field',
558 'second_field',
559 'third_field')
561 It will add each column to :attr:`columns`.
562 """
563 for key in keys:
564 if key not in self.columns:
565 self.columns.append(key)
567 def remove(self, *keys):
568 """
569 Remove some column(s) from the grid.
571 This is a convenience to allow removal of multiple columns at
572 once::
574 grid.remove('first_field',
575 'second_field',
576 'third_field')
578 It will remove each column from :attr:`columns`.
579 """
580 for key in keys:
581 if key in self.columns:
582 self.columns.remove(key)
584 def set_hidden(self, key, hidden=True):
585 """
586 Set/override the hidden flag for a column.
588 Hidden columns are sometimes useful to pass "extra" data into
589 the grid, for use by other component logic etc.
591 See also :meth:`is_hidden()`; the list is tracked via
592 :attr:`hidden_columns`.
594 :param key: Column key as string.
596 :param hidden: Flag indicating whether column should be hidden
597 (vs. shown).
598 """
599 if hidden:
600 if key not in self.hidden_columns:
601 self.hidden_columns.append(key)
602 else: # un-hide
603 if self.hidden_columns and key in self.hidden_columns:
604 self.hidden_columns.remove(key)
606 def is_hidden(self, key):
607 """
608 Returns boolean indicating if the column is hidden from view.
610 See also :meth:`set_hidden()` and :attr:`hidden_columns`.
612 :param key: Column key as string.
614 :rtype: bool
615 """
616 if self.hidden_columns:
617 if key in self.hidden_columns:
618 return True
619 return False
621 def set_label(self, key, label, column_only=False):
622 """
623 Set/override the label for a column.
625 :param key: Name of column.
627 :param label: New label for the column header.
629 :param column_only: Boolean indicating whether the label
630 should be applied *only* to the column header (if
631 ``True``), vs. applying also to the filter (if ``False``).
633 See also :meth:`get_label()`. Label overrides are tracked via
634 :attr:`labels`.
635 """
636 self.labels[key] = label
638 if not column_only and key in self.filters:
639 self.filters[key].label = label
641 def get_label(self, key):
642 """
643 Returns the label text for a given column.
645 If no override is defined, the label is derived from ``key``.
647 See also :meth:`set_label()`.
648 """
649 if key in self.labels:
650 return self.labels[key]
651 return self.app.make_title(key)
653 def set_renderer(self, key, renderer, **kwargs):
654 """
655 Set/override the value renderer for a column.
657 :param key: Name of column.
659 :param renderer: Callable as described below.
661 Depending on the nature of grid data, sometimes a cell's
662 "as-is" value will be undesirable for display purposes.
664 The logic in :meth:`get_vue_context()` will first "convert"
665 all grid data as necessary so that it is at least
666 JSON-compatible.
668 But then it also will invoke a renderer override (if defined)
669 to obtain the "final" cell value.
671 A renderer must be a callable which accepts 3 args ``(record,
672 key, value)``:
674 * ``record`` is the "original" record from :attr:`data`
675 * ``key`` is the column name
676 * ``value`` is the JSON-safe cell value
678 Whatever the renderer returns, is then used as final cell
679 value. For instance::
681 from webhelpers2.html import HTML
683 def render_foo(record, key, value):
684 return HTML.literal("<p>this is the final cell value</p>")
686 grid = Grid(request, columns=['foo', 'bar'])
687 grid.set_renderer('foo', render_foo)
689 For convenience, in lieu of a renderer callable, you may
690 specify one of the following strings, which will be
691 interpreted as a built-in renderer callable, as shown below:
693 * ``'batch_id'`` -> :meth:`render_batch_id()`
694 * ``'boolean'`` -> :meth:`render_boolean()`
695 * ``'currency'`` -> :meth:`render_currency()`
696 * ``'date'`` -> :meth:`render_date()`
697 * ``'datetime'`` -> :meth:`render_datetime()`
698 * ``'quantity'`` -> :meth:`render_quantity()`
699 * ``'percent'`` -> :meth:`render_percent()`
701 Renderer overrides are tracked via :attr:`renderers`.
702 """
703 builtins = {
704 "batch_id": self.render_batch_id,
705 "boolean": self.render_boolean,
706 "currency": self.render_currency,
707 "date": self.render_date,
708 "datetime": self.render_datetime,
709 "quantity": self.render_quantity,
710 "percent": self.render_percent,
711 }
713 if renderer in builtins: # pylint: disable=consider-using-get
714 renderer = builtins[renderer]
716 if kwargs:
717 renderer = functools.partial(renderer, **kwargs)
718 self.renderers[key] = renderer
720 def set_default_renderers(self):
721 """
722 Set default column value renderers, where applicable.
724 This is called automatically from the class constructor. It
725 will add new entries to :attr:`renderers` for columns whose
726 data type implies a default renderer. This is only possible
727 if :attr:`model_class` is set to a SQLAlchemy mapped class.
729 This only looks for a few data types, and configures as
730 follows:
732 * :class:`sqlalchemy:sqlalchemy.types.Boolean` ->
733 :meth:`render_boolean()`
734 * :class:`sqlalchemy:sqlalchemy.types.Date` ->
735 :meth:`render_date()`
736 * :class:`sqlalchemy:sqlalchemy.types.DateTime` ->
737 :meth:`render_datetime()`
738 """
739 if not self.model_class:
740 return
742 for key in self.columns:
743 if key in self.renderers:
744 continue
746 attr = getattr(self.model_class, key, None)
747 if attr:
748 prop = getattr(attr, "prop", None)
749 if prop and isinstance(prop, orm.ColumnProperty):
750 column = prop.columns[0]
751 if isinstance(column.type, sa.Date):
752 self.set_renderer(key, self.render_date)
753 elif isinstance(column.type, sa.DateTime):
754 self.set_renderer(key, self.render_datetime)
755 elif isinstance(column.type, sa.Boolean):
756 self.set_renderer(key, self.render_boolean)
758 def set_enum(self, key, enum):
759 """
760 Set the "enum" collection for a given column.
762 This will set the column renderer to show the appropriate enum
763 value for each row in the grid. See also
764 :meth:`render_enum()`.
766 If the grid has a corresponding filter for the column, it will
767 be modified to show "choices" for values contained in the
768 enum.
770 :param key: Name of column.
772 :param enum: Instance of :class:`python:enum.Enum`, or a dict.
773 """
774 self.enums[key] = enum
775 self.set_renderer(key, self.render_enum, enum=enum)
776 if key in self.filters:
777 self.filters[key].set_choices(enum)
779 def set_link(self, key, link=True):
780 """
781 Explicitly enable or disable auto-link behavior for a given
782 column.
784 If a column has auto-link enabled, then each of its cell
785 contents will automatically be wrapped with a hyperlink. The
786 URL for this will be the same as for the "View"
787 :class:`GridAction`
788 (aka. :meth:`~wuttaweb.views.master.MasterView.view()`).
789 Although of course each cell in the column gets a different
790 link depending on which data record it points to.
792 It is typical to enable auto-link for fields relating to ID,
793 description etc. or some may prefer to auto-link all columns.
795 See also :meth:`is_linked()`; the list is tracked via
796 :attr:`linked_columns`.
798 :param key: Column key as string.
800 :param link: Boolean indicating whether column's cell contents
801 should be auto-linked.
802 """
803 if link:
804 if key not in self.linked_columns:
805 self.linked_columns.append(key)
806 else: # unlink
807 if self.linked_columns and key in self.linked_columns:
808 self.linked_columns.remove(key)
810 def is_linked(self, key):
811 """
812 Returns boolean indicating if auto-link behavior is enabled
813 for a given column.
815 See also :meth:`set_link()` which describes auto-link behavior.
817 :param key: Column key as string.
818 """
819 if self.linked_columns:
820 if key in self.linked_columns:
821 return True
822 return False
824 def set_searchable(self, key, searchable=True):
825 """
826 (Un)set the given column's searchable flag for the Vue
827 component.
829 See also :meth:`is_searchable()`. Flags are tracked via
830 :attr:`searchable_columns`.
831 """
832 if searchable:
833 self.searchable_columns.add(key)
834 elif key in self.searchable_columns:
835 self.searchable_columns.remove(key)
837 def is_searchable(self, key):
838 """
839 Check if the given column is marked as searchable for the Vue
840 component.
842 See also :meth:`set_searchable()`.
843 """
844 return key in self.searchable_columns
846 def add_action(self, key, **kwargs):
847 """
848 Convenience to add a new :class:`GridAction` instance to the
849 grid's :attr:`actions` list.
850 """
851 self.actions.append(GridAction(self.request, key, **kwargs))
853 def set_tools(self, tools):
854 """
855 Set the :attr:`tools` attribute using the given tools collection.
856 This will normalize the list/dict to desired internal format.
858 See also :meth:`add_tool()`.
859 """
860 if tools and isinstance(tools, list):
861 if not any(isinstance(t, (tuple, list)) for t in tools):
862 tools = [(self.app.make_true_uuid().hex, t) for t in tools]
863 self.tools = OrderedDict(tools or [])
865 def add_tool(self, html, key=None):
866 """
867 Add a new HTML snippet to the :attr:`tools` dict.
869 :param html: HTML literal for the tool element.
871 :param key: Optional key to use when adding to the
872 :attr:`tools` dict. If not specified, a random string is
873 generated.
875 See also :meth:`set_tools()`.
876 """
877 if not key:
878 key = self.app.make_true_uuid().hex
879 self.tools[key] = html
881 ##############################
882 # joining methods
883 ##############################
885 def set_joiner(self, key, joiner):
886 """
887 Set/override the backend joiner for a column.
889 A "joiner" is sometimes needed when a column with "related but
890 not primary" data is involved in a sort or filter operation.
892 A sorter or filter may need to "join" other table(s) to get at
893 the appropriate data. But if a given column has both a sorter
894 and filter defined, and both are used at the same time, we
895 don't want the join to happen twice.
897 Hence we track joiners separately, also keyed by column name
898 (as are sorters and filters). When a column's sorter **and/or**
899 filter is needed, the joiner will be invoked.
901 :param key: Name of column.
903 :param joiner: A joiner callable, as described below.
905 A joiner callable must accept just one ``(data)`` arg and
906 return the "joined" data/query, for example::
908 model = app.model
909 grid = Grid(request, model_class=model.Person)
911 def join_external_profile_value(query):
912 return query.join(model.ExternalProfile)
914 def sort_external_profile(query, direction):
915 sortspec = getattr(model.ExternalProfile.description, direction)
916 return query.order_by(sortspec())
918 grid.set_joiner('external_profile', join_external_profile)
919 grid.set_sorter('external_profile', sort_external_profile)
921 See also :meth:`remove_joiner()`. Backend joiners are tracked
922 via :attr:`joiners`.
923 """
924 self.joiners[key] = joiner
926 def remove_joiner(self, key):
927 """
928 Remove the backend joiner for a column.
930 Note that this removes the joiner *function*, so there is no
931 way to apply joins for this column unless another joiner is
932 later defined for it.
934 See also :meth:`set_joiner()`.
935 """
936 self.joiners.pop(key, None)
938 ##############################
939 # sorting methods
940 ##############################
942 def make_backend_sorters(self, sorters=None):
943 """
944 Make backend sorters for all columns in the grid.
946 This is called by the constructor, if both :attr:`sortable`
947 and :attr:`sort_on_backend` are true.
949 For each column in the grid, this checks the provided
950 ``sorters`` and if the column is not yet in there, will call
951 :meth:`make_sorter()` to add it.
953 .. note::
955 This only works if grid has a :attr:`model_class`. If not,
956 this method just returns the initial sorters (or empty
957 dict).
959 :param sorters: Optional dict of initial sorters. Any
960 existing sorters will be left intact, not replaced.
962 :returns: Final dict of all sorters. Includes any from the
963 initial ``sorters`` param as well as any which were
964 created.
965 """
966 sorters = sorters or {}
968 if self.model_class:
969 for key in self.columns:
970 if key in sorters:
971 continue
972 prop = getattr(self.model_class, key, None)
973 if (
974 prop
975 and hasattr(prop, "property")
976 and isinstance(prop.property, orm.ColumnProperty)
977 ):
978 sorters[prop.key] = self.make_sorter(prop)
980 return sorters
982 def make_sorter(self, columninfo, keyfunc=None, foldcase=True):
983 """
984 Returns a function suitable for use as a backend sorter on the
985 given column.
987 Code usually does not need to call this directly. See also
988 :meth:`set_sorter()`, which calls this method automatically.
990 :param columninfo: Can be either a model property (see below),
991 or a column name.
993 :param keyfunc: Optional function to use as the "sort key
994 getter" callable, if the sorter is manual (as opposed to
995 SQLAlchemy query). More on this below. If not specified,
996 a default function is used.
998 :param foldcase: If the sorter is manual (not SQLAlchemy), and
999 the column data is of text type, this may be used to
1000 automatically "fold case" for the sorting. Defaults to
1001 ``True`` since this behavior is presumably expected, but
1002 may be disabled if needed.
1004 The term "model property" is a bit technical, an example
1005 should help to clarify::
1007 model = app.model
1008 grid = Grid(request, model_class=model.Person)
1010 # explicit property
1011 sorter = grid.make_sorter(model.Person.full_name)
1013 # property name works if grid has model class
1014 sorter = grid.make_sorter('full_name')
1016 # nb. this will *not* work
1017 person = model.Person(full_name="John Doe")
1018 sorter = grid.make_sorter(person.full_name)
1020 The ``keyfunc`` param allows you to override the way sort keys
1021 are obtained from data records (this only applies for a
1022 "manual" sort, where data is a list and not a SQLAlchemy
1023 query)::
1025 data = [
1026 {'foo': 1},
1027 {'bar': 2},
1028 ]
1030 # nb. no model_class, just as an example
1031 grid = Grid(request, columns=['foo', 'bar'], data=data)
1033 def getkey(obj):
1034 if obj.get('foo')
1035 return obj['foo']
1036 if obj.get('bar'):
1037 return obj['bar']
1038 return ''
1040 # nb. sortfunc will ostensibly sort by 'foo' column, but in
1041 # practice it is sorted per value from getkey() above
1042 sortfunc = grid.make_sorter('foo', keyfunc=getkey)
1043 sorted_data = sortfunc(data, 'asc')
1045 :returns: A function suitable for backend sorting. This
1046 function will behave differently when it is given a
1047 SQLAlchemy query vs. a "list" of data. In either case it
1048 will return the sorted result.
1050 This function may be called as shown above. It expects 2
1051 args: ``(data, direction)``
1052 """
1053 model_class = None
1054 model_property = None
1055 if isinstance(columninfo, str):
1056 key = columninfo
1057 model_class = self.model_class
1058 model_property = getattr(self.model_class, key, None)
1059 else:
1060 model_property = columninfo
1061 model_class = model_property.class_
1062 key = model_property.key
1064 def sorter(data, direction):
1066 # query is sorted with order_by()
1067 if isinstance(data, orm.Query):
1068 if not model_property:
1069 raise TypeError(
1070 f"grid sorter for '{key}' does not map to a model property"
1071 )
1072 query = data
1073 return query.order_by(getattr(model_property, direction)())
1075 # other data is sorted manually. first step is to
1076 # identify the function used to produce a sort key for
1077 # each record
1078 kfunc = keyfunc
1079 if not kfunc:
1080 if model_property:
1081 # TODO: may need this for String etc. as well?
1082 if isinstance(model_property.type, sa.Text):
1083 if foldcase:
1085 def kfunc_folded(obj):
1086 return (obj[key] or "").lower()
1088 kfunc = kfunc_folded
1090 else:
1092 def kfunc_standard(obj):
1093 return obj[key] or ""
1095 kfunc = kfunc_standard
1097 if not kfunc:
1098 # nb. sorting with this can raise error if data
1099 # contains varying types, e.g. str and None
1101 def kfunc_fallback(obj):
1102 return obj[key]
1104 kfunc = kfunc_fallback
1106 # then sort the data and return
1107 return sorted(data, key=kfunc, reverse=direction == "desc")
1109 # TODO: this should be improved; is needed in tailbone for
1110 # multi-column sorting with sqlalchemy queries
1111 if model_property:
1112 sorter._class = model_class # pylint: disable=protected-access
1113 sorter._column = model_property # pylint: disable=protected-access
1115 return sorter
1117 def set_sorter(self, key, sortinfo=None):
1118 """
1119 Set/override the backend sorter for a column.
1121 Only relevant if both :attr:`sortable` and
1122 :attr:`sort_on_backend` are true.
1124 :param key: Name of column.
1126 :param sortinfo: Can be either a sorter callable, or else a
1127 model property (see below).
1129 If ``sortinfo`` is a callable, it will be used as-is for the
1130 backend sorter.
1132 Otherwise :meth:`make_sorter()` will be called to obtain the
1133 backend sorter. The ``sortinfo`` will be passed along to that
1134 call; if it is empty then ``key`` will be used instead.
1136 A backend sorter callable must accept ``(data, direction)``
1137 args and return the sorted data/query, for example::
1139 model = app.model
1140 grid = Grid(request, model_class=model.Person)
1142 def sort_full_name(query, direction):
1143 sortspec = getattr(model.Person.full_name, direction)
1144 return query.order_by(sortspec())
1146 grid.set_sorter('full_name', sort_full_name)
1148 See also :meth:`remove_sorter()` and :meth:`is_sortable()`.
1149 Backend sorters are tracked via :attr:`sorters`.
1150 """
1151 sorter = None
1153 if sortinfo and callable(sortinfo):
1154 sorter = sortinfo
1155 else:
1156 sorter = self.make_sorter(sortinfo or key)
1158 self.sorters[key] = sorter
1160 def remove_sorter(self, key):
1161 """
1162 Remove the backend sorter for a column.
1164 Note that this removes the sorter *function*, so there is
1165 no way to sort by this column unless another sorter is
1166 later defined for it.
1168 See also :meth:`set_sorter()`.
1169 """
1170 self.sorters.pop(key, None)
1172 def set_sort_defaults(self, *args):
1173 """
1174 Set the default sorting method for the grid. This sorting is
1175 used unless/until the user requests a different sorting
1176 method.
1178 ``args`` for this method are interpreted as follows:
1180 If 2 args are received, they should be for ``sortkey`` and
1181 ``sortdir``; for instance::
1183 grid.set_sort_defaults('name', 'asc')
1185 If just one 2-tuple arg is received, it is handled similarly::
1187 grid.set_sort_defaults(('name', 'asc'))
1189 If just one string arg is received, the default ``sortdir`` is
1190 assumed::
1192 grid.set_sort_defaults('name') # assumes 'asc'
1194 Otherwise there should be just one list arg, elements of
1195 which are each 2-tuples of ``(sortkey, sortdir)`` info::
1197 grid.set_sort_defaults([('name', 'asc'),
1198 ('value', 'desc')])
1200 .. note::
1202 Note that :attr:`sort_multiple` determines whether the grid
1203 is actually allowed to have multiple sort defaults. The
1204 defaults requested by the method call may be pruned if
1205 necessary to accommodate that.
1207 Default sorting info is tracked via :attr:`sort_defaults`.
1208 """
1210 # convert args to sort defaults
1211 sort_defaults = []
1212 if len(args) == 1:
1213 if isinstance(args[0], str):
1214 sort_defaults = [SortInfo(args[0], "asc")]
1215 elif isinstance(args[0], tuple) and len(args[0]) == 2:
1216 sort_defaults = [SortInfo(*args[0])]
1217 elif isinstance(args[0], list):
1218 sort_defaults = [SortInfo(*tup) for tup in args[0]]
1219 else:
1220 raise ValueError(
1221 "for just one positional arg, must pass string, 2-tuple or list"
1222 )
1223 elif len(args) == 2:
1224 sort_defaults = [SortInfo(*args)]
1225 else:
1226 raise ValueError("must pass just one or two positional args")
1228 # prune if multi-column requested but not supported
1229 if len(sort_defaults) > 1 and not self.sort_multiple:
1230 log.warning(
1231 "multi-column sorting is not enabled for the instance; "
1232 "list will be pruned to first element for '%s' grid: %s",
1233 self.key,
1234 sort_defaults,
1235 )
1236 sort_defaults = [sort_defaults[0]]
1238 self.sort_defaults = sort_defaults
1240 def is_sortable(self, key):
1241 """
1242 Returns boolean indicating if a given column should allow
1243 sorting.
1245 If :attr:`sortable` is false, this always returns ``False``.
1247 For frontend sorting (i.e. :attr:`sort_on_backend` is false),
1248 this always returns ``True``.
1250 For backend sorting, may return true or false depending on
1251 whether the column is listed in :attr:`sorters`.
1253 :param key: Column key as string.
1255 See also :meth:`set_sorter()`.
1256 """
1257 if not self.sortable:
1258 return False
1259 if self.sort_on_backend:
1260 return key in self.sorters
1261 return True
1263 ##############################
1264 # filtering methods
1265 ##############################
1267 def make_backend_filters(self, filters=None):
1268 """
1269 Make "automatic" backend filters for the grid.
1271 This is called by the constructor, if :attr:`filterable` is
1272 true.
1274 For each "column" in the model class, this will call
1275 :meth:`make_filter()` to add an automatic filter. However it
1276 first checks the provided ``filters`` and will not override
1277 any of those.
1279 .. note::
1281 This only works if grid has a :attr:`model_class`. If not,
1282 this method just returns the initial filters (or empty
1283 dict).
1285 :param filters: Optional dict of initial filters. Any
1286 existing filters will be left intact, not replaced.
1288 :returns: Final dict of all filters. Includes any from the
1289 initial ``filters`` param as well as any which were
1290 created.
1291 """
1292 filters = filters or {}
1294 if self.model_class:
1296 # nb. i have found this confusing for some reason. some
1297 # things i've tried so far include:
1298 #
1299 # i first tried self.get_model_columns() but my notes say
1300 # that was too aggressive in many cases.
1301 #
1302 # then i tried using the *subset* of self.columns, just
1303 # the ones which correspond to a property on the model
1304 # class. but sometimes that skips filters we need.
1305 #
1306 # then i tried get_columns() from sa-utils to give the
1307 # "true" column list, but that fails when the underlying
1308 # column has different name than the prop/attr key.
1309 #
1310 # so now, we are looking directly at the sa mapper, for
1311 # all column attrs and then using the prop key.
1313 inspector = sa.inspect(self.model_class)
1314 for prop in inspector.column_attrs:
1316 # do not overwrite existing filters
1317 if prop.key in filters:
1318 continue
1320 # do not create filter for UUID field
1321 if len(prop.columns) == 1 and isinstance(prop.columns[0].type, UUID):
1322 continue
1324 attr = getattr(self.model_class, prop.key)
1325 filters[prop.key] = self.make_filter(attr)
1327 return filters
1329 def make_filter(self, columninfo, **kwargs):
1330 """
1331 Create and return a
1332 :class:`~wuttaweb.grids.filters.GridFilter` instance suitable
1333 for use on the given column.
1335 Code usually does not need to call this directly. See also
1336 :meth:`set_filter()`, which calls this method automatically.
1338 :param columninfo: Can be either a model property (see below),
1339 or a column name.
1341 :returns: A :class:`~wuttaweb.grids.filters.GridFilter`
1342 instance.
1343 """
1344 key = kwargs.pop("key", None)
1346 # model_property is required
1347 model_property = None
1348 if kwargs.get("model_property"):
1349 model_property = kwargs["model_property"]
1350 elif isinstance(columninfo, str):
1351 key = columninfo
1352 if self.model_class:
1353 model_property = getattr(self.model_class, key, None)
1354 if not model_property:
1355 raise ValueError(f"cannot locate model property for key: {key}")
1356 else:
1357 model_property = columninfo
1359 # optional factory override
1360 factory = kwargs.pop("factory", None)
1361 if not factory:
1362 typ = model_property.type
1363 factory = default_sqlalchemy_filters.get(type(typ))
1364 if not factory:
1365 factory = default_sqlalchemy_filters[None]
1367 # make filter
1368 kwargs["model_property"] = model_property
1369 return factory(self.request, key or model_property.key, **kwargs)
1371 def set_filter(self, key, filterinfo=None, **kwargs):
1372 """
1373 Set/override the backend filter for a column.
1375 Only relevant if :attr:`filterable` is true.
1377 :param key: Name of column.
1379 :param filterinfo: Can be either a
1380 :class:`~wuttweb.grids.filters.GridFilter` instance, or
1381 else a model property (see below).
1383 If ``filterinfo`` is a ``GridFilter`` instance, it will be
1384 used as-is for the backend filter.
1386 Otherwise :meth:`make_filter()` will be called to obtain the
1387 backend filter. The ``filterinfo`` will be passed along to
1388 that call; if it is empty then ``key`` will be used instead.
1390 See also :meth:`remove_filter()`. Backend filters are tracked
1391 via :attr:`filters`.
1392 """
1393 filtr = None
1395 if filterinfo and callable(filterinfo):
1396 # filtr = filterinfo
1397 raise NotImplementedError
1399 kwargs["key"] = key
1400 kwargs.setdefault("label", self.get_label(key))
1401 filtr = self.make_filter(filterinfo or key, **kwargs)
1403 self.filters[key] = filtr
1405 def remove_filter(self, key):
1406 """
1407 Remove the backend filter for a column.
1409 This removes the filter *instance*, so there is no way to
1410 filter by this column unless another filter is later defined
1411 for it.
1413 See also :meth:`set_filter()`.
1414 """
1415 self.filters.pop(key, None)
1417 def set_filter_defaults(self, **defaults):
1418 """
1419 Set default state preferences for the grid filters.
1421 These preferences will affect the initial grid display, until
1422 user requests a different filtering method.
1424 Each kwarg should be named by filter key, and the value should
1425 be a dict of preferences for that filter. For instance::
1427 grid.set_filter_defaults(name={'active': True,
1428 'verb': 'contains',
1429 'value': 'foo'},
1430 value={'active': True})
1432 Filter defaults are tracked via :attr:`filter_defaults`.
1433 """
1434 filter_defaults = dict(getattr(self, "filter_defaults", {}))
1436 for key, values in defaults.items():
1437 filtr = filter_defaults.setdefault(key, {})
1438 filtr.update(values)
1440 self.filter_defaults = filter_defaults
1442 ##############################
1443 # paging methods
1444 ##############################
1446 def get_pagesize_options(self, default=None):
1447 """
1448 Returns a list of default page size options for the grid.
1450 It will check config but if no setting exists, will fall
1451 back to::
1453 [5, 10, 20, 50, 100, 200]
1455 :param default: Alternate default value to return if none is
1456 configured.
1458 This method is intended for use in the constructor. Code can
1459 instead access :attr:`pagesize_options` directly.
1460 """
1461 options = self.config.get_list("wuttaweb.grids.default_pagesize_options")
1462 if options:
1463 options = [int(size) for size in options if size.isdigit()]
1464 if options:
1465 return options
1467 return default or [5, 10, 20, 50, 100, 200]
1469 def get_pagesize(self, default=None):
1470 """
1471 Returns the default page size for the grid.
1473 It will check config but if no setting exists, will fall back
1474 to a value from :attr:`pagesize_options` (will return ``20`` if
1475 that is listed; otherwise the "first" option).
1477 :param default: Alternate default value to return if none is
1478 configured.
1480 This method is intended for use in the constructor. Code can
1481 instead access :attr:`pagesize` directly.
1482 """
1483 size = self.config.get_int("wuttaweb.grids.default_pagesize")
1484 if size:
1485 return size
1487 if default:
1488 return default
1490 if 20 in self.pagesize_options:
1491 return 20
1493 return self.pagesize_options[0]
1495 ##############################
1496 # configuration methods
1497 ##############################
1499 def load_settings( # pylint: disable=too-many-branches,too-many-statements
1500 self, persist=True
1501 ):
1502 """
1503 Load all effective settings for the grid.
1505 If the request GET params (query string) contains grid
1506 settings, they are used; otherwise the settings are loaded
1507 from user session.
1509 .. note::
1511 As of now, "sorting" and "pagination" settings are the only
1512 type supported by this logic. Settings for "filtering"
1513 coming soon...
1515 The overall logic for this method is as follows:
1517 * collect settings
1518 * apply settings to current grid
1519 * optionally save settings to user session
1521 Saving the settings to user session will allow the grid to
1522 remember its current settings when user refreshes the page, or
1523 navigates away then comes back. Therefore normally, settings
1524 are saved each time they are loaded. Note that such settings
1525 are wiped upon user logout.
1527 :param persist: Whether the collected settings should be saved
1528 to the user session.
1529 """
1531 # initial default settings
1532 settings = {}
1533 if self.filterable:
1534 for filtr in self.filters.values():
1535 defaults = self.filter_defaults.get(filtr.key, {})
1536 settings[f"filter.{filtr.key}.active"] = defaults.get(
1537 "active", filtr.default_active
1538 )
1539 settings[f"filter.{filtr.key}.verb"] = defaults.get(
1540 "verb", filtr.get_default_verb()
1541 )
1542 settings[f"filter.{filtr.key}.value"] = defaults.get(
1543 "value", filtr.default_value
1544 )
1545 if self.sortable:
1546 if self.sort_defaults:
1547 # nb. as of writing neither Buefy nor Oruga support a
1548 # multi-column *default* sort; so just use first sorter
1549 sortinfo = self.sort_defaults[0]
1550 settings["sorters.length"] = 1
1551 settings["sorters.1.key"] = sortinfo.sortkey
1552 settings["sorters.1.dir"] = sortinfo.sortdir
1553 else:
1554 settings["sorters.length"] = 0
1555 if self.paginated and self.paginate_on_backend:
1556 settings["pagesize"] = self.pagesize
1557 settings["page"] = self.page
1559 # update settings dict based on what we find in the request
1560 # and/or user session. always prioritize the former.
1562 # nb. do not read settings if user wants a reset
1563 if self.request.GET.get("reset-view"):
1564 # at this point we only have default settings, and we want
1565 # to keep those *and* persist them for next time, below
1566 pass
1568 elif self.request_has_settings("filter"):
1569 self.update_filter_settings(settings, src="request")
1570 if self.request_has_settings("sort"):
1571 self.update_sort_settings(settings, src="request")
1572 else:
1573 self.update_sort_settings(settings, src="session")
1574 self.update_page_settings(settings)
1576 elif self.request_has_settings("sort"):
1577 self.update_filter_settings(settings, src="session")
1578 self.update_sort_settings(settings, src="request")
1579 self.update_page_settings(settings)
1581 elif self.request_has_settings("page"):
1582 self.update_filter_settings(settings, src="session")
1583 self.update_sort_settings(settings, src="session")
1584 self.update_page_settings(settings)
1586 else:
1587 # nothing found in request, so nothing new to save
1588 persist = False
1590 # but still should load whatever is in user session
1591 self.update_filter_settings(settings, src="session")
1592 self.update_sort_settings(settings, src="session")
1593 self.update_page_settings(settings)
1595 # maybe save settings in user session, for next time
1596 if persist:
1597 self.persist_settings(settings, dest="session")
1599 # update ourself to reflect settings dict..
1601 # filtering
1602 if self.filterable:
1603 for filtr in self.filters.values():
1604 filtr.active = settings[f"filter.{filtr.key}.active"]
1605 filtr.verb = (
1606 settings[f"filter.{filtr.key}.verb"] or filtr.get_default_verb()
1607 )
1608 filtr.value = settings[f"filter.{filtr.key}.value"]
1610 # sorting
1611 if self.sortable:
1612 # nb. doing this for frontend sorting also
1613 self.active_sorters = []
1614 for i in range(1, settings["sorters.length"] + 1):
1615 self.active_sorters.append(
1616 {
1617 "key": settings[f"sorters.{i}.key"],
1618 "dir": settings[f"sorters.{i}.dir"],
1619 }
1620 )
1621 # TODO: i thought this was needed, but now idk?
1622 # # nb. when showing full index page (i.e. not partial)
1623 # # this implies we must set the default sorter for Vue
1624 # # component, and only single-column is allowed there.
1625 # if not self.request.GET.get('partial'):
1626 # break
1628 # paging
1629 if self.paginated and self.paginate_on_backend:
1630 self.pagesize = settings["pagesize"]
1631 self.page = settings["page"]
1633 def request_has_settings(self, typ): # pylint: disable=empty-docstring
1634 """ """
1636 if typ == "filter" and self.filterable:
1637 for filtr in self.filters.values():
1638 if filtr.key in self.request.GET:
1639 return True
1640 if "filter" in self.request.GET: # user may be applying empty filters
1641 return True
1643 elif typ == "sort" and self.sortable and self.sort_on_backend:
1644 if "sort1key" in self.request.GET:
1645 return True
1647 elif typ == "page" and self.paginated and self.paginate_on_backend:
1648 for key in ["pagesize", "page"]:
1649 if key in self.request.GET:
1650 return True
1652 return False
1654 def get_setting( # pylint: disable=empty-docstring,too-many-arguments,too-many-positional-arguments
1655 self, settings, key, src="session", default=None, normalize=lambda v: v
1656 ):
1657 """ """
1659 if src == "request":
1660 value = self.request.GET.get(key)
1661 if value is not None:
1662 try:
1663 return normalize(value)
1664 except ValueError:
1665 pass
1667 elif src == "session":
1668 value = self.request.session.get(f"grid.{self.key}.{key}")
1669 if value is not None:
1670 return normalize(value)
1672 # if src had nothing, try default/existing settings
1673 value = settings.get(key)
1674 if value is not None:
1675 return normalize(value)
1677 # okay then, default it is
1678 return default
1680 def update_filter_settings( # pylint: disable=empty-docstring
1681 self, settings, src=None
1682 ):
1683 """ """
1684 if not self.filterable:
1685 return
1687 for filtr in self.filters.values():
1688 prefix = f"filter.{filtr.key}"
1690 if src == "request":
1691 # consider filter active if query string contains a value for it
1692 settings[f"{prefix}.active"] = filtr.key in self.request.GET
1693 settings[f"{prefix}.verb"] = self.get_setting(
1694 settings, f"{filtr.key}.verb", src="request", default=""
1695 )
1696 settings[f"{prefix}.value"] = self.get_setting(
1697 settings, filtr.key, src="request", default=""
1698 )
1700 elif src == "session":
1701 settings[f"{prefix}.active"] = self.get_setting(
1702 settings,
1703 f"{prefix}.active",
1704 src="session",
1705 normalize=lambda v: str(v).lower() == "true",
1706 default=False,
1707 )
1708 settings[f"{prefix}.verb"] = self.get_setting(
1709 settings, f"{prefix}.verb", src="session", default=""
1710 )
1711 settings[f"{prefix}.value"] = self.get_setting(
1712 settings, f"{prefix}.value", src="session", default=""
1713 )
1715 def update_sort_settings( # pylint: disable=empty-docstring
1716 self, settings, src=None
1717 ):
1718 """ """
1719 if not (self.sortable and self.sort_on_backend):
1720 return
1722 if src == "request":
1723 i = 1
1724 while True:
1725 skey = f"sort{i}key"
1726 if skey in self.request.GET:
1727 settings[f"sorters.{i}.key"] = self.get_setting(
1728 settings, skey, src="request"
1729 )
1730 settings[f"sorters.{i}.dir"] = self.get_setting(
1731 settings, f"sort{i}dir", src="request", default="asc"
1732 )
1733 else:
1734 break
1735 i += 1
1736 settings["sorters.length"] = i - 1
1738 elif src == "session":
1739 settings["sorters.length"] = self.get_setting(
1740 settings, "sorters.length", src="session", normalize=int
1741 )
1742 for i in range(1, settings["sorters.length"] + 1):
1743 for key in ("key", "dir"):
1744 skey = f"sorters.{i}.{key}"
1745 settings[skey] = self.get_setting(settings, skey, src="session")
1747 def update_page_settings(self, settings): # pylint: disable=empty-docstring
1748 """ """
1749 if not (self.paginated and self.paginate_on_backend):
1750 return
1752 # update the settings dict from request and/or user session
1754 # pagesize
1755 pagesize = self.request.GET.get("pagesize")
1756 if pagesize is not None:
1757 if pagesize.isdigit():
1758 settings["pagesize"] = int(pagesize)
1759 else:
1760 pagesize = self.request.session.get(f"grid.{self.key}.pagesize")
1761 if pagesize is not None:
1762 settings["pagesize"] = pagesize
1764 # page
1765 page = self.request.GET.get("page")
1766 if page is not None:
1767 if page.isdigit():
1768 settings["page"] = int(page)
1769 else:
1770 page = self.request.session.get(f"grid.{self.key}.page")
1771 if page is not None:
1772 settings["page"] = int(page)
1774 def persist_settings(self, settings, dest=None): # pylint: disable=empty-docstring
1775 """ """
1776 if dest not in ("session",):
1777 raise ValueError(f"invalid dest identifier: {dest}")
1779 # func to save a setting value to user session
1780 def persist(key, value=settings.get):
1781 assert dest == "session"
1782 skey = f"grid.{self.key}.{key}"
1783 self.request.session[skey] = value(key)
1785 # filter settings
1786 if self.filterable:
1788 # always save all filters, with status
1789 for filtr in self.filters.values():
1790 persist(
1791 f"filter.{filtr.key}.active",
1792 value=lambda k: "true" if settings.get(k) else "false",
1793 )
1794 persist(f"filter.{filtr.key}.verb")
1795 persist(f"filter.{filtr.key}.value")
1797 # sort settings
1798 if self.sortable and self.sort_on_backend:
1800 # first must clear all sort settings from dest. this is
1801 # because number of sort settings will vary, so we delete
1802 # all and then write all
1804 if dest == "session":
1805 # remove sort settings from user session
1806 prefix = f"grid.{self.key}.sorters."
1807 for key in list(self.request.session):
1808 if key.startswith(prefix):
1809 del self.request.session[key]
1811 # now save sort settings to dest
1812 if "sorters.length" in settings:
1813 persist("sorters.length")
1814 for i in range(1, settings["sorters.length"] + 1):
1815 persist(f"sorters.{i}.key")
1816 persist(f"sorters.{i}.dir")
1818 # pagination settings
1819 if self.paginated and self.paginate_on_backend:
1821 # save to dest
1822 persist("pagesize")
1823 persist("page")
1825 ##############################
1826 # data methods
1827 ##############################
1829 def get_visible_data(self):
1830 """
1831 Returns the "effective" visible data for the grid.
1833 This uses :attr:`data` as the starting point but may morph it
1834 for pagination etc. per the grid settings.
1836 Code can either access :attr:`data` directly, or call this
1837 method to get only the data for current view (e.g. assuming
1838 pagination is used), depending on the need.
1840 See also these methods which may be called by this one:
1842 * :meth:`filter_data()`
1843 * :meth:`sort_data()`
1844 * :meth:`paginate_data()`
1845 """
1846 data = self.data or []
1847 self.joined = set()
1849 if self.filterable:
1850 data = self.filter_data(data)
1852 if self.sortable and self.sort_on_backend:
1853 data = self.sort_data(data)
1855 if self.paginated and self.paginate_on_backend:
1856 self.pager = self.paginate_data(data)
1857 data = self.pager
1859 return data
1861 @property
1862 def active_filters(self):
1863 """
1864 Returns the list of currently active filters.
1866 This inspects each :class:`~wuttaweb.grids.filters.GridFilter`
1867 in :attr:`filters` and only returns the ones marked active.
1868 """
1869 return [filtr for filtr in self.filters.values() if filtr.active]
1871 def filter_data(self, data, filters=None):
1872 """
1873 Filter the given data and return the result. This is called
1874 by :meth:`get_visible_data()`.
1876 :param filters: Optional list of filters to use. If not
1877 specified, the grid's :attr:`active_filters` are used.
1878 """
1879 if filters is None:
1880 filters = self.active_filters
1881 if not filters:
1882 return data
1884 for filtr in filters:
1885 key = filtr.key
1887 if key in self.joiners and key not in self.joined:
1888 data = self.joiners[key](data)
1889 self.joined.add(key)
1891 try:
1892 data = filtr.apply_filter(data)
1893 except VerbNotSupported as error:
1894 log.warning("verb not supported for '%s' filter: %s", key, error.verb)
1895 except Exception: # pylint: disable=broad-exception-caught
1896 log.exception("filtering data by '%s' failed!", key)
1898 return data
1900 def sort_data(self, data, sorters=None):
1901 """
1902 Sort the given data and return the result. This is called by
1903 :meth:`get_visible_data()`.
1905 :param sorters: Optional list of sorters to use. If not
1906 specified, the grid's :attr:`active_sorters` are used.
1907 """
1908 if sorters is None:
1909 sorters = self.active_sorters
1910 if not sorters:
1911 return data
1913 # nb. when data is a query, we want to apply sorters in the
1914 # requested order, so the final query has order_by() in the
1915 # correct "as-is" sequence. however when data is a list we
1916 # must do the opposite, applying in the reverse order, so the
1917 # final list has the most "important" sort(s) applied last.
1918 if not isinstance(data, orm.Query):
1919 sorters = reversed(sorters)
1921 for sorter in sorters:
1922 sortkey = sorter["key"]
1923 sortdir = sorter["dir"]
1925 # cannot sort unless we have a sorter callable
1926 sortfunc = self.sorters.get(sortkey)
1927 if not sortfunc:
1928 return data
1930 # join appropriate model if needed
1931 if sortkey in self.joiners and sortkey not in self.joined:
1932 data = self.joiners[sortkey](data)
1933 self.joined.add(sortkey)
1935 # invoke the sorter
1936 data = sortfunc(data, sortdir)
1938 return data
1940 def paginate_data(self, data):
1941 """
1942 Apply pagination to the given data set, based on grid settings.
1944 This returns a "pager" object which can then be used as a
1945 "data replacement" in subsequent logic.
1947 This method is called by :meth:`get_visible_data()`.
1948 """
1949 if isinstance(data, orm.Query):
1950 pager = SqlalchemyOrmPage(
1951 data, items_per_page=self.pagesize, page=self.page
1952 )
1954 else:
1955 pager = paginate.Page(data, items_per_page=self.pagesize, page=self.page)
1957 # pager may have detected that our current page is outside the
1958 # valid range. if so we should update ourself to match
1959 if pager.page != self.page:
1960 self.page = pager.page
1961 key = f"grid.{self.key}.page"
1962 if key in self.request.session:
1963 self.request.session[key] = self.page
1965 # and re-make the pager just to be safe (?)
1966 pager = self.paginate_data(data)
1968 return pager
1970 ##############################
1971 # rendering methods
1972 ##############################
1974 def render_batch_id(self, obj, key, value): # pylint: disable=unused-argument
1975 """
1976 Column renderer for batch ID values.
1978 This is not used automatically but you can use it explicitly::
1980 grid.set_renderer('foo', 'batch_id')
1981 """
1982 if value is None:
1983 return ""
1985 batch_id = int(value)
1986 return f"{batch_id:08d}"
1988 def render_boolean(self, obj, key, value): # pylint: disable=unused-argument
1989 """
1990 Column renderer for boolean values.
1992 This calls
1993 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_boolean()`
1994 for the return value.
1996 This may be used automatically per
1997 :meth:`set_default_renderers()` or you can use it explicitly::
1999 grid.set_renderer('foo', 'boolean')
2000 """
2001 return self.app.render_boolean(value)
2003 def render_currency( # pylint: disable=unused-argument
2004 self, obj, key, value, **kwargs
2005 ):
2006 """
2007 Column renderer for currency values.
2009 This calls
2010 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()`
2011 for the return value.
2013 This is not used automatically but you can use it explicitly::
2015 grid.set_renderer('foo', 'currency')
2016 grid.set_renderer('foo', 'currency', scale=4)
2017 """
2018 return self.app.render_currency(value, **kwargs)
2020 def render_date(self, obj, key, value): # pylint: disable=unused-argument
2021 """
2022 Column renderer for :class:`python:datetime.date` values.
2024 This calls
2025 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_date()`
2026 for the return value.
2028 This may be used automatically per
2029 :meth:`set_default_renderers()` or you can use it explicitly::
2031 grid.set_renderer('foo', 'date')
2032 """
2033 try:
2034 dt = getattr(obj, key)
2035 except AttributeError:
2036 dt = obj[key]
2037 return self.app.render_date(dt)
2039 def render_datetime(self, obj, key, value): # pylint: disable=unused-argument
2040 """
2041 Column renderer for :class:`python:datetime.datetime` values.
2043 This calls
2044 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()`
2045 for the return value.
2047 This may be used automatically per
2048 :meth:`set_default_renderers()` or you can use it explicitly::
2050 grid.set_renderer('foo', 'datetime')
2051 """
2052 try:
2053 dt = getattr(obj, key)
2054 except AttributeError:
2055 dt = obj[key]
2056 return self.app.render_datetime(dt, html=True)
2058 def render_enum(self, obj, key, value, enum=None):
2059 """
2060 Custom grid value renderer for "enum" fields.
2062 See also :meth:`set_enum()`.
2064 :param enum: Enum class for the field. This should be an
2065 instance of :class:`~python:enum.Enum` or else a dict.
2067 To use this feature for your grid::
2069 from enum import Enum
2071 class MyEnum(Enum):
2072 ONE = 1
2073 TWO = 2
2074 THREE = 3
2076 grid.set_enum("my_enum_field", MyEnum)
2078 Or, perhaps more common::
2080 myenum = {
2081 1: "ONE",
2082 2: "TWO",
2083 3: "THREE",
2084 }
2086 grid.set_enum("my_enum_field", myenum)
2087 """
2088 if enum:
2090 if isinstance(enum, EnumType):
2091 if raw_value := obj[key]:
2092 return raw_value.value
2094 if isinstance(enum, dict):
2095 return enum.get(value, value)
2097 return value
2099 def render_percent( # pylint: disable=unused-argument
2100 self, obj, key, value, **kwargs
2101 ):
2102 """
2103 Column renderer for percentage values.
2105 This calls
2106 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_percent()`
2107 for the return value.
2109 This is not used automatically but you can use it explicitly::
2111 grid.set_renderer('foo', 'percent')
2112 """
2113 return self.app.render_percent(value, **kwargs)
2115 def render_quantity(self, obj, key, value): # pylint: disable=unused-argument
2116 """
2117 Column renderer for quantity values.
2119 This calls
2120 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()`
2121 for the return value.
2123 This is not used automatically but you can use it explicitly::
2125 grid.set_renderer('foo', 'quantity')
2126 """
2127 return self.app.render_quantity(value)
2129 def render_table_element(
2130 self, form=None, template="/grids/table_element.mako", **context
2131 ):
2132 """
2133 Render a simple Vue table element for the grid.
2135 This is what you want for a "simple" grid which does not
2136 require a unique Vue component, but can instead use the
2137 standard table component.
2139 This returns something like:
2141 .. code-block:: html
2143 <b-table :data="gridContext['mykey'].data">
2144 <!-- columns etc. -->
2145 </b-table>
2147 See :meth:`render_vue_template()` for a more complete variant.
2149 Actual output will of course depend on grid attributes,
2150 :attr:`key`, :attr:`columns` etc.
2152 :param form: Reference to the
2153 :class:`~wuttaweb.forms.base.Form` instance which
2154 "contains" this grid. This is needed in order to ensure
2155 the grid data is available to the form Vue component.
2157 :param template: Path to Mako template which is used to render
2158 the output.
2160 .. note::
2162 The above example shows ``gridContext['mykey'].data`` as
2163 the Vue data reference. This should "just work" if you
2164 provide the correct ``form`` arg and the grid is contained
2165 directly by that form's Vue component.
2167 However, this may not account for all use cases. For now
2168 we wait and see what comes up, but know the dust may not
2169 yet be settled here.
2170 """
2172 # nb. must register data for inclusion on page template
2173 if form:
2174 form.add_grid_vue_context(self)
2176 # otherwise logic is the same, just different template
2177 return self.render_vue_template(template=template, **context)
2179 def render_vue_tag(self, **kwargs):
2180 """
2181 Render the Vue component tag for the grid.
2183 By default this simply returns:
2185 .. code-block:: html
2187 <wutta-grid></wutta-grid>
2189 The actual output will depend on various grid attributes, in
2190 particular :attr:`vue_tagname`.
2191 """
2192 return HTML.tag(self.vue_tagname, **kwargs)
2194 def render_vue_template(self, template="/grids/vue_template.mako", **context):
2195 """
2196 Render the Vue template block for the grid.
2198 This is what you want for a "full-featured" grid which will
2199 exist as its own unique Vue component on the frontend.
2201 This returns something like:
2203 .. code-block:: none
2205 <script type="text/x-template" id="wutta-grid-template">
2206 <b-table>
2207 <!-- columns etc. -->
2208 </b-table>
2209 </script>
2211 <script>
2212 WuttaGridData = {}
2213 WuttaGrid = {
2214 template: 'wutta-grid-template',
2215 }
2216 </script>
2218 .. todo::
2220 Why can't Sphinx render the above code block as 'html' ?
2222 It acts like it can't handle a ``<script>`` tag at all?
2224 See :meth:`render_table_element()` for a simpler variant.
2226 Actual output will of course depend on grid attributes,
2227 :attr:`vue_tagname` and :attr:`columns` etc.
2229 :param template: Path to Mako template which is used to render
2230 the output.
2231 """
2232 context["grid"] = self
2233 context.setdefault("request", self.request)
2234 output = render(template, context)
2235 return HTML.literal(output)
2237 def render_vue_finalize(self):
2238 """
2239 Render the Vue "finalize" script for the grid.
2241 By default this simply returns:
2243 .. code-block:: html
2245 <script>
2246 WuttaGrid.data = function() { return WuttaGridData }
2247 Vue.component('wutta-grid', WuttaGrid)
2248 </script>
2250 The actual output may depend on various grid attributes, in
2251 particular :attr:`vue_tagname`.
2252 """
2253 return render_vue_finalize(self.vue_tagname, self.vue_component)
2255 def get_vue_columns(self):
2256 """
2257 Returns a list of Vue-compatible column definitions.
2259 This uses :attr:`columns` as the basis; each definition
2260 returned will be a dict in this format::
2262 {
2263 'field': 'foo',
2264 'label': "Foo",
2265 'sortable': True,
2266 'searchable': False,
2267 }
2269 The full format is determined by Buefy; see the Column section
2270 in its `Table docs
2271 <https://buefy.org/documentation/table/#api-view>`_.
2273 See also :meth:`get_vue_context()`.
2274 """
2275 if not self.columns:
2276 raise ValueError(f"you must define columns for the grid! key = {self.key}")
2278 columns = []
2279 for name in self.columns:
2280 columns.append(
2281 {
2282 "field": name,
2283 "label": self.get_label(name),
2284 "hidden": self.is_hidden(name),
2285 "sortable": self.is_sortable(name),
2286 "searchable": self.is_searchable(name),
2287 }
2288 )
2289 return columns
2291 def get_vue_active_sorters(self):
2292 """
2293 Returns a list of Vue-compatible column sorter definitions.
2295 The list returned is the same as :attr:`active_sorters`;
2296 however the format used in Vue is different. So this method
2297 just "converts" them to the required format, e.g.::
2299 # active_sorters format
2300 {'key': 'name', 'dir': 'asc'}
2302 # get_vue_active_sorters() format
2303 {'field': 'name', 'order': 'asc'}
2305 :returns: The :attr:`active_sorters` list, converted as
2306 described above.
2307 """
2308 sorters = []
2309 for sorter in self.active_sorters:
2310 sorters.append({"field": sorter["key"], "order": sorter["dir"]})
2311 return sorters
2313 def get_vue_first_sorter(self):
2314 """
2315 Returns the first active sorter, if applicable.
2317 This method is used to declare the initial sort for a simple
2318 table component, i.e. for use with the ``table-element.mako``
2319 template. It generally is assumed that frontend sorting is in
2320 use, as opposed to backend sorting, although it should work
2321 for either scenario.
2323 This checks :attr:`active_sorters` and if set, will use the
2324 first sorter from that. Note that ``active_sorters`` will
2325 *not* be set unless :meth:`load_settings()` has been called.
2327 Otherwise this will use the first sorter from
2328 :attr:`sort_defaults` which is defined in constructor.
2330 :returns: The first sorter in format ``[sortkey, sortdir]``,
2331 or ``None``.
2332 """
2333 if self.active_sorters:
2334 sorter = self.active_sorters[0]
2335 return [sorter["key"], sorter["dir"]]
2337 if self.sort_defaults:
2338 sorter = self.sort_defaults[0]
2339 return [sorter.sortkey, sorter.sortdir]
2341 return None
2343 def get_vue_filters(self):
2344 """
2345 Returns a list of Vue-compatible filter definitions.
2347 This returns the full set of :attr:`filters` but represents
2348 each as a simple dict with the filter state.
2349 """
2350 filters = []
2351 for filtr in self.filters.values():
2353 choices = []
2354 choice_labels = {}
2355 if filtr.choices:
2356 choices = list(filtr.choices)
2357 choice_labels = dict(filtr.choices)
2359 filters.append(
2360 {
2361 "key": filtr.key,
2362 "data_type": filtr.data_type,
2363 "active": filtr.active,
2364 "visible": filtr.active,
2365 "verbs": filtr.get_verbs(),
2366 "verb_labels": filtr.get_verb_labels(),
2367 "valueless_verbs": filtr.get_valueless_verbs(),
2368 "verb": filtr.verb,
2369 "choices": choices,
2370 "choice_labels": choice_labels,
2371 "value": filtr.value,
2372 "label": filtr.label,
2373 }
2374 )
2375 return filters
2377 def object_to_dict(self, obj): # pylint: disable=empty-docstring
2378 """ """
2379 try:
2380 dct = dict(obj)
2381 except TypeError:
2382 dct = dict(obj.__dict__)
2383 dct.pop("_sa_instance_state", None)
2384 return dct
2386 def get_vue_context(self):
2387 """
2388 Returns a dict of context for the grid, for use with the Vue
2389 component. This contains the following keys:
2391 * ``data`` - list of Vue-compatible data records
2392 * ``row_classes`` - dict of per-row CSS classes
2394 This first calls :meth:`get_visible_data()` to get the
2395 original data set. Each record is converted to a dict.
2397 Then it calls :func:`~wuttaweb.util.make_json_safe()` to
2398 ensure each record can be serialized to JSON.
2400 Then it invokes any :attr:`renderers` which are defined, to
2401 obtain the "final" values for each record.
2403 Then it adds a URL key/value for each of the :attr:`actions`
2404 defined, to each record.
2406 Then it calls :meth:`get_row_class()` for each record. If a
2407 value is returned, it is added to the ``row_classes`` dict.
2408 Note that this dict is keyed by "zero-based row sequence as
2409 string" - the Vue component expects that.
2411 :returns: Dict of grid data/CSS context as described above.
2412 """
2413 original_data = self.get_visible_data()
2415 # loop thru data
2416 data = []
2417 row_classes = {}
2418 for i, record in enumerate(original_data, 1):
2419 original_record = record
2421 # convert record to new dict
2422 record = self.object_to_dict(record)
2424 # discard non-declared fields
2425 record = {field: record[field] for field in record if field in self.columns}
2427 # make all values safe for json
2428 record = make_json_safe(record, warn=False)
2430 # customize value rendering where applicable
2431 for key, renderer in self.renderers.items():
2432 value = record.get(key, None)
2433 record[key] = renderer(original_record, key, value)
2435 # add action urls to each record
2436 for action in self.actions:
2437 key = f"_action_url_{action.key}"
2438 if key not in record:
2439 url = action.get_url(original_record, i)
2440 if url:
2441 record[key] = url
2443 # set row css class if applicable
2444 css_class = self.get_row_class(original_record, record, i)
2445 if css_class:
2446 # nb. use *string* zero-based index, for js compat
2447 row_classes[str(i - 1)] = css_class
2449 data.append(record)
2451 return {
2452 "data": data,
2453 "row_classes": row_classes,
2454 }
2456 def get_vue_data(self): # pylint: disable=empty-docstring
2457 """ """
2458 warnings.warn(
2459 "grid.get_vue_data() is deprecated; "
2460 "please use grid.get_vue_context() instead",
2461 DeprecationWarning,
2462 stacklevel=2,
2463 )
2464 return self.get_vue_context()["data"]
2466 def get_row_class(self, obj, data, i):
2467 """
2468 Returns the row CSS ``class`` attribute for the given record.
2469 This method is called by :meth:`get_vue_context()`.
2471 This will inspect/invoke :attr:`row_class` and return the
2472 value obtained from there.
2474 :param obj: Reference to the original model instance.
2476 :param data: Dict of record data for the instance; part of the
2477 Vue grid data set in/from :meth:`get_vue_context()`.
2479 :param i: One-based sequence for this object/record (row)
2480 within the grid.
2482 :returns: String of CSS class name(s), or ``None``.
2483 """
2484 if self.row_class:
2485 if callable(self.row_class):
2486 return self.row_class(obj, data, i)
2487 return self.row_class
2488 return None
2490 def get_vue_pager_stats(self):
2491 """
2492 Returns a simple dict with current grid pager stats.
2494 This is used when :attr:`paginate_on_backend` is in effect.
2495 """
2496 pager = self.pager
2497 return {
2498 "item_count": pager.item_count,
2499 "items_per_page": pager.items_per_page,
2500 "page": pager.page,
2501 "page_count": pager.page_count,
2502 "first_item": pager.first_item,
2503 "last_item": pager.last_item,
2504 }
2507class GridAction: # pylint: disable=too-many-instance-attributes
2508 """
2509 Represents a "row action" hyperlink within a grid context.
2511 All such actions are displayed as a group, in a dedicated
2512 **Actions** column in the grid. So each row in the grid has its
2513 own set of action links.
2515 A :class:`Grid` can have one (or zero) or more of these in its
2516 :attr:`~Grid.actions` list. You can call
2517 :meth:`~wuttaweb.views.base.View.make_grid_action()` to add custom
2518 actions from within a view.
2520 :param request: Current :term:`request` object.
2522 .. note::
2524 Some parameters are not explicitly described above. However
2525 their corresponding attributes are described below.
2527 .. attribute:: key
2529 String key for the action (e.g. ``'edit'``), unique within the
2530 grid.
2532 .. attribute:: label
2534 Label to be displayed for the action link. If not set, will be
2535 generated from :attr:`key` by calling
2536 :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.make_title()`.
2538 See also :meth:`render_label()`.
2540 .. attribute:: url
2542 URL for the action link, if applicable. This *can* be a simple
2543 string, however that will cause every row in the grid to have
2544 the same URL for this action.
2546 A better way is to specify a callable which can return a unique
2547 URL for each record. The callable should expect ``(obj, i)``
2548 args, for instance::
2550 def myurl(obj, i):
2551 return request.route_url('widgets.view', uuid=obj.uuid)
2553 action = GridAction(request, 'view', url=myurl)
2555 See also :meth:`get_url()`.
2557 .. attribute:: target
2559 Optional ``target`` attribute for the ``<a>`` tag.
2561 .. attribute:: click_handler
2563 Optional JS click handler for the action. This value will be
2564 rendered as-is within the final grid template, hence the JS
2565 string must be callable code. Note that ``props.row`` will be
2566 available in the calling context, so a couple of examples:
2568 * ``deleteThisThing(props.row)``
2569 * ``$emit('do-something', props.row)``
2571 .. attribute:: icon
2573 Name of icon to be shown for the action link.
2575 See also :meth:`render_icon()`.
2577 .. attribute:: link_class
2579 Optional HTML class attribute for the action's ``<a>`` tag.
2580 """
2582 def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
2583 self,
2584 request,
2585 key,
2586 label=None,
2587 url=None,
2588 target=None,
2589 click_handler=None,
2590 icon=None,
2591 link_class=None,
2592 ):
2593 self.request = request
2594 self.config = self.request.wutta_config
2595 self.app = self.config.get_app()
2596 self.key = key
2597 self.url = url
2598 self.target = target
2599 self.click_handler = click_handler
2600 self.label = label or self.app.make_title(key)
2601 self.icon = icon or key
2602 self.link_class = link_class or ""
2604 def render_icon_and_label(self):
2605 """
2606 Render the HTML snippet for action link icon and label.
2608 Default logic returns the output from :meth:`render_icon()`
2609 and :meth:`render_label()`.
2610 """
2611 html = [
2612 self.render_icon(),
2613 HTML.literal(" "),
2614 self.render_label(),
2615 ]
2616 return HTML.tag("span", c=html, style="white-space: nowrap;")
2618 def render_icon(self):
2619 """
2620 Render the HTML snippet for the action link icon.
2622 This uses :attr:`icon` to identify the named icon to be shown.
2623 Output is something like (here ``'trash'`` is the icon name):
2625 .. code-block:: html
2627 <i class="fas fa-trash"></i>
2629 See also :meth:`render_icon_and_label()`.
2630 """
2631 if self.request.use_oruga:
2632 return HTML.tag("o-icon", icon=self.icon)
2634 return HTML.tag("i", class_=f"fas fa-{self.icon}")
2636 def render_label(self):
2637 """
2638 Render the label text for the action link.
2640 Default behavior is to return :attr:`label` as-is.
2642 See also :meth:`render_icon_and_label()`.
2643 """
2644 return self.label
2646 def get_url(self, obj, i=None):
2647 """
2648 Returns the action link URL for the given object (model
2649 instance).
2651 If :attr:`url` is a simple string, it is returned as-is.
2653 But if :attr:`url` is a callable (which is typically the most
2654 useful), that will be called with the same ``(obj, i)`` args
2655 passed along.
2657 :param obj: Model instance of whatever type the parent grid is
2658 setup to use.
2660 :param i: One-based sequence for the object's row within the
2661 parent grid.
2663 See also :attr:`url`.
2664 """
2665 if callable(self.url):
2666 return self.url(obj, i)
2668 return self.url