feat: add initial support for proper grid filters
only "text contains" filter supported so far, more to come as needed
This commit is contained in:
parent
9751bf4c2e
commit
1443f5253f
|
@ -39,6 +39,7 @@ from webhelpers2.html import HTML
|
||||||
|
|
||||||
from wuttaweb.db import Session
|
from wuttaweb.db import Session
|
||||||
from wuttaweb.util import FieldList, get_model_fields, make_json_safe
|
from wuttaweb.util import FieldList, get_model_fields, make_json_safe
|
||||||
|
from wuttjamaican.util import UNSPECIFIED
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
@ -304,6 +305,12 @@ class Grid:
|
||||||
|
|
||||||
See also :meth:`set_filter()`.
|
See also :meth:`set_filter()`.
|
||||||
|
|
||||||
|
.. attribute:: filter_defaults
|
||||||
|
|
||||||
|
Dict containing default state preferences for the filters.
|
||||||
|
|
||||||
|
See also :meth:`set_filter_defaults()`.
|
||||||
|
|
||||||
.. attribute:: joiners
|
.. attribute:: joiners
|
||||||
|
|
||||||
Dict of "joiner" functions for use with backend filtering and
|
Dict of "joiner" functions for use with backend filtering and
|
||||||
|
@ -337,6 +344,7 @@ class Grid:
|
||||||
searchable_columns=None,
|
searchable_columns=None,
|
||||||
filterable=False,
|
filterable=False,
|
||||||
filters=None,
|
filters=None,
|
||||||
|
filter_defaults=None,
|
||||||
joiners=None,
|
joiners=None,
|
||||||
):
|
):
|
||||||
self.request = request
|
self.request = request
|
||||||
|
@ -388,6 +396,7 @@ class Grid:
|
||||||
self.filters = self.make_backend_filters()
|
self.filters = self.make_backend_filters()
|
||||||
else:
|
else:
|
||||||
self.filters = {}
|
self.filters = {}
|
||||||
|
self.set_filter_defaults(**(filter_defaults or {}))
|
||||||
|
|
||||||
def get_columns(self):
|
def get_columns(self):
|
||||||
"""
|
"""
|
||||||
|
@ -1025,14 +1034,8 @@ class Grid:
|
||||||
|
|
||||||
def make_filter(self, columninfo, **kwargs):
|
def make_filter(self, columninfo, **kwargs):
|
||||||
"""
|
"""
|
||||||
Creates and returns a
|
Create and return a :class:`GridFilter` instance suitable for
|
||||||
:class:`~wuttaweb.grids.filters.GridFilter` instance suitable
|
use on the given column.
|
||||||
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
|
Code usually does not need to call this directly. See also
|
||||||
:meth:`set_filter()`, which calls this method automatically.
|
:meth:`set_filter()`, which calls this method automatically.
|
||||||
|
@ -1040,16 +1043,24 @@ class Grid:
|
||||||
:param columninfo: Can be either a model property (see below),
|
:param columninfo: Can be either a model property (see below),
|
||||||
or a column name.
|
or a column name.
|
||||||
|
|
||||||
:returns: A :class:`~wuttaweb.grids.filters.GridFilter`
|
:returns: A :class:`GridFilter` instance.
|
||||||
instance suitable for backend sorting.
|
|
||||||
"""
|
"""
|
||||||
|
model_property = None
|
||||||
if isinstance(columninfo, str):
|
if isinstance(columninfo, str):
|
||||||
key = columninfo
|
key = columninfo
|
||||||
|
if self.model_class:
|
||||||
|
try:
|
||||||
|
mapper = sa.inspect(self.model_class)
|
||||||
|
except sa.exc.NoInspectionAvailable:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
model_property = mapper.get_property(key)
|
||||||
|
if not model_property:
|
||||||
|
raise ValueError(f"cannot locate model property for key: {key}")
|
||||||
else:
|
else:
|
||||||
model_property = columninfo
|
model_property = columninfo
|
||||||
key = model_property.key
|
|
||||||
|
|
||||||
return GridFilter(self.request, key, **kwargs)
|
return GridFilter(self.request, model_property, **kwargs)
|
||||||
|
|
||||||
def set_filter(self, key, filterinfo=None, **kwargs):
|
def set_filter(self, key, filterinfo=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -1096,6 +1107,31 @@ class Grid:
|
||||||
"""
|
"""
|
||||||
self.filters.pop(key, None)
|
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
|
# paging methods
|
||||||
##############################
|
##############################
|
||||||
|
@ -1188,9 +1224,13 @@ class Grid:
|
||||||
settings = {}
|
settings = {}
|
||||||
if self.filterable:
|
if self.filterable:
|
||||||
for filtr in self.filters.values():
|
for filtr in self.filters.values():
|
||||||
settings[f'filter.{filtr.key}.active'] = filtr.default_active
|
defaults = self.filter_defaults.get(filtr.key, {})
|
||||||
settings[f'filter.{filtr.key}.verb'] = filtr.default_verb
|
settings[f'filter.{filtr.key}.active'] = defaults.get('active',
|
||||||
settings[f'filter.{filtr.key}.value'] = filtr.default_value
|
filtr.default_active)
|
||||||
|
settings[f'filter.{filtr.key}.verb'] = defaults.get('verb',
|
||||||
|
filtr.default_verb)
|
||||||
|
settings[f'filter.{filtr.key}.value'] = defaults.get('value',
|
||||||
|
filtr.default_value)
|
||||||
if self.sortable:
|
if self.sortable:
|
||||||
if self.sort_defaults:
|
if self.sort_defaults:
|
||||||
# nb. as of writing neither Buefy nor Oruga support a
|
# nb. as of writing neither Buefy nor Oruga support a
|
||||||
|
@ -1205,21 +1245,16 @@ class Grid:
|
||||||
settings['pagesize'] = self.pagesize
|
settings['pagesize'] = self.pagesize
|
||||||
settings['page'] = self.page
|
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
|
# update settings dict based on what we find in the request
|
||||||
# and/or user session. always prioritize the former.
|
# and/or user session. always prioritize the former.
|
||||||
|
|
||||||
if self.request_has_settings('filter'):
|
# 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')
|
self.update_filter_settings(settings, src='request')
|
||||||
if self.request_has_settings('sort'):
|
if self.request_has_settings('sort'):
|
||||||
self.update_sort_settings(settings, src='request')
|
self.update_sort_settings(settings, src='request')
|
||||||
|
@ -1250,19 +1285,13 @@ class Grid:
|
||||||
if persist:
|
if persist:
|
||||||
self.persist_settings(settings, dest='session')
|
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..
|
# update ourself to reflect settings dict..
|
||||||
|
|
||||||
# filtering
|
# filtering
|
||||||
if self.filterable:
|
if self.filterable:
|
||||||
for filtr in self.filters.values():
|
for filtr in self.filters.values():
|
||||||
filtr.active = settings[f'filter.{filtr.key}.active']
|
filtr.active = settings[f'filter.{filtr.key}.active']
|
||||||
filtr.verb = settings[f'filter.{filtr.key}.verb']
|
filtr.verb = settings[f'filter.{filtr.key}.verb'] or filtr.default_verb
|
||||||
filtr.value = settings[f'filter.{filtr.key}.value']
|
filtr.value = settings[f'filter.{filtr.key}.value']
|
||||||
|
|
||||||
# sorting
|
# sorting
|
||||||
|
@ -1497,20 +1526,45 @@ class Grid:
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_filters(self):
|
||||||
|
"""
|
||||||
|
Returns the list of currently active filters.
|
||||||
|
|
||||||
|
This inspects each :class:`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):
|
def filter_data(self, data, filters=None):
|
||||||
"""
|
"""
|
||||||
Filter the given data and return the result. This is called
|
Filter the given data and return the result. This is called
|
||||||
by :meth:`get_visible_data()`.
|
by :meth:`get_visible_data()`.
|
||||||
|
|
||||||
:param filters: Optional list of filters to use. If not
|
:param filters: Optional list of filters to use. If not
|
||||||
specified, the grid's "active" filters are used.
|
specified, the grid's :attr:`active_filters` are used.
|
||||||
|
|
||||||
.. warning::
|
|
||||||
|
|
||||||
This method is not yet implemented; subclass *must*
|
|
||||||
override.
|
|
||||||
"""
|
"""
|
||||||
raise NotImplementedError
|
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):
|
def sort_data(self, data, sorters=None):
|
||||||
"""
|
"""
|
||||||
|
@ -1588,6 +1642,58 @@ class Grid:
|
||||||
# rendering methods
|
# rendering methods
|
||||||
##############################
|
##############################
|
||||||
|
|
||||||
|
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="gridData['mykey']">
|
||||||
|
<!-- 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 ``gridData['mykey']`` 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_data(self)
|
||||||
|
|
||||||
|
# otherwise logic is the same, just different template
|
||||||
|
return self.render_vue_template(template=template, **context)
|
||||||
|
|
||||||
def render_vue_tag(self, **kwargs):
|
def render_vue_tag(self, **kwargs):
|
||||||
"""
|
"""
|
||||||
Render the Vue component tag for the grid.
|
Render the Vue component tag for the grid.
|
||||||
|
@ -1649,58 +1755,6 @@ class Grid:
|
||||||
output = render(template, context)
|
output = render(template, context)
|
||||||
return HTML.literal(output)
|
return HTML.literal(output)
|
||||||
|
|
||||||
def render_table_element(
|
|
||||||
self,
|
|
||||||
form=None,
|
|
||||||
template='/grids/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="gridData['mykey']">
|
|
||||||
<!-- 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 ``gridData['mykey']`` 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_data(self)
|
|
||||||
|
|
||||||
# otherwise logic is the same, just different template
|
|
||||||
return self.render_vue_template(template=template, **context)
|
|
||||||
|
|
||||||
def render_vue_finalize(self):
|
def render_vue_finalize(self):
|
||||||
"""
|
"""
|
||||||
Render the Vue "finalize" script for the grid.
|
Render the Vue "finalize" script for the grid.
|
||||||
|
@ -1781,6 +1835,26 @@ class Grid:
|
||||||
'order': sorter['dir']})
|
'order': sorter['dir']})
|
||||||
return sorters
|
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,
|
||||||
|
'active': filtr.active,
|
||||||
|
'visible': filtr.active,
|
||||||
|
'verbs': filtr.verbs,
|
||||||
|
'verb': filtr.verb,
|
||||||
|
'value': filtr.value,
|
||||||
|
'label': filtr.label,
|
||||||
|
})
|
||||||
|
return filters
|
||||||
|
|
||||||
def get_vue_data(self):
|
def get_vue_data(self):
|
||||||
"""
|
"""
|
||||||
Returns a list of Vue-compatible data records.
|
Returns a list of Vue-compatible data records.
|
||||||
|
@ -2009,24 +2083,162 @@ class GridAction:
|
||||||
return self.url
|
return self.url
|
||||||
|
|
||||||
|
|
||||||
# TODO: this needs plenty of work yet..and probably will move?
|
|
||||||
class GridFilter:
|
class GridFilter:
|
||||||
""" """
|
"""
|
||||||
|
Filter option for a grid. Represents both the "features" as well
|
||||||
|
as "state" for the filter.
|
||||||
|
|
||||||
|
:param request: Current :term:`request` object.
|
||||||
|
|
||||||
|
:param model_property: Property of a model class, representing the
|
||||||
|
column by which to filter. For instance,
|
||||||
|
``model.Person.full_name``.
|
||||||
|
|
||||||
|
:param \**kwargs: Any additional kwargs will be set as attributes
|
||||||
|
on the filter instance.
|
||||||
|
|
||||||
|
Filter instances have the following attributes:
|
||||||
|
|
||||||
|
.. attribute:: key
|
||||||
|
|
||||||
|
Unique key for the filter. This often corresponds to a "column
|
||||||
|
name" for the grid, but not always.
|
||||||
|
|
||||||
|
.. attribute:: label
|
||||||
|
|
||||||
|
Display label for the filter field.
|
||||||
|
|
||||||
|
.. attribute:: active
|
||||||
|
|
||||||
|
Boolean indicating whether the filter is currently active.
|
||||||
|
|
||||||
|
See also :attr:`verb` and :attr:`value`.
|
||||||
|
|
||||||
|
.. attribute:: verb
|
||||||
|
|
||||||
|
Verb for current filter, if :attr:`active` is true.
|
||||||
|
|
||||||
|
See also :attr:`value`.
|
||||||
|
|
||||||
|
.. attribute:: value
|
||||||
|
|
||||||
|
Value for current filter, if :attr:`active` is true.
|
||||||
|
|
||||||
|
See also :attr:`verb`.
|
||||||
|
|
||||||
|
.. attribute:: default_active
|
||||||
|
|
||||||
|
Boolean indicating whether the filter should be active by
|
||||||
|
default, i.e. when first displaying the grid.
|
||||||
|
|
||||||
|
See also :attr:`default_verb` and :attr:`default_value`.
|
||||||
|
|
||||||
|
.. attribute:: default_verb
|
||||||
|
|
||||||
|
Filter verb to use by default. This will be auto-selected when
|
||||||
|
the filter is first activated, or when first displaying the
|
||||||
|
grid if :attr:`default_active` is true.
|
||||||
|
|
||||||
|
See also :attr:`default_value`.
|
||||||
|
|
||||||
|
.. attribute:: default_value
|
||||||
|
|
||||||
|
Filter value to use by default. This will be auto-populated
|
||||||
|
when the filter is first activated, or when first displaying
|
||||||
|
the grid if :attr:`default_active` is true.
|
||||||
|
|
||||||
|
See also :attr:`default_verb`.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
request,
|
request,
|
||||||
key,
|
model_property,
|
||||||
|
label=None,
|
||||||
default_active=False,
|
default_active=False,
|
||||||
default_verb=None,
|
default_verb=None,
|
||||||
default_value=None,
|
default_value=None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
self.request = request
|
self.request = request
|
||||||
self.key = key
|
self.config = self.request.wutta_config
|
||||||
|
self.app = self.config.get_app()
|
||||||
|
|
||||||
|
self.model_property = model_property
|
||||||
|
self.key = self.model_property.key
|
||||||
|
self.label = label or self.app.make_title(self.key)
|
||||||
|
|
||||||
self.default_active = default_active
|
self.default_active = default_active
|
||||||
self.default_verb = default_verb
|
self.active = self.default_active
|
||||||
|
|
||||||
|
self.verbs = ['contains'] # TODO
|
||||||
|
self.default_verb = default_verb or self.verbs[0]
|
||||||
|
self.verb = self.default_verb
|
||||||
|
|
||||||
self.default_value = default_value
|
self.default_value = default_value
|
||||||
|
self.value = self.default_value
|
||||||
|
|
||||||
self.__dict__.update(kwargs)
|
self.__dict__.update(kwargs)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return ("GridFilter("
|
||||||
|
f"key='{self.key}', "
|
||||||
|
f"active={self.active}, "
|
||||||
|
f"verb='{self.verb}', "
|
||||||
|
f"value={repr(self.value)})")
|
||||||
|
|
||||||
|
def apply_filter(self, data, verb=None, value=UNSPECIFIED):
|
||||||
|
"""
|
||||||
|
Filter the given data set according to a verb/value pair.
|
||||||
|
|
||||||
|
If verb and/or value are not specified, will use :attr:`verb`
|
||||||
|
and/or :attr:`value` instead.
|
||||||
|
|
||||||
|
This method does not directly filter the data; rather it
|
||||||
|
delegates (based on ``verb``) to some other method. The
|
||||||
|
latter may choose *not* to filter the data, e.g. if ``value``
|
||||||
|
is empty, in which case this may return the original data set
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
:returns: The (possibly) filtered data set.
|
||||||
|
"""
|
||||||
|
if verb is None:
|
||||||
|
verb = self.verb
|
||||||
|
if not verb:
|
||||||
|
log.warn("missing verb for '%s' filter, will use default verb: %s",
|
||||||
|
self.key, self.default_verb)
|
||||||
|
verb = self.default_verb
|
||||||
|
|
||||||
|
if value is UNSPECIFIED:
|
||||||
|
value = self.value
|
||||||
|
|
||||||
|
func = getattr(self, f'filter_{verb}', None)
|
||||||
|
if not func:
|
||||||
|
raise VerbNotSupported(verb)
|
||||||
|
|
||||||
|
return func(data, value)
|
||||||
|
|
||||||
|
def filter_contains(self, query, value):
|
||||||
|
"""
|
||||||
|
Filter data with a full 'ILIKE' query.
|
||||||
|
"""
|
||||||
|
if value is None or value == '':
|
||||||
|
return query
|
||||||
|
|
||||||
|
criteria = []
|
||||||
|
for val in value.split():
|
||||||
|
val = val.replace('_', r'\_')
|
||||||
|
val = f'%{val}%'
|
||||||
|
criteria.append(self.model_property.ilike(val))
|
||||||
|
|
||||||
|
return query.filter(sa.and_(*criteria))
|
||||||
|
|
||||||
|
|
||||||
|
class VerbNotSupported(Exception):
|
||||||
|
""" """
|
||||||
|
|
||||||
|
def __init__(self, verb):
|
||||||
|
self.verb = verb
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"unknown filter verb not supported: {self.verb}"
|
||||||
|
|
|
@ -151,6 +151,33 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
##############################
|
||||||
|
## grids
|
||||||
|
##############################
|
||||||
|
|
||||||
|
.wutta-filter {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wutta-filter .button.filter-toggle {
|
||||||
|
justify-content: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wutta-filter .button.filter-toggle,
|
||||||
|
.wutta-filter .filter-verb {
|
||||||
|
min-width: 15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wutta-filter .filter-verb .select,
|
||||||
|
.wutta-filter .filter-verb .select select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
##############################
|
||||||
|
## forms
|
||||||
|
##############################
|
||||||
|
|
||||||
.wutta-form-wrapper {
|
.wutta-form-wrapper {
|
||||||
margin-left: 5rem;
|
margin-left: 5rem;
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
|
|
|
@ -1,6 +1,90 @@
|
||||||
## -*- coding: utf-8; -*-
|
## -*- coding: utf-8; -*-
|
||||||
|
|
||||||
<script type="text/x-template" id="${grid.vue_tagname}-template">
|
<script type="text/x-template" id="${grid.vue_tagname}-template">
|
||||||
|
<div>
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;">
|
||||||
|
|
||||||
|
% if grid.filterable:
|
||||||
|
<form action="${request.path_url}" method="GET"
|
||||||
|
@submit.prevent="applyFilters()">
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
|
<wutta-filter v-for="filtr in filters"
|
||||||
|
:key="filtr.key"
|
||||||
|
:filter="filtr"
|
||||||
|
:is-small="smallFilters"
|
||||||
|
ref="gridFilters" />
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<b-button type="is-primary"
|
||||||
|
native-type="submit"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-left="filter"
|
||||||
|
:size="smallFilters ? 'is-small' : null">
|
||||||
|
Apply Filters
|
||||||
|
</b-button>
|
||||||
|
|
||||||
|
<b-button v-if="!addFilterShow"
|
||||||
|
@click="addFilterInit()"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-left="plus"
|
||||||
|
:size="smallFilters ? 'is-small' : null">
|
||||||
|
Add Filter
|
||||||
|
</b-button>
|
||||||
|
|
||||||
|
<b-autocomplete v-if="addFilterShow"
|
||||||
|
ref="addFilterAutocomplete"
|
||||||
|
:data="addFilterChoices"
|
||||||
|
v-model="addFilterTerm"
|
||||||
|
placeholder="Add Filter"
|
||||||
|
field="key"
|
||||||
|
:custom-formatter="formatAddFilterItem"
|
||||||
|
open-on-focus
|
||||||
|
keep-first
|
||||||
|
clearable
|
||||||
|
clear-on-select
|
||||||
|
@select="addFilterSelect"
|
||||||
|
icon-pack="fas"
|
||||||
|
:size="smallFilters ? 'is-small' : null" />
|
||||||
|
|
||||||
|
<b-button @click="resetView()"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-left="undo"
|
||||||
|
:size="smallFilters ? 'is-small' : null">
|
||||||
|
Reset View
|
||||||
|
</b-button>
|
||||||
|
|
||||||
|
<b-button v-show="activeFilters"
|
||||||
|
@click="clearFilters()"
|
||||||
|
icon-pack="fas"
|
||||||
|
icon-left="trash"
|
||||||
|
:size="smallFilters ? 'is-small' : null">
|
||||||
|
No Filters
|
||||||
|
</b-button>
|
||||||
|
|
||||||
|
## TODO: this semi-works but is not persisted for user
|
||||||
|
## <b-button v-if="!smallFilters"
|
||||||
|
## @click="smallFilters = true"
|
||||||
|
## icon-pack="fas"
|
||||||
|
## icon-left="compress"
|
||||||
|
## title="toggle filter size" />
|
||||||
|
##
|
||||||
|
## <span v-if="smallFilters">
|
||||||
|
## <b-button @click="smallFilters = false"
|
||||||
|
## icon-pack="fas"
|
||||||
|
## icon-left="expand"
|
||||||
|
## size="is-small"
|
||||||
|
## title="toggle filter size" />
|
||||||
|
## </span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
% endif
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<${b}-table :data="data"
|
<${b}-table :data="data"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
narrowed
|
narrowed
|
||||||
|
@ -125,6 +209,7 @@
|
||||||
% endif
|
% endif
|
||||||
|
|
||||||
</${b}-table>
|
</${b}-table>
|
||||||
|
</div>
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -138,6 +223,14 @@
|
||||||
## nb. this tracks whether grid.fetchFirstData() happened
|
## nb. this tracks whether grid.fetchFirstData() happened
|
||||||
fetchedFirstData: false,
|
fetchedFirstData: false,
|
||||||
|
|
||||||
|
## filtering
|
||||||
|
% if grid.filterable:
|
||||||
|
filters: ${json.dumps(grid.get_vue_filters())|n},
|
||||||
|
addFilterShow: false,
|
||||||
|
addFilterTerm: '',
|
||||||
|
smallFilters: false,
|
||||||
|
% endif
|
||||||
|
|
||||||
## sorting
|
## sorting
|
||||||
% if grid.sortable:
|
% if grid.sortable:
|
||||||
sorters: ${json.dumps(grid.get_vue_active_sorters())|n},
|
sorters: ${json.dumps(grid.get_vue_active_sorters())|n},
|
||||||
|
@ -175,6 +268,47 @@
|
||||||
template: '#${grid.vue_tagname}-template',
|
template: '#${grid.vue_tagname}-template',
|
||||||
computed: {
|
computed: {
|
||||||
|
|
||||||
|
% if grid.filterable:
|
||||||
|
|
||||||
|
addFilterChoices() {
|
||||||
|
|
||||||
|
// parse list of search terms
|
||||||
|
const terms = []
|
||||||
|
for (let term of this.addFilterTerm.toLowerCase().split(' ')) {
|
||||||
|
term = term.trim()
|
||||||
|
if (term) {
|
||||||
|
terms.push(term)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// show all if no search terms
|
||||||
|
if (!terms.length) {
|
||||||
|
return this.filters
|
||||||
|
}
|
||||||
|
|
||||||
|
// only show filters matching all search terms
|
||||||
|
return this.filters.filter(option => {
|
||||||
|
let label = option.label.toLowerCase()
|
||||||
|
for (let term of terms) {
|
||||||
|
if (label.indexOf(term) < 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
activeFilters() {
|
||||||
|
for (let filtr of this.filters) {
|
||||||
|
if (filtr.active) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
|
||||||
|
% endif
|
||||||
|
|
||||||
% if not grid.paginate_on_backend:
|
% if not grid.paginate_on_backend:
|
||||||
|
|
||||||
pagerStats() {
|
pagerStats() {
|
||||||
|
@ -245,9 +379,12 @@
|
||||||
this.fetchedFirstData = true
|
this.fetchedFirstData = true
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchData() {
|
async fetchData(params) {
|
||||||
|
if (params === undefined || params === null) {
|
||||||
|
params = this.getBasicParams()
|
||||||
|
}
|
||||||
|
|
||||||
let params = new URLSearchParams(this.getBasicParams())
|
params = new URLSearchParams(params)
|
||||||
if (!params.has('partial')) {
|
if (!params.has('partial')) {
|
||||||
params.append('partial', true)
|
params.append('partial', true)
|
||||||
}
|
}
|
||||||
|
@ -281,6 +418,129 @@
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
resetView() {
|
||||||
|
this.loading = true
|
||||||
|
|
||||||
|
// use current url proper, plus reset param
|
||||||
|
let url = '?reset-view=true'
|
||||||
|
|
||||||
|
// add current hash, to preserve that in redirect
|
||||||
|
if (location.hash) {
|
||||||
|
url += '&hash=' + location.hash.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
location.href = url
|
||||||
|
},
|
||||||
|
|
||||||
|
% if grid.filterable:
|
||||||
|
|
||||||
|
formatAddFilterItem(filtr) {
|
||||||
|
return filtr.label || filtr.key
|
||||||
|
},
|
||||||
|
|
||||||
|
addFilterInit() {
|
||||||
|
this.addFilterShow = true
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const input = this.$refs.addFilterAutocomplete.$el.querySelector('input')
|
||||||
|
input.addEventListener('keydown', this.addFilterKeydown)
|
||||||
|
this.$refs.addFilterAutocomplete.focus()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
addFilterKeydown(event) {
|
||||||
|
|
||||||
|
// ESC will clear searchbox
|
||||||
|
if (event.which == 27) {
|
||||||
|
this.addFilterHide()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addFilterHide() {
|
||||||
|
const input = this.$refs.addFilterAutocomplete.$el.querySelector('input')
|
||||||
|
input.removeEventListener('keydown', this.addFilterKeydown)
|
||||||
|
this.addFilterTerm = ''
|
||||||
|
this.addFilterShow = false
|
||||||
|
},
|
||||||
|
|
||||||
|
addFilterSelect(filtr) {
|
||||||
|
this.addFilter(filtr.key)
|
||||||
|
this.addFilterHide()
|
||||||
|
},
|
||||||
|
|
||||||
|
findFilter(key) {
|
||||||
|
for (let filtr of this.filters) {
|
||||||
|
if (filtr.key == key) {
|
||||||
|
return filtr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
findFilterComponent(key) {
|
||||||
|
for (let filtr of this.$refs.gridFilters) {
|
||||||
|
if (filtr.filter.key == key) {
|
||||||
|
return filtr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addFilter(key) {
|
||||||
|
|
||||||
|
// show the filter
|
||||||
|
let filtr = this.findFilter(key)
|
||||||
|
filtr.active = true
|
||||||
|
filtr.visible = true
|
||||||
|
|
||||||
|
// focus the filter
|
||||||
|
filtr = this.findFilterComponent(key)
|
||||||
|
this.$nextTick(() => {
|
||||||
|
filtr.focusValue()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
clearFilters() {
|
||||||
|
|
||||||
|
// explicitly deactivate all filters
|
||||||
|
for (let filter of this.filters) {
|
||||||
|
filter.active = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// then just "apply" as normal
|
||||||
|
this.applyFilters()
|
||||||
|
},
|
||||||
|
|
||||||
|
applyFilters(params) {
|
||||||
|
if (params === undefined) {
|
||||||
|
params = this.getFilterParams()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hide inactive filters
|
||||||
|
for (let filter of this.filters) {
|
||||||
|
if (!filter.active) {
|
||||||
|
filter.visible = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch new data
|
||||||
|
params.filter = true
|
||||||
|
this.fetchData(params)
|
||||||
|
},
|
||||||
|
|
||||||
|
getFilterParams() {
|
||||||
|
const params = {}
|
||||||
|
for (let filter of this.filters) {
|
||||||
|
if (filter.active) {
|
||||||
|
params[filter.key] = filter.value
|
||||||
|
params[filter.key+'.verb'] = filter.verb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(params).length) {
|
||||||
|
params.filter = 'true'
|
||||||
|
}
|
||||||
|
return params
|
||||||
|
},
|
||||||
|
|
||||||
|
% endif
|
||||||
|
|
||||||
% if grid.sortable and grid.sort_on_backend:
|
% if grid.sortable and grid.sort_on_backend:
|
||||||
|
|
||||||
onSort(field, order, event) {
|
onSort(field, order, event) {
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
|
|
||||||
<%def name="make_wutta_components()">
|
<%def name="make_wutta_components()">
|
||||||
${self.make_wutta_button_component()}
|
${self.make_wutta_button_component()}
|
||||||
|
${self.make_wutta_filter_component()}
|
||||||
|
${self.make_wutta_filter_value_component()}
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
<%def name="make_wutta_button_component()">
|
<%def name="make_wutta_button_component()">
|
||||||
|
@ -69,3 +71,97 @@
|
||||||
Vue.component('wutta-button', WuttaButton)
|
Vue.component('wutta-button', WuttaButton)
|
||||||
</script>
|
</script>
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
|
<%def name="make_wutta_filter_component()">
|
||||||
|
<script type="text/x-template" id="wutta-filter-template">
|
||||||
|
<div v-show="filter.visible"
|
||||||
|
class="wutta-filter">
|
||||||
|
|
||||||
|
<b-button @click="filter.active = !filter.active"
|
||||||
|
class="filter-toggle"
|
||||||
|
icon-pack="fas"
|
||||||
|
:icon-left="filter.active ? 'check' : null"
|
||||||
|
:size="isSmall ? 'is-small' : null">
|
||||||
|
{{ filter.label }}
|
||||||
|
</b-button>
|
||||||
|
|
||||||
|
<div v-show="filter.active"
|
||||||
|
style="display: flex; gap: 0.5rem;">
|
||||||
|
|
||||||
|
<b-select v-model="filter.verb"
|
||||||
|
class="filter-verb"
|
||||||
|
:size="isSmall ? 'is-small' : null">
|
||||||
|
<option v-for="verb in filter.verbs"
|
||||||
|
:key="verb"
|
||||||
|
:value="verb">
|
||||||
|
{{ verb }}
|
||||||
|
</option>
|
||||||
|
</b-select>
|
||||||
|
|
||||||
|
<wutta-filter-value v-model="filter.value"
|
||||||
|
ref="filterValue"
|
||||||
|
:is-small="isSmall" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
|
||||||
|
const WuttaFilter = {
|
||||||
|
template: '#wutta-filter-template',
|
||||||
|
props: {
|
||||||
|
filter: Object,
|
||||||
|
isSmall: Boolean,
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
|
||||||
|
focusValue: function() {
|
||||||
|
this.$refs.filterValue.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.component('wutta-filter', WuttaFilter)
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</%def>
|
||||||
|
|
||||||
|
<%def name="make_wutta_filter_value_component()">
|
||||||
|
<script type="text/x-template" id="wutta-filter-value-template">
|
||||||
|
<div class="wutta-filter-value">
|
||||||
|
|
||||||
|
<b-input v-model="inputValue"
|
||||||
|
ref="valueInput"
|
||||||
|
@input="val => $emit('input', val)"
|
||||||
|
:size="isSmall ? 'is-small' : null" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
|
||||||
|
const WuttaFilterValue = {
|
||||||
|
template: '#wutta-filter-value-template',
|
||||||
|
props: {
|
||||||
|
value: String,
|
||||||
|
isSmall: Boolean,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
inputValue: this.value,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
|
||||||
|
focus: function() {
|
||||||
|
this.$refs.valueInput.focus()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Vue.component('wutta-filter-value', WuttaFilterValue)
|
||||||
|
|
||||||
|
</script>
|
||||||
|
</%def>
|
||||||
|
|
|
@ -181,6 +181,23 @@ class MasterView(View):
|
||||||
|
|
||||||
This is optional; see also :meth:`get_grid_columns()`.
|
This is optional; see also :meth:`get_grid_columns()`.
|
||||||
|
|
||||||
|
.. attribute:: filterable
|
||||||
|
|
||||||
|
Boolean indicating whether the grid for the :meth:`index()`
|
||||||
|
view should allow filtering of data. Default is ``True``.
|
||||||
|
|
||||||
|
This is used by :meth:`make_model_grid()` to set the grid's
|
||||||
|
:attr:`~wuttaweb.grids.base.Grid.filterable` flag.
|
||||||
|
|
||||||
|
.. attribute:: filter_defaults
|
||||||
|
|
||||||
|
Optional dict of default filter state.
|
||||||
|
|
||||||
|
This is used by :meth:`make_model_grid()` to set the grid's
|
||||||
|
:attr:`~wuttaweb.grids.base.Grid.filter_defaults`.
|
||||||
|
|
||||||
|
Only relevant if :attr:`filterable` is true.
|
||||||
|
|
||||||
.. attribute:: sortable
|
.. attribute:: sortable
|
||||||
|
|
||||||
Boolean indicating whether the grid for the :meth:`index()`
|
Boolean indicating whether the grid for the :meth:`index()`
|
||||||
|
@ -283,6 +300,8 @@ class MasterView(View):
|
||||||
# features
|
# features
|
||||||
listable = True
|
listable = True
|
||||||
has_grid = True
|
has_grid = True
|
||||||
|
filterable = True
|
||||||
|
filter_defaults = None
|
||||||
sortable = True
|
sortable = True
|
||||||
sort_on_backend = True
|
sort_on_backend = True
|
||||||
sort_defaults = None
|
sort_defaults = None
|
||||||
|
@ -337,13 +356,26 @@ class MasterView(View):
|
||||||
if self.has_grid:
|
if self.has_grid:
|
||||||
grid = self.make_model_grid()
|
grid = self.make_model_grid()
|
||||||
|
|
||||||
# so-called 'partial' requests get just data, no html
|
# handle "full" vs. "partial" differently
|
||||||
if self.request.GET.get('partial'):
|
if self.request.GET.get('partial'):
|
||||||
|
|
||||||
|
# so-called 'partial' requests get just data, no html
|
||||||
context = {'data': grid.get_vue_data()}
|
context = {'data': grid.get_vue_data()}
|
||||||
if grid.paginated and grid.paginate_on_backend:
|
if grid.paginated and grid.paginate_on_backend:
|
||||||
context['pager_stats'] = grid.get_vue_pager_stats()
|
context['pager_stats'] = grid.get_vue_pager_stats()
|
||||||
return self.json_response(context)
|
return self.json_response(context)
|
||||||
|
|
||||||
|
else: # full, not partial
|
||||||
|
|
||||||
|
# nb. when user asks to reset view, it is via the query
|
||||||
|
# string. if so we then redirect to discard that.
|
||||||
|
if self.request.GET.get('reset-view'):
|
||||||
|
|
||||||
|
# nb. we want to preserve url hash if applicable
|
||||||
|
kw = {'_query': None,
|
||||||
|
'_anchor': self.request.GET.get('hash')}
|
||||||
|
return self.redirect(self.request.current_route_url(**kw))
|
||||||
|
|
||||||
context['grid'] = grid
|
context['grid'] = grid
|
||||||
|
|
||||||
return self.render_to_response('index', context)
|
return self.render_to_response('index', context)
|
||||||
|
@ -1208,6 +1240,8 @@ class MasterView(View):
|
||||||
|
|
||||||
kwargs['actions'] = actions
|
kwargs['actions'] = actions
|
||||||
|
|
||||||
|
kwargs.setdefault('filterable', self.filterable)
|
||||||
|
kwargs.setdefault('filter_defaults', self.filter_defaults)
|
||||||
kwargs.setdefault('sortable', self.sortable)
|
kwargs.setdefault('sortable', self.sortable)
|
||||||
kwargs.setdefault('sort_multiple', not self.request.use_oruga)
|
kwargs.setdefault('sort_multiple', not self.request.use_oruga)
|
||||||
kwargs.setdefault('sort_on_backend', self.sort_on_backend)
|
kwargs.setdefault('sort_on_backend', self.sort_on_backend)
|
||||||
|
|
|
@ -58,6 +58,10 @@ class PersonView(MasterView):
|
||||||
'last_name',
|
'last_name',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
filter_defaults = {
|
||||||
|
'full_name': {'active': True},
|
||||||
|
}
|
||||||
|
|
||||||
def configure_grid(self, g):
|
def configure_grid(self, g):
|
||||||
""" """
|
""" """
|
||||||
super().configure_grid(g)
|
super().configure_grid(g)
|
||||||
|
|
|
@ -52,6 +52,10 @@ class RoleView(MasterView):
|
||||||
'notes',
|
'notes',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
filter_defaults = {
|
||||||
|
'name': {'active': True},
|
||||||
|
}
|
||||||
|
|
||||||
# TODO: master should handle this, possibly via configure_form()
|
# TODO: master should handle this, possibly via configure_form()
|
||||||
def get_query(self, session=None):
|
def get_query(self, session=None):
|
||||||
""" """
|
""" """
|
||||||
|
|
|
@ -201,6 +201,9 @@ class SettingView(MasterView):
|
||||||
"""
|
"""
|
||||||
model_class = Setting
|
model_class = Setting
|
||||||
model_title = "Raw Setting"
|
model_title = "Raw Setting"
|
||||||
|
filter_defaults = {
|
||||||
|
'name': {'active': True},
|
||||||
|
}
|
||||||
sort_defaults = 'name'
|
sort_defaults = 'name'
|
||||||
|
|
||||||
# TODO: master should handle this (per model key)
|
# TODO: master should handle this (per model key)
|
||||||
|
|
|
@ -55,6 +55,10 @@ class UserView(MasterView):
|
||||||
'active',
|
'active',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
filter_defaults = {
|
||||||
|
'username': {'active': True},
|
||||||
|
}
|
||||||
|
|
||||||
# TODO: master should handle this, possibly via configure_form()
|
# TODO: master should handle this, possibly via configure_form()
|
||||||
def get_query(self, session=None):
|
def get_query(self, session=None):
|
||||||
""" """
|
""" """
|
||||||
|
|
|
@ -383,8 +383,7 @@ class TestGrid(WebTestCase):
|
||||||
grid = self.make_grid(key='settings', model_class=model.Setting,
|
grid = self.make_grid(key='settings', model_class=model.Setting,
|
||||||
filterable=True)
|
filterable=True)
|
||||||
self.assertEqual(len(grid.filters), 2)
|
self.assertEqual(len(grid.filters), 2)
|
||||||
self.assertFalse(hasattr(grid.filters['name'], 'active'))
|
self.assertEqual(len(grid.active_filters), 0)
|
||||||
self.assertFalse(hasattr(grid.filters['value'], 'active'))
|
|
||||||
self.assertNotIn('grid.settings.filter.name.active', self.request.session)
|
self.assertNotIn('grid.settings.filter.name.active', self.request.session)
|
||||||
self.assertNotIn('grid.settings.filter.value.active', self.request.session)
|
self.assertNotIn('grid.settings.filter.value.active', self.request.session)
|
||||||
self.request.GET = {'name': 'john', 'name.verb': 'contains'}
|
self.request.GET = {'name': 'john', 'name.verb': 'contains'}
|
||||||
|
@ -401,8 +400,7 @@ class TestGrid(WebTestCase):
|
||||||
grid = self.make_grid(key='settings', model_class=model.Setting,
|
grid = self.make_grid(key='settings', model_class=model.Setting,
|
||||||
sortable=True, filterable=True)
|
sortable=True, filterable=True)
|
||||||
self.assertEqual(len(grid.filters), 2)
|
self.assertEqual(len(grid.filters), 2)
|
||||||
self.assertFalse(hasattr(grid.filters['name'], 'active'))
|
self.assertEqual(len(grid.active_filters), 0)
|
||||||
self.assertFalse(hasattr(grid.filters['value'], 'active'))
|
|
||||||
self.assertNotIn('grid.settings.filter.name.active', self.request.session)
|
self.assertNotIn('grid.settings.filter.name.active', self.request.session)
|
||||||
self.assertNotIn('grid.settings.filter.value.active', self.request.session)
|
self.assertNotIn('grid.settings.filter.value.active', self.request.session)
|
||||||
self.assertNotIn('grid.settings.sorters.length', self.request.session)
|
self.assertNotIn('grid.settings.sorters.length', self.request.session)
|
||||||
|
@ -419,6 +417,12 @@ class TestGrid(WebTestCase):
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
|
self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'asc')
|
self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'asc')
|
||||||
|
|
||||||
|
# can reset view to defaults
|
||||||
|
self.request.GET = {'reset-view': 'true'}
|
||||||
|
grid.load_settings()
|
||||||
|
self.assertEqual(grid.active_filters, [])
|
||||||
|
self.assertIsNone(grid.filters['name'].value)
|
||||||
|
|
||||||
def test_request_has_settings(self):
|
def test_request_has_settings(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
grid = self.make_grid(key='settings', model_class=model.Setting)
|
grid = self.make_grid(key='settings', model_class=model.Setting)
|
||||||
|
@ -927,6 +931,10 @@ class TestGrid(WebTestCase):
|
||||||
filtr = grid.make_filter(model.Setting.name)
|
filtr = grid.make_filter(model.Setting.name)
|
||||||
self.assertIsInstance(filtr, mod.GridFilter)
|
self.assertIsInstance(filtr, mod.GridFilter)
|
||||||
|
|
||||||
|
# invalid model class
|
||||||
|
grid = self.make_grid(model_class=42)
|
||||||
|
self.assertRaises(ValueError, grid.make_filter, 'name')
|
||||||
|
|
||||||
def test_set_filter(self):
|
def test_set_filter(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
|
||||||
|
@ -968,6 +976,22 @@ class TestGrid(WebTestCase):
|
||||||
grid.remove_filter('value')
|
grid.remove_filter('value')
|
||||||
self.assertNotIn('value', grid.filters)
|
self.assertNotIn('value', grid.filters)
|
||||||
|
|
||||||
|
def test_set_filter_defaults(self):
|
||||||
|
model = self.app.model
|
||||||
|
|
||||||
|
# empty by default
|
||||||
|
grid = self.make_grid(model_class=model.Setting, filterable=True)
|
||||||
|
self.assertEqual(grid.filter_defaults, {})
|
||||||
|
|
||||||
|
# can specify via method call
|
||||||
|
grid.set_filter_defaults(name={'active': True})
|
||||||
|
self.assertEqual(grid.filter_defaults, {'name': {'active': True}})
|
||||||
|
|
||||||
|
# can specify via constructor
|
||||||
|
grid = self.make_grid(model_class=model.Setting, filterable=True,
|
||||||
|
filter_defaults={'name': {'active': True}})
|
||||||
|
self.assertEqual(grid.filter_defaults, {'name': {'active': True}})
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
# data methods
|
# data methods
|
||||||
##############################
|
##############################
|
||||||
|
@ -1008,11 +1032,82 @@ class TestGrid(WebTestCase):
|
||||||
|
|
||||||
def test_filter_data(self):
|
def test_filter_data(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
sample_data = [
|
||||||
|
{'name': 'foo1', 'value': 'ONE'},
|
||||||
|
{'name': 'foo2', 'value': 'two'},
|
||||||
|
{'name': 'foo3', 'value': 'ggg'},
|
||||||
|
{'name': 'foo4', 'value': 'ggg'},
|
||||||
|
{'name': 'foo5', 'value': 'ggg'},
|
||||||
|
{'name': 'foo6', 'value': 'six'},
|
||||||
|
{'name': 'foo7', 'value': 'seven'},
|
||||||
|
{'name': 'foo8', 'value': 'eight'},
|
||||||
|
{'name': 'foo9', 'value': 'nine'},
|
||||||
|
]
|
||||||
|
for setting in sample_data:
|
||||||
|
self.app.save_setting(self.session, setting['name'], setting['value'])
|
||||||
|
self.session.commit()
|
||||||
|
sample_query = self.session.query(model.Setting)
|
||||||
|
|
||||||
query = self.session.query(model.Setting)
|
grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
|
||||||
grid = self.make_grid(model_class=model.Setting, filterable=True)
|
|
||||||
|
# not filtered by default
|
||||||
grid.load_settings()
|
grid.load_settings()
|
||||||
self.assertRaises(NotImplementedError, grid.filter_data, query)
|
self.assertEqual(grid.active_filters, [])
|
||||||
|
filtered_query = grid.filter_data(sample_query)
|
||||||
|
self.assertIs(filtered_query, sample_query)
|
||||||
|
|
||||||
|
# can be filtered per session settings
|
||||||
|
self.request.session['grid.settings.filter.value.active'] = True
|
||||||
|
self.request.session['grid.settings.filter.value.verb'] = 'contains'
|
||||||
|
self.request.session['grid.settings.filter.value.value'] = 'ggg'
|
||||||
|
grid.load_settings()
|
||||||
|
self.assertEqual(len(grid.active_filters), 1)
|
||||||
|
self.assertEqual(grid.active_filters[0].key, 'value')
|
||||||
|
filtered_query = grid.filter_data(sample_query)
|
||||||
|
self.assertIsInstance(filtered_query, orm.Query)
|
||||||
|
self.assertIsNot(filtered_query, sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 3)
|
||||||
|
|
||||||
|
# can be filtered per request settings
|
||||||
|
self.request.GET = {'value': 's', 'value.verb': 'contains'}
|
||||||
|
grid.load_settings()
|
||||||
|
self.assertEqual(len(grid.active_filters), 1)
|
||||||
|
self.assertEqual(grid.active_filters[0].key, 'value')
|
||||||
|
filtered_query = grid.filter_data(sample_query)
|
||||||
|
self.assertIsInstance(filtered_query, orm.Query)
|
||||||
|
self.assertEqual(filtered_query.count(), 2)
|
||||||
|
|
||||||
|
# not filtered if verb is invalid
|
||||||
|
self.request.GET = {'value': 'ggg', 'value.verb': 'doesnotexist'}
|
||||||
|
grid.load_settings()
|
||||||
|
self.assertEqual(len(grid.active_filters), 1)
|
||||||
|
self.assertEqual(grid.active_filters[0].verb, 'doesnotexist')
|
||||||
|
filtered_query = grid.filter_data(sample_query)
|
||||||
|
self.assertIs(filtered_query, sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 9)
|
||||||
|
|
||||||
|
# not filtered if error
|
||||||
|
self.request.GET = {'value': 'ggg', 'value.verb': 'contains'}
|
||||||
|
grid.load_settings()
|
||||||
|
self.assertEqual(len(grid.active_filters), 1)
|
||||||
|
self.assertEqual(grid.active_filters[0].verb, 'contains')
|
||||||
|
filtered_query = grid.filter_data(sample_query)
|
||||||
|
self.assertIsNot(filtered_query, sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 3)
|
||||||
|
with patch.object(grid.active_filters[0], 'filter_contains', side_effect=RuntimeError):
|
||||||
|
filtered_query = grid.filter_data(sample_query)
|
||||||
|
self.assertIs(filtered_query, sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 9)
|
||||||
|
|
||||||
|
# joiner is invoked
|
||||||
|
self.assertEqual(len(grid.active_filters), 1)
|
||||||
|
self.assertEqual(grid.active_filters[0].key, 'value')
|
||||||
|
joiner = MagicMock(side_effect=lambda q: q)
|
||||||
|
grid.joiners = {'value': joiner}
|
||||||
|
grid.joined = set()
|
||||||
|
filtered_query = grid.filter_data(sample_query)
|
||||||
|
joiner.assert_called_once_with(sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 3)
|
||||||
|
|
||||||
def test_sort_data(self):
|
def test_sort_data(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
@ -1210,6 +1305,15 @@ class TestGrid(WebTestCase):
|
||||||
sorters = grid.get_vue_active_sorters()
|
sorters = grid.get_vue_active_sorters()
|
||||||
self.assertEqual(sorters, [{'field': 'name', 'order': 'asc'}])
|
self.assertEqual(sorters, [{'field': 'name', 'order': 'asc'}])
|
||||||
|
|
||||||
|
def test_get_vue_filters(self):
|
||||||
|
model = self.app.model
|
||||||
|
|
||||||
|
# basic
|
||||||
|
grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
|
||||||
|
grid.load_settings()
|
||||||
|
filters = grid.get_vue_filters()
|
||||||
|
self.assertEqual(len(filters), 2)
|
||||||
|
|
||||||
def test_get_vue_data(self):
|
def test_get_vue_data(self):
|
||||||
|
|
||||||
# empty if no columns defined
|
# empty if no columns defined
|
||||||
|
@ -1317,3 +1421,86 @@ class TestGridAction(TestCase):
|
||||||
action = self.make_action('blarg', url=lambda o, i: '/yeehaw')
|
action = self.make_action('blarg', url=lambda o, i: '/yeehaw')
|
||||||
url = action.get_url(obj)
|
url = action.get_url(obj)
|
||||||
self.assertEqual(url, '/yeehaw')
|
self.assertEqual(url, '/yeehaw')
|
||||||
|
|
||||||
|
|
||||||
|
class TestGridFilter(WebTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.setup_web()
|
||||||
|
|
||||||
|
model = self.app.model
|
||||||
|
self.sample_data = [
|
||||||
|
{'name': 'foo1', 'value': 'ONE'},
|
||||||
|
{'name': 'foo2', 'value': 'two'},
|
||||||
|
{'name': 'foo3', 'value': 'ggg'},
|
||||||
|
{'name': 'foo4', 'value': 'ggg'},
|
||||||
|
{'name': 'foo5', 'value': 'ggg'},
|
||||||
|
{'name': 'foo6', 'value': 'six'},
|
||||||
|
{'name': 'foo7', 'value': 'seven'},
|
||||||
|
{'name': 'foo8', 'value': 'eight'},
|
||||||
|
{'name': 'foo9', 'value': 'nine'},
|
||||||
|
]
|
||||||
|
for setting in self.sample_data:
|
||||||
|
self.app.save_setting(self.session, setting['name'], setting['value'])
|
||||||
|
self.session.commit()
|
||||||
|
self.sample_query = self.session.query(model.Setting)
|
||||||
|
|
||||||
|
def make_filter(self, model_property, **kwargs):
|
||||||
|
return mod.GridFilter(self.request, model_property, **kwargs)
|
||||||
|
|
||||||
|
def test_repr(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.Setting.name)
|
||||||
|
self.assertEqual(repr(filtr), "GridFilter(key='name', active=False, verb='contains', value=None)")
|
||||||
|
|
||||||
|
def test_apply_filter(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.Setting.value)
|
||||||
|
|
||||||
|
# default verb used as fallback
|
||||||
|
self.assertEqual(filtr.default_verb, 'contains')
|
||||||
|
filtr.verb = None
|
||||||
|
with patch.object(filtr, 'filter_contains', side_effect=lambda q, v: q) as filter_contains:
|
||||||
|
filtered_query = filtr.apply_filter(self.sample_query, value='foo')
|
||||||
|
filter_contains.assert_called_once_with(self.sample_query, 'foo')
|
||||||
|
self.assertIsNone(filtr.verb)
|
||||||
|
|
||||||
|
# filter verb used as fallback
|
||||||
|
filtr.verb = 'equal'
|
||||||
|
with patch.object(filtr, 'filter_equal', create=True, side_effect=lambda q, v: q) as filter_equal:
|
||||||
|
filtered_query = filtr.apply_filter(self.sample_query, value='foo')
|
||||||
|
filter_equal.assert_called_once_with(self.sample_query, 'foo')
|
||||||
|
|
||||||
|
# filter value used as fallback
|
||||||
|
filtr.verb = 'contains'
|
||||||
|
filtr.value = 'blarg'
|
||||||
|
with patch.object(filtr, 'filter_contains', side_effect=lambda q, v: q) as filter_contains:
|
||||||
|
filtered_query = filtr.apply_filter(self.sample_query)
|
||||||
|
filter_contains.assert_called_once_with(self.sample_query, 'blarg')
|
||||||
|
|
||||||
|
# error if invalid verb
|
||||||
|
self.assertRaises(mod.VerbNotSupported, filtr.apply_filter,
|
||||||
|
self.sample_query, verb='doesnotexist')
|
||||||
|
|
||||||
|
def test_filter_contains(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.Setting.value)
|
||||||
|
self.assertEqual(self.sample_query.count(), 9)
|
||||||
|
|
||||||
|
# not filtered for empty value
|
||||||
|
filtered_query = filtr.filter_contains(self.sample_query, None)
|
||||||
|
self.assertIs(filtered_query, self.sample_query)
|
||||||
|
filtered_query = filtr.filter_contains(self.sample_query, '')
|
||||||
|
self.assertIs(filtered_query, self.sample_query)
|
||||||
|
|
||||||
|
# filtered by value
|
||||||
|
filtered_query = filtr.filter_contains(self.sample_query, 'ggg')
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 3)
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerbNotSupported(TestCase):
|
||||||
|
|
||||||
|
def test_basic(self):
|
||||||
|
error = mod.VerbNotSupported('equal')
|
||||||
|
self.assertEqual(str(error), "unknown filter verb not supported: equal")
|
||||||
|
|
|
@ -761,6 +761,12 @@ class TestMasterView(WebTestCase):
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.content_type, 'application/json')
|
self.assertEqual(response.content_type, 'application/json')
|
||||||
|
|
||||||
|
# redirects when view is reset
|
||||||
|
self.request.GET = {'reset-view': '1', 'hash': 'foo'}
|
||||||
|
with patch.object(self.request, 'current_route_url'):
|
||||||
|
response = view.index()
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
def test_create(self):
|
def test_create(self):
|
||||||
self.pyramid_config.include('wuttaweb.views.common')
|
self.pyramid_config.include('wuttaweb.views.common')
|
||||||
self.pyramid_config.include('wuttaweb.views.auth')
|
self.pyramid_config.include('wuttaweb.views.auth')
|
||||||
|
|
Loading…
Reference in a new issue