feat: add initial filtering logic to grid class
still missing the actual filters, subclass must provide those for now
This commit is contained in:
parent
a042d511fb
commit
9751bf4c2e
2 changed files with 633 additions and 20 deletions
|
@ -288,6 +288,28 @@ class Grid:
|
|||
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:: joiners
|
||||
|
||||
Dict of "joiner" functions for use with backend filtering and
|
||||
sorting.
|
||||
|
||||
See :meth:`set_joiner()` for more info.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
|
@ -313,6 +335,9 @@ class Grid:
|
|||
pagesize=None,
|
||||
page=1,
|
||||
searchable_columns=None,
|
||||
filterable=False,
|
||||
filters=None,
|
||||
joiners=None,
|
||||
):
|
||||
self.request = request
|
||||
self.vue_tagname = vue_tagname
|
||||
|
@ -323,6 +348,7 @@ class Grid:
|
|||
self.renderers = renderers or {}
|
||||
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()
|
||||
|
@ -354,6 +380,15 @@ class Grid:
|
|||
# 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 = {}
|
||||
|
||||
def get_columns(self):
|
||||
"""
|
||||
Returns the official list of column names for the grid, or
|
||||
|
@ -442,7 +477,7 @@ class Grid:
|
|||
if key in self.columns:
|
||||
self.columns.remove(key)
|
||||
|
||||
def set_label(self, key, label):
|
||||
def set_label(self, key, label, column_only=False):
|
||||
"""
|
||||
Set/override the label for a column.
|
||||
|
||||
|
@ -450,11 +485,18 @@ class Grid:
|
|||
|
||||
: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.
|
||||
|
@ -582,6 +624,63 @@ class Grid:
|
|||
"""
|
||||
self.actions.append(GridAction(self.request, key, **kwargs))
|
||||
|
||||
##############################
|
||||
# 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
|
||||
##############################
|
||||
|
@ -883,6 +982,120 @@ class Grid:
|
|||
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:
|
||||
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
|
||||
|
||||
def make_filter(self, columninfo, **kwargs):
|
||||
"""
|
||||
Creates and returns a
|
||||
:class:`~wuttaweb.grids.filters.GridFilter` instance suitable
|
||||
for use as a backend filter on the given column.
|
||||
|
||||
.. warning::
|
||||
|
||||
This method is not yet implemented; subclass *must*
|
||||
override.
|
||||
|
||||
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 suitable for backend sorting.
|
||||
"""
|
||||
if isinstance(columninfo, str):
|
||||
key = columninfo
|
||||
else:
|
||||
model_property = columninfo
|
||||
key = model_property.key
|
||||
|
||||
return GridFilter(self.request, 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.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)
|
||||
|
||||
##############################
|
||||
# paging methods
|
||||
##############################
|
||||
|
@ -973,6 +1186,11 @@ class Grid:
|
|||
|
||||
# initial default settings
|
||||
settings = {}
|
||||
if self.filterable:
|
||||
for filtr in self.filters.values():
|
||||
settings[f'filter.{filtr.key}.active'] = filtr.default_active
|
||||
settings[f'filter.{filtr.key}.verb'] = filtr.default_verb
|
||||
settings[f'filter.{filtr.key}.value'] = filtr.default_value
|
||||
if self.sortable:
|
||||
if self.sort_defaults:
|
||||
# nb. as of writing neither Buefy nor Oruga support a
|
||||
|
@ -987,14 +1205,35 @@ class Grid:
|
|||
settings['pagesize'] = self.pagesize
|
||||
settings['page'] = self.page
|
||||
|
||||
# TODO
|
||||
# # If user has default settings on file, apply those first.
|
||||
# if self.user_has_defaults():
|
||||
# self.apply_user_defaults(settings)
|
||||
|
||||
# TODO
|
||||
# # If request contains instruction to reset to default filters, then we
|
||||
# # can skip the rest of the request/session checks.
|
||||
# if self.request.GET.get('reset-to-default-filters') == 'true':
|
||||
# pass
|
||||
|
||||
# update settings dict based on what we find in the request
|
||||
# and/or user session. always prioritize the former.
|
||||
|
||||
if self.request_has_settings('sort'):
|
||||
if 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)
|
||||
|
||||
|
@ -1003,6 +1242,7 @@ class Grid:
|
|||
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)
|
||||
|
||||
|
@ -1010,8 +1250,21 @@ class Grid:
|
|||
if persist:
|
||||
self.persist_settings(settings, dest='session')
|
||||
|
||||
# TODO
|
||||
# # If request contained instruction to save current settings as defaults
|
||||
# # for the current user, then do that.
|
||||
# if self.request.GET.get('save-current-filters-as-defaults') == 'true':
|
||||
# self.persist_settings(settings, dest='defaults')
|
||||
|
||||
# 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']
|
||||
filtr.value = settings[f'filter.{filtr.key}.value']
|
||||
|
||||
# sorting
|
||||
if self.sortable:
|
||||
# nb. doing this for frontend sorting also
|
||||
|
@ -1036,11 +1289,18 @@ class Grid:
|
|||
def request_has_settings(self, typ):
|
||||
""" """
|
||||
|
||||
if typ == 'sort':
|
||||
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':
|
||||
elif typ == 'page' and self.paginated and self.paginate_on_backend:
|
||||
for key in ['pagesize', 'page']:
|
||||
if key in self.request.GET:
|
||||
return True
|
||||
|
@ -1072,6 +1332,31 @@ class Grid:
|
|||
# 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):
|
||||
|
@ -1138,8 +1423,18 @@ class Grid:
|
|||
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:
|
||||
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
|
||||
|
@ -1183,10 +1478,15 @@ class Grid:
|
|||
|
||||
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)
|
||||
|
@ -1197,6 +1497,21 @@ class Grid:
|
|||
|
||||
return data
|
||||
|
||||
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 "active" filters are used.
|
||||
|
||||
.. warning::
|
||||
|
||||
This method is not yet implemented; subclass *must*
|
||||
override.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def sort_data(self, data, sorters=None):
|
||||
"""
|
||||
Sort the given data and return the result. This is called by
|
||||
|
@ -1227,6 +1542,11 @@ class Grid:
|
|||
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)
|
||||
|
||||
|
@ -1687,3 +2007,26 @@ class GridAction:
|
|||
return self.url(obj, i)
|
||||
|
||||
return self.url
|
||||
|
||||
|
||||
# TODO: this needs plenty of work yet..and probably will move?
|
||||
class GridFilter:
|
||||
""" """
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
request,
|
||||
key,
|
||||
default_active=False,
|
||||
default_verb=None,
|
||||
default_value=None,
|
||||
**kwargs,
|
||||
):
|
||||
self.request = request
|
||||
self.key = key
|
||||
|
||||
self.default_active = default_active
|
||||
self.default_verb = default_verb
|
||||
self.default_value = default_value
|
||||
|
||||
self.__dict__.update(kwargs)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue