3
0
Fork 0

Compare commits

...

7 commits

Author SHA1 Message Date
Lance Edgar bf2ca4b475 fix: use autocomplete for grid filter verb choices 2024-08-23 14:38:02 -05:00
Lance Edgar e332975ce9 feat: add per-row css class support for grids 2024-08-23 14:14:41 -05:00
Lance Edgar f6fb6957e3 feat: improve grid filter API a bit, support string/bool filters 2024-08-23 12:23:49 -05:00
Lance Edgar 4525f91c21 fix: small cleanup for grid filters template 2024-08-22 20:00:07 -05:00
Lance Edgar 2e5feeb6f4 fix: add once-button action for grid Reset View 2024-08-22 19:43:42 -05:00
Lance Edgar db5e4e88f6 fix: set sort defaults for users, roles 2024-08-22 19:38:33 -05:00
Lance Edgar 122c6650d5 fix: add override hook for base form template 2024-08-22 16:42:42 -05:00
19 changed files with 1271 additions and 361 deletions

View file

@ -0,0 +1,6 @@
``wuttaweb.grids.filters``
==========================
.. automodule:: wuttaweb.grids.filters
:members:

View file

@ -16,6 +16,7 @@
forms.widgets
grids
grids.base
grids.filters
handler
helpers
menus

View file

@ -313,7 +313,7 @@ class Form:
self.set_fields(fields or self.get_fields())
# 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):
"""
@ -826,16 +826,16 @@ class Form:
output = render(template, context)
return HTML.literal(output)
def add_grid_vue_data(self, grid):
def add_grid_vue_context(self, grid):
""" """
if not grid.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, "
"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(
self,

View file

@ -27,6 +27,7 @@ Base grid classes
import functools
import json
import logging
import warnings
from collections import namedtuple
import sqlalchemy as sa
@ -40,6 +41,7 @@ from webhelpers2.html import HTML
from wuttaweb.db import Session
from wuttaweb.util import FieldList, get_model_fields, make_json_safe
from wuttjamaican.util import UNSPECIFIED
from wuttaweb.grids.filters import default_sqlalchemy_filters, VerbNotSupported
log = logging.getLogger(__name__)
@ -115,6 +117,26 @@ class Grid:
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
List of :class:`GridAction` instances represenging action links
@ -329,6 +351,7 @@ class Grid:
data=None,
labels={},
renderers={},
row_class=None,
actions=[],
linked_columns=[],
sortable=False,
@ -354,6 +377,7 @@ class Grid:
self.data = data
self.labels = labels or {}
self.renderers = renderers or {}
self.row_class = row_class
self.actions = actions or []
self.linked_columns = linked_columns or []
self.joiners = joiners or {}
@ -529,8 +553,9 @@ class Grid:
Depending on the nature of grid data, sometimes a cell's
"as-is" value will be undesirable for display purposes.
The logic in :meth:`get_vue_data()` will first "convert" all
grid data as necessary so that it is at least JSON-compatible.
The logic in :meth:`get_vue_context()` will first "convert"
all grid data as necessary so that it is at least
JSON-compatible.
But then it also will invoke a renderer override (if defined)
to obtain the "final" cell value.
@ -1034,8 +1059,9 @@ class Grid:
def make_filter(self, columninfo, **kwargs):
"""
Create and return a :class:`GridFilter` instance suitable for
use on the given column.
Create and return a
:class:`~wuttaweb.grids.filters.GridFilter` instance suitable
for use on the given column.
Code usually does not need to call this directly. See also
:meth:`set_filter()`, which calls this method automatically.
@ -1043,24 +1069,34 @@ class Grid:
:param columninfo: Can be either a model property (see below),
or a column name.
:returns: A :class:`GridFilter` instance.
:returns: A :class:`~wuttaweb.grids.filters.GridFilter`
instance.
"""
# model_property is required
model_property = None
if isinstance(columninfo, str):
if kwargs.get('model_property'):
model_property = kwargs['model_property']
elif isinstance(columninfo, str):
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)
model_property = getattr(self.model_class, key, None)
if not model_property:
raise ValueError(f"cannot locate model property for key: {key}")
else:
model_property = columninfo
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):
"""
@ -1228,7 +1264,7 @@ class Grid:
settings[f'filter.{filtr.key}.active'] = defaults.get('active',
filtr.default_active)
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',
filtr.default_value)
if self.sortable:
@ -1291,7 +1327,7 @@ class Grid:
if self.filterable:
for filtr in self.filters.values():
filtr.active = settings[f'filter.{filtr.key}.active']
filtr.verb = settings[f'filter.{filtr.key}.verb'] or filtr.default_verb
filtr.verb = settings[f'filter.{filtr.key}.verb'] or filtr.get_default_verb()
filtr.value = settings[f'filter.{filtr.key}.value']
# sorting
@ -1531,8 +1567,8 @@ class Grid:
"""
Returns the list of currently active filters.
This inspects each :class:`GridFilter` in :attr:`filters` and
only returns the ones marked active.
This inspects each :class:`~wuttaweb.grids.filters.GridFilter`
in :attr:`filters` and only returns the ones marked active.
"""
return [filtr for filtr in self.filters.values()
if filtr.active]
@ -1658,7 +1694,7 @@ class Grid:
.. code-block:: html
<b-table :data="gridData['mykey']">
<b-table :data="gridContext['mykey'].data">
<!-- columns etc. -->
</b-table>
@ -1677,10 +1713,10 @@ class Grid:
.. 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.
The above example shows ``gridContext['mykey'].data`` as
the Vue data reference. This should "just work" if you
provide the correct ``form`` arg and the grid is contained
directly by that form's Vue component.
However, this may not account for all use cases. For now
we wait and see what comes up, but know the dust may not
@ -1689,7 +1725,7 @@ class Grid:
# nb. must register data for inclusion on page template
if form:
form.add_grid_vue_data(self)
form.add_grid_vue_context(self)
# otherwise logic is the same, just different template
return self.render_vue_template(template=template, **context)
@ -1797,7 +1833,7 @@ class Grid:
in its `Table docs
<https://buefy.org/documentation/table/#api-view>`_.
See also :meth:`get_vue_data()`.
See also :meth:`get_vue_context()`.
"""
if not self.columns:
raise ValueError(f"you must define columns for the grid! key = {self.key}")
@ -1848,61 +1884,55 @@ class Grid:
'key': filtr.key,
'active': 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,
'value': filtr.value,
'label': filtr.label,
})
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
result, e.g. to add URLs for :attr:`actions` etc.
* ``data`` - list of Vue-compatible data records
* ``row_classes`` - dict of per-row CSS classes
Importantly, this also ensures each value in the dict is
JSON-serializable, using
:func:`~wuttaweb.util.make_json_safe()`.
This first calls :meth:`get_visible_data()` to get the
original data set. Each record is converted to a dict.
:returns: List of data record dicts for use with Vue table
component. May be the full set of data, or just the
current page, per :attr:`paginate_on_backend`.
Then it calls :func:`~wuttaweb.util.make_json_safe()` to
ensure each record can be serialized to JSON.
Then it invokes any :attr:`renderers` which are defined, to
obtain the "final" values for each record.
Then it adds a URL key/value for each of the :attr:`actions`
defined, to each record.
Then it calls :meth:`get_row_class()` for each record. If a
value is returned, it is added to the ``row_classes`` dict.
Note that this dict is keyed by "zero-based row sequence as
string" - the Vue component expects that.
:returns: Dict of grid data/CSS context as described above.
"""
original_data = self.get_visible_data()
# TODO: at some point i thought it was useful to wrangle the
# 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
# loop thru data
data = []
for i, record in enumerate(original_data):
row_classes = {}
for i, record in enumerate(original_data, 1):
original_record = record
# convert record to new dict
record = dict(record)
# convert data if needed, for json compat
record = make_json_safe(record,
# TODO: is this a good idea?
warn=False)
# make all values safe for json
record = make_json_safe(record, warn=False)
# customize value rendering where applicable
for key in self.renderers:
@ -1917,9 +1947,48 @@ class Grid:
if url:
record[key] = url
# set row css class if applicable
css_class = self.get_row_class(original_record, record, i)
if css_class:
# nb. use *string* zero-based index, for js compat
row_classes[str(i-1)] = css_class
data.append(record)
return data
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):
"""
@ -2072,7 +2141,7 @@ class GridAction:
:param obj: Model instance of whatever type the parent grid is
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.
See also :attr:`url`.
@ -2081,164 +2150,3 @@ class GridAction:
return self.url(obj, i)
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}"

View 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,
}

View file

@ -164,12 +164,9 @@
gap: 0.5rem;
}
.wutta-filter .button.filter-toggle {
justify-content: left;
}
.wutta-filter .button.filter-toggle,
.wutta-filter .filter-verb {
justify-content: left;
min-width: 15rem;
}

View file

@ -9,13 +9,17 @@
% endif
</%def>
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
<%def name="render_vue_template_form()">
% if form is not Undefined:
${form.render_vue_template()}
% endif
</%def>
<%def name="render_vue_templates()">
${parent.render_vue_templates()}
${self.render_vue_template_form()}
</%def>
<%def name="make_vue_components()">
${parent.make_vue_components()}
% if form is not Undefined:

View file

@ -69,9 +69,9 @@
% endif
% if form.grid_vue_data:
gridData: {
% for key, data in form.grid_vue_data.items():
% if form.grid_vue_context:
gridContext: {
% for key, data in form.grid_vue_context.items():
'${key}': ${json.dumps(data)|n},
% endfor
},

View file

@ -1,5 +1,5 @@
## -*- coding: utf-8; -*-
<${b}-table :data="gridData['${grid.key}']">
<${b}-table :data="gridContext['${grid.key}'].data">
% for column in grid.get_vue_columns():
<${b}-table-column field="${column['field']}"

View file

@ -5,8 +5,7 @@
<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()">
<form @submit.prevent="applyFilters()">
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
<wutta-filter v-for="filtr in filters"
@ -55,10 +54,11 @@
:size="smallFilters ? 'is-small' : null" />
<b-button @click="resetView()"
:disabled="viewResetting"
icon-pack="fas"
icon-left="undo"
:size="smallFilters ? 'is-small' : null">
Reset View
{{ viewResetting ? "Working, please wait..." : "Reset View" }}
</b-button>
<b-button v-show="activeFilters"
@ -93,6 +93,7 @@
</div>
<${b}-table :data="data"
:row-class="getRowClass"
:loading="loading"
narrowed
hoverable
@ -227,10 +228,12 @@
<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 = {
data: ${grid.vue_component}CurrentData,
rowClasses: ${grid.vue_component}Context.row_classes,
loading: false,
## nb. this tracks whether grid.fetchFirstData() happened
@ -247,6 +250,7 @@
addFilterShow: false,
addFilterTerm: '',
smallFilters: false,
viewResetting: false,
% endif
## sorting
@ -398,6 +402,11 @@
})
},
getRowClass(row, i) {
// nb. use *string* index
return this.rowClasses[i.toString()]
},
renderNumber(value) {
if (value != undefined) {
return value.toLocaleString('en')
@ -456,6 +465,7 @@
if (!response.data.error) {
${grid.vue_component}CurrentData = response.data.data
this.data = ${grid.vue_component}CurrentData
this.rowClasses = response.data.row_classes || {}
% if grid.paginated and grid.paginate_on_backend:
this.pagerStats = response.data.pager_stats
% endif
@ -480,6 +490,7 @@
},
resetView() {
this.viewResetting = true
this.loading = true
// use current url proper, plus reset param

View file

@ -88,18 +88,30 @@
<div v-show="filter.active"
style="display: flex; gap: 0.5rem;">
<b-select v-model="filter.verb"
<b-button v-if="verbKnown"
class="filter-verb"
@click="verbChoiceInit()"
:size="isSmall ? 'is-small' : null">
<option v-for="verb in filter.verbs"
:key="verb"
:value="verb">
{{ verb }}
</option>
</b-select>
{{ verbLabel }}
</b-button>
<b-autocomplete v-if="!verbKnown"
ref="verbAutocomplete"
: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"
ref="filterValue"
v-show="valuedVerb()"
:is-small="isSmall" />
</div>
@ -114,11 +126,97 @@
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: {
focusValue: function() {
focusValue() {
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
},
}
}

View file

@ -181,6 +181,16 @@ class MasterView(View):
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
Boolean indicating whether the grid for the :meth:`index()`
@ -360,7 +370,7 @@ class MasterView(View):
if self.request.GET.get('partial'):
# 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:
context['pager_stats'] = grid.get_vue_pager_stats()
return self.json_response(context)
@ -1240,6 +1250,8 @@ class MasterView(View):
kwargs['actions'] = actions
if hasattr(self, 'grid_row_class'):
kwargs.setdefault('row_class', self.grid_row_class)
kwargs.setdefault('filterable', self.filterable)
kwargs.setdefault('filter_defaults', self.filter_defaults)
kwargs.setdefault('sortable', self.sortable)

View file

@ -55,6 +55,7 @@ class RoleView(MasterView):
filter_defaults = {
'name': {'active': True},
}
sort_defaults = 'name'
# TODO: master should handle this, possibly via configure_form()
def get_query(self, session=None):

View file

@ -57,18 +57,24 @@ class UserView(MasterView):
filter_defaults = {
'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):
""" """
model = self.app.model
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):
""" """
super().configure_grid(g)
model = self.app.model
# never show these
g.remove('person_uuid',
@ -80,6 +86,14 @@ class UserView(MasterView):
# 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):
""" """

View file

@ -406,28 +406,34 @@ class TestForm(TestCase):
self.assertIn('<script type="text/x-template" id="wutta-form-template">', html)
self.assertNotIn('@submit', html)
def test_add_grid_vue_data(self):
def test_add_grid_vue_context(self):
form = self.make_form()
# grid must have key
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
grid = Grid(self.request, key='foo')
self.assertEqual(len(form.grid_vue_data), 0)
form.add_grid_vue_data(grid)
self.assertEqual(len(form.grid_vue_data), 1)
self.assertIn('foo', form.grid_vue_data)
self.assertEqual(form.grid_vue_data['foo'], [])
self.assertEqual(len(form.grid_vue_context), 0)
form.add_grid_vue_context(grid)
self.assertEqual(len(form.grid_vue_context), 1)
self.assertIn('foo', form.grid_vue_context)
self.assertEqual(form.grid_vue_context['foo'], {
'data': [],
'row_classes': {},
})
# calling again with same key will replace data
records = [{'foo': 1}, {'foo': 2}]
grid = Grid(self.request, key='foo', columns=['foo'], data=records)
form.add_grid_vue_data(grid)
self.assertEqual(len(form.grid_vue_data), 1)
self.assertIn('foo', form.grid_vue_data)
self.assertEqual(form.grid_vue_data['foo'], records)
form.add_grid_vue_context(grid)
self.assertEqual(len(form.grid_vue_context), 1)
self.assertIn('foo', form.grid_vue_context)
self.assertEqual(form.grid_vue_context['foo'], {
'data': records,
'row_classes': {},
})
def test_render_vue_finalize(self):
form = self.make_form()

View file

@ -3,6 +3,7 @@
from unittest import TestCase
from unittest.mock import patch, MagicMock
import sqlalchemy as sa
from sqlalchemy import orm
from paginate import Page
from paginate_sqlalchemy import SqlalchemyOrmPage
@ -10,6 +11,7 @@ from pyramid import testing
from wuttjamaican.conf import WuttaConfig
from wuttaweb.grids import base as mod
from wuttaweb.grids.filters import GridFilter, StringAlchemyFilter, default_sqlalchemy_filters
from wuttaweb.util import FieldList
from wuttaweb.forms import Form
from tests.util import WebTestCase
@ -921,20 +923,38 @@ class TestGrid(WebTestCase):
def test_make_filter(self):
model = self.app.model
# basic
# arg is column name
grid = self.make_grid(model_class=model.Setting)
filtr = grid.make_filter('name')
self.assertIsInstance(filtr, mod.GridFilter)
self.assertIsInstance(filtr, StringAlchemyFilter)
# property
grid = self.make_grid(model_class=model.Setting)
filtr = grid.make_filter(model.Setting.name)
self.assertIsInstance(filtr, mod.GridFilter)
# invalid model class
# arg is column name, but model class is invalid
grid = self.make_grid(model_class=42)
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):
model = self.app.model
@ -1049,6 +1069,9 @@ class TestGrid(WebTestCase):
sample_query = self.session.query(model.Setting)
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
grid.load_settings()
@ -1263,10 +1286,10 @@ class TestGrid(WebTestCase):
# form will register grid data
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)
self.assertEqual(len(form.grid_vue_data), 1)
self.assertIn('foobar', form.grid_vue_data)
self.assertEqual(len(form.grid_vue_context), 1)
self.assertIn('foobar', form.grid_vue_context)
def test_render_vue_finalize(self):
grid = self.make_grid()
@ -1314,6 +1337,40 @@ class TestGrid(WebTestCase):
filters = grid.get_vue_filters()
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):
# empty if no columns defined
@ -1335,11 +1392,35 @@ class TestGrid(WebTestCase):
self.assertIsNot(data, mydata)
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")
data = grid.get_vue_data()
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):
data = [
{'foo': 1, 'bar': 1},
@ -1421,86 +1502,3 @@ class TestGridAction(TestCase):
action = self.make_action('blarg', url=lambda o, i: '/yeehaw')
url = action.get_url(obj)
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
View 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")

View file

@ -487,6 +487,20 @@ class TestMasterView(WebTestCase):
grid = view.make_model_grid(session=self.session)
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
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):

View file

@ -31,6 +31,17 @@ class TestUserView(WebTestCase):
view.configure_grid(grid)
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):
model = self.app.model
barney = model.User(username='barney')