3
0
Fork 0

feat: add initial filtering logic to grid class

still missing the actual filters, subclass must provide those for now
This commit is contained in:
Lance Edgar 2024-08-21 20:15:23 -05:00
parent a042d511fb
commit 9751bf4c2e
2 changed files with 633 additions and 20 deletions

View file

@ -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)