# -*- 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 set_link(self, key, link=True):
"""
Explicitly enable or disable auto-link behavior for a given
column.
If a column has auto-link enabled, then each of its cell
contents will automatically be wrapped with a hyperlink. The
URL for this will be the same as for the "View"
:class:`GridAction`
(aka. :meth:`~wuttaweb.views.master.MasterView.view()`).
Although of course each cell in the column gets a different
link depending on which data record it points to.
It is typical to enable auto-link for fields relating to ID,
description etc. or some may prefer to auto-link all columns.
See also :meth:`is_linked()`; the list is tracked via
:attr:`linked_columns`.
:param key: Column key as string.
:param link: Boolean indicating whether column's cell contents
should be auto-linked.
"""
if link:
if key not in self.linked_columns:
self.linked_columns.append(key)
else: # unlink
if self.linked_columns and key in self.linked_columns:
self.linked_columns.remove(key)
[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))
##############################
# 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]
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