previous logic was using underlying column name, which breaks when the prop key does not match
2354 lines
78 KiB
Python
2354 lines
78 KiB
Python
# -*- 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.
|
|
"""
|
|
|
|
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()` and
|
|
:meth:`set_default_renderers()`.
|
|
|
|
.. 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.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.renderers = {}
|
|
if renderers:
|
|
for key, val in renderers.items():
|
|
self.set_renderer(key, val)
|
|
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 {}))
|
|
|
|
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 []
|
|
|
|
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])
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
For convenience, in lieu of a renderer callable, you may
|
|
specify one of the following strings, which will be
|
|
interpreted as a built-in renderer callable, as shown below:
|
|
|
|
* ``'batch_id'`` -> :meth:`render_batch_id()`
|
|
* ``'boolean'`` -> :meth:`render_boolean()`
|
|
* ``'currency'`` -> :meth:`render_currency()`
|
|
* ``'datetime'`` -> :meth:`render_datetime()`
|
|
* ``'quantity'`` -> :meth:`render_quantity()`
|
|
|
|
Renderer overrides are tracked via :attr:`renderers`.
|
|
"""
|
|
builtins = {
|
|
'batch_id': self.render_batch_id,
|
|
'boolean': self.render_boolean,
|
|
'currency': self.render_currency,
|
|
'datetime': self.render_datetime,
|
|
'quantity': self.render_quantity,
|
|
}
|
|
|
|
if renderer in builtins:
|
|
renderer = builtins[renderer]
|
|
|
|
if kwargs:
|
|
renderer = functools.partial(renderer, **kwargs)
|
|
self.renderers[key] = renderer
|
|
|
|
def set_default_renderers(self):
|
|
"""
|
|
Set default column value renderers, where applicable.
|
|
|
|
This is called automatically from the class constructor. It
|
|
will add new entries to :attr:`renderers` for columns whose
|
|
data type implies a default renderer. This is only possible
|
|
if :attr:`model_class` is set to a SQLAlchemy mapped class.
|
|
|
|
This only looks for a couple of data types, and configures as
|
|
follows:
|
|
|
|
* :class:`sqlalchemy:sqlalchemy.types.Boolean` ->
|
|
:meth:`render_boolean()`
|
|
* :class:`sqlalchemy:sqlalchemy.types.DateTime` ->
|
|
: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)
|
|
elif isinstance(column.type, sa.Boolean):
|
|
self.set_renderer(key, self.render_boolean)
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
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))
|
|
|
|
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 [])
|
|
|
|
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
|
|
##############################
|
|
|
|
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
|
|
|
|
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
|
|
##############################
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
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
|
|
##############################
|
|
|
|
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:
|
|
|
|
# nb. i have found this confusing for some reason. some
|
|
# things i've tried so far include:
|
|
#
|
|
# i first tried self.get_model_columns() but my notes say
|
|
# that was too aggressive in many cases.
|
|
#
|
|
# then i tried using the *subset* of self.columns, just
|
|
# the ones which correspond to a property on the model
|
|
# class. but sometimes that skips filters we need.
|
|
#
|
|
# then i tried get_columns() from sa-utils to give the
|
|
# "true" column list, but that fails when the underlying
|
|
# column has different name than the prop/attr key.
|
|
#
|
|
# so now, we are looking directly at the sa mapper, for
|
|
# all column attrs and then using the prop key.
|
|
|
|
inspector = sa.inspect(self.model_class)
|
|
for prop in inspector.column_attrs:
|
|
if prop.key not in filters:
|
|
attr = getattr(self.model_class, prop.key)
|
|
filters[prop.key] = self.make_filter(attr)
|
|
|
|
return filters
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
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
|
|
##############################
|
|
|
|
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]
|
|
|
|
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
|
|
##############################
|
|
|
|
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
|
|
##############################
|
|
|
|
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]
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
##############################
|
|
|
|
def render_batch_id(self, obj, key, value):
|
|
"""
|
|
Column renderer for batch ID values.
|
|
|
|
This is not used automatically but you can use it explicitly::
|
|
|
|
grid.set_renderer('foo', 'batch_id')
|
|
"""
|
|
if value is None:
|
|
return ""
|
|
|
|
batch_id = int(value)
|
|
return f'{batch_id:08d}'
|
|
|
|
def render_boolean(self, obj, key, value):
|
|
"""
|
|
Column renderer for boolean values.
|
|
|
|
This calls
|
|
:meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_boolean()`
|
|
for the return value.
|
|
|
|
This may be used automatically per
|
|
:meth:`set_default_renderers()` or you can use it explicitly::
|
|
|
|
grid.set_renderer('foo', grid.render_boolean)
|
|
"""
|
|
return self.app.render_boolean(value)
|
|
|
|
def render_currency(self, obj, key, value, **kwargs):
|
|
"""
|
|
Column renderer for currency values.
|
|
|
|
This calls
|
|
:meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_currency()`
|
|
for the return value.
|
|
|
|
This is not used automatically but you can use it explicitly::
|
|
|
|
grid.set_renderer('foo', 'currency')
|
|
grid.set_renderer('foo', 'currency', scale=4)
|
|
"""
|
|
return self.app.render_currency(value, **kwargs)
|
|
|
|
def render_datetime(self, obj, key, value):
|
|
"""
|
|
Column renderer for :class:`python:datetime.datetime` values.
|
|
|
|
This 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::
|
|
|
|
grid.set_renderer('foo', grid.render_datetime)
|
|
"""
|
|
dt = getattr(obj, key)
|
|
return self.app.render_datetime(dt)
|
|
|
|
def render_quantity(self, obj, key, value):
|
|
"""
|
|
Column renderer for quantity values.
|
|
|
|
This calls
|
|
:meth:`~wuttjamaican:wuttjamaican.app.AppHandler.render_quantity()`
|
|
for the return value.
|
|
|
|
This is not used automatically but you can use it explicitly::
|
|
|
|
grid.set_renderer('foo', grid.render_quantity)
|
|
"""
|
|
return self.app.render_quantity(value)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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'])
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
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']
|
|
|
|
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
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
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 ''
|
|
|
|
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)
|
|
|
|
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}')
|
|
|
|
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
|
|
|
|
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
|