Source code for wuttaweb.grids.base

# -*- coding: utf-8; -*-
################################################################################
#
#  wuttaweb -- Web App for Wutta Framework
#  Copyright © 2024 Lance Edgar
#
#  This file is part of Wutta Framework.
#
#  Wutta Framework is free software: you can redistribute it and/or modify it
#  under the terms of the GNU General Public License as published by the Free
#  Software Foundation, either version 3 of the License, or (at your option) any
#  later version.
#
#  Wutta Framework is distributed in the hope that it will be useful, but
#  WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
#  FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
#  more details.
#
#  You should have received a copy of the GNU General Public License along with
#  Wutta Framework.  If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Base grid classes
"""

import functools
import json
import logging
import warnings
from collections import namedtuple, OrderedDict

import sqlalchemy as sa
from sqlalchemy import orm

import paginate
from paginate_sqlalchemy import SqlalchemyOrmPage
from pyramid.renderers import render
from webhelpers2.html import HTML

from wuttaweb.db import Session
from wuttaweb.util import FieldList, get_model_fields, make_json_safe
from wuttjamaican.util import UNSPECIFIED
from wuttaweb.grids.filters import default_sqlalchemy_filters, VerbNotSupported


log = logging.getLogger(__name__)


SortInfo = namedtuple('SortInfo', ['sortkey', 'sortdir'])
SortInfo.__doc__ = """
Named tuple to track sorting info.

Elements of :attr:`~Grid.sort_defaults` will be of this type.
"""

[docs] class Grid: """ Base class for all :term:`grids <grid>`. :param request: Reference to current :term:`request` object. :param columns: List of column names for the grid. This is optional; if not specified an attempt will be made to deduce the list automatically. See also :attr:`columns`. .. note:: Some parameters are not explicitly described above. However their corresponding attributes are described below. Grid instances contain the following attributes: .. attribute:: key Presumably unique key for the grid; used to track per-grid sort/filter settings etc. .. attribute:: vue_tagname String name for Vue component tag. By default this is ``'wutta-grid'``. See also :meth:`render_vue_tag()`. .. attribute:: model_class Model class for the grid, if applicable. When set, this is usually a SQLAlchemy mapped class. This may be used for deriving the default :attr:`columns` among other things. .. attribute:: columns :class:`~wuttaweb.util.FieldList` instance containing string column names for the grid. Columns will appear in the same order as they are in this list. See also :meth:`set_columns()` and :meth:`get_columns()`. .. attribute:: data Data set for the grid. This should either be a list of dicts (or objects with dict-like access to fields, corresponding to model records) or else an object capable of producing such a list, e.g. SQLAlchemy query. This is the "full" data set; see also :meth:`get_visible_data()`. .. attribute:: labels Dict of column label overrides. See also :meth:`get_label()` and :meth:`set_label()`. .. attribute:: renderers Dict of column (cell) value renderer overrides. See also :meth:`set_renderer()`. .. attribute:: row_class This represents the CSS ``class`` attribute for a row within the grid. Default is ``None``. This can be a simple string, in which case the same class is applied to all rows. Or it can be a callable, which can then return different class(es) depending on each row. The callable must take three args: ``(obj, data, i)`` - for example:: def my_row_class(obj, data, i): if obj.archived: return 'poser-archived' grid = Grid(request, key='foo', row_class=my_row_class) See :meth:`get_row_class()` for more info. .. attribute:: actions List of :class:`GridAction` instances represenging action links to be shown for each record in the grid. .. attribute:: linked_columns List of column names for which auto-link behavior should be applied. See also :meth:`set_link()` and :meth:`is_linked()`. .. attribute:: sortable Boolean indicating whether *any* column sorting is allowed for the grid. Default is ``False``. See also :attr:`sort_multiple` and :attr:`sort_on_backend`. .. attribute:: sort_multiple Boolean indicating whether "multi-column" sorting is allowed. Default is ``True``; if this is ``False`` then only one column may be sorted at a time. Only relevant if :attr:`sortable` is true, but applies to both frontend and backend sorting. .. warning:: This feature is limited by frontend JS capabilities, regardless of :attr:`sort_on_backend` value (i.e. for both frontend and backend sorting). In particular, if the app theme templates use Vue 2 + Buefy, then multi-column sorting should work. But not so with Vue 3 + Oruga, *yet* - see also the `open issue <https://github.com/oruga-ui/oruga/issues/962>`_ regarding that. For now this flag is simply ignored for Vue 3 + Oruga templates. Additionally, even with Vue 2 + Buefy this flag can only allow the user to *request* a multi-column sort. Whereas the "default sort" in the Vue component can only ever be single-column, regardless of :attr:`sort_defaults`. .. attribute:: sort_on_backend Boolean indicating whether the grid data should be sorted on the backend. Default is ``True``. If ``False``, the client-side Vue component will handle the sorting. Only relevant if :attr:`sortable` is also true. .. attribute:: sorters Dict of functions to use for backend sorting. Only relevant if both :attr:`sortable` and :attr:`sort_on_backend` are true. See also :meth:`set_sorter()`, :attr:`sort_defaults` and :attr:`active_sorters`. .. attribute:: sort_defaults List of options to be used for default sorting, until the user requests a different sorting method. This list usually contains either zero or one elements. (More are allowed if :attr:`sort_multiple` is true, but see note below.) Each list element is a :class:`SortInfo` tuple and must correspond to an entry in :attr:`sorters`. Used with both frontend and backend sorting. See also :meth:`set_sort_defaults()` and :attr:`active_sorters`. .. warning:: While the grid logic is built to handle multi-column sorting, this feature is limited by frontend JS capabilities. Even if ``sort_defaults`` contains multiple entries (i.e. for multi-column sorting to be used "by default" for the grid), only the *first* entry (i.e. single-column sorting) will actually be used as the default for the Vue component. See also :attr:`sort_multiple` for more details. .. attribute:: active_sorters List of sorters currently in effect for the grid; used by :meth:`sort_data()`. Whereas :attr:`sorters` defines all "available" sorters, and :attr:`sort_defaults` defines the "default" sorters, ``active_sorters`` defines the "current/effective" sorters. This attribute is set by :meth:`load_settings()`; until that is called it will not exist. This is conceptually a "subset" of :attr:`sorters` although a different format is used here:: grid.active_sorters = [ {'key': 'name', 'dir': 'asc'}, {'key': 'id', 'dir': 'asc'}, ] The above is for example only; there is usually no reason to set this attribute directly. This list may contain multiple elements only if :attr:`sort_multiple` is true. Otherewise it should always have either zero or one element. .. attribute:: paginated Boolean indicating whether the grid data should be paginated, i.e. split up into pages. Default is ``False`` which means all data is shown at once. See also :attr:`pagesize` and :attr:`page`, and :attr:`paginate_on_backend`. .. attribute:: paginate_on_backend Boolean indicating whether the grid data should be paginated on the backend. Default is ``True`` which means only one "page" of data is sent to the client-side component. If this is ``False``, the full set of grid data is sent for each request, and the client-side Vue component will handle the pagination. Only relevant if :attr:`paginated` is also true. .. attribute:: pagesize_options List of "page size" options for the grid. See also :attr:`pagesize`. Only relevant if :attr:`paginated` is true. If not specified, constructor will call :meth:`get_pagesize_options()` to get the value. .. attribute:: pagesize Number of records to show in a data page. See also :attr:`pagesize_options` and :attr:`page`. Only relevant if :attr:`paginated` is true. If not specified, constructor will call :meth:`get_pagesize()` to get the value. .. attribute:: page The current page number (of data) to display in the grid. See also :attr:`pagesize`. Only relevant if :attr:`paginated` is true. If not specified, constructor will assume ``1`` (first page). .. attribute:: searchable_columns Set of columns declared as searchable for the Vue component. See also :meth:`set_searchable()` and :meth:`is_searchable()`. .. attribute:: filterable Boolean indicating whether the grid should show a "filters" section where user can filter data in various ways. Default is ``False``. .. attribute:: filters Dict of :class:`~wuttaweb.grids.filters.GridFilter` instances available for use with backend filtering. Only relevant if :attr:`filterable` is true. See also :meth:`set_filter()`. .. attribute:: filter_defaults Dict containing default state preferences for the filters. See also :meth:`set_filter_defaults()`. .. attribute:: joiners Dict of "joiner" functions for use with backend filtering and sorting. See :meth:`set_joiner()` for more info. .. attribute:: tools Dict of "tool" elements for the grid. Tools are usually buttons (e.g. "Delete Results"), shown on top right of the grid. The keys for this dict are somewhat arbitrary, defined by the caller. Values should be HTML literal elements. See also :meth:`add_tool()` and :meth:`set_tools()`. """ def __init__( self, request, vue_tagname='wutta-grid', model_class=None, key=None, columns=None, data=None, labels={}, renderers={}, row_class=None, actions=[], linked_columns=[], sortable=False, sort_multiple=True, sort_on_backend=True, sorters=None, sort_defaults=None, paginated=False, paginate_on_backend=True, pagesize_options=None, pagesize=None, page=1, searchable_columns=None, filterable=False, filters=None, filter_defaults=None, joiners=None, tools=None, ): self.request = request self.vue_tagname = vue_tagname self.model_class = model_class self.key = key self.data = data self.labels = labels or {} self.renderers = renderers or {} self.row_class = row_class self.actions = actions or [] self.linked_columns = linked_columns or [] self.joiners = joiners or {} self.config = self.request.wutta_config self.app = self.config.get_app() self.set_columns(columns or self.get_columns()) self.set_default_renderers() self.set_tools(tools) # sorting self.sortable = sortable self.sort_multiple = sort_multiple if self.sort_multiple and self.request.use_oruga: log.warning("grid.sort_multiple is not implemented for Oruga-based templates") self.sort_multiple = False self.sort_on_backend = sort_on_backend if sorters is not None: self.sorters = sorters elif self.sortable and self.sort_on_backend: self.sorters = self.make_backend_sorters() else: self.sorters = {} self.set_sort_defaults(sort_defaults or []) # paging self.paginated = paginated self.paginate_on_backend = paginate_on_backend self.pagesize_options = pagesize_options or self.get_pagesize_options() self.pagesize = pagesize or self.get_pagesize() self.page = page # searching self.searchable_columns = set(searchable_columns or []) # filtering self.filterable = filterable if filters is not None: self.filters = filters elif self.filterable: self.filters = self.make_backend_filters() else: self.filters = {} self.set_filter_defaults(**(filter_defaults or {}))
[docs] def get_columns(self): """ Returns the official list of column names for the grid, or ``None``. If :attr:`columns` is set and non-empty, it is returned. Or, if :attr:`model_class` is set, the field list is derived from that, via :meth:`get_model_columns()`. Otherwise ``None`` is returned. """ if hasattr(self, 'columns') and self.columns: return self.columns columns = self.get_model_columns() if columns: return columns return []
[docs] def get_model_columns(self, model_class=None): """ This method is a shortcut which calls :func:`~wuttaweb.util.get_model_fields()`. :param model_class: Optional model class for which to return fields. If not set, the grid's :attr:`model_class` is assumed. """ return get_model_fields(self.config, model_class=model_class or self.model_class)
@property def vue_component(self): """ String name for the Vue component, e.g. ``'WuttaGrid'``. This is a generated value based on :attr:`vue_tagname`. """ words = self.vue_tagname.split('-') return ''.join([word.capitalize() for word in words])
[docs] def set_columns(self, columns): """ Explicitly set the list of grid columns. This will overwrite :attr:`columns` with a new :class:`~wuttaweb.util.FieldList` instance. :param columns: List of string column names. """ self.columns = FieldList(columns)
[docs] def append(self, *keys): """ Add some columns(s) to the grid. This is a convenience to allow adding multiple columns at once:: grid.append('first_field', 'second_field', 'third_field') It will add each column to :attr:`columns`. """ for key in keys: if key not in self.columns: self.columns.append(key)
[docs] def remove(self, *keys): """ Remove some column(s) from the grid. This is a convenience to allow removal of multiple columns at once:: grid.remove('first_field', 'second_field', 'third_field') It will remove each column from :attr:`columns`. """ for key in keys: if key in self.columns: self.columns.remove(key)
[docs] def set_label(self, key, label, column_only=False): """ Set/override the label for a column. :param key: Name of column. :param label: New label for the column header. :param column_only: Boolean indicating whether the label should be applied *only* to the column header (if ``True``), vs. applying also to the filter (if ``False``). See also :meth:`get_label()`. Label overrides are tracked via :attr:`labels`. """ self.labels[key] = label if not column_only and key in self.filters: self.filters[key].label = label
[docs] def get_label(self, key): """ Returns the label text for a given column. If no override is defined, the label is derived from ``key``. See also :meth:`set_label()`. """ if key in self.labels: return self.labels[key] return self.app.make_title(key)
[docs] def set_renderer(self, key, renderer, **kwargs): """ Set/override the value renderer for a column. :param key: Name of column. :param renderer: Callable as described below. Depending on the nature of grid data, sometimes a cell's "as-is" value will be undesirable for display purposes. The logic in :meth:`get_vue_context()` will first "convert" all grid data as necessary so that it is at least JSON-compatible. But then it also will invoke a renderer override (if defined) to obtain the "final" cell value. A renderer must be a callable which accepts 3 args ``(record, key, value)``: * ``record`` is the "original" record from :attr:`data` * ``key`` is the column name * ``value`` is the JSON-safe cell value Whatever the renderer returns, is then used as final cell value. For instance:: from webhelpers2.html import HTML def render_foo(record, key, value): return HTML.literal("<p>this is the final cell value</p>") grid = Grid(request, columns=['foo', 'bar']) grid.set_renderer('foo', render_foo) Renderer overrides are tracked via :attr:`renderers`. """ if kwargs: renderer = functools.partial(renderer, **kwargs) self.renderers[key] = renderer
[docs] def set_default_renderers(self): """ Set default column value renderers, where applicable. This will add new entries to :attr:`renderers` for columns whose data type implies a default renderer should be used. This is generally only possible if :attr:`model_class` is set to a valid SQLAlchemy mapped class. This (for now?) only looks for :class:`sqlalchemy:sqlalchemy.types.DateTime` columns and if any are found, they are configured to use :meth:`render_datetime()`. """ if not self.model_class: return for key in self.columns: if key in self.renderers: continue attr = getattr(self.model_class, key, None) if attr: prop = getattr(attr, 'prop', None) if prop and isinstance(prop, orm.ColumnProperty): column = prop.columns[0] if isinstance(column.type, sa.DateTime): self.set_renderer(key, self.render_datetime)
[docs] def is_linked(self, key): """ Returns boolean indicating if auto-link behavior is enabled for a given column. See also :meth:`set_link()` which describes auto-link behavior. :param key: Column key as string. """ if self.linked_columns: if key in self.linked_columns: return True return False
[docs] def set_searchable(self, key, searchable=True): """ (Un)set the given column's searchable flag for the Vue component. See also :meth:`is_searchable()`. Flags are tracked via :attr:`searchable_columns`. """ if searchable: self.searchable_columns.add(key) elif key in self.searchable_columns: self.searchable_columns.remove(key)
[docs] def is_searchable(self, key): """ Check if the given column is marked as searchable for the Vue component. See also :meth:`set_searchable()`. """ return key in self.searchable_columns
[docs] def add_action(self, key, **kwargs): """ Convenience to add a new :class:`GridAction` instance to the grid's :attr:`actions` list. """ self.actions.append(GridAction(self.request, key, **kwargs))
[docs] def set_tools(self, tools): """ Set the :attr:`tools` attribute using the given tools collection. This will normalize the list/dict to desired internal format. """ if tools and isinstance(tools, list): if not any([isinstance(t, (tuple, list)) for t in tools]): tools = [(self.app.make_uuid(), t) for t in tools] self.tools = OrderedDict(tools or [])
[docs] def add_tool(self, html, key=None): """ Add a new HTML snippet to the :attr:`tools` dict. :param html: HTML literal for the tool element. :param key: Optional key to use when adding to the :attr:`tools` dict. If not specified, a random string is generated. See also :meth:`set_tools()`. """ if not key: key = self.app.make_uuid() self.tools[key] = html
############################## # joining methods ##############################
[docs] def set_joiner(self, key, joiner): """ Set/override the backend joiner for a column. A "joiner" is sometimes needed when a column with "related but not primary" data is involved in a sort or filter operation. A sorter or filter may need to "join" other table(s) to get at the appropriate data. But if a given column has both a sorter and filter defined, and both are used at the same time, we don't want the join to happen twice. Hence we track joiners separately, also keyed by column name (as are sorters and filters). When a column's sorter **and/or** filter is needed, the joiner will be invoked. :param key: Name of column. :param joiner: A joiner callable, as described below. A joiner callable must accept just one ``(data)`` arg and return the "joined" data/query, for example:: model = app.model grid = Grid(request, model_class=model.Person) def join_external_profile_value(query): return query.join(model.ExternalProfile) def sort_external_profile(query, direction): sortspec = getattr(model.ExternalProfile.description, direction) return query.order_by(sortspec()) grid.set_joiner('external_profile', join_external_profile) grid.set_sorter('external_profile', sort_external_profile) See also :meth:`remove_joiner()`. Backend joiners are tracked via :attr:`joiners`. """ self.joiners[key] = joiner
[docs] def remove_joiner(self, key): """ Remove the backend joiner for a column. Note that this removes the joiner *function*, so there is no way to apply joins for this column unless another joiner is later defined for it. See also :meth:`set_joiner()`. """ self.joiners.pop(key, None)
############################## # sorting methods ##############################
[docs] def make_backend_sorters(self, sorters=None): """ Make backend sorters for all columns in the grid. This is called by the constructor, if both :attr:`sortable` and :attr:`sort_on_backend` are true. For each column in the grid, this checks the provided ``sorters`` and if the column is not yet in there, will call :meth:`make_sorter()` to add it. .. note:: This only works if grid has a :attr:`model_class`. If not, this method just returns the initial sorters (or empty dict). :param sorters: Optional dict of initial sorters. Any existing sorters will be left intact, not replaced. :returns: Final dict of all sorters. Includes any from the initial ``sorters`` param as well as any which were created. """ sorters = sorters or {} if self.model_class: for key in self.columns: if key in sorters: continue prop = getattr(self.model_class, key, None) if (prop and hasattr(prop, 'property') and isinstance(prop.property, orm.ColumnProperty)): sorters[prop.key] = self.make_sorter(prop) return sorters
[docs] def make_sorter(self, columninfo, keyfunc=None, foldcase=True): """ Returns a function suitable for use as a backend sorter on the given column. Code usually does not need to call this directly. See also :meth:`set_sorter()`, which calls this method automatically. :param columninfo: Can be either a model property (see below), or a column name. :param keyfunc: Optional function to use as the "sort key getter" callable, if the sorter is manual (as opposed to SQLAlchemy query). More on this below. If not specified, a default function is used. :param foldcase: If the sorter is manual (not SQLAlchemy), and the column data is of text type, this may be used to automatically "fold case" for the sorting. Defaults to ``True`` since this behavior is presumably expected, but may be disabled if needed. The term "model property" is a bit technical, an example should help to clarify:: model = app.model grid = Grid(request, model_class=model.Person) # explicit property sorter = grid.make_sorter(model.Person.full_name) # property name works if grid has model class sorter = grid.make_sorter('full_name') # nb. this will *not* work person = model.Person(full_name="John Doe") sorter = grid.make_sorter(person.full_name) The ``keyfunc`` param allows you to override the way sort keys are obtained from data records (this only applies for a "manual" sort, where data is a list and not a SQLAlchemy query):: data = [ {'foo': 1}, {'bar': 2}, ] # nb. no model_class, just as an example grid = Grid(request, columns=['foo', 'bar'], data=data) def getkey(obj): if obj.get('foo') return obj['foo'] if obj.get('bar'): return obj['bar'] return '' # nb. sortfunc will ostensibly sort by 'foo' column, but in # practice it is sorted per value from getkey() above sortfunc = grid.make_sorter('foo', keyfunc=getkey) sorted_data = sortfunc(data, 'asc') :returns: A function suitable for backend sorting. This function will behave differently when it is given a SQLAlchemy query vs. a "list" of data. In either case it will return the sorted result. This function may be called as shown above. It expects 2 args: ``(data, direction)`` """ model_class = None model_property = None if isinstance(columninfo, str): key = columninfo model_class = self.model_class model_property = getattr(self.model_class, key, None) else: model_property = columninfo model_class = model_property.class_ key = model_property.key def sorter(data, direction): # query is sorted with order_by() if isinstance(data, orm.Query): if not model_property: raise TypeError(f"grid sorter for '{key}' does not map to a model property") query = data return query.order_by(getattr(model_property, direction)()) # other data is sorted manually. first step is to # identify the function used to produce a sort key for # each record kfunc = keyfunc if not kfunc: if model_property: # TODO: may need this for String etc. as well? if isinstance(model_property.type, sa.Text): if foldcase: kfunc = lambda obj: (obj[key] or '').lower() else: kfunc = lambda obj: obj[key] or '' if not kfunc: # nb. sorting with this can raise error if data # contains varying types, e.g. str and None kfunc = lambda obj: obj[key] # then sort the data and return return sorted(data, key=kfunc, reverse=direction == 'desc') # TODO: this should be improved; is needed in tailbone for # multi-column sorting with sqlalchemy queries if model_property: sorter._class = model_class sorter._column = model_property return sorter
[docs] def set_sorter(self, key, sortinfo=None): """ Set/override the backend sorter for a column. Only relevant if both :attr:`sortable` and :attr:`sort_on_backend` are true. :param key: Name of column. :param sortinfo: Can be either a sorter callable, or else a model property (see below). If ``sortinfo`` is a callable, it will be used as-is for the backend sorter. Otherwise :meth:`make_sorter()` will be called to obtain the backend sorter. The ``sortinfo`` will be passed along to that call; if it is empty then ``key`` will be used instead. A backend sorter callable must accept ``(data, direction)`` args and return the sorted data/query, for example:: model = app.model grid = Grid(request, model_class=model.Person) def sort_full_name(query, direction): sortspec = getattr(model.Person.full_name, direction) return query.order_by(sortspec()) grid.set_sorter('full_name', sort_full_name) See also :meth:`remove_sorter()` and :meth:`is_sortable()`. Backend sorters are tracked via :attr:`sorters`. """ sorter = None if sortinfo and callable(sortinfo): sorter = sortinfo else: sorter = self.make_sorter(sortinfo or key) self.sorters[key] = sorter
[docs] def remove_sorter(self, key): """ Remove the backend sorter for a column. Note that this removes the sorter *function*, so there is no way to sort by this column unless another sorter is later defined for it. See also :meth:`set_sorter()`. """ self.sorters.pop(key, None)
[docs] def set_sort_defaults(self, *args): """ Set the default sorting method for the grid. This sorting is used unless/until the user requests a different sorting method. ``args`` for this method are interpreted as follows: If 2 args are received, they should be for ``sortkey`` and ``sortdir``; for instance:: grid.set_sort_defaults('name', 'asc') If just one 2-tuple arg is received, it is handled similarly:: grid.set_sort_defaults(('name', 'asc')) If just one string arg is received, the default ``sortdir`` is assumed:: grid.set_sort_defaults('name') # assumes 'asc' Otherwise there should be just one list arg, elements of which are each 2-tuples of ``(sortkey, sortdir)`` info:: grid.set_sort_defaults([('name', 'asc'), ('value', 'desc')]) .. note:: Note that :attr:`sort_multiple` determines whether the grid is actually allowed to have multiple sort defaults. The defaults requested by the method call may be pruned if necessary to accommodate that. Default sorting info is tracked via :attr:`sort_defaults`. """ # convert args to sort defaults sort_defaults = [] if len(args) == 1: if isinstance(args[0], str): sort_defaults = [SortInfo(args[0], 'asc')] elif isinstance(args[0], tuple) and len(args[0]) == 2: sort_defaults = [SortInfo(*args[0])] elif isinstance(args[0], list): sort_defaults = [SortInfo(*tup) for tup in args[0]] else: raise ValueError("for just one positional arg, must pass string, 2-tuple or list") elif len(args) == 2: sort_defaults = [SortInfo(*args)] else: raise ValueError("must pass just one or two positional args") # prune if multi-column requested but not supported if len(sort_defaults) > 1 and not self.sort_multiple: log.warning("multi-column sorting is not enabled for the instance; " "list will be pruned to first element for '%s' grid: %s", self.key, sort_defaults) sort_defaults = [sort_defaults[0]] self.sort_defaults = sort_defaults
[docs] def is_sortable(self, key): """ Returns boolean indicating if a given column should allow sorting. If :attr:`sortable` is false, this always returns ``False``. For frontend sorting (i.e. :attr:`sort_on_backend` is false), this always returns ``True``. For backend sorting, may return true or false depending on whether the column is listed in :attr:`sorters`. :param key: Column key as string. See also :meth:`set_sorter()`. """ if not self.sortable: return False if self.sort_on_backend: return key in self.sorters return True
############################## # filtering methods ##############################
[docs] def make_backend_filters(self, filters=None): """ Make backend filters for all columns in the grid. This is called by the constructor, if :attr:`filterable` is true. For each column in the grid, this checks the provided ``filters`` and if the column is not yet in there, will call :meth:`make_filter()` to add it. .. note:: This only works if grid has a :attr:`model_class`. If not, this method just returns the initial filters (or empty dict). :param filters: Optional dict of initial filters. Any existing filters will be left intact, not replaced. :returns: Final dict of all filters. Includes any from the initial ``filters`` param as well as any which were created. """ filters = filters or {} if self.model_class: # TODO: i tried using self.get_model_columns() here but in # many cases that will be too aggressive. however it is # often the case that the *grid* columns are a subset of # the unerlying *table* columns. so until a better way # is found, we choose "too few" instead of "too many" # filters here. surely must improve it at some point. for key in self.columns: if key in filters: continue prop = getattr(self.model_class, key, None) if (prop and hasattr(prop, 'property') and isinstance(prop.property, orm.ColumnProperty)): filters[prop.key] = self.make_filter(prop) return filters
[docs] def make_filter(self, columninfo, **kwargs): """ Create and return a :class:`~wuttaweb.grids.filters.GridFilter` instance suitable for use on the given column. Code usually does not need to call this directly. See also :meth:`set_filter()`, which calls this method automatically. :param columninfo: Can be either a model property (see below), or a column name. :returns: A :class:`~wuttaweb.grids.filters.GridFilter` instance. """ key = kwargs.pop('key', None) # model_property is required model_property = None if kwargs.get('model_property'): model_property = kwargs['model_property'] elif isinstance(columninfo, str): key = columninfo if self.model_class: model_property = getattr(self.model_class, key, None) if not model_property: raise ValueError(f"cannot locate model property for key: {key}") else: model_property = columninfo # optional factory override factory = kwargs.pop('factory', None) if not factory: typ = model_property.type factory = default_sqlalchemy_filters.get(type(typ)) if not factory: factory = default_sqlalchemy_filters[None] # make filter kwargs['model_property'] = model_property return factory(self.request, key or model_property.key, **kwargs)
[docs] def set_filter(self, key, filterinfo=None, **kwargs): """ Set/override the backend filter for a column. Only relevant if :attr:`filterable` is true. :param key: Name of column. :param filterinfo: Can be either a :class:`~wuttweb.grids.filters.GridFilter` instance, or else a model property (see below). If ``filterinfo`` is a ``GridFilter`` instance, it will be used as-is for the backend filter. Otherwise :meth:`make_filter()` will be called to obtain the backend filter. The ``filterinfo`` will be passed along to that call; if it is empty then ``key`` will be used instead. See also :meth:`remove_filter()`. Backend filters are tracked via :attr:`filters`. """ filtr = None if filterinfo and callable(filterinfo): # filtr = filterinfo raise NotImplementedError else: kwargs['key'] = key kwargs.setdefault('label', self.get_label(key)) filtr = self.make_filter(filterinfo or key, **kwargs) self.filters[key] = filtr
[docs] def remove_filter(self, key): """ Remove the backend filter for a column. This removes the filter *instance*, so there is no way to filter by this column unless another filter is later defined for it. See also :meth:`set_filter()`. """ self.filters.pop(key, None)
[docs] def set_filter_defaults(self, **defaults): """ Set default state preferences for the grid filters. These preferences will affect the initial grid display, until user requests a different filtering method. Each kwarg should be named by filter key, and the value should be a dict of preferences for that filter. For instance:: grid.set_filter_defaults(name={'active': True, 'verb': 'contains', 'value': 'foo'}, value={'active': True}) Filter defaults are tracked via :attr:`filter_defaults`. """ filter_defaults = dict(getattr(self, 'filter_defaults', {})) for key, values in defaults.items(): filtr = filter_defaults.setdefault(key, {}) filtr.update(values) self.filter_defaults = filter_defaults
############################## # paging methods ##############################
[docs] def get_pagesize_options(self, default=None): """ Returns a list of default page size options for the grid. It will check config but if no setting exists, will fall back to:: [5, 10, 20, 50, 100, 200] :param default: Alternate default value to return if none is configured. This method is intended for use in the constructor. Code can instead access :attr:`pagesize_options` directly. """ options = self.config.get_list('wuttaweb.grids.default_pagesize_options') if options: options = [int(size) for size in options if size.isdigit()] if options: return options return default or [5, 10, 20, 50, 100, 200]
[docs] def get_pagesize(self, default=None): """ Returns the default page size for the grid. It will check config but if no setting exists, will fall back to a value from :attr:`pagesize_options` (will return ``20`` if that is listed; otherwise the "first" option). :param default: Alternate default value to return if none is configured. This method is intended for use in the constructor. Code can instead access :attr:`pagesize` directly. """ size = self.config.get_int('wuttaweb.grids.default_pagesize') if size: return size if default: return default if 20 in self.pagesize_options: return 20 return self.pagesize_options[0]
############################## # configuration methods ##############################
[docs] def load_settings(self, persist=True): """ Load all effective settings for the grid. If the request GET params (query string) contains grid settings, they are used; otherwise the settings are loaded from user session. .. note:: As of now, "sorting" and "pagination" settings are the only type supported by this logic. Settings for "filtering" coming soon... The overall logic for this method is as follows: * collect settings * apply settings to current grid * optionally save settings to user session Saving the settings to user session will allow the grid to remember its current settings when user refreshes the page, or navigates away then comes back. Therefore normally, settings are saved each time they are loaded. Note that such settings are wiped upon user logout. :param persist: Whether the collected settings should be saved to the user session. """ # initial default settings settings = {} if self.filterable: for filtr in self.filters.values(): defaults = self.filter_defaults.get(filtr.key, {}) settings[f'filter.{filtr.key}.active'] = defaults.get('active', filtr.default_active) settings[f'filter.{filtr.key}.verb'] = defaults.get('verb', filtr.get_default_verb()) settings[f'filter.{filtr.key}.value'] = defaults.get('value', filtr.default_value) if self.sortable: if self.sort_defaults: # nb. as of writing neither Buefy nor Oruga support a # multi-column *default* sort; so just use first sorter sortinfo = self.sort_defaults[0] settings['sorters.length'] = 1 settings['sorters.1.key'] = sortinfo.sortkey settings['sorters.1.dir'] = sortinfo.sortdir else: settings['sorters.length'] = 0 if self.paginated and self.paginate_on_backend: settings['pagesize'] = self.pagesize settings['page'] = self.page # update settings dict based on what we find in the request # and/or user session. always prioritize the former. # nb. do not read settings if user wants a reset if self.request.GET.get('reset-view'): # at this point we only have default settings, and we want # to keep those *and* persist them for next time, below pass elif self.request_has_settings('filter'): self.update_filter_settings(settings, src='request') if self.request_has_settings('sort'): self.update_sort_settings(settings, src='request') else: self.update_sort_settings(settings, src='session') self.update_page_settings(settings) elif self.request_has_settings('sort'): self.update_filter_settings(settings, src='session') self.update_sort_settings(settings, src='request') self.update_page_settings(settings) elif self.request_has_settings('page'): self.update_filter_settings(settings, src='session') self.update_sort_settings(settings, src='session') self.update_page_settings(settings) else: # nothing found in request, so nothing new to save persist = False # but still should load whatever is in user session self.update_filter_settings(settings, src='session') self.update_sort_settings(settings, src='session') self.update_page_settings(settings) # maybe save settings in user session, for next time if persist: self.persist_settings(settings, dest='session') # update ourself to reflect settings dict.. # filtering if self.filterable: for filtr in self.filters.values(): filtr.active = settings[f'filter.{filtr.key}.active'] filtr.verb = settings[f'filter.{filtr.key}.verb'] or filtr.get_default_verb() filtr.value = settings[f'filter.{filtr.key}.value'] # sorting if self.sortable: # nb. doing this for frontend sorting also self.active_sorters = [] for i in range(1, settings['sorters.length'] + 1): self.active_sorters.append({ 'key': settings[f'sorters.{i}.key'], 'dir': settings[f'sorters.{i}.dir'], }) # TODO: i thought this was needed, but now idk? # # nb. when showing full index page (i.e. not partial) # # this implies we must set the default sorter for Vue # # component, and only single-column is allowed there. # if not self.request.GET.get('partial'): # break # paging if self.paginated and self.paginate_on_backend: self.pagesize = settings['pagesize'] self.page = settings['page']
def request_has_settings(self, typ): """ """ if typ == 'filter' and self.filterable: for filtr in self.filters.values(): if filtr.key in self.request.GET: return True if 'filter' in self.request.GET: # user may be applying empty filters return True elif typ == 'sort' and self.sortable and self.sort_on_backend: if 'sort1key' in self.request.GET: return True elif typ == 'page' and self.paginated and self.paginate_on_backend: for key in ['pagesize', 'page']: if key in self.request.GET: return True return False def get_setting(self, settings, key, src='session', default=None, normalize=lambda v: v): """ """ if src == 'request': value = self.request.GET.get(key) if value is not None: try: return normalize(value) except ValueError: pass elif src == 'session': value = self.request.session.get(f'grid.{self.key}.{key}') if value is not None: return normalize(value) # if src had nothing, try default/existing settings value = settings.get(key) if value is not None: return normalize(value) # okay then, default it is return default def update_filter_settings(self, settings, src=None): """ """ if not self.filterable: return for filtr in self.filters.values(): prefix = f'filter.{filtr.key}' if src == 'request': # consider filter active if query string contains a value for it settings[f'{prefix}.active'] = filtr.key in self.request.GET settings[f'{prefix}.verb'] = self.get_setting( settings, f'{filtr.key}.verb', src='request', default='') settings[f'{prefix}.value'] = self.get_setting( settings, filtr.key, src='request', default='') elif src == 'session': settings[f'{prefix}.active'] = self.get_setting( settings, f'{prefix}.active', src='session', normalize=lambda v: str(v).lower() == 'true', default=False) settings[f'{prefix}.verb'] = self.get_setting( settings, f'{prefix}.verb', src='session', default='') settings[f'{prefix}.value'] = self.get_setting( settings, f'{prefix}.value', src='session', default='') def update_sort_settings(self, settings, src=None): """ """ if not (self.sortable and self.sort_on_backend): return if src == 'request': i = 1 while True: skey = f'sort{i}key' if skey in self.request.GET: settings[f'sorters.{i}.key'] = self.get_setting(settings, skey, src='request') settings[f'sorters.{i}.dir'] = self.get_setting(settings, f'sort{i}dir', src='request', default='asc') else: break i += 1 settings['sorters.length'] = i - 1 elif src == 'session': settings['sorters.length'] = self.get_setting(settings, 'sorters.length', src='session', normalize=int) for i in range(1, settings['sorters.length'] + 1): for key in ('key', 'dir'): skey = f'sorters.{i}.{key}' settings[skey] = self.get_setting(settings, skey, src='session') def update_page_settings(self, settings): """ """ if not (self.paginated and self.paginate_on_backend): return # update the settings dict from request and/or user session # pagesize pagesize = self.request.GET.get('pagesize') if pagesize is not None: if pagesize.isdigit(): settings['pagesize'] = int(pagesize) else: pagesize = self.request.session.get(f'grid.{self.key}.pagesize') if pagesize is not None: settings['pagesize'] = pagesize # page page = self.request.GET.get('page') if page is not None: if page.isdigit(): settings['page'] = int(page) else: page = self.request.session.get(f'grid.{self.key}.page') if page is not None: settings['page'] = int(page) def persist_settings(self, settings, dest=None): """ """ if dest not in ('session',): raise ValueError(f"invalid dest identifier: {dest}") # func to save a setting value to user session def persist(key, value=lambda k: settings.get(k)): assert dest == 'session' skey = f'grid.{self.key}.{key}' self.request.session[skey] = value(key) # filter settings if self.filterable: # always save all filters, with status for filtr in self.filters.values(): persist(f'filter.{filtr.key}.active', value=lambda k: 'true' if settings.get(k) else 'false') persist(f'filter.{filtr.key}.verb') persist(f'filter.{filtr.key}.value') # sort settings if self.sortable and self.sort_on_backend: # first must clear all sort settings from dest. this is # because number of sort settings will vary, so we delete # all and then write all if dest == 'session': # remove sort settings from user session prefix = f'grid.{self.key}.sorters.' for key in list(self.request.session): if key.startswith(prefix): del self.request.session[key] # now save sort settings to dest if 'sorters.length' in settings: persist('sorters.length') for i in range(1, settings['sorters.length'] + 1): persist(f'sorters.{i}.key') persist(f'sorters.{i}.dir') # pagination settings if self.paginated and self.paginate_on_backend: # save to dest persist('pagesize') persist('page') ############################## # data methods ##############################
[docs] def get_visible_data(self): """ Returns the "effective" visible data for the grid. This uses :attr:`data` as the starting point but may morph it for pagination etc. per the grid settings. Code can either access :attr:`data` directly, or call this method to get only the data for current view (e.g. assuming pagination is used), depending on the need. See also these methods which may be called by this one: * :meth:`filter_data()` * :meth:`sort_data()` * :meth:`paginate_data()` """ data = self.data or [] self.joined = set() if self.filterable: data = self.filter_data(data) if self.sortable and self.sort_on_backend: data = self.sort_data(data) if self.paginated and self.paginate_on_backend: self.pager = self.paginate_data(data) data = self.pager return data
@property def active_filters(self): """ Returns the list of currently active filters. This inspects each :class:`~wuttaweb.grids.filters.GridFilter` in :attr:`filters` and only returns the ones marked active. """ return [filtr for filtr in self.filters.values() if filtr.active]
[docs] def filter_data(self, data, filters=None): """ Filter the given data and return the result. This is called by :meth:`get_visible_data()`. :param filters: Optional list of filters to use. If not specified, the grid's :attr:`active_filters` are used. """ if filters is None: filters = self.active_filters if not filters: return data for filtr in filters: key = filtr.key if key in self.joiners and key not in self.joined: data = self.joiners[key](data) self.joined.add(key) try: data = filtr.apply_filter(data) except VerbNotSupported as error: log.warning("verb not supported for '%s' filter: %s", key, error.verb) except: log.exception("filtering data by '%s' failed!", key) return data
[docs] def sort_data(self, data, sorters=None): """ Sort the given data and return the result. This is called by :meth:`get_visible_data()`. :param sorters: Optional list of sorters to use. If not specified, the grid's :attr:`active_sorters` are used. """ if sorters is None: sorters = self.active_sorters if not sorters: return data # nb. when data is a query, we want to apply sorters in the # requested order, so the final query has order_by() in the # correct "as-is" sequence. however when data is a list we # must do the opposite, applying in the reverse order, so the # final list has the most "important" sort(s) applied last. if not isinstance(data, orm.Query): sorters = reversed(sorters) for sorter in sorters: sortkey = sorter['key'] sortdir = sorter['dir'] # cannot sort unless we have a sorter callable sortfunc = self.sorters.get(sortkey) if not sortfunc: return data # join appropriate model if needed if sortkey in self.joiners and sortkey not in self.joined: data = self.joiners[sortkey](data) self.joined.add(sortkey) # invoke the sorter data = sortfunc(data, sortdir) return data
[docs] def paginate_data(self, data): """ Apply pagination to the given data set, based on grid settings. This returns a "pager" object which can then be used as a "data replacement" in subsequent logic. This method is called by :meth:`get_visible_data()`. """ if isinstance(data, orm.Query): pager = SqlalchemyOrmPage(data, items_per_page=self.pagesize, page=self.page) else: pager = paginate.Page(data, items_per_page=self.pagesize, page=self.page) # pager may have detected that our current page is outside the # valid range. if so we should update ourself to match if pager.page != self.page: self.page = pager.page key = f'grid.{self.key}.page' if key in self.request.session: self.request.session[key] = self.page # and re-make the pager just to be safe (?) pager = self.paginate_data(data) return pager
############################## # rendering methods ##############################
[docs] def render_datetime(self, obj, key, value): """ Default cell value renderer for :class:`sqlalchemy:sqlalchemy.types.DateTime` columns, which calls :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_datetime()` for the return value. This may be used automatically per :meth:`set_default_renderers()` or you can use it explicitly for any :class:`python:datetime.datetime` column with:: grid.set_renderer('foo', grid.render_datetime) """ dt = getattr(obj, key) return self.app.render_datetime(dt)
[docs] def render_table_element( self, form=None, template='/grids/table_element.mako', **context): """ Render a simple Vue table element for the grid. This is what you want for a "simple" grid which does require a unique Vue component, but can instead use the standard table component. This returns something like: .. code-block:: html <b-table :data="gridContext['mykey'].data"> <!-- columns etc. --> </b-table> See :meth:`render_vue_template()` for a more complete variant. Actual output will of course depend on grid attributes, :attr:`key`, :attr:`columns` etc. :param form: Reference to the :class:`~wuttaweb.forms.base.Form` instance which "contains" this grid. This is needed in order to ensure the grid data is available to the form Vue component. :param template: Path to Mako template which is used to render the output. .. note:: The above example shows ``gridContext['mykey'].data`` as the Vue data reference. This should "just work" if you provide the correct ``form`` arg and the grid is contained directly by that form's Vue component. However, this may not account for all use cases. For now we wait and see what comes up, but know the dust may not yet be settled here. """ # nb. must register data for inclusion on page template if form: form.add_grid_vue_context(self) # otherwise logic is the same, just different template return self.render_vue_template(template=template, **context)
[docs] def render_vue_tag(self, **kwargs): """ Render the Vue component tag for the grid. By default this simply returns: .. code-block:: html <wutta-grid></wutta-grid> The actual output will depend on various grid attributes, in particular :attr:`vue_tagname`. """ return HTML.tag(self.vue_tagname, **kwargs)
[docs] def render_vue_template( self, template='/grids/vue_template.mako', **context): """ Render the Vue template block for the grid. This is what you want for a "full-featured" grid which will exist as its own unique Vue component on the frontend. This returns something like: .. code-block:: none <script type="text/x-template" id="wutta-grid-template"> <b-table> <!-- columns etc. --> </b-table> </script> <script> WuttaGridData = {} WuttaGrid = { template: 'wutta-grid-template', } </script> .. todo:: Why can't Sphinx render the above code block as 'html' ? It acts like it can't handle a ``<script>`` tag at all? See :meth:`render_table_element()` for a simpler variant. Actual output will of course depend on grid attributes, :attr:`vue_tagname` and :attr:`columns` etc. :param template: Path to Mako template which is used to render the output. """ context['grid'] = self context.setdefault('request', self.request) output = render(template, context) return HTML.literal(output)
[docs] def render_vue_finalize(self): """ Render the Vue "finalize" script for the grid. By default this simply returns: .. code-block:: html <script> WuttaGrid.data = function() { return WuttaGridData } Vue.component('wutta-grid', WuttaGrid) </script> The actual output may depend on various grid attributes, in particular :attr:`vue_tagname`. """ set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}" make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})" return HTML.tag('script', c=['\n', HTML.literal(set_data), '\n', HTML.literal(make_component), '\n'])
[docs] def get_vue_columns(self): """ Returns a list of Vue-compatible column definitions. This uses :attr:`columns` as the basis; each definition returned will be a dict in this format:: { 'field': 'foo', 'label': "Foo", 'sortable': True, 'searchable': False, } The full format is determined by Buefy; see the Column section in its `Table docs <https://buefy.org/documentation/table/#api-view>`_. See also :meth:`get_vue_context()`. """ if not self.columns: raise ValueError(f"you must define columns for the grid! key = {self.key}") columns = [] for name in self.columns: columns.append({ 'field': name, 'label': self.get_label(name), 'sortable': self.is_sortable(name), 'searchable': self.is_searchable(name), }) return columns
[docs] def get_vue_active_sorters(self): """ Returns a list of Vue-compatible column sorter definitions. The list returned is the same as :attr:`active_sorters`; however the format used in Vue is different. So this method just "converts" them to the required format, e.g.:: # active_sorters format {'key': 'name', 'dir': 'asc'} # get_vue_active_sorters() format {'field': 'name', 'order': 'asc'} :returns: The :attr:`active_sorters` list, converted as described above. """ sorters = [] for sorter in self.active_sorters: sorters.append({'field': sorter['key'], 'order': sorter['dir']}) return sorters
[docs] def get_vue_filters(self): """ Returns a list of Vue-compatible filter definitions. This returns the full set of :attr:`filters` but represents each as a simple dict with the filter state. """ filters = [] for filtr in self.filters.values(): filters.append({ 'key': filtr.key, 'data_type': filtr.data_type, 'active': filtr.active, 'visible': filtr.active, 'verbs': filtr.get_verbs(), 'verb_labels': filtr.get_verb_labels(), 'valueless_verbs': filtr.get_valueless_verbs(), 'verb': filtr.verb, 'value': filtr.value, 'label': filtr.label, }) return filters
def object_to_dict(self, obj): """ """ try: dct = dict(obj) except TypeError: dct = dict(obj.__dict__) dct.pop('_sa_instance_state', None) return dct
[docs] def get_vue_context(self): """ Returns a dict of context for the grid, for use with the Vue component. This contains the following keys: * ``data`` - list of Vue-compatible data records * ``row_classes`` - dict of per-row CSS classes This first calls :meth:`get_visible_data()` to get the original data set. Each record is converted to a dict. Then it calls :func:`~wuttaweb.util.make_json_safe()` to ensure each record can be serialized to JSON. Then it invokes any :attr:`renderers` which are defined, to obtain the "final" values for each record. Then it adds a URL key/value for each of the :attr:`actions` defined, to each record. Then it calls :meth:`get_row_class()` for each record. If a value is returned, it is added to the ``row_classes`` dict. Note that this dict is keyed by "zero-based row sequence as string" - the Vue component expects that. :returns: Dict of grid data/CSS context as described above. """ original_data = self.get_visible_data() # loop thru data data = [] row_classes = {} for i, record in enumerate(original_data, 1): original_record = record # convert record to new dict record = self.object_to_dict(record) # make all values safe for json record = make_json_safe(record, warn=False) # customize value rendering where applicable for key in self.renderers: value = record.get(key, None) record[key] = self.renderers[key](original_record, key, value) # add action urls to each record for action in self.actions: key = f'_action_url_{action.key}' if key not in record: url = action.get_url(original_record, i) if url: record[key] = url # set row css class if applicable css_class = self.get_row_class(original_record, record, i) if css_class: # nb. use *string* zero-based index, for js compat row_classes[str(i-1)] = css_class data.append(record) return { 'data': data, 'row_classes': row_classes, }
def get_vue_data(self): """ """ warnings.warn("grid.get_vue_data() is deprecated; " "please use grid.get_vue_context() instead", DeprecationWarning, stacklevel=2) return self.get_vue_context()['data']
[docs] def get_row_class(self, obj, data, i): """ Returns the row CSS ``class`` attribute for the given record. This method is called by :meth:`get_vue_context()`. This will inspect/invoke :attr:`row_class` and return the value obtained from there. :param obj: Reference to the original model instance. :param data: Dict of record data for the instance; part of the Vue grid data set in/from :meth:`get_vue_context()`. :param i: One-based sequence for this object/record (row) within the grid. :returns: String of CSS class name(s), or ``None``. """ if self.row_class: if callable(self.row_class): return self.row_class(obj, data, i) return self.row_class
[docs] def get_vue_pager_stats(self): """ Returns a simple dict with current grid pager stats. This is used when :attr:`paginate_on_backend` is in effect. """ pager = self.pager return { 'item_count': pager.item_count, 'items_per_page': pager.items_per_page, 'page': pager.page, 'page_count': pager.page_count, 'first_item': pager.first_item, 'last_item': pager.last_item, }
[docs] class GridAction: """ Represents a "row action" hyperlink within a grid context. All such actions are displayed as a group, in a dedicated **Actions** column in the grid. So each row in the grid has its own set of action links. A :class:`Grid` can have one (or zero) or more of these in its :attr:`~Grid.actions` list. You can call :meth:`~wuttaweb.views.base.View.make_grid_action()` to add custom actions from within a view. :param request: Current :term:`request` object. .. note:: Some parameters are not explicitly described above. However their corresponding attributes are described below. .. attribute:: key String key for the action (e.g. ``'edit'``), unique within the grid. .. attribute:: label Label to be displayed for the action link. If not set, will be generated from :attr:`key` by calling :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.make_title()`. See also :meth:`render_label()`. .. attribute:: url URL for the action link, if applicable. This *can* be a simple string, however that will cause every row in the grid to have the same URL for this action. A better way is to specify a callable which can return a unique URL for each record. The callable should expect ``(obj, i)`` args, for instance:: def myurl(obj, i): return request.route_url('widgets.view', uuid=obj.uuid) action = GridAction(request, 'view', url=myurl) See also :meth:`get_url()`. .. attribute:: icon Name of icon to be shown for the action link. See also :meth:`render_icon()`. .. attribute:: link_class Optional HTML class attribute for the action's ``<a>`` tag. """ def __init__( self, request, key, label=None, url=None, icon=None, link_class=None, ): self.request = request self.config = self.request.wutta_config self.app = self.config.get_app() self.key = key self.url = url self.label = label or self.app.make_title(key) self.icon = icon or key self.link_class = link_class or ''
[docs] def render_icon_and_label(self): """ Render the HTML snippet for action link icon and label. Default logic returns the output from :meth:`render_icon()` and :meth:`render_label()`. """ html = [ self.render_icon(), self.render_label(), ] return HTML.literal(' ').join(html)
[docs] def render_icon(self): """ Render the HTML snippet for the action link icon. This uses :attr:`icon` to identify the named icon to be shown. Output is something like (here ``'trash'`` is the icon name): .. code-block:: html <i class="fas fa-trash"></i> See also :meth:`render_icon_and_label()`. """ if self.request.use_oruga: return HTML.tag('o-icon', icon=self.icon) return HTML.tag('i', class_=f'fas fa-{self.icon}')
[docs] def render_label(self): """ Render the label text for the action link. Default behavior is to return :attr:`label` as-is. See also :meth:`render_icon_and_label()`. """ return self.label
[docs] def get_url(self, obj, i=None): """ Returns the action link URL for the given object (model instance). If :attr:`url` is a simple string, it is returned as-is. But if :attr:`url` is a callable (which is typically the most useful), that will be called with the same ``(obj, i)`` args passed along. :param obj: Model instance of whatever type the parent grid is setup to use. :param i: One-based sequence for the object's row within the parent grid. See also :attr:`url`. """ if callable(self.url): return self.url(obj, i) return self.url