Compare commits
7 commits
60526f646a
...
bf2ca4b475
Author | SHA1 | Date | |
---|---|---|---|
![]() |
bf2ca4b475 | ||
![]() |
e332975ce9 | ||
![]() |
f6fb6957e3 | ||
![]() |
4525f91c21 | ||
![]() |
2e5feeb6f4 | ||
![]() |
db5e4e88f6 | ||
![]() |
122c6650d5 |
6
docs/api/wuttaweb/grids.filters.rst
Normal file
6
docs/api/wuttaweb/grids.filters.rst
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
|
||||||
|
``wuttaweb.grids.filters``
|
||||||
|
==========================
|
||||||
|
|
||||||
|
.. automodule:: wuttaweb.grids.filters
|
||||||
|
:members:
|
|
@ -16,6 +16,7 @@
|
||||||
forms.widgets
|
forms.widgets
|
||||||
grids
|
grids
|
||||||
grids.base
|
grids.base
|
||||||
|
grids.filters
|
||||||
handler
|
handler
|
||||||
helpers
|
helpers
|
||||||
menus
|
menus
|
||||||
|
|
|
@ -313,7 +313,7 @@ class Form:
|
||||||
self.set_fields(fields or self.get_fields())
|
self.set_fields(fields or self.get_fields())
|
||||||
|
|
||||||
# nb. this tracks grid JSON data for inclusion in page template
|
# nb. this tracks grid JSON data for inclusion in page template
|
||||||
self.grid_vue_data = OrderedDict()
|
self.grid_vue_context = OrderedDict()
|
||||||
|
|
||||||
def __contains__(self, name):
|
def __contains__(self, name):
|
||||||
"""
|
"""
|
||||||
|
@ -826,16 +826,16 @@ class Form:
|
||||||
output = render(template, context)
|
output = render(template, context)
|
||||||
return HTML.literal(output)
|
return HTML.literal(output)
|
||||||
|
|
||||||
def add_grid_vue_data(self, grid):
|
def add_grid_vue_context(self, grid):
|
||||||
""" """
|
""" """
|
||||||
if not grid.key:
|
if not grid.key:
|
||||||
raise ValueError("grid must have a key!")
|
raise ValueError("grid must have a key!")
|
||||||
|
|
||||||
if grid.key in self.grid_vue_data:
|
if grid.key in self.grid_vue_context:
|
||||||
log.warning("grid data with key '%s' already registered, "
|
log.warning("grid data with key '%s' already registered, "
|
||||||
"but will be replaced", grid.key)
|
"but will be replaced", grid.key)
|
||||||
|
|
||||||
self.grid_vue_data[grid.key] = grid.get_vue_data()
|
self.grid_vue_context[grid.key] = grid.get_vue_context()
|
||||||
|
|
||||||
def render_vue_field(
|
def render_vue_field(
|
||||||
self,
|
self,
|
||||||
|
|
|
@ -27,6 +27,7 @@ Base grid classes
|
||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import warnings
|
||||||
from collections import namedtuple
|
from collections import namedtuple
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
|
@ -40,6 +41,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
|
from wuttjamaican.util import UNSPECIFIED
|
||||||
|
from wuttaweb.grids.filters import default_sqlalchemy_filters, VerbNotSupported
|
||||||
|
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
@ -115,6 +117,26 @@ class Grid:
|
||||||
|
|
||||||
See also :meth:`set_renderer()`.
|
See also :meth:`set_renderer()`.
|
||||||
|
|
||||||
|
.. attribute:: row_class
|
||||||
|
|
||||||
|
This represents the CSS ``class`` attribute for a row within
|
||||||
|
the grid. Default is ``None``.
|
||||||
|
|
||||||
|
This can be a simple string, in which case the same class is
|
||||||
|
applied to all rows.
|
||||||
|
|
||||||
|
Or it can be a callable, which can then return different
|
||||||
|
class(es) depending on each row. The callable must take three
|
||||||
|
args: ``(obj, data, i)`` - for example::
|
||||||
|
|
||||||
|
def my_row_class(obj, data, i):
|
||||||
|
if obj.archived:
|
||||||
|
return 'poser-archived'
|
||||||
|
|
||||||
|
grid = Grid(request, key='foo', row_class=my_row_class)
|
||||||
|
|
||||||
|
See :meth:`get_row_class()` for more info.
|
||||||
|
|
||||||
.. attribute:: actions
|
.. attribute:: actions
|
||||||
|
|
||||||
List of :class:`GridAction` instances represenging action links
|
List of :class:`GridAction` instances represenging action links
|
||||||
|
@ -329,6 +351,7 @@ class Grid:
|
||||||
data=None,
|
data=None,
|
||||||
labels={},
|
labels={},
|
||||||
renderers={},
|
renderers={},
|
||||||
|
row_class=None,
|
||||||
actions=[],
|
actions=[],
|
||||||
linked_columns=[],
|
linked_columns=[],
|
||||||
sortable=False,
|
sortable=False,
|
||||||
|
@ -354,6 +377,7 @@ class Grid:
|
||||||
self.data = data
|
self.data = data
|
||||||
self.labels = labels or {}
|
self.labels = labels or {}
|
||||||
self.renderers = renderers or {}
|
self.renderers = renderers or {}
|
||||||
|
self.row_class = row_class
|
||||||
self.actions = actions or []
|
self.actions = actions or []
|
||||||
self.linked_columns = linked_columns or []
|
self.linked_columns = linked_columns or []
|
||||||
self.joiners = joiners or {}
|
self.joiners = joiners or {}
|
||||||
|
@ -529,8 +553,9 @@ class Grid:
|
||||||
Depending on the nature of grid data, sometimes a cell's
|
Depending on the nature of grid data, sometimes a cell's
|
||||||
"as-is" value will be undesirable for display purposes.
|
"as-is" value will be undesirable for display purposes.
|
||||||
|
|
||||||
The logic in :meth:`get_vue_data()` will first "convert" all
|
The logic in :meth:`get_vue_context()` will first "convert"
|
||||||
grid data as necessary so that it is at least JSON-compatible.
|
all grid data as necessary so that it is at least
|
||||||
|
JSON-compatible.
|
||||||
|
|
||||||
But then it also will invoke a renderer override (if defined)
|
But then it also will invoke a renderer override (if defined)
|
||||||
to obtain the "final" cell value.
|
to obtain the "final" cell value.
|
||||||
|
@ -1034,8 +1059,9 @@ class Grid:
|
||||||
|
|
||||||
def make_filter(self, columninfo, **kwargs):
|
def make_filter(self, columninfo, **kwargs):
|
||||||
"""
|
"""
|
||||||
Create and return a :class:`GridFilter` instance suitable for
|
Create and return a
|
||||||
use on the given column.
|
:class:`~wuttaweb.grids.filters.GridFilter` instance suitable
|
||||||
|
for use on the given column.
|
||||||
|
|
||||||
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.
|
||||||
|
@ -1043,24 +1069,34 @@ 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:`GridFilter` instance.
|
:returns: A :class:`~wuttaweb.grids.filters.GridFilter`
|
||||||
|
instance.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# model_property is required
|
||||||
model_property = None
|
model_property = None
|
||||||
if isinstance(columninfo, str):
|
if kwargs.get('model_property'):
|
||||||
|
model_property = kwargs['model_property']
|
||||||
|
elif isinstance(columninfo, str):
|
||||||
key = columninfo
|
key = columninfo
|
||||||
if self.model_class:
|
if self.model_class:
|
||||||
try:
|
model_property = getattr(self.model_class, key, None)
|
||||||
mapper = sa.inspect(self.model_class)
|
|
||||||
except sa.exc.NoInspectionAvailable:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
model_property = mapper.get_property(key)
|
|
||||||
if not model_property:
|
if not model_property:
|
||||||
raise ValueError(f"cannot locate model property for key: {key}")
|
raise ValueError(f"cannot locate model property for key: {key}")
|
||||||
else:
|
else:
|
||||||
model_property = columninfo
|
model_property = columninfo
|
||||||
|
|
||||||
return GridFilter(self.request, model_property, **kwargs)
|
# 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, model_property.key, **kwargs)
|
||||||
|
|
||||||
def set_filter(self, key, filterinfo=None, **kwargs):
|
def set_filter(self, key, filterinfo=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -1228,7 +1264,7 @@ class Grid:
|
||||||
settings[f'filter.{filtr.key}.active'] = defaults.get('active',
|
settings[f'filter.{filtr.key}.active'] = defaults.get('active',
|
||||||
filtr.default_active)
|
filtr.default_active)
|
||||||
settings[f'filter.{filtr.key}.verb'] = defaults.get('verb',
|
settings[f'filter.{filtr.key}.verb'] = defaults.get('verb',
|
||||||
filtr.default_verb)
|
filtr.get_default_verb())
|
||||||
settings[f'filter.{filtr.key}.value'] = defaults.get('value',
|
settings[f'filter.{filtr.key}.value'] = defaults.get('value',
|
||||||
filtr.default_value)
|
filtr.default_value)
|
||||||
if self.sortable:
|
if self.sortable:
|
||||||
|
@ -1291,7 +1327,7 @@ class Grid:
|
||||||
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'] or filtr.default_verb
|
filtr.verb = settings[f'filter.{filtr.key}.verb'] or filtr.get_default_verb()
|
||||||
filtr.value = settings[f'filter.{filtr.key}.value']
|
filtr.value = settings[f'filter.{filtr.key}.value']
|
||||||
|
|
||||||
# sorting
|
# sorting
|
||||||
|
@ -1531,8 +1567,8 @@ class Grid:
|
||||||
"""
|
"""
|
||||||
Returns the list of currently active filters.
|
Returns the list of currently active filters.
|
||||||
|
|
||||||
This inspects each :class:`GridFilter` in :attr:`filters` and
|
This inspects each :class:`~wuttaweb.grids.filters.GridFilter`
|
||||||
only returns the ones marked active.
|
in :attr:`filters` and only returns the ones marked active.
|
||||||
"""
|
"""
|
||||||
return [filtr for filtr in self.filters.values()
|
return [filtr for filtr in self.filters.values()
|
||||||
if filtr.active]
|
if filtr.active]
|
||||||
|
@ -1658,7 +1694,7 @@ class Grid:
|
||||||
|
|
||||||
.. code-block:: html
|
.. code-block:: html
|
||||||
|
|
||||||
<b-table :data="gridData['mykey']">
|
<b-table :data="gridContext['mykey'].data">
|
||||||
<!-- columns etc. -->
|
<!-- columns etc. -->
|
||||||
</b-table>
|
</b-table>
|
||||||
|
|
||||||
|
@ -1677,10 +1713,10 @@ class Grid:
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
The above example shows ``gridData['mykey']`` as the Vue
|
The above example shows ``gridContext['mykey'].data`` as
|
||||||
data reference. This should "just work" if you provide the
|
the Vue data reference. This should "just work" if you
|
||||||
correct ``form`` arg and the grid is contained directly by
|
provide the correct ``form`` arg and the grid is contained
|
||||||
that form's Vue component.
|
directly by that form's Vue component.
|
||||||
|
|
||||||
However, this may not account for all use cases. For now
|
However, this may not account for all use cases. For now
|
||||||
we wait and see what comes up, but know the dust may not
|
we wait and see what comes up, but know the dust may not
|
||||||
|
@ -1689,7 +1725,7 @@ class Grid:
|
||||||
|
|
||||||
# nb. must register data for inclusion on page template
|
# nb. must register data for inclusion on page template
|
||||||
if form:
|
if form:
|
||||||
form.add_grid_vue_data(self)
|
form.add_grid_vue_context(self)
|
||||||
|
|
||||||
# otherwise logic is the same, just different template
|
# otherwise logic is the same, just different template
|
||||||
return self.render_vue_template(template=template, **context)
|
return self.render_vue_template(template=template, **context)
|
||||||
|
@ -1797,7 +1833,7 @@ class Grid:
|
||||||
in its `Table docs
|
in its `Table docs
|
||||||
<https://buefy.org/documentation/table/#api-view>`_.
|
<https://buefy.org/documentation/table/#api-view>`_.
|
||||||
|
|
||||||
See also :meth:`get_vue_data()`.
|
See also :meth:`get_vue_context()`.
|
||||||
"""
|
"""
|
||||||
if not self.columns:
|
if not self.columns:
|
||||||
raise ValueError(f"you must define columns for the grid! key = {self.key}")
|
raise ValueError(f"you must define columns for the grid! key = {self.key}")
|
||||||
|
@ -1848,61 +1884,55 @@ class Grid:
|
||||||
'key': filtr.key,
|
'key': filtr.key,
|
||||||
'active': filtr.active,
|
'active': filtr.active,
|
||||||
'visible': filtr.active,
|
'visible': filtr.active,
|
||||||
'verbs': filtr.verbs,
|
'verbs': filtr.get_verbs(),
|
||||||
|
'verb_labels': filtr.get_verb_labels(),
|
||||||
|
'valueless_verbs': filtr.get_valueless_verbs(),
|
||||||
'verb': filtr.verb,
|
'verb': filtr.verb,
|
||||||
'value': filtr.value,
|
'value': filtr.value,
|
||||||
'label': filtr.label,
|
'label': filtr.label,
|
||||||
})
|
})
|
||||||
return filters
|
return filters
|
||||||
|
|
||||||
def get_vue_data(self):
|
def get_vue_context(self):
|
||||||
"""
|
"""
|
||||||
Returns a list of Vue-compatible data records.
|
Returns a dict of context for the grid, for use with the Vue
|
||||||
|
component. This contains the following keys:
|
||||||
|
|
||||||
This calls :meth:`get_visible_data()` but then may modify the
|
* ``data`` - list of Vue-compatible data records
|
||||||
result, e.g. to add URLs for :attr:`actions` etc.
|
* ``row_classes`` - dict of per-row CSS classes
|
||||||
|
|
||||||
Importantly, this also ensures each value in the dict is
|
This first calls :meth:`get_visible_data()` to get the
|
||||||
JSON-serializable, using
|
original data set. Each record is converted to a dict.
|
||||||
:func:`~wuttaweb.util.make_json_safe()`.
|
|
||||||
|
|
||||||
:returns: List of data record dicts for use with Vue table
|
Then it calls :func:`~wuttaweb.util.make_json_safe()` to
|
||||||
component. May be the full set of data, or just the
|
ensure each record can be serialized to JSON.
|
||||||
current page, per :attr:`paginate_on_backend`.
|
|
||||||
|
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()
|
original_data = self.get_visible_data()
|
||||||
|
|
||||||
# TODO: at some point i thought it was useful to wrangle the
|
# loop thru data
|
||||||
# columns here, but now i can't seem to figure out why..?
|
|
||||||
|
|
||||||
# # determine which columns are relevant for data set
|
|
||||||
# columns = None
|
|
||||||
# if not columns:
|
|
||||||
# columns = self.get_columns()
|
|
||||||
# if not columns:
|
|
||||||
# raise ValueError("cannot determine columns for the grid")
|
|
||||||
# columns = set(columns)
|
|
||||||
# if self.model_class:
|
|
||||||
# mapper = sa.inspect(self.model_class)
|
|
||||||
# for column in mapper.primary_key:
|
|
||||||
# columns.add(column.key)
|
|
||||||
|
|
||||||
# # prune data fields for which no column is defined
|
|
||||||
# for i, record in enumerate(original_data):
|
|
||||||
# original_data[i]= dict([(key, record[key])
|
|
||||||
# for key in columns])
|
|
||||||
|
|
||||||
# we have action(s), so add URL(s) for each record in data
|
|
||||||
data = []
|
data = []
|
||||||
for i, record in enumerate(original_data):
|
row_classes = {}
|
||||||
|
for i, record in enumerate(original_data, 1):
|
||||||
original_record = record
|
original_record = record
|
||||||
|
|
||||||
|
# convert record to new dict
|
||||||
record = dict(record)
|
record = dict(record)
|
||||||
|
|
||||||
# convert data if needed, for json compat
|
# make all values safe for json
|
||||||
record = make_json_safe(record,
|
record = make_json_safe(record, warn=False)
|
||||||
# TODO: is this a good idea?
|
|
||||||
warn=False)
|
|
||||||
|
|
||||||
# customize value rendering where applicable
|
# customize value rendering where applicable
|
||||||
for key in self.renderers:
|
for key in self.renderers:
|
||||||
|
@ -1917,9 +1947,48 @@ class Grid:
|
||||||
if url:
|
if url:
|
||||||
record[key] = 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)
|
data.append(record)
|
||||||
|
|
||||||
return data
|
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):
|
def get_vue_pager_stats(self):
|
||||||
"""
|
"""
|
||||||
|
@ -2072,7 +2141,7 @@ class GridAction:
|
||||||
:param obj: Model instance of whatever type the parent grid is
|
:param obj: Model instance of whatever type the parent grid is
|
||||||
setup to use.
|
setup to use.
|
||||||
|
|
||||||
:param i: Zero-based sequence for the object, within the
|
:param i: One-based sequence for the object's row within the
|
||||||
parent grid.
|
parent grid.
|
||||||
|
|
||||||
See also :attr:`url`.
|
See also :attr:`url`.
|
||||||
|
@ -2081,164 +2150,3 @@ class GridAction:
|
||||||
return self.url(obj, i)
|
return self.url(obj, i)
|
||||||
|
|
||||||
return self.url
|
return self.url
|
||||||
|
|
||||||
|
|
||||||
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__(
|
|
||||||
self,
|
|
||||||
request,
|
|
||||||
model_property,
|
|
||||||
label=None,
|
|
||||||
default_active=False,
|
|
||||||
default_verb=None,
|
|
||||||
default_value=None,
|
|
||||||
**kwargs,
|
|
||||||
):
|
|
||||||
self.request = request
|
|
||||||
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.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.value = self.default_value
|
|
||||||
|
|
||||||
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}"
|
|
||||||
|
|
444
src/wuttaweb/grids/filters.py
Normal file
444
src/wuttaweb/grids/filters.py
Normal file
|
@ -0,0 +1,444 @@
|
||||||
|
# -*- 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/>.
|
||||||
|
#
|
||||||
|
################################################################################
|
||||||
|
"""
|
||||||
|
Grid Filters
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from wuttjamaican.util import UNSPECIFIED
|
||||||
|
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class VerbNotSupported(Exception):
|
||||||
|
""" """
|
||||||
|
|
||||||
|
def __init__(self, verb):
|
||||||
|
self.verb = verb
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"unknown filter verb not supported: {self.verb}"
|
||||||
|
|
||||||
|
|
||||||
|
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`.
|
||||||
|
"""
|
||||||
|
default_verbs = ['equal', 'not_equal']
|
||||||
|
|
||||||
|
default_verb_labels = {
|
||||||
|
'is_any': "is any",
|
||||||
|
'equal': "equal to",
|
||||||
|
'not_equal': "not equal to",
|
||||||
|
'is_null': "is null",
|
||||||
|
'is_not_null': "is not null",
|
||||||
|
'is_true': "is true",
|
||||||
|
'is_false': "is false",
|
||||||
|
'contains': "contains",
|
||||||
|
'does_not_contain': "does not contain",
|
||||||
|
}
|
||||||
|
|
||||||
|
valueless_verbs = [
|
||||||
|
'is_any',
|
||||||
|
'is_null',
|
||||||
|
'is_not_null',
|
||||||
|
'is_true',
|
||||||
|
'is_false',
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
request,
|
||||||
|
key,
|
||||||
|
label=None,
|
||||||
|
verbs=None,
|
||||||
|
default_active=False,
|
||||||
|
default_verb=None,
|
||||||
|
default_value=None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
self.request = request
|
||||||
|
self.key = key
|
||||||
|
self.config = self.request.wutta_config
|
||||||
|
self.app = self.config.get_app()
|
||||||
|
self.label = label or self.app.make_title(self.key)
|
||||||
|
|
||||||
|
# active
|
||||||
|
self.default_active = default_active
|
||||||
|
self.active = self.default_active
|
||||||
|
|
||||||
|
# verb
|
||||||
|
if verbs is not None:
|
||||||
|
self.verbs = verbs
|
||||||
|
if default_verb:
|
||||||
|
self.default_verb = default_verb
|
||||||
|
|
||||||
|
# value
|
||||||
|
self.default_value = default_value
|
||||||
|
self.value = self.default_value
|
||||||
|
|
||||||
|
self.__dict__.update(kwargs)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
verb = getattr(self, 'verb', None)
|
||||||
|
return (f"{self.__class__.__name__}("
|
||||||
|
f"key='{self.key}', "
|
||||||
|
f"active={self.active}, "
|
||||||
|
f"verb={repr(verb)}, "
|
||||||
|
f"value={repr(self.value)})")
|
||||||
|
|
||||||
|
def get_verbs(self):
|
||||||
|
"""
|
||||||
|
Returns the list of verbs supported by the filter.
|
||||||
|
"""
|
||||||
|
verbs = None
|
||||||
|
|
||||||
|
if hasattr(self, 'verbs'):
|
||||||
|
verbs = self.verbs
|
||||||
|
|
||||||
|
else:
|
||||||
|
verbs = self.default_verbs
|
||||||
|
|
||||||
|
if callable(verbs):
|
||||||
|
verbs = verbs()
|
||||||
|
verbs = list(verbs)
|
||||||
|
|
||||||
|
if self.nullable:
|
||||||
|
if 'is_null' not in verbs:
|
||||||
|
verbs.append('is_null')
|
||||||
|
if 'is_not_null' not in verbs:
|
||||||
|
verbs.append('is_not_null')
|
||||||
|
|
||||||
|
if 'is_any' not in verbs:
|
||||||
|
verbs.append('is_any')
|
||||||
|
|
||||||
|
return verbs
|
||||||
|
|
||||||
|
def get_verb_labels(self):
|
||||||
|
"""
|
||||||
|
Returns a dict of all defined verb labels.
|
||||||
|
"""
|
||||||
|
# TODO: should traverse hierarchy
|
||||||
|
labels = dict([(verb, verb) for verb in self.get_verbs()])
|
||||||
|
labels.update(self.default_verb_labels)
|
||||||
|
return labels
|
||||||
|
|
||||||
|
def get_valueless_verbs(self):
|
||||||
|
"""
|
||||||
|
Returns a list of verb names which do not need a value.
|
||||||
|
"""
|
||||||
|
return self.valueless_verbs
|
||||||
|
|
||||||
|
def get_default_verb(self):
|
||||||
|
"""
|
||||||
|
Returns the default verb for the filter.
|
||||||
|
"""
|
||||||
|
verb = None
|
||||||
|
|
||||||
|
if hasattr(self, 'default_verb'):
|
||||||
|
verb = self.default_verb
|
||||||
|
|
||||||
|
elif hasattr(self, 'verb'):
|
||||||
|
verb = self.verb
|
||||||
|
|
||||||
|
if not verb:
|
||||||
|
verbs = self.get_verbs()
|
||||||
|
if verbs:
|
||||||
|
verb = verbs[0]
|
||||||
|
|
||||||
|
return verb
|
||||||
|
|
||||||
|
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:
|
||||||
|
verb = self.get_default_verb()
|
||||||
|
log.warn("missing verb for '%s' filter, will use default verb: %s",
|
||||||
|
self.key, verb)
|
||||||
|
|
||||||
|
# only attempt for known verbs
|
||||||
|
if verb not in self.get_verbs():
|
||||||
|
raise VerbNotSupported(verb)
|
||||||
|
|
||||||
|
# fallback value
|
||||||
|
if value is UNSPECIFIED:
|
||||||
|
value = self.value
|
||||||
|
|
||||||
|
# locate filter method
|
||||||
|
func = getattr(self, f'filter_{verb}', None)
|
||||||
|
if not func:
|
||||||
|
raise VerbNotSupported(verb)
|
||||||
|
|
||||||
|
# invoke filter method
|
||||||
|
return func(data, value)
|
||||||
|
|
||||||
|
def filter_is_any(self, data, value):
|
||||||
|
"""
|
||||||
|
This is a no-op which always ignores the value and returns the
|
||||||
|
data as-is.
|
||||||
|
"""
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class AlchemyFilter(GridFilter):
|
||||||
|
"""
|
||||||
|
Filter option for a grid with SQLAlchemy query data.
|
||||||
|
|
||||||
|
This is a subclass of :class:`GridFilter`. It requires a
|
||||||
|
``model_property`` to know how to filter the query.
|
||||||
|
|
||||||
|
:param model_property: Property of a model class, representing the
|
||||||
|
column by which to filter. For instance,
|
||||||
|
``model.Person.full_name``.
|
||||||
|
|
||||||
|
:param nullable: Boolean indicating whether the filter should
|
||||||
|
include ``is_null`` and ``is_not_null`` verbs. If not
|
||||||
|
specified, the column will be inspected and use its nullable
|
||||||
|
flag.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
nullable = kwargs.pop('nullable', None)
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
self.nullable = nullable
|
||||||
|
if self.nullable is None:
|
||||||
|
columns = self.model_property.prop.columns
|
||||||
|
if len(columns) == 1:
|
||||||
|
self.nullable = columns[0].nullable
|
||||||
|
|
||||||
|
def coerce_value(self, value):
|
||||||
|
"""
|
||||||
|
Coerce the given value to the correct type/format for use with
|
||||||
|
the filter.
|
||||||
|
|
||||||
|
Default logic returns value as-is; subclass may override.
|
||||||
|
"""
|
||||||
|
return value
|
||||||
|
|
||||||
|
def filter_equal(self, query, value):
|
||||||
|
"""
|
||||||
|
Filter data with an equal (``=``) condition.
|
||||||
|
"""
|
||||||
|
value = self.coerce_value(value)
|
||||||
|
if value is None:
|
||||||
|
return query
|
||||||
|
|
||||||
|
return query.filter(self.model_property == value)
|
||||||
|
|
||||||
|
def filter_not_equal(self, query, value):
|
||||||
|
"""
|
||||||
|
Filter data with a not equal (``!=``) condition.
|
||||||
|
"""
|
||||||
|
value = self.coerce_value(value)
|
||||||
|
if value is None:
|
||||||
|
return query
|
||||||
|
|
||||||
|
# sql probably excludes null values from results, but user
|
||||||
|
# probably does not expect that, so explicitly include them.
|
||||||
|
return query.filter(sa.or_(
|
||||||
|
self.model_property == None,
|
||||||
|
self.model_property != value,
|
||||||
|
))
|
||||||
|
|
||||||
|
def filter_is_null(self, query, value):
|
||||||
|
"""
|
||||||
|
Filter data with an ``IS NULL`` query. The value is ignored.
|
||||||
|
"""
|
||||||
|
return query.filter(self.model_property == None)
|
||||||
|
|
||||||
|
def filter_is_not_null(self, query, value):
|
||||||
|
"""
|
||||||
|
Filter data with an ``IS NOT NULL`` query. The value is
|
||||||
|
ignored.
|
||||||
|
"""
|
||||||
|
return query.filter(self.model_property != None)
|
||||||
|
|
||||||
|
|
||||||
|
class StringAlchemyFilter(AlchemyFilter):
|
||||||
|
"""
|
||||||
|
SQLAlchemy filter option for a text data column.
|
||||||
|
|
||||||
|
Subclass of :class:`AlchemyFilter`.
|
||||||
|
"""
|
||||||
|
default_verbs = ['contains', 'does_not_contain',
|
||||||
|
'equal', 'not_equal']
|
||||||
|
|
||||||
|
def coerce_value(self, value):
|
||||||
|
""" """
|
||||||
|
if value is not None:
|
||||||
|
value = str(value)
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
|
||||||
|
def filter_contains(self, query, value):
|
||||||
|
"""
|
||||||
|
Filter data with an ``ILIKE`` condition.
|
||||||
|
"""
|
||||||
|
value = self.coerce_value(value)
|
||||||
|
if not 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))
|
||||||
|
|
||||||
|
def filter_does_not_contain(self, query, value):
|
||||||
|
"""
|
||||||
|
Filter data with a ``NOT ILIKE`` condition.
|
||||||
|
"""
|
||||||
|
value = self.coerce_value(value)
|
||||||
|
if not value:
|
||||||
|
return query
|
||||||
|
|
||||||
|
criteria = []
|
||||||
|
for val in value.split():
|
||||||
|
val = val.replace('_', r'\_')
|
||||||
|
val = f'%{val}%'
|
||||||
|
criteria.append(~self.model_property.ilike(val))
|
||||||
|
|
||||||
|
# sql probably excludes null values from results, but user
|
||||||
|
# probably does not expect that, so explicitly include them.
|
||||||
|
return query.filter(sa.or_(
|
||||||
|
self.model_property == None,
|
||||||
|
sa.and_(*criteria)))
|
||||||
|
|
||||||
|
|
||||||
|
class BooleanAlchemyFilter(AlchemyFilter):
|
||||||
|
"""
|
||||||
|
SQLAlchemy filter option for a boolean data column.
|
||||||
|
|
||||||
|
Subclass of :class:`AlchemyFilter`.
|
||||||
|
"""
|
||||||
|
default_verbs = ['is_true', 'is_false']
|
||||||
|
|
||||||
|
def coerce_value(self, value):
|
||||||
|
""" """
|
||||||
|
if value is not None:
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
def filter_is_true(self, query, value):
|
||||||
|
"""
|
||||||
|
Filter data with an "is true" condition. The value is
|
||||||
|
ignored.
|
||||||
|
"""
|
||||||
|
return query.filter(self.model_property == True)
|
||||||
|
|
||||||
|
def filter_is_false(self, query, value):
|
||||||
|
"""
|
||||||
|
Filter data with an "is false" condition. The value is
|
||||||
|
ignored.
|
||||||
|
"""
|
||||||
|
return query.filter(self.model_property == False)
|
||||||
|
|
||||||
|
|
||||||
|
default_sqlalchemy_filters = {
|
||||||
|
None: AlchemyFilter,
|
||||||
|
sa.String: StringAlchemyFilter,
|
||||||
|
sa.Text: StringAlchemyFilter,
|
||||||
|
sa.Boolean: BooleanAlchemyFilter,
|
||||||
|
}
|
|
@ -164,12 +164,9 @@
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wutta-filter .button.filter-toggle {
|
|
||||||
justify-content: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wutta-filter .button.filter-toggle,
|
.wutta-filter .button.filter-toggle,
|
||||||
.wutta-filter .filter-verb {
|
.wutta-filter .filter-verb {
|
||||||
|
justify-content: left;
|
||||||
min-width: 15rem;
|
min-width: 15rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -9,13 +9,17 @@
|
||||||
% endif
|
% endif
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
<%def name="render_vue_templates()">
|
<%def name="render_vue_template_form()">
|
||||||
${parent.render_vue_templates()}
|
|
||||||
% if form is not Undefined:
|
% if form is not Undefined:
|
||||||
${form.render_vue_template()}
|
${form.render_vue_template()}
|
||||||
% endif
|
% endif
|
||||||
</%def>
|
</%def>
|
||||||
|
|
||||||
|
<%def name="render_vue_templates()">
|
||||||
|
${parent.render_vue_templates()}
|
||||||
|
${self.render_vue_template_form()}
|
||||||
|
</%def>
|
||||||
|
|
||||||
<%def name="make_vue_components()">
|
<%def name="make_vue_components()">
|
||||||
${parent.make_vue_components()}
|
${parent.make_vue_components()}
|
||||||
% if form is not Undefined:
|
% if form is not Undefined:
|
||||||
|
|
|
@ -69,9 +69,9 @@
|
||||||
|
|
||||||
% endif
|
% endif
|
||||||
|
|
||||||
% if form.grid_vue_data:
|
% if form.grid_vue_context:
|
||||||
gridData: {
|
gridContext: {
|
||||||
% for key, data in form.grid_vue_data.items():
|
% for key, data in form.grid_vue_context.items():
|
||||||
'${key}': ${json.dumps(data)|n},
|
'${key}': ${json.dumps(data)|n},
|
||||||
% endfor
|
% endfor
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
## -*- coding: utf-8; -*-
|
## -*- coding: utf-8; -*-
|
||||||
<${b}-table :data="gridData['${grid.key}']">
|
<${b}-table :data="gridContext['${grid.key}'].data">
|
||||||
|
|
||||||
% for column in grid.get_vue_columns():
|
% for column in grid.get_vue_columns():
|
||||||
<${b}-table-column field="${column['field']}"
|
<${b}-table-column field="${column['field']}"
|
||||||
|
|
|
@ -5,8 +5,7 @@
|
||||||
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;">
|
<div style="display: flex; justify-content: space-between; margin-bottom: 0.5em;">
|
||||||
|
|
||||||
% if grid.filterable:
|
% if grid.filterable:
|
||||||
<form action="${request.path_url}" method="GET"
|
<form @submit.prevent="applyFilters()">
|
||||||
@submit.prevent="applyFilters()">
|
|
||||||
|
|
||||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
<wutta-filter v-for="filtr in filters"
|
<wutta-filter v-for="filtr in filters"
|
||||||
|
@ -55,10 +54,11 @@
|
||||||
:size="smallFilters ? 'is-small' : null" />
|
:size="smallFilters ? 'is-small' : null" />
|
||||||
|
|
||||||
<b-button @click="resetView()"
|
<b-button @click="resetView()"
|
||||||
|
:disabled="viewResetting"
|
||||||
icon-pack="fas"
|
icon-pack="fas"
|
||||||
icon-left="undo"
|
icon-left="undo"
|
||||||
:size="smallFilters ? 'is-small' : null">
|
:size="smallFilters ? 'is-small' : null">
|
||||||
Reset View
|
{{ viewResetting ? "Working, please wait..." : "Reset View" }}
|
||||||
</b-button>
|
</b-button>
|
||||||
|
|
||||||
<b-button v-show="activeFilters"
|
<b-button v-show="activeFilters"
|
||||||
|
@ -93,6 +93,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<${b}-table :data="data"
|
<${b}-table :data="data"
|
||||||
|
:row-class="getRowClass"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
narrowed
|
narrowed
|
||||||
hoverable
|
hoverable
|
||||||
|
@ -227,10 +228,12 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
let ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_data())|n}
|
const ${grid.vue_component}Context = ${json.dumps(grid.get_vue_context())|n}
|
||||||
|
let ${grid.vue_component}CurrentData = ${grid.vue_component}Context.data
|
||||||
|
|
||||||
const ${grid.vue_component}Data = {
|
const ${grid.vue_component}Data = {
|
||||||
data: ${grid.vue_component}CurrentData,
|
data: ${grid.vue_component}CurrentData,
|
||||||
|
rowClasses: ${grid.vue_component}Context.row_classes,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|
||||||
## nb. this tracks whether grid.fetchFirstData() happened
|
## nb. this tracks whether grid.fetchFirstData() happened
|
||||||
|
@ -247,6 +250,7 @@
|
||||||
addFilterShow: false,
|
addFilterShow: false,
|
||||||
addFilterTerm: '',
|
addFilterTerm: '',
|
||||||
smallFilters: false,
|
smallFilters: false,
|
||||||
|
viewResetting: false,
|
||||||
% endif
|
% endif
|
||||||
|
|
||||||
## sorting
|
## sorting
|
||||||
|
@ -398,6 +402,11 @@
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getRowClass(row, i) {
|
||||||
|
// nb. use *string* index
|
||||||
|
return this.rowClasses[i.toString()]
|
||||||
|
},
|
||||||
|
|
||||||
renderNumber(value) {
|
renderNumber(value) {
|
||||||
if (value != undefined) {
|
if (value != undefined) {
|
||||||
return value.toLocaleString('en')
|
return value.toLocaleString('en')
|
||||||
|
@ -456,6 +465,7 @@
|
||||||
if (!response.data.error) {
|
if (!response.data.error) {
|
||||||
${grid.vue_component}CurrentData = response.data.data
|
${grid.vue_component}CurrentData = response.data.data
|
||||||
this.data = ${grid.vue_component}CurrentData
|
this.data = ${grid.vue_component}CurrentData
|
||||||
|
this.rowClasses = response.data.row_classes || {}
|
||||||
% if grid.paginated and grid.paginate_on_backend:
|
% if grid.paginated and grid.paginate_on_backend:
|
||||||
this.pagerStats = response.data.pager_stats
|
this.pagerStats = response.data.pager_stats
|
||||||
% endif
|
% endif
|
||||||
|
@ -480,6 +490,7 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
resetView() {
|
resetView() {
|
||||||
|
this.viewResetting = true
|
||||||
this.loading = true
|
this.loading = true
|
||||||
|
|
||||||
// use current url proper, plus reset param
|
// use current url proper, plus reset param
|
||||||
|
|
|
@ -88,18 +88,30 @@
|
||||||
<div v-show="filter.active"
|
<div v-show="filter.active"
|
||||||
style="display: flex; gap: 0.5rem;">
|
style="display: flex; gap: 0.5rem;">
|
||||||
|
|
||||||
<b-select v-model="filter.verb"
|
<b-button v-if="verbKnown"
|
||||||
class="filter-verb"
|
class="filter-verb"
|
||||||
|
@click="verbChoiceInit()"
|
||||||
:size="isSmall ? 'is-small' : null">
|
:size="isSmall ? 'is-small' : null">
|
||||||
<option v-for="verb in filter.verbs"
|
{{ verbLabel }}
|
||||||
:key="verb"
|
</b-button>
|
||||||
:value="verb">
|
|
||||||
{{ verb }}
|
<b-autocomplete v-if="!verbKnown"
|
||||||
</option>
|
ref="verbAutocomplete"
|
||||||
</b-select>
|
:data="verbOptions"
|
||||||
|
v-model="verbTerm"
|
||||||
|
field="verb"
|
||||||
|
:custom-formatter="formatVerb"
|
||||||
|
open-on-focus
|
||||||
|
keep-first
|
||||||
|
clearable
|
||||||
|
clear-on-select
|
||||||
|
@select="verbChoiceSelect"
|
||||||
|
icon-pack="fas"
|
||||||
|
:size="isSmall ? 'is-small' : null" />
|
||||||
|
|
||||||
<wutta-filter-value v-model="filter.value"
|
<wutta-filter-value v-model="filter.value"
|
||||||
ref="filterValue"
|
ref="filterValue"
|
||||||
|
v-show="valuedVerb()"
|
||||||
:is-small="isSmall" />
|
:is-small="isSmall" />
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
@ -114,11 +126,97 @@
|
||||||
isSmall: Boolean,
|
isSmall: Boolean,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
verbKnown: !!this.filter.verb,
|
||||||
|
verbLabel: this.filter.verb_labels[this.filter.verb],
|
||||||
|
verbTerm: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
|
||||||
|
verbOptions() {
|
||||||
|
|
||||||
|
// construct list of options
|
||||||
|
const options = []
|
||||||
|
for (let verb of this.filter.verbs) {
|
||||||
|
options.push({
|
||||||
|
verb,
|
||||||
|
label: this.filter.verb_labels[verb],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse list of search terms
|
||||||
|
const terms = []
|
||||||
|
for (let term of this.verbTerm.toLowerCase().split(' ')) {
|
||||||
|
term = term.trim()
|
||||||
|
if (term) {
|
||||||
|
terms.push(term)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// show all if no search terms
|
||||||
|
if (!terms.length) {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
// only show filters matching all search terms
|
||||||
|
return options.filter(option => {
|
||||||
|
let label = option.label.toLowerCase()
|
||||||
|
for (let term of terms) {
|
||||||
|
if (label.indexOf(term) < 0) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
return options
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
|
||||||
focusValue: function() {
|
focusValue() {
|
||||||
this.$refs.filterValue.focus()
|
this.$refs.filterValue.focus()
|
||||||
}
|
},
|
||||||
|
|
||||||
|
formatVerb(option) {
|
||||||
|
return option.label || option.verb
|
||||||
|
},
|
||||||
|
|
||||||
|
verbChoiceInit(option) {
|
||||||
|
this.verbKnown = false
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$refs.verbAutocomplete.focus()
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
verbChoiceSelect(option) {
|
||||||
|
this.filter.verb = option.verb
|
||||||
|
this.verbLabel = option.label
|
||||||
|
this.verbKnown = true
|
||||||
|
this.verbTerm = ''
|
||||||
|
this.focusValue()
|
||||||
|
},
|
||||||
|
|
||||||
|
valuedVerb() {
|
||||||
|
/* return true if the current verb should expose value input(s) */
|
||||||
|
|
||||||
|
// if filter has no "valueless" verbs, then all verbs should expose value inputs
|
||||||
|
if (!this.filter.valueless_verbs) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// if filter *does* have valueless verbs, check if "current" verb is valueless
|
||||||
|
if (this.filter.valueless_verbs.includes(this.filter.verb)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// current verb is *not* valueless
|
||||||
|
return true
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -181,6 +181,16 @@ class MasterView(View):
|
||||||
|
|
||||||
This is optional; see also :meth:`get_grid_columns()`.
|
This is optional; see also :meth:`get_grid_columns()`.
|
||||||
|
|
||||||
|
.. method:: grid_row_class(obj, data, i)
|
||||||
|
|
||||||
|
This method is *not* defined on the ``MasterView`` base class;
|
||||||
|
however if a subclass defines it then it will be automatically
|
||||||
|
used to provide :attr:`~wuttaweb.grids.base.Grid.row_class` for
|
||||||
|
the main :meth:`index()` grid.
|
||||||
|
|
||||||
|
For more info see
|
||||||
|
:meth:`~wuttaweb.grids.base.Grid.get_row_class()`.
|
||||||
|
|
||||||
.. attribute:: filterable
|
.. attribute:: filterable
|
||||||
|
|
||||||
Boolean indicating whether the grid for the :meth:`index()`
|
Boolean indicating whether the grid for the :meth:`index()`
|
||||||
|
@ -360,7 +370,7 @@ class MasterView(View):
|
||||||
if self.request.GET.get('partial'):
|
if self.request.GET.get('partial'):
|
||||||
|
|
||||||
# so-called 'partial' requests get just data, no html
|
# so-called 'partial' requests get just data, no html
|
||||||
context = {'data': grid.get_vue_data()}
|
context = grid.get_vue_context()
|
||||||
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)
|
||||||
|
@ -1240,6 +1250,8 @@ class MasterView(View):
|
||||||
|
|
||||||
kwargs['actions'] = actions
|
kwargs['actions'] = actions
|
||||||
|
|
||||||
|
if hasattr(self, 'grid_row_class'):
|
||||||
|
kwargs.setdefault('row_class', self.grid_row_class)
|
||||||
kwargs.setdefault('filterable', self.filterable)
|
kwargs.setdefault('filterable', self.filterable)
|
||||||
kwargs.setdefault('filter_defaults', self.filter_defaults)
|
kwargs.setdefault('filter_defaults', self.filter_defaults)
|
||||||
kwargs.setdefault('sortable', self.sortable)
|
kwargs.setdefault('sortable', self.sortable)
|
||||||
|
|
|
@ -55,6 +55,7 @@ class RoleView(MasterView):
|
||||||
filter_defaults = {
|
filter_defaults = {
|
||||||
'name': {'active': True},
|
'name': {'active': True},
|
||||||
}
|
}
|
||||||
|
sort_defaults = 'name'
|
||||||
|
|
||||||
# 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):
|
||||||
|
|
|
@ -57,18 +57,24 @@ class UserView(MasterView):
|
||||||
|
|
||||||
filter_defaults = {
|
filter_defaults = {
|
||||||
'username': {'active': True},
|
'username': {'active': True},
|
||||||
|
'active': {'active': True, 'verb': 'is_true'},
|
||||||
}
|
}
|
||||||
|
sort_defaults = 'username'
|
||||||
|
|
||||||
# TODO: master should handle this, possibly via configure_form()
|
|
||||||
def get_query(self, session=None):
|
def get_query(self, session=None):
|
||||||
""" """
|
""" """
|
||||||
model = self.app.model
|
|
||||||
query = super().get_query(session=session)
|
query = super().get_query(session=session)
|
||||||
return query.order_by(model.User.username)
|
|
||||||
|
# nb. always join Person
|
||||||
|
model = self.app.model
|
||||||
|
query = query.outerjoin(model.Person)
|
||||||
|
|
||||||
|
return query
|
||||||
|
|
||||||
def configure_grid(self, g):
|
def configure_grid(self, g):
|
||||||
""" """
|
""" """
|
||||||
super().configure_grid(g)
|
super().configure_grid(g)
|
||||||
|
model = self.app.model
|
||||||
|
|
||||||
# never show these
|
# never show these
|
||||||
g.remove('person_uuid',
|
g.remove('person_uuid',
|
||||||
|
@ -80,6 +86,14 @@ class UserView(MasterView):
|
||||||
|
|
||||||
# person
|
# person
|
||||||
g.set_link('person')
|
g.set_link('person')
|
||||||
|
g.set_sorter('person', model.Person.full_name)
|
||||||
|
g.set_filter('person', model.Person.full_name,
|
||||||
|
label="Person Full Name")
|
||||||
|
|
||||||
|
def grid_row_class(self, user, data, i):
|
||||||
|
""" """
|
||||||
|
if not user.active:
|
||||||
|
return 'has-background-warning'
|
||||||
|
|
||||||
def configure_form(self, f):
|
def configure_form(self, f):
|
||||||
""" """
|
""" """
|
||||||
|
|
|
@ -406,28 +406,34 @@ class TestForm(TestCase):
|
||||||
self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
|
self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
|
||||||
self.assertNotIn('@submit', html)
|
self.assertNotIn('@submit', html)
|
||||||
|
|
||||||
def test_add_grid_vue_data(self):
|
def test_add_grid_vue_context(self):
|
||||||
form = self.make_form()
|
form = self.make_form()
|
||||||
|
|
||||||
# grid must have key
|
# grid must have key
|
||||||
grid = Grid(self.request)
|
grid = Grid(self.request)
|
||||||
self.assertRaises(ValueError, form.add_grid_vue_data, grid)
|
self.assertRaises(ValueError, form.add_grid_vue_context, grid)
|
||||||
|
|
||||||
# otherwise it works
|
# otherwise it works
|
||||||
grid = Grid(self.request, key='foo')
|
grid = Grid(self.request, key='foo')
|
||||||
self.assertEqual(len(form.grid_vue_data), 0)
|
self.assertEqual(len(form.grid_vue_context), 0)
|
||||||
form.add_grid_vue_data(grid)
|
form.add_grid_vue_context(grid)
|
||||||
self.assertEqual(len(form.grid_vue_data), 1)
|
self.assertEqual(len(form.grid_vue_context), 1)
|
||||||
self.assertIn('foo', form.grid_vue_data)
|
self.assertIn('foo', form.grid_vue_context)
|
||||||
self.assertEqual(form.grid_vue_data['foo'], [])
|
self.assertEqual(form.grid_vue_context['foo'], {
|
||||||
|
'data': [],
|
||||||
|
'row_classes': {},
|
||||||
|
})
|
||||||
|
|
||||||
# calling again with same key will replace data
|
# calling again with same key will replace data
|
||||||
records = [{'foo': 1}, {'foo': 2}]
|
records = [{'foo': 1}, {'foo': 2}]
|
||||||
grid = Grid(self.request, key='foo', columns=['foo'], data=records)
|
grid = Grid(self.request, key='foo', columns=['foo'], data=records)
|
||||||
form.add_grid_vue_data(grid)
|
form.add_grid_vue_context(grid)
|
||||||
self.assertEqual(len(form.grid_vue_data), 1)
|
self.assertEqual(len(form.grid_vue_context), 1)
|
||||||
self.assertIn('foo', form.grid_vue_data)
|
self.assertIn('foo', form.grid_vue_context)
|
||||||
self.assertEqual(form.grid_vue_data['foo'], records)
|
self.assertEqual(form.grid_vue_context['foo'], {
|
||||||
|
'data': records,
|
||||||
|
'row_classes': {},
|
||||||
|
})
|
||||||
|
|
||||||
def test_render_vue_finalize(self):
|
def test_render_vue_finalize(self):
|
||||||
form = self.make_form()
|
form = self.make_form()
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import orm
|
from sqlalchemy import orm
|
||||||
from paginate import Page
|
from paginate import Page
|
||||||
from paginate_sqlalchemy import SqlalchemyOrmPage
|
from paginate_sqlalchemy import SqlalchemyOrmPage
|
||||||
|
@ -10,6 +11,7 @@ from pyramid import testing
|
||||||
|
|
||||||
from wuttjamaican.conf import WuttaConfig
|
from wuttjamaican.conf import WuttaConfig
|
||||||
from wuttaweb.grids import base as mod
|
from wuttaweb.grids import base as mod
|
||||||
|
from wuttaweb.grids.filters import GridFilter, StringAlchemyFilter, default_sqlalchemy_filters
|
||||||
from wuttaweb.util import FieldList
|
from wuttaweb.util import FieldList
|
||||||
from wuttaweb.forms import Form
|
from wuttaweb.forms import Form
|
||||||
from tests.util import WebTestCase
|
from tests.util import WebTestCase
|
||||||
|
@ -921,20 +923,38 @@ class TestGrid(WebTestCase):
|
||||||
def test_make_filter(self):
|
def test_make_filter(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
|
||||||
# basic
|
# arg is column name
|
||||||
grid = self.make_grid(model_class=model.Setting)
|
grid = self.make_grid(model_class=model.Setting)
|
||||||
filtr = grid.make_filter('name')
|
filtr = grid.make_filter('name')
|
||||||
self.assertIsInstance(filtr, mod.GridFilter)
|
self.assertIsInstance(filtr, StringAlchemyFilter)
|
||||||
|
|
||||||
# property
|
# arg is column name, but model class is invalid
|
||||||
grid = self.make_grid(model_class=model.Setting)
|
|
||||||
filtr = grid.make_filter(model.Setting.name)
|
|
||||||
self.assertIsInstance(filtr, mod.GridFilter)
|
|
||||||
|
|
||||||
# invalid model class
|
|
||||||
grid = self.make_grid(model_class=42)
|
grid = self.make_grid(model_class=42)
|
||||||
self.assertRaises(ValueError, grid.make_filter, 'name')
|
self.assertRaises(ValueError, grid.make_filter, 'name')
|
||||||
|
|
||||||
|
# arg is model property
|
||||||
|
grid = self.make_grid(model_class=model.Setting)
|
||||||
|
filtr = grid.make_filter(model.Setting.name)
|
||||||
|
self.assertIsInstance(filtr, StringAlchemyFilter)
|
||||||
|
|
||||||
|
# model property as kwarg
|
||||||
|
grid = self.make_grid(model_class=model.Setting)
|
||||||
|
filtr = grid.make_filter(None, model_property=model.Setting.name)
|
||||||
|
self.assertIsInstance(filtr, StringAlchemyFilter)
|
||||||
|
|
||||||
|
# default factory
|
||||||
|
grid = self.make_grid(model_class=model.Setting)
|
||||||
|
with patch.dict(default_sqlalchemy_filters, {None: GridFilter}, clear=True):
|
||||||
|
filtr = grid.make_filter(model.Setting.name)
|
||||||
|
self.assertIsInstance(filtr, GridFilter)
|
||||||
|
self.assertNotIsInstance(filtr, StringAlchemyFilter)
|
||||||
|
|
||||||
|
# factory override
|
||||||
|
grid = self.make_grid(model_class=model.Setting)
|
||||||
|
filtr = grid.make_filter(model.Setting.name, factory=GridFilter)
|
||||||
|
self.assertIsInstance(filtr, GridFilter)
|
||||||
|
self.assertNotIsInstance(filtr, StringAlchemyFilter)
|
||||||
|
|
||||||
def test_set_filter(self):
|
def test_set_filter(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
|
|
||||||
|
@ -1049,6 +1069,9 @@ class TestGrid(WebTestCase):
|
||||||
sample_query = self.session.query(model.Setting)
|
sample_query = self.session.query(model.Setting)
|
||||||
|
|
||||||
grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
|
grid = self.make_grid(key='settings', model_class=model.Setting, filterable=True)
|
||||||
|
self.assertEqual(list(grid.filters), ['name', 'value'])
|
||||||
|
self.assertIsInstance(grid.filters['name'], StringAlchemyFilter)
|
||||||
|
self.assertIsInstance(grid.filters['value'], StringAlchemyFilter)
|
||||||
|
|
||||||
# not filtered by default
|
# not filtered by default
|
||||||
grid.load_settings()
|
grid.load_settings()
|
||||||
|
@ -1263,10 +1286,10 @@ class TestGrid(WebTestCase):
|
||||||
|
|
||||||
# form will register grid data
|
# form will register grid data
|
||||||
form = Form(self.request)
|
form = Form(self.request)
|
||||||
self.assertEqual(len(form.grid_vue_data), 0)
|
self.assertEqual(len(form.grid_vue_context), 0)
|
||||||
html = grid.render_table_element(form)
|
html = grid.render_table_element(form)
|
||||||
self.assertEqual(len(form.grid_vue_data), 1)
|
self.assertEqual(len(form.grid_vue_context), 1)
|
||||||
self.assertIn('foobar', form.grid_vue_data)
|
self.assertIn('foobar', form.grid_vue_context)
|
||||||
|
|
||||||
def test_render_vue_finalize(self):
|
def test_render_vue_finalize(self):
|
||||||
grid = self.make_grid()
|
grid = self.make_grid()
|
||||||
|
@ -1314,6 +1337,40 @@ class TestGrid(WebTestCase):
|
||||||
filters = grid.get_vue_filters()
|
filters = grid.get_vue_filters()
|
||||||
self.assertEqual(len(filters), 2)
|
self.assertEqual(len(filters), 2)
|
||||||
|
|
||||||
|
def test_get_vue_context(self):
|
||||||
|
|
||||||
|
# empty if no columns defined
|
||||||
|
grid = self.make_grid()
|
||||||
|
context = grid.get_vue_context()
|
||||||
|
self.assertEqual(context, {'data': [], 'row_classes': {}})
|
||||||
|
|
||||||
|
# typical data is a list
|
||||||
|
mydata = [
|
||||||
|
{'foo': 'bar'},
|
||||||
|
]
|
||||||
|
grid = self.make_grid(columns=['foo'], data=mydata)
|
||||||
|
context = grid.get_vue_context()
|
||||||
|
self.assertEqual(context, {'data': [{'foo': 'bar'}], 'row_classes': {}})
|
||||||
|
|
||||||
|
# if grid has actions, that list may be supplemented
|
||||||
|
grid.actions.append(mod.GridAction(self.request, 'view', url='/blarg'))
|
||||||
|
context = grid.get_vue_context()
|
||||||
|
self.assertIsNot(context['data'], mydata)
|
||||||
|
self.assertEqual(context, {'data': [{'foo': 'bar', '_action_url_view': '/blarg'}],
|
||||||
|
'row_classes': {}})
|
||||||
|
|
||||||
|
# can override value rendering
|
||||||
|
grid.set_renderer('foo', lambda record, key, value: "blah blah")
|
||||||
|
context = grid.get_vue_context()
|
||||||
|
self.assertEqual(context, {'data': [{'foo': 'blah blah', '_action_url_view': '/blarg'}],
|
||||||
|
'row_classes': {}})
|
||||||
|
|
||||||
|
# can set row class
|
||||||
|
grid.row_class = 'whatever'
|
||||||
|
context = grid.get_vue_context()
|
||||||
|
self.assertEqual(context, {'data': [{'foo': 'blah blah', '_action_url_view': '/blarg'}],
|
||||||
|
'row_classes': {'0': 'whatever'}})
|
||||||
|
|
||||||
def test_get_vue_data(self):
|
def test_get_vue_data(self):
|
||||||
|
|
||||||
# empty if no columns defined
|
# empty if no columns defined
|
||||||
|
@ -1335,11 +1392,35 @@ class TestGrid(WebTestCase):
|
||||||
self.assertIsNot(data, mydata)
|
self.assertIsNot(data, mydata)
|
||||||
self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}])
|
self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}])
|
||||||
|
|
||||||
# also can override value rendering
|
# can override value rendering
|
||||||
grid.set_renderer('foo', lambda record, key, value: "blah blah")
|
grid.set_renderer('foo', lambda record, key, value: "blah blah")
|
||||||
data = grid.get_vue_data()
|
data = grid.get_vue_data()
|
||||||
self.assertEqual(data, [{'foo': 'blah blah', '_action_url_view': '/blarg'}])
|
self.assertEqual(data, [{'foo': 'blah blah', '_action_url_view': '/blarg'}])
|
||||||
|
|
||||||
|
def test_get_row_class(self):
|
||||||
|
model = self.app.model
|
||||||
|
user = model.User(username='barney', active=True)
|
||||||
|
self.session.add(user)
|
||||||
|
self.session.commit()
|
||||||
|
data = dict(user)
|
||||||
|
|
||||||
|
# null by default
|
||||||
|
grid = self.make_grid()
|
||||||
|
self.assertIsNone(grid.get_row_class(user, data, 1))
|
||||||
|
|
||||||
|
# can use static class
|
||||||
|
grid.row_class = 'foo'
|
||||||
|
self.assertEqual(grid.get_row_class(user, data, 1), 'foo')
|
||||||
|
|
||||||
|
# can use callable
|
||||||
|
def status(u, d, i):
|
||||||
|
if not u.active:
|
||||||
|
return 'inactive'
|
||||||
|
grid.row_class = status
|
||||||
|
self.assertIsNone(grid.get_row_class(user, data, 1))
|
||||||
|
user.active = False
|
||||||
|
self.assertEqual(grid.get_row_class(user, data, 1), 'inactive')
|
||||||
|
|
||||||
def test_get_vue_pager_stats(self):
|
def test_get_vue_pager_stats(self):
|
||||||
data = [
|
data = [
|
||||||
{'foo': 1, 'bar': 1},
|
{'foo': 1, 'bar': 1},
|
||||||
|
@ -1421,86 +1502,3 @@ 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")
|
|
||||||
|
|
385
tests/grids/test_filters.py
Normal file
385
tests/grids/test_filters.py
Normal file
|
@ -0,0 +1,385 @@
|
||||||
|
# -*- coding: utf-8; -*-
|
||||||
|
|
||||||
|
from unittest import TestCase
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from wuttaweb.grids import filters as mod
|
||||||
|
from tests.util import WebTestCase
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
factory = kwargs.pop('factory', mod.GridFilter)
|
||||||
|
kwargs['model_property'] = model_property
|
||||||
|
return factory(self.request, model_property.key, **kwargs)
|
||||||
|
|
||||||
|
def test_constructor(self):
|
||||||
|
model = self.app.model
|
||||||
|
|
||||||
|
# verbs is not set by default, but can be set
|
||||||
|
filtr = self.make_filter(model.Setting.name)
|
||||||
|
self.assertFalse(hasattr(filtr, 'verbs'))
|
||||||
|
filtr = self.make_filter(model.Setting.name, verbs=['foo', 'bar'])
|
||||||
|
self.assertEqual(filtr.verbs, ['foo', 'bar'])
|
||||||
|
|
||||||
|
# verb is not set by default, but can be set
|
||||||
|
filtr = self.make_filter(model.Setting.name)
|
||||||
|
self.assertFalse(hasattr(filtr, 'verb'))
|
||||||
|
filtr = self.make_filter(model.Setting.name, verb='foo')
|
||||||
|
self.assertEqual(filtr.verb, 'foo')
|
||||||
|
|
||||||
|
# default verb is not set by default, but can be set
|
||||||
|
filtr = self.make_filter(model.Setting.name)
|
||||||
|
self.assertFalse(hasattr(filtr, 'default_verb'))
|
||||||
|
filtr = self.make_filter(model.Setting.name, default_verb='foo')
|
||||||
|
self.assertEqual(filtr.default_verb, 'foo')
|
||||||
|
|
||||||
|
def test_repr(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.Setting.name, factory=mod.GridFilter)
|
||||||
|
self.assertEqual(repr(filtr), "GridFilter(key='name', active=False, verb=None, value=None)")
|
||||||
|
|
||||||
|
def test_get_verbs(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter)
|
||||||
|
self.assertFalse(hasattr(filtr, 'verbs'))
|
||||||
|
self.assertEqual(filtr.default_verbs, ['equal', 'not_equal'])
|
||||||
|
|
||||||
|
# by default, returns default verbs (plus 'is_any')
|
||||||
|
self.assertEqual(filtr.get_verbs(), ['equal', 'not_equal', 'is_any'])
|
||||||
|
|
||||||
|
# default verbs can be a callable
|
||||||
|
filtr.default_verbs = lambda: ['foo', 'bar']
|
||||||
|
self.assertEqual(filtr.get_verbs(), ['foo', 'bar', 'is_any'])
|
||||||
|
|
||||||
|
# uses filtr.verbs if set
|
||||||
|
filtr.verbs = ['is_true', 'is_false']
|
||||||
|
self.assertEqual(filtr.get_verbs(), ['is_true', 'is_false', 'is_any'])
|
||||||
|
|
||||||
|
# may add is/null verbs
|
||||||
|
filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter,
|
||||||
|
nullable=True)
|
||||||
|
self.assertEqual(filtr.get_verbs(), ['equal', 'not_equal',
|
||||||
|
'is_null', 'is_not_null',
|
||||||
|
'is_any'])
|
||||||
|
|
||||||
|
# filtr.verbs can be a callable
|
||||||
|
filtr.nullable = False
|
||||||
|
filtr.verbs = lambda: ['baz', 'blarg']
|
||||||
|
self.assertEqual(filtr.get_verbs(), ['baz', 'blarg', 'is_any'])
|
||||||
|
|
||||||
|
def test_get_default_verb(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter)
|
||||||
|
self.assertFalse(hasattr(filtr, 'verbs'))
|
||||||
|
self.assertEqual(filtr.default_verbs, ['equal', 'not_equal'])
|
||||||
|
self.assertEqual(filtr.get_verbs(), ['equal', 'not_equal', 'is_any'])
|
||||||
|
|
||||||
|
# returns first verb by default
|
||||||
|
self.assertEqual(filtr.get_default_verb(), 'equal')
|
||||||
|
|
||||||
|
# returns filtr.verb if set
|
||||||
|
filtr.verb = 'foo'
|
||||||
|
self.assertEqual(filtr.get_default_verb(), 'foo')
|
||||||
|
|
||||||
|
# returns filtr.default_verb if set
|
||||||
|
# (nb. this overrides filtr.verb since the point of this
|
||||||
|
# method is to return the *default* verb)
|
||||||
|
filtr.default_verb = 'bar'
|
||||||
|
self.assertEqual(filtr.get_default_verb(), 'bar')
|
||||||
|
|
||||||
|
def test_get_verb_labels(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter)
|
||||||
|
self.assertFalse(hasattr(filtr, 'verbs'))
|
||||||
|
self.assertEqual(filtr.get_verbs(), ['equal', 'not_equal', 'is_any'])
|
||||||
|
|
||||||
|
labels = filtr.get_verb_labels()
|
||||||
|
self.assertIsInstance(labels, dict)
|
||||||
|
self.assertEqual(labels['equal'], "equal to")
|
||||||
|
self.assertEqual(labels['not_equal'], "not equal to")
|
||||||
|
self.assertEqual(labels['is_any'], "is any")
|
||||||
|
|
||||||
|
def test_get_valueless_verbs(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.Setting.name, factory=mod.AlchemyFilter)
|
||||||
|
self.assertFalse(hasattr(filtr, 'verbs'))
|
||||||
|
self.assertEqual(filtr.get_verbs(), ['equal', 'not_equal', 'is_any'])
|
||||||
|
|
||||||
|
verbs = filtr.get_valueless_verbs()
|
||||||
|
self.assertIsInstance(verbs, list)
|
||||||
|
self.assertIn('is_any', verbs)
|
||||||
|
|
||||||
|
def test_apply_filter(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.Setting.value, factory=mod.StringAlchemyFilter)
|
||||||
|
|
||||||
|
# default verb used as fallback
|
||||||
|
# self.assertEqual(filtr.default_verb, 'contains')
|
||||||
|
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')
|
||||||
|
filtr.verbs = ['doesnotexist']
|
||||||
|
self.assertRaises(mod.VerbNotSupported, filtr.apply_filter,
|
||||||
|
self.sample_query, verb='doesnotexist')
|
||||||
|
|
||||||
|
def test_filter_is_any(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.Setting.value)
|
||||||
|
self.assertEqual(self.sample_query.count(), 9)
|
||||||
|
|
||||||
|
# nb. value None is ignored
|
||||||
|
filtered_query = filtr.filter_is_any(self.sample_query, None)
|
||||||
|
self.assertIs(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 9)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAlchemyFilter(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': None},
|
||||||
|
]
|
||||||
|
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):
|
||||||
|
factory = kwargs.pop('factory', mod.AlchemyFilter)
|
||||||
|
kwargs['model_property'] = model_property
|
||||||
|
return factory(self.request, model_property.key, **kwargs)
|
||||||
|
|
||||||
|
def test_filter_equal(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.Setting.value)
|
||||||
|
self.assertEqual(self.sample_query.count(), 9)
|
||||||
|
|
||||||
|
# not filtered for null value
|
||||||
|
filtered_query = filtr.filter_equal(self.sample_query, None)
|
||||||
|
self.assertIs(filtered_query, self.sample_query)
|
||||||
|
|
||||||
|
# nb. by default, *is filtered* by empty string
|
||||||
|
filtered_query = filtr.filter_equal(self.sample_query, '')
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 0)
|
||||||
|
|
||||||
|
# filtered by value
|
||||||
|
filtered_query = filtr.filter_equal(self.sample_query, 'ggg')
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 3)
|
||||||
|
|
||||||
|
def test_filter_not_equal(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_not_equal(self.sample_query, None)
|
||||||
|
self.assertIs(filtered_query, self.sample_query)
|
||||||
|
|
||||||
|
# nb. by default, *is filtered* by empty string
|
||||||
|
filtered_query = filtr.filter_not_equal(self.sample_query, '')
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 9)
|
||||||
|
|
||||||
|
# filtered by value
|
||||||
|
filtered_query = filtr.filter_not_equal(self.sample_query, 'ggg')
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 6)
|
||||||
|
|
||||||
|
def test_filter_is_null(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.Setting.value)
|
||||||
|
self.assertEqual(self.sample_query.count(), 9)
|
||||||
|
|
||||||
|
# nb. value None is ignored
|
||||||
|
filtered_query = filtr.filter_is_null(self.sample_query, None)
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 1)
|
||||||
|
|
||||||
|
def test_filter_is_not_null(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.Setting.value)
|
||||||
|
self.assertEqual(self.sample_query.count(), 9)
|
||||||
|
|
||||||
|
# nb. value None is ignored
|
||||||
|
filtered_query = filtr.filter_is_not_null(self.sample_query, None)
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 8)
|
||||||
|
|
||||||
|
|
||||||
|
class TestStringAlchemyFilter(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):
|
||||||
|
factory = kwargs.pop('factory', mod.StringAlchemyFilter)
|
||||||
|
kwargs['model_property'] = model_property
|
||||||
|
return factory(self.request, model_property.key, **kwargs)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def test_filter_does_not_contain(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_does_not_contain(self.sample_query, None)
|
||||||
|
self.assertIs(filtered_query, self.sample_query)
|
||||||
|
filtered_query = filtr.filter_does_not_contain(self.sample_query, '')
|
||||||
|
self.assertIs(filtered_query, self.sample_query)
|
||||||
|
|
||||||
|
# filtered by value
|
||||||
|
filtered_query = filtr.filter_does_not_contain(self.sample_query, 'ggg')
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 6)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBooleanAlchemyFilter(WebTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.setup_web()
|
||||||
|
|
||||||
|
model = self.app.model
|
||||||
|
self.sample_data = [
|
||||||
|
{'username': 'alice', 'active': True},
|
||||||
|
{'username': 'bob', 'active': True},
|
||||||
|
{'username': 'charlie', 'active': False},
|
||||||
|
]
|
||||||
|
for user in self.sample_data:
|
||||||
|
user = model.User(**user)
|
||||||
|
self.session.add(user)
|
||||||
|
self.session.commit()
|
||||||
|
self.sample_query = self.session.query(model.User)
|
||||||
|
|
||||||
|
def make_filter(self, model_property, **kwargs):
|
||||||
|
factory = kwargs.pop('factory', mod.BooleanAlchemyFilter)
|
||||||
|
kwargs['model_property'] = model_property
|
||||||
|
return factory(self.request, model_property.key, **kwargs)
|
||||||
|
|
||||||
|
def test_coerce_value(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.User.active)
|
||||||
|
|
||||||
|
self.assertIsNone(filtr.coerce_value(None))
|
||||||
|
|
||||||
|
self.assertTrue(filtr.coerce_value(True))
|
||||||
|
self.assertTrue(filtr.coerce_value(1))
|
||||||
|
self.assertTrue(filtr.coerce_value('1'))
|
||||||
|
|
||||||
|
self.assertFalse(filtr.coerce_value(False))
|
||||||
|
self.assertFalse(filtr.coerce_value(0))
|
||||||
|
self.assertFalse(filtr.coerce_value(''))
|
||||||
|
|
||||||
|
def test_filter_is_true(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.User.active)
|
||||||
|
self.assertEqual(self.sample_query.count(), 3)
|
||||||
|
|
||||||
|
# nb. value None is ignored
|
||||||
|
filtered_query = filtr.filter_is_true(self.sample_query, None)
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 2)
|
||||||
|
|
||||||
|
def test_filter_is_false(self):
|
||||||
|
model = self.app.model
|
||||||
|
filtr = self.make_filter(model.User.active)
|
||||||
|
self.assertEqual(self.sample_query.count(), 3)
|
||||||
|
|
||||||
|
# nb. value None is ignored
|
||||||
|
filtered_query = filtr.filter_is_false(self.sample_query, None)
|
||||||
|
self.assertIsNot(filtered_query, self.sample_query)
|
||||||
|
self.assertEqual(filtered_query.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerbNotSupported(TestCase):
|
||||||
|
|
||||||
|
def test_basic(self):
|
||||||
|
error = mod.VerbNotSupported('equal')
|
||||||
|
self.assertEqual(str(error), "unknown filter verb not supported: equal")
|
|
@ -487,6 +487,20 @@ class TestMasterView(WebTestCase):
|
||||||
grid = view.make_model_grid(session=self.session)
|
grid = view.make_model_grid(session=self.session)
|
||||||
self.assertIs(grid.model_class, model.Setting)
|
self.assertIs(grid.model_class, model.Setting)
|
||||||
|
|
||||||
|
# no row class by default
|
||||||
|
with patch.multiple(mod.MasterView, create=True,
|
||||||
|
model_class=model.Setting):
|
||||||
|
grid = view.make_model_grid(session=self.session)
|
||||||
|
self.assertIsNone(grid.row_class)
|
||||||
|
|
||||||
|
# can specify row class
|
||||||
|
get_row_class = MagicMock()
|
||||||
|
with patch.multiple(mod.MasterView, create=True,
|
||||||
|
model_class=model.Setting,
|
||||||
|
grid_row_class=get_row_class):
|
||||||
|
grid = view.make_model_grid(session=self.session)
|
||||||
|
self.assertIs(grid.row_class, get_row_class)
|
||||||
|
|
||||||
# no actions by default
|
# no actions by default
|
||||||
with patch.multiple(mod.MasterView, create=True,
|
with patch.multiple(mod.MasterView, create=True,
|
||||||
model_class=model.Setting):
|
model_class=model.Setting):
|
||||||
|
|
|
@ -31,6 +31,17 @@ class TestUserView(WebTestCase):
|
||||||
view.configure_grid(grid)
|
view.configure_grid(grid)
|
||||||
self.assertTrue(grid.is_linked('person'))
|
self.assertTrue(grid.is_linked('person'))
|
||||||
|
|
||||||
|
def test_grid_row_class(self):
|
||||||
|
model = self.app.model
|
||||||
|
user = model.User(username='barney', active=True)
|
||||||
|
data = dict(user)
|
||||||
|
view = self.make_view()
|
||||||
|
|
||||||
|
self.assertIsNone(view.grid_row_class(user, data, 1))
|
||||||
|
|
||||||
|
user.active = False
|
||||||
|
self.assertEqual(view.grid_row_class(user, data, 1), 'has-background-warning')
|
||||||
|
|
||||||
def test_configure_form(self):
|
def test_configure_form(self):
|
||||||
model = self.app.model
|
model = self.app.model
|
||||||
barney = model.User(username='barney')
|
barney = model.User(username='barney')
|
||||||
|
|
Loading…
Reference in a new issue