Compare commits
No commits in common. "91e10274eab7e82970089333da52f16a36b8f54f" and "f9fad67f4a2a27f76e28756e8c4eeec2536b56c5" have entirely different histories.
91e10274ea
...
f9fad67f4a
10
CHANGELOG.md
10
CHANGELOG.md
|
@ -5,16 +5,6 @@ All notable changes to wuttaweb will be documented in this file.
|
||||||
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## v0.10.0 (2024-08-18)
|
|
||||||
|
|
||||||
### Feat
|
|
||||||
|
|
||||||
- add multi-column sorting (frontend or backend) for grids
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- improve grid display when data is empty
|
|
||||||
|
|
||||||
## v0.9.0 (2024-08-16)
|
## v0.9.0 (2024-08-16)
|
||||||
|
|
||||||
### Feat
|
### Feat
|
||||||
|
|
|
@ -6,7 +6,7 @@ build-backend = "hatchling.build"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "WuttaWeb"
|
name = "WuttaWeb"
|
||||||
version = "0.10.0"
|
version = "0.9.0"
|
||||||
description = "Web App for Wutta Framework"
|
description = "Web App for Wutta Framework"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
|
authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
|
||||||
|
@ -32,7 +32,6 @@ requires-python = ">= 3.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ColanderAlchemy",
|
"ColanderAlchemy",
|
||||||
"paginate",
|
"paginate",
|
||||||
"paginate_sqlalchemy",
|
|
||||||
"pyramid>=2",
|
"pyramid>=2",
|
||||||
"pyramid_beaker",
|
"pyramid_beaker",
|
||||||
"pyramid_deform",
|
"pyramid_deform",
|
||||||
|
|
|
@ -26,8 +26,6 @@ Grids Library
|
||||||
The ``wuttaweb.grids`` namespace contains the following:
|
The ``wuttaweb.grids`` namespace contains the following:
|
||||||
|
|
||||||
* :class:`~wuttaweb.grids.base.Grid`
|
* :class:`~wuttaweb.grids.base.Grid`
|
||||||
* :class:`~wuttaweb.grids.base.GridAction`
|
|
||||||
* :class:`~wuttaweb.grids.base.SortInfo`
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .base import Grid, GridAction, SortInfo
|
from .base import Grid, GridAction
|
||||||
|
|
|
@ -27,13 +27,10 @@ Base grid classes
|
||||||
import functools
|
import functools
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from collections import namedtuple
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from sqlalchemy import orm
|
|
||||||
|
|
||||||
import paginate
|
import paginate
|
||||||
from paginate_sqlalchemy import SqlalchemyOrmPage
|
|
||||||
from pyramid.renderers import render
|
from pyramid.renderers import render
|
||||||
from webhelpers2.html import HTML
|
from webhelpers2.html import HTML
|
||||||
|
|
||||||
|
@ -44,13 +41,6 @@ from wuttaweb.util import FieldList, get_model_fields, make_json_safe
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
SortInfo = namedtuple('SortInfo', ['sortkey', 'sortdir'])
|
|
||||||
SortInfo.__doc__ = """
|
|
||||||
Named tuple to track sorting info.
|
|
||||||
|
|
||||||
Elements of :attr:`~Grid.sort_defaults` will be of this type.
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Grid:
|
class Grid:
|
||||||
"""
|
"""
|
||||||
Base class for all grids.
|
Base class for all grids.
|
||||||
|
@ -126,117 +116,6 @@ class Grid:
|
||||||
|
|
||||||
See also :meth:`set_link()` and :meth:`is_linked()`.
|
See also :meth:`set_link()` and :meth:`is_linked()`.
|
||||||
|
|
||||||
.. attribute:: sortable
|
|
||||||
|
|
||||||
Boolean indicating whether *any* column sorting is allowed for
|
|
||||||
the grid. Default is ``False``.
|
|
||||||
|
|
||||||
See also :attr:`sort_multiple` and :attr:`sort_on_backend`.
|
|
||||||
|
|
||||||
.. attribute:: sort_multiple
|
|
||||||
|
|
||||||
Boolean indicating whether "multi-column" sorting is allowed.
|
|
||||||
Default is ``True``; if this is ``False`` then only one column
|
|
||||||
may be sorted at a time.
|
|
||||||
|
|
||||||
Only relevant if :attr:`sortable` is true, but applies to both
|
|
||||||
frontend and backend sorting.
|
|
||||||
|
|
||||||
.. warning::
|
|
||||||
|
|
||||||
This feature is limited by frontend JS capabilities,
|
|
||||||
regardless of :attr:`sort_on_backend` value (i.e. for both
|
|
||||||
frontend and backend sorting).
|
|
||||||
|
|
||||||
In particular, if the app theme templates use Vue 2 + Buefy,
|
|
||||||
then multi-column sorting should work.
|
|
||||||
|
|
||||||
But not so with Vue 3 + Oruga, *yet* - see also the `open
|
|
||||||
issue <https://github.com/oruga-ui/oruga/issues/962>`_
|
|
||||||
regarding that. For now this flag is simply ignored for
|
|
||||||
Vue 3 + Oruga templates.
|
|
||||||
|
|
||||||
Additionally, even with Vue 2 + Buefy this flag can only
|
|
||||||
allow the user to *request* a multi-column sort. Whereas
|
|
||||||
the "default sort" in the Vue component can only ever be
|
|
||||||
single-column, regardless of :attr:`sort_defaults`.
|
|
||||||
|
|
||||||
.. attribute:: sort_on_backend
|
|
||||||
|
|
||||||
Boolean indicating whether the grid data should be sorted on the
|
|
||||||
backend. Default is ``True``.
|
|
||||||
|
|
||||||
If ``False``, the client-side Vue component will handle the
|
|
||||||
sorting.
|
|
||||||
|
|
||||||
Only relevant if :attr:`sortable` is also true.
|
|
||||||
|
|
||||||
.. attribute:: sorters
|
|
||||||
|
|
||||||
Dict of functions to use for backend sorting.
|
|
||||||
|
|
||||||
Only relevant if both :attr:`sortable` and
|
|
||||||
:attr:`sort_on_backend` are true.
|
|
||||||
|
|
||||||
See also :meth:`set_sorter()`, :attr:`sort_defaults` and
|
|
||||||
:attr:`active_sorters`.
|
|
||||||
|
|
||||||
.. attribute:: sort_defaults
|
|
||||||
|
|
||||||
List of options to be used for default sorting, until the user
|
|
||||||
requests a different sorting method.
|
|
||||||
|
|
||||||
This list usually contains either zero or one elements. (More
|
|
||||||
are allowed if :attr:`sort_multiple` is true, but see note
|
|
||||||
below.) Each list element is a :class:`SortInfo` tuple and
|
|
||||||
must correspond to an entry in :attr:`sorters`.
|
|
||||||
|
|
||||||
Used with both frontend and backend sorting.
|
|
||||||
|
|
||||||
See also :meth:`set_sort_defaults()` and
|
|
||||||
:attr:`active_sorters`.
|
|
||||||
|
|
||||||
.. warning::
|
|
||||||
|
|
||||||
While the grid logic is built to handle multi-column
|
|
||||||
sorting, this feature is limited by frontend JS
|
|
||||||
capabilities.
|
|
||||||
|
|
||||||
Even if ``sort_defaults`` contains multiple entries
|
|
||||||
(i.e. for multi-column sorting to be used "by default" for
|
|
||||||
the grid), only the *first* entry (i.e. single-column
|
|
||||||
sorting) will actually be used as the default for the Vue
|
|
||||||
component.
|
|
||||||
|
|
||||||
See also :attr:`sort_multiple` for more details.
|
|
||||||
|
|
||||||
.. attribute:: active_sorters
|
|
||||||
|
|
||||||
List of sorters currently in effect for the grid; used by
|
|
||||||
:meth:`sort_data()`.
|
|
||||||
|
|
||||||
Whereas :attr:`sorters` defines all "available" sorters, and
|
|
||||||
:attr:`sort_defaults` defines the "default" sorters,
|
|
||||||
``active_sorters`` defines the "current/effective" sorters.
|
|
||||||
|
|
||||||
This attribute is set by :meth:`load_settings()`; until that is
|
|
||||||
called it will not exist.
|
|
||||||
|
|
||||||
This is conceptually a "subset" of :attr:`sorters` although a
|
|
||||||
different format is used here::
|
|
||||||
|
|
||||||
grid.active_sorters = [
|
|
||||||
{'key': 'name', 'dir': 'asc'},
|
|
||||||
{'key': 'id', 'dir': 'asc'},
|
|
||||||
]
|
|
||||||
|
|
||||||
The above is for example only; there is usually no reason to
|
|
||||||
set this attribute directly.
|
|
||||||
|
|
||||||
This list may contain multiple elements only if
|
|
||||||
:attr:`sort_multiple` is true. Otherewise it should always
|
|
||||||
have either zero or one element.
|
|
||||||
|
|
||||||
.. attribute:: paginated
|
.. attribute:: paginated
|
||||||
|
|
||||||
Boolean indicating whether the grid data should be paginated
|
Boolean indicating whether the grid data should be paginated
|
||||||
|
@ -296,11 +175,6 @@ class Grid:
|
||||||
renderers={},
|
renderers={},
|
||||||
actions=[],
|
actions=[],
|
||||||
linked_columns=[],
|
linked_columns=[],
|
||||||
sortable=False,
|
|
||||||
sort_multiple=True,
|
|
||||||
sort_on_backend=True,
|
|
||||||
sorters=None,
|
|
||||||
sort_defaults=None,
|
|
||||||
paginated=False,
|
paginated=False,
|
||||||
paginate_on_backend=True,
|
paginate_on_backend=True,
|
||||||
pagesize_options=None,
|
pagesize_options=None,
|
||||||
|
@ -322,22 +196,6 @@ class Grid:
|
||||||
|
|
||||||
self.set_columns(columns or self.get_columns())
|
self.set_columns(columns or self.get_columns())
|
||||||
|
|
||||||
# sorting
|
|
||||||
self.sortable = sortable
|
|
||||||
self.sort_multiple = sort_multiple
|
|
||||||
if self.sort_multiple and self.request.use_oruga:
|
|
||||||
log.warning("grid.sort_multiple is not implemented for Oruga-based templates")
|
|
||||||
self.sort_multiple = False
|
|
||||||
self.sort_on_backend = sort_on_backend
|
|
||||||
if sorters is not None:
|
|
||||||
self.sorters = sorters
|
|
||||||
elif self.sortable and self.sort_on_backend:
|
|
||||||
self.sorters = self.make_backend_sorters()
|
|
||||||
else:
|
|
||||||
self.sorters = {}
|
|
||||||
self.set_sort_defaults(sort_defaults or [])
|
|
||||||
|
|
||||||
# paging
|
|
||||||
self.paginated = paginated
|
self.paginated = paginated
|
||||||
self.paginate_on_backend = paginate_on_backend
|
self.paginate_on_backend = paginate_on_backend
|
||||||
self.pagesize_options = pagesize_options or self.get_pagesize_options()
|
self.pagesize_options = pagesize_options or self.get_pagesize_options()
|
||||||
|
@ -544,302 +402,6 @@ class Grid:
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
##############################
|
|
||||||
# sorting methods
|
|
||||||
##############################
|
|
||||||
|
|
||||||
def make_backend_sorters(self, sorters=None):
|
|
||||||
"""
|
|
||||||
Make backend sorters for all columns in the grid.
|
|
||||||
|
|
||||||
This is called by the constructor, if both :attr:`sortable`
|
|
||||||
and :attr:`sort_on_backend` are true.
|
|
||||||
|
|
||||||
For each column in the grid, this checks the provided
|
|
||||||
``sorters`` and if the column is not yet in there, will call
|
|
||||||
:meth:`make_sorter()` to add it.
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
This only works if grid has a :attr:`model_class`. If not,
|
|
||||||
this method just returns the initial sorters (or empty
|
|
||||||
dict).
|
|
||||||
|
|
||||||
:param sorters: Optional dict of initial sorters. Any
|
|
||||||
existing sorters will be left intact, not replaced.
|
|
||||||
|
|
||||||
:returns: Final dict of all sorters. Includes any from the
|
|
||||||
initial ``sorters`` param as well as any which were
|
|
||||||
created.
|
|
||||||
"""
|
|
||||||
sorters = sorters or {}
|
|
||||||
|
|
||||||
if self.model_class:
|
|
||||||
for key in self.columns:
|
|
||||||
if key in sorters:
|
|
||||||
continue
|
|
||||||
prop = getattr(self.model_class, key, None)
|
|
||||||
if prop and isinstance(prop.property, orm.ColumnProperty):
|
|
||||||
sorters[prop.key] = self.make_sorter(prop)
|
|
||||||
|
|
||||||
return sorters
|
|
||||||
|
|
||||||
def make_sorter(self, columninfo, keyfunc=None, foldcase=True):
|
|
||||||
"""
|
|
||||||
Returns a function suitable for use as a backend sorter on the
|
|
||||||
given column.
|
|
||||||
|
|
||||||
Code usually does not need to call this directly. See also
|
|
||||||
:meth:`set_sorter()`, which calls this method automatically.
|
|
||||||
|
|
||||||
:param columninfo: Can be either a model property (see below),
|
|
||||||
or a column name.
|
|
||||||
|
|
||||||
:param keyfunc: Optional function to use as the "sort key
|
|
||||||
getter" callable, if the sorter is manual (as opposed to
|
|
||||||
SQLAlchemy query). More on this below. If not specified,
|
|
||||||
a default function is used.
|
|
||||||
|
|
||||||
:param foldcase: If the sorter is manual (not SQLAlchemy), and
|
|
||||||
the column data is of text type, this may be used to
|
|
||||||
automatically "fold case" for the sorting. Defaults to
|
|
||||||
``True`` since this behavior is presumably expected, but
|
|
||||||
may be disabled if needed.
|
|
||||||
|
|
||||||
The term "model property" is a bit technical, an example
|
|
||||||
should help to clarify::
|
|
||||||
|
|
||||||
model = self.app.model
|
|
||||||
grid = Grid(self.request, model_class=model.Person)
|
|
||||||
|
|
||||||
# explicit property
|
|
||||||
sorter = grid.make_sorter(model.Person.full_name)
|
|
||||||
|
|
||||||
# property name works if grid has model class
|
|
||||||
sorter = grid.make_sorter('full_name')
|
|
||||||
|
|
||||||
# nb. this will *not* work
|
|
||||||
person = model.Person(full_name="John Doe")
|
|
||||||
sorter = grid.make_sorter(person.full_name)
|
|
||||||
|
|
||||||
The ``keyfunc`` param allows you to override the way sort keys
|
|
||||||
are obtained from data records (this only applies for a
|
|
||||||
"manual" sort, where data is a list and not a SQLAlchemy
|
|
||||||
query)::
|
|
||||||
|
|
||||||
data = [
|
|
||||||
{'foo': 1},
|
|
||||||
{'bar': 2},
|
|
||||||
]
|
|
||||||
|
|
||||||
# nb. no model_class, just as an example
|
|
||||||
grid = Grid(self.request, columns=['foo', 'bar'], data=data)
|
|
||||||
|
|
||||||
def getkey(obj):
|
|
||||||
if obj.get('foo')
|
|
||||||
return obj['foo']
|
|
||||||
if obj.get('bar'):
|
|
||||||
return obj['bar']
|
|
||||||
return ''
|
|
||||||
|
|
||||||
# nb. sortfunc will ostensibly sort by 'foo' column, but in
|
|
||||||
# practice it is sorted per value from getkey() above
|
|
||||||
sortfunc = grid.make_sorter('foo', keyfunc=getkey)
|
|
||||||
sorted_data = sortfunc(data, 'asc')
|
|
||||||
|
|
||||||
:returns: A function suitable for backend sorting. This
|
|
||||||
function will behave differently when it is given a
|
|
||||||
SQLAlchemy query vs. a "list" of data. In either case it
|
|
||||||
will return the sorted result.
|
|
||||||
|
|
||||||
This function may be called as shown above. It expects 2
|
|
||||||
args: ``(data, direction)``
|
|
||||||
"""
|
|
||||||
model_class = None
|
|
||||||
model_property = None
|
|
||||||
if isinstance(columninfo, str):
|
|
||||||
key = columninfo
|
|
||||||
model_class = self.model_class
|
|
||||||
model_property = getattr(self.model_class, key, None)
|
|
||||||
else:
|
|
||||||
model_property = columninfo
|
|
||||||
model_class = model_property.class_
|
|
||||||
key = model_property.key
|
|
||||||
|
|
||||||
def sorter(data, direction):
|
|
||||||
|
|
||||||
# query is sorted with order_by()
|
|
||||||
if isinstance(data, orm.Query):
|
|
||||||
if not model_property:
|
|
||||||
raise TypeError(f"grid sorter for '{key}' does not map to a model property")
|
|
||||||
query = data
|
|
||||||
return query.order_by(getattr(model_property, direction)())
|
|
||||||
|
|
||||||
# other data is sorted manually. first step is to
|
|
||||||
# identify the function used to produce a sort key for
|
|
||||||
# each record
|
|
||||||
kfunc = keyfunc
|
|
||||||
if not kfunc:
|
|
||||||
if model_property:
|
|
||||||
# TODO: may need this for String etc. as well?
|
|
||||||
if isinstance(model_property.type, sa.Text):
|
|
||||||
if foldcase:
|
|
||||||
kfunc = lambda obj: (obj[key] or '').lower()
|
|
||||||
else:
|
|
||||||
kfunc = lambda obj: obj[key] or ''
|
|
||||||
if not kfunc:
|
|
||||||
# nb. sorting with this can raise error if data
|
|
||||||
# contains varying types, e.g. str and None
|
|
||||||
kfunc = lambda obj: obj[key]
|
|
||||||
|
|
||||||
# then sort the data and return
|
|
||||||
return sorted(data, key=kfunc, reverse=direction == 'desc')
|
|
||||||
|
|
||||||
# TODO: this should be improved; is needed in tailbone for
|
|
||||||
# multi-column sorting with sqlalchemy queries
|
|
||||||
if model_property:
|
|
||||||
sorter._class = model_class
|
|
||||||
sorter._column = model_property
|
|
||||||
|
|
||||||
return sorter
|
|
||||||
|
|
||||||
def set_sorter(self, key, sortinfo=None):
|
|
||||||
"""
|
|
||||||
Set/override the backend sorter for a column.
|
|
||||||
|
|
||||||
Only relevant if both :attr:`sortable` and
|
|
||||||
:attr:`sort_on_backend` are true.
|
|
||||||
|
|
||||||
:param key: Name of column.
|
|
||||||
|
|
||||||
:param sortinfo: Can be either a sorter callable, or else a
|
|
||||||
model property (see below).
|
|
||||||
|
|
||||||
If ``sortinfo`` is a callable, it will be used as-is for the
|
|
||||||
backend sorter.
|
|
||||||
|
|
||||||
Otherwise :meth:`make_sorter()` will be called to obtain the
|
|
||||||
backend sorter. The ``sortinfo`` will be passed along to that
|
|
||||||
call; if it is empty then ``key`` will be used instead.
|
|
||||||
|
|
||||||
A backend sorter callable must accept ``(data, direction)``
|
|
||||||
args and return the sorted data/query, for example::
|
|
||||||
|
|
||||||
model = self.app.model
|
|
||||||
grid = Grid(self.request, model_class=model.Person)
|
|
||||||
|
|
||||||
def sort_full_name(query, direction):
|
|
||||||
sortspec = getattr(model.Person.full_name, direction)
|
|
||||||
return query.order_by(sortspec())
|
|
||||||
|
|
||||||
grid.set_sorter('full_name', sort_full_name)
|
|
||||||
|
|
||||||
See also :meth:`remove_sorter()` and :meth:`is_sortable()`.
|
|
||||||
Backend sorters are tracked via :attr:`sorters`.
|
|
||||||
"""
|
|
||||||
sorter = None
|
|
||||||
|
|
||||||
if sortinfo and callable(sortinfo):
|
|
||||||
sorter = sortinfo
|
|
||||||
else:
|
|
||||||
sorter = self.make_sorter(sortinfo or key)
|
|
||||||
|
|
||||||
self.sorters[key] = sorter
|
|
||||||
|
|
||||||
def remove_sorter(self, key):
|
|
||||||
"""
|
|
||||||
Remove the backend sorter for a column.
|
|
||||||
|
|
||||||
See also :meth:`set_sorter()`.
|
|
||||||
"""
|
|
||||||
self.sorters.pop(key, None)
|
|
||||||
|
|
||||||
def set_sort_defaults(self, *args):
|
|
||||||
"""
|
|
||||||
Set the default sorting method for the grid. This sorting is
|
|
||||||
used unless/until the user requests a different sorting
|
|
||||||
method.
|
|
||||||
|
|
||||||
``args`` for this method are interpreted as follows:
|
|
||||||
|
|
||||||
If 2 args are received, they should be for ``sortkey`` and
|
|
||||||
``sortdir``; for instance::
|
|
||||||
|
|
||||||
grid.set_sort_defaults('name', 'asc')
|
|
||||||
|
|
||||||
If just one 2-tuple arg is received, it is handled similarly::
|
|
||||||
|
|
||||||
grid.set_sort_defaults(('name', 'asc'))
|
|
||||||
|
|
||||||
If just one string arg is received, the default ``sortdir`` is
|
|
||||||
assumed::
|
|
||||||
|
|
||||||
grid.set_sort_defaults('name') # assumes 'asc'
|
|
||||||
|
|
||||||
Otherwise there should be just one list arg, elements of
|
|
||||||
which are each 2-tuples of ``(sortkey, sortdir)`` info::
|
|
||||||
|
|
||||||
grid.set_sort_defaults([('name', 'asc'),
|
|
||||||
('value', 'desc')])
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
|
|
||||||
Note that :attr:`sort_multiple` determines whether the grid
|
|
||||||
is actually allowed to have multiple sort defaults. The
|
|
||||||
defaults requested by the method call may be pruned if
|
|
||||||
necessary to accommodate that.
|
|
||||||
|
|
||||||
Default sorting info is tracked via :attr:`sort_defaults`.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# convert args to sort defaults
|
|
||||||
sort_defaults = []
|
|
||||||
if len(args) == 1:
|
|
||||||
if isinstance(args[0], str):
|
|
||||||
sort_defaults = [SortInfo(args[0], 'asc')]
|
|
||||||
elif isinstance(args[0], tuple) and len(args[0]) == 2:
|
|
||||||
sort_defaults = [SortInfo(*args[0])]
|
|
||||||
elif isinstance(args[0], list):
|
|
||||||
sort_defaults = [SortInfo(*tup) for tup in args[0]]
|
|
||||||
else:
|
|
||||||
raise ValueError("for just one positional arg, must pass string, 2-tuple or list")
|
|
||||||
elif len(args) == 2:
|
|
||||||
sort_defaults = [SortInfo(*args)]
|
|
||||||
else:
|
|
||||||
raise ValueError("must pass just one or two positional args")
|
|
||||||
|
|
||||||
# prune if multi-column requested but not supported
|
|
||||||
if len(sort_defaults) > 1 and not self.sort_multiple:
|
|
||||||
log.warning("multi-column sorting is not enabled for the instance; "
|
|
||||||
"list will be pruned to first element for '%s' grid: %s",
|
|
||||||
self.key, sort_defaults)
|
|
||||||
sort_defaults = [sort_defaults[0]]
|
|
||||||
|
|
||||||
self.sort_defaults = sort_defaults
|
|
||||||
|
|
||||||
def is_sortable(self, key):
|
|
||||||
"""
|
|
||||||
Returns boolean indicating if a given column should allow
|
|
||||||
sorting.
|
|
||||||
|
|
||||||
If :attr:`sortable` is false, this always returns ``False``.
|
|
||||||
|
|
||||||
For frontend sorting (i.e. :attr:`sort_on_backend` is false),
|
|
||||||
this always returns ``True``.
|
|
||||||
|
|
||||||
For backend sorting, may return true or false depending on
|
|
||||||
whether the column is listed in :attr:`sorters`.
|
|
||||||
|
|
||||||
:param key: Column key as string.
|
|
||||||
|
|
||||||
See also :meth:`set_sorter()`.
|
|
||||||
"""
|
|
||||||
if not self.sortable:
|
|
||||||
return False
|
|
||||||
if self.sort_on_backend:
|
|
||||||
return key in self.sorters
|
|
||||||
return True
|
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
# paging methods
|
# paging methods
|
||||||
##############################
|
##############################
|
||||||
|
@ -898,19 +460,21 @@ class Grid:
|
||||||
# configuration methods
|
# configuration methods
|
||||||
##############################
|
##############################
|
||||||
|
|
||||||
def load_settings(self, persist=True):
|
def load_settings(self, store=True):
|
||||||
"""
|
"""
|
||||||
Load all effective settings for the grid.
|
Load all effective settings for the grid, from the following
|
||||||
|
places:
|
||||||
|
|
||||||
If the request GET params (query string) contains grid
|
* request params
|
||||||
settings, they are used; otherwise the settings are loaded
|
* user session
|
||||||
from user session.
|
|
||||||
|
The first value found for a given setting will be applied to
|
||||||
|
the grid.
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
|
||||||
As of now, "sorting" and "pagination" settings are the only
|
As of now, "pagination" settings are the only type
|
||||||
type supported by this logic. Settings for "filtering"
|
supported by this logic. Filter/sort coming soon...
|
||||||
coming soon...
|
|
||||||
|
|
||||||
The overall logic for this method is as follows:
|
The overall logic for this method is as follows:
|
||||||
|
|
||||||
|
@ -919,149 +483,45 @@ class Grid:
|
||||||
* optionally save settings to user session
|
* optionally save settings to user session
|
||||||
|
|
||||||
Saving the settings to user session will allow the grid to
|
Saving the settings to user session will allow the grid to
|
||||||
remember its current settings when user refreshes the page, or
|
"remember" its current settings when user refreshes the page.
|
||||||
navigates away then comes back. Therefore normally, settings
|
|
||||||
are saved each time they are loaded. Note that such settings
|
|
||||||
are wiped upon user logout.
|
|
||||||
|
|
||||||
:param persist: Whether the collected settings should be saved
|
:param store: Flag indicating whether the collected settings
|
||||||
to the user session.
|
should then be saved to the user session.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# initial default settings
|
# initial default settings
|
||||||
settings = {}
|
settings = {}
|
||||||
if self.sortable:
|
|
||||||
if self.sort_defaults:
|
|
||||||
# nb. as of writing neither Buefy nor Oruga support a
|
|
||||||
# multi-column *default* sort; so just use first sorter
|
|
||||||
sortinfo = self.sort_defaults[0]
|
|
||||||
settings['sorters.length'] = 1
|
|
||||||
settings['sorters.1.key'] = sortinfo.sortkey
|
|
||||||
settings['sorters.1.dir'] = sortinfo.sortdir
|
|
||||||
else:
|
|
||||||
settings['sorters.length'] = 0
|
|
||||||
if self.paginated and self.paginate_on_backend:
|
if self.paginated and self.paginate_on_backend:
|
||||||
settings['pagesize'] = self.pagesize
|
settings['pagesize'] = self.pagesize
|
||||||
settings['page'] = self.page
|
settings['page'] = self.page
|
||||||
|
|
||||||
# update settings dict based on what we find in the request
|
# grab settings from request and/or user session
|
||||||
# and/or user session. always prioritize the former.
|
if self.paginated and self.paginate_on_backend:
|
||||||
|
|
||||||
if self.request_has_settings('sort'):
|
|
||||||
self.update_sort_settings(settings, src='request')
|
|
||||||
self.update_page_settings(settings)
|
|
||||||
|
|
||||||
elif self.request_has_settings('page'):
|
|
||||||
self.update_sort_settings(settings, src='session')
|
|
||||||
self.update_page_settings(settings)
|
self.update_page_settings(settings)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# nothing found in request, so nothing new to save
|
# no settings were found in request or user session, so
|
||||||
persist = False
|
# nothing needs to be saved
|
||||||
|
store = False
|
||||||
|
|
||||||
# but still should load whatever is in user session
|
# maybe store settings in user session, for next time
|
||||||
self.update_sort_settings(settings, src='session')
|
if store:
|
||||||
self.update_page_settings(settings)
|
self.persist_settings(settings)
|
||||||
|
|
||||||
# maybe save settings in user session, for next time
|
# update ourself to reflect settings
|
||||||
if persist:
|
|
||||||
self.persist_settings(settings, dest='session')
|
|
||||||
|
|
||||||
# update ourself to reflect settings dict..
|
|
||||||
|
|
||||||
# sorting
|
|
||||||
if self.sortable:
|
|
||||||
# nb. doing this for frontend sorting also
|
|
||||||
self.active_sorters = []
|
|
||||||
for i in range(1, settings['sorters.length'] + 1):
|
|
||||||
self.active_sorters.append({
|
|
||||||
'key': settings[f'sorters.{i}.key'],
|
|
||||||
'dir': settings[f'sorters.{i}.dir'],
|
|
||||||
})
|
|
||||||
# TODO: i thought this was needed, but now idk?
|
|
||||||
# # nb. when showing full index page (i.e. not partial)
|
|
||||||
# # this implies we must set the default sorter for Vue
|
|
||||||
# # component, and only single-column is allowed there.
|
|
||||||
# if not self.request.GET.get('partial'):
|
|
||||||
# break
|
|
||||||
|
|
||||||
# paging
|
|
||||||
if self.paginated and self.paginate_on_backend:
|
if self.paginated and self.paginate_on_backend:
|
||||||
self.pagesize = settings['pagesize']
|
self.pagesize = settings['pagesize']
|
||||||
self.page = settings['page']
|
self.page = settings['page']
|
||||||
|
|
||||||
def request_has_settings(self, typ):
|
def request_has_settings(self):
|
||||||
""" """
|
""" """
|
||||||
|
for key in ['pagesize', 'page']:
|
||||||
if typ == 'sort':
|
if key in self.request.GET:
|
||||||
if 'sort1key' in self.request.GET:
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
elif typ == 'page':
|
|
||||||
for key in ['pagesize', 'page']:
|
|
||||||
if key in self.request.GET:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def get_setting(self, settings, key, src='session', default=None,
|
|
||||||
normalize=lambda v: v):
|
|
||||||
""" """
|
|
||||||
|
|
||||||
if src == 'request':
|
|
||||||
value = self.request.GET.get(key)
|
|
||||||
if value is not None:
|
|
||||||
try:
|
|
||||||
return normalize(value)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
elif src == 'session':
|
|
||||||
value = self.request.session.get(f'grid.{self.key}.{key}')
|
|
||||||
if value is not None:
|
|
||||||
return normalize(value)
|
|
||||||
|
|
||||||
# if src had nothing, try default/existing settings
|
|
||||||
value = settings.get(key)
|
|
||||||
if value is not None:
|
|
||||||
return normalize(value)
|
|
||||||
|
|
||||||
# okay then, default it is
|
|
||||||
return default
|
|
||||||
|
|
||||||
def update_sort_settings(self, settings, src=None):
|
|
||||||
""" """
|
|
||||||
if not (self.sortable and self.sort_on_backend):
|
|
||||||
return
|
|
||||||
|
|
||||||
if src == 'request':
|
|
||||||
i = 1
|
|
||||||
while True:
|
|
||||||
skey = f'sort{i}key'
|
|
||||||
if skey in self.request.GET:
|
|
||||||
settings[f'sorters.{i}.key'] = self.get_setting(settings, skey,
|
|
||||||
src='request')
|
|
||||||
settings[f'sorters.{i}.dir'] = self.get_setting(settings, f'sort{i}dir',
|
|
||||||
src='request',
|
|
||||||
default='asc')
|
|
||||||
else:
|
|
||||||
break
|
|
||||||
i += 1
|
|
||||||
settings['sorters.length'] = i - 1
|
|
||||||
|
|
||||||
elif src == 'session':
|
|
||||||
settings['sorters.length'] = self.get_setting(settings, 'sorters.length',
|
|
||||||
src='session', normalize=int)
|
|
||||||
for i in range(1, settings['sorters.length'] + 1):
|
|
||||||
for key in ('key', 'dir'):
|
|
||||||
skey = f'sorters.{i}.{key}'
|
|
||||||
settings[skey] = self.get_setting(settings, skey, src='session')
|
|
||||||
|
|
||||||
def update_page_settings(self, settings):
|
def update_page_settings(self, settings):
|
||||||
""" """
|
""" """
|
||||||
if not (self.paginated and self.paginate_on_backend):
|
|
||||||
return
|
|
||||||
|
|
||||||
# update the settings dict from request and/or user session
|
# update the settings dict from request and/or user session
|
||||||
|
|
||||||
# pagesize
|
# pagesize
|
||||||
|
@ -1084,42 +544,17 @@ class Grid:
|
||||||
if page is not None:
|
if page is not None:
|
||||||
settings['page'] = int(page)
|
settings['page'] = int(page)
|
||||||
|
|
||||||
def persist_settings(self, settings, dest=None):
|
def persist_settings(self, settings):
|
||||||
""" """
|
""" """
|
||||||
if dest not in ('session',):
|
model = self.app.model
|
||||||
raise ValueError(f"invalid dest identifier: {dest}")
|
session = Session()
|
||||||
|
|
||||||
# func to save a setting value to user session
|
# func to save a setting value to user session
|
||||||
def persist(key, value=lambda k: settings.get(k)):
|
def persist(key, value=lambda k: settings.get(k)):
|
||||||
assert dest == 'session'
|
|
||||||
skey = f'grid.{self.key}.{key}'
|
skey = f'grid.{self.key}.{key}'
|
||||||
self.request.session[skey] = value(key)
|
self.request.session[skey] = value(key)
|
||||||
|
|
||||||
# sort settings
|
|
||||||
if self.sortable:
|
|
||||||
|
|
||||||
# first must clear all sort settings from dest. this is
|
|
||||||
# because number of sort settings will vary, so we delete
|
|
||||||
# all and then write all
|
|
||||||
|
|
||||||
if dest == 'session':
|
|
||||||
# remove sort settings from user session
|
|
||||||
prefix = f'grid.{self.key}.sorters.'
|
|
||||||
for key in list(self.request.session):
|
|
||||||
if key.startswith(prefix):
|
|
||||||
del self.request.session[key]
|
|
||||||
|
|
||||||
# now save sort settings to dest
|
|
||||||
if 'sorters.length' in settings:
|
|
||||||
persist('sorters.length')
|
|
||||||
for i in range(1, settings['sorters.length'] + 1):
|
|
||||||
persist(f'sorters.{i}.key')
|
|
||||||
persist(f'sorters.{i}.dir')
|
|
||||||
|
|
||||||
# pagination settings
|
|
||||||
if self.paginated and self.paginate_on_backend:
|
if self.paginated and self.paginate_on_backend:
|
||||||
|
|
||||||
# save to dest
|
|
||||||
persist('pagesize')
|
persist('pagesize')
|
||||||
persist('page')
|
persist('page')
|
||||||
|
|
||||||
|
@ -1144,50 +579,12 @@ class Grid:
|
||||||
"""
|
"""
|
||||||
data = self.data or []
|
data = self.data or []
|
||||||
|
|
||||||
if self.sortable and self.sort_on_backend:
|
|
||||||
data = self.sort_data(data)
|
|
||||||
|
|
||||||
if self.paginated and self.paginate_on_backend:
|
if self.paginated and self.paginate_on_backend:
|
||||||
self.pager = self.paginate_data(data)
|
self.pager = self.paginate_data(data)
|
||||||
data = self.pager
|
data = self.pager
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def sort_data(self, data, sorters=None):
|
|
||||||
"""
|
|
||||||
Sort the given data and return the result. This is called by
|
|
||||||
:meth:`get_visible_data()`.
|
|
||||||
|
|
||||||
:param sorters: Optional list of sorters to use. If not
|
|
||||||
specified, the grid's :attr:`active_sorters` are used.
|
|
||||||
"""
|
|
||||||
if sorters is None:
|
|
||||||
sorters = self.active_sorters
|
|
||||||
if not sorters:
|
|
||||||
return data
|
|
||||||
|
|
||||||
# nb. when data is a query, we want to apply sorters in the
|
|
||||||
# requested order, so the final query has order_by() in the
|
|
||||||
# correct "as-is" sequence. however when data is a list we
|
|
||||||
# must do the opposite, applying in the reverse order, so the
|
|
||||||
# final list has the most "important" sort(s) applied last.
|
|
||||||
if not isinstance(data, orm.Query):
|
|
||||||
sorters = reversed(sorters)
|
|
||||||
|
|
||||||
for sorter in sorters:
|
|
||||||
sortkey = sorter['key']
|
|
||||||
sortdir = sorter['dir']
|
|
||||||
|
|
||||||
# cannot sort unless we have a sorter callable
|
|
||||||
sortfunc = self.sorters.get(sortkey)
|
|
||||||
if not sortfunc:
|
|
||||||
return data
|
|
||||||
|
|
||||||
# invoke the sorter
|
|
||||||
data = sortfunc(data, sortdir)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def paginate_data(self, data):
|
def paginate_data(self, data):
|
||||||
"""
|
"""
|
||||||
Apply pagination to the given data set, based on grid settings.
|
Apply pagination to the given data set, based on grid settings.
|
||||||
|
@ -1197,27 +594,9 @@ class Grid:
|
||||||
|
|
||||||
This method is called by :meth:`get_visible_data()`.
|
This method is called by :meth:`get_visible_data()`.
|
||||||
"""
|
"""
|
||||||
if isinstance(data, orm.Query):
|
pager = paginate.Page(data,
|
||||||
pager = SqlalchemyOrmPage(data,
|
items_per_page=self.pagesize,
|
||||||
items_per_page=self.pagesize,
|
page=self.page)
|
||||||
page=self.page)
|
|
||||||
|
|
||||||
else:
|
|
||||||
pager = paginate.Page(data,
|
|
||||||
items_per_page=self.pagesize,
|
|
||||||
page=self.page)
|
|
||||||
|
|
||||||
# pager may have detected that our current page is outside the
|
|
||||||
# valid range. if so we should update ourself to match
|
|
||||||
if pager.page != self.page:
|
|
||||||
self.page = pager.page
|
|
||||||
key = f'grid.{self.key}.page'
|
|
||||||
if key in self.request.session:
|
|
||||||
self.request.session[key] = self.page
|
|
||||||
|
|
||||||
# and re-make the pager just to be safe (?)
|
|
||||||
pager = self.paginate_data(data)
|
|
||||||
|
|
||||||
return pager
|
return pager
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
|
@ -1295,33 +674,9 @@ class Grid:
|
||||||
columns.append({
|
columns.append({
|
||||||
'field': name,
|
'field': name,
|
||||||
'label': self.get_label(name),
|
'label': self.get_label(name),
|
||||||
'sortable': self.is_sortable(name),
|
|
||||||
})
|
})
|
||||||
return columns
|
return columns
|
||||||
|
|
||||||
def get_vue_active_sorters(self):
|
|
||||||
"""
|
|
||||||
Returns a list of Vue-compatible column sorter definitions.
|
|
||||||
|
|
||||||
The list returned is the same as :attr:`active_sorters`;
|
|
||||||
however the format used in Vue is different. So this method
|
|
||||||
just "converts" them to the required format, e.g.::
|
|
||||||
|
|
||||||
# active_sorters format
|
|
||||||
{'key': 'name', 'dir': 'asc'}
|
|
||||||
|
|
||||||
# get_vue_active_sorters() format
|
|
||||||
{'field': 'name', 'order': 'asc'}
|
|
||||||
|
|
||||||
:returns: The :attr:`active_sorters` list, converted as
|
|
||||||
described above.
|
|
||||||
"""
|
|
||||||
sorters = []
|
|
||||||
for sorter in self.active_sorters:
|
|
||||||
sorters.append({'field': sorter['key'],
|
|
||||||
'order': sorter['dir']})
|
|
||||||
return sorters
|
|
||||||
|
|
||||||
def get_vue_data(self):
|
def get_vue_data(self):
|
||||||
"""
|
"""
|
||||||
Returns a list of Vue-compatible data records.
|
Returns a list of Vue-compatible data records.
|
||||||
|
|
|
@ -3,37 +3,11 @@
|
||||||
<script type="text/x-template" id="${grid.vue_tagname}-template">
|
<script type="text/x-template" id="${grid.vue_tagname}-template">
|
||||||
<${b}-table :data="data"
|
<${b}-table :data="data"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
|
||||||
narrowed
|
narrowed
|
||||||
hoverable
|
hoverable
|
||||||
icon-pack="fas"
|
icon-pack="fas"
|
||||||
|
|
||||||
## sorting
|
|
||||||
% if grid.sortable:
|
|
||||||
## nb. buefy/oruga only support *one* default sorter
|
|
||||||
:default-sort="sorters.length ? [sorters[0].field, sorters[0].order] : null"
|
|
||||||
% if grid.sort_on_backend:
|
|
||||||
backend-sorting
|
|
||||||
@sort="onSort"
|
|
||||||
% endif
|
|
||||||
% if grid.sort_multiple:
|
|
||||||
% if grid.sort_on_backend:
|
|
||||||
## TODO: there is a bug (?) which prevents the arrow
|
|
||||||
## from displaying for simple default single-column sort,
|
|
||||||
## when multi-column sort is allowed for the table. for
|
|
||||||
## now we work around that by waiting until mount to
|
|
||||||
## enable the multi-column support. see also
|
|
||||||
## https://github.com/buefy/buefy/issues/2584
|
|
||||||
:sort-multiple="allowMultiSort"
|
|
||||||
:sort-multiple-data="sortingPriority"
|
|
||||||
@sorting-priority-removed="sortingPriorityRemoved"
|
|
||||||
% else:
|
|
||||||
sort-multiple
|
|
||||||
% endif
|
|
||||||
## nb. user must ctrl-click column header for multi-sort
|
|
||||||
sort-multiple-key="ctrlKey"
|
|
||||||
% endif
|
|
||||||
% endif
|
|
||||||
|
|
||||||
## paging
|
## paging
|
||||||
% if grid.paginated:
|
% if grid.paginated:
|
||||||
paginated
|
paginated
|
||||||
|
@ -52,7 +26,6 @@
|
||||||
<${b}-table-column field="${column['field']}"
|
<${b}-table-column field="${column['field']}"
|
||||||
label="${column['label']}"
|
label="${column['label']}"
|
||||||
v-slot="props"
|
v-slot="props"
|
||||||
:sortable="${json.dumps(column.get('sortable', False))|n}"
|
|
||||||
cell-class="c_${column['field']}">
|
cell-class="c_${column['field']}">
|
||||||
% if grid.is_linked(column['field']):
|
% if grid.is_linked(column['field']):
|
||||||
<a :href="props.row._action_url_view"
|
<a :href="props.row._action_url_view"
|
||||||
|
@ -78,27 +51,11 @@
|
||||||
</${b}-table-column>
|
</${b}-table-column>
|
||||||
% endif
|
% endif
|
||||||
|
|
||||||
<template #empty>
|
|
||||||
<section class="section">
|
|
||||||
<div class="content has-text-grey has-text-centered">
|
|
||||||
<p>
|
|
||||||
<b-icon
|
|
||||||
pack="fas"
|
|
||||||
icon="sad-tear"
|
|
||||||
size="is-large">
|
|
||||||
</b-icon>
|
|
||||||
</p>
|
|
||||||
<p>Nothing here.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
% if grid.paginated:
|
% if grid.paginated:
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div style="display: flex; justify-content: space-between;">
|
<div style="display: flex; justify-content: space-between;">
|
||||||
<div></div>
|
<div></div>
|
||||||
<div v-if="pagerStats.first_item"
|
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||||
style="display: flex; gap: 0.5rem; align-items: center;">
|
|
||||||
<span>
|
<span>
|
||||||
showing
|
showing
|
||||||
{{ renderNumber(pagerStats.first_item) }}
|
{{ renderNumber(pagerStats.first_item) }}
|
||||||
|
@ -134,28 +91,6 @@
|
||||||
data: ${grid.vue_component}CurrentData,
|
data: ${grid.vue_component}CurrentData,
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|
||||||
## sorting
|
|
||||||
% if grid.sortable:
|
|
||||||
sorters: ${json.dumps(grid.get_vue_active_sorters())|n},
|
|
||||||
% if grid.sort_multiple:
|
|
||||||
% if grid.sort_on_backend:
|
|
||||||
## TODO: there is a bug (?) which prevents the arrow
|
|
||||||
## from displaying for simple default single-column sort,
|
|
||||||
## when multi-column sort is allowed for the table. for
|
|
||||||
## now we work around that by waiting until mount to
|
|
||||||
## enable the multi-column support. see also
|
|
||||||
## https://github.com/buefy/buefy/issues/2584
|
|
||||||
allowMultiSort: false,
|
|
||||||
## nb. this should be empty when current sort is single-column
|
|
||||||
% if len(grid.active_sorters) > 1:
|
|
||||||
sortingPriority: ${json.dumps(grid.get_vue_active_sorters())|n},
|
|
||||||
% else:
|
|
||||||
sortingPriority: [],
|
|
||||||
% endif
|
|
||||||
% endif
|
|
||||||
% endif
|
|
||||||
% endif
|
|
||||||
|
|
||||||
## paging
|
## paging
|
||||||
% if grid.paginated:
|
% if grid.paginated:
|
||||||
pageSizeOptions: ${json.dumps(grid.pagesize_options)|n},
|
pageSizeOptions: ${json.dumps(grid.pagesize_options)|n},
|
||||||
|
@ -174,14 +109,13 @@
|
||||||
% if not grid.paginate_on_backend:
|
% if not grid.paginate_on_backend:
|
||||||
|
|
||||||
pagerStats() {
|
pagerStats() {
|
||||||
const data = this.data
|
|
||||||
let last = this.currentPage * this.perPage
|
let last = this.currentPage * this.perPage
|
||||||
let first = last - this.perPage + 1
|
let first = last - this.perPage + 1
|
||||||
if (last > data.length) {
|
if (last > this.data.length) {
|
||||||
last = data.length
|
last = this.data.length
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
'item_count': data.length,
|
'item_count': this.data.length,
|
||||||
'items_per_page': this.perPage,
|
'items_per_page': this.perPage,
|
||||||
'page': this.currentPage,
|
'page': this.currentPage,
|
||||||
'first_item': first,
|
'first_item': first,
|
||||||
|
@ -191,21 +125,6 @@
|
||||||
|
|
||||||
% endif
|
% endif
|
||||||
},
|
},
|
||||||
|
|
||||||
% if grid.sortable and grid.sort_multiple and grid.sort_on_backend:
|
|
||||||
|
|
||||||
## TODO: there is a bug (?) which prevents the arrow
|
|
||||||
## from displaying for simple default single-column sort,
|
|
||||||
## when multi-column sort is allowed for the table. for
|
|
||||||
## now we work around that by waiting until mount to
|
|
||||||
## enable the multi-column support. see also
|
|
||||||
## https://github.com/buefy/buefy/issues/2584
|
|
||||||
mounted() {
|
|
||||||
this.allowMultiSort = true
|
|
||||||
},
|
|
||||||
|
|
||||||
% endif
|
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
|
||||||
renderNumber(value) {
|
renderNumber(value) {
|
||||||
|
@ -215,24 +134,21 @@
|
||||||
},
|
},
|
||||||
|
|
||||||
getBasicParams() {
|
getBasicParams() {
|
||||||
const params = {
|
return {
|
||||||
% if grid.paginated and grid.paginate_on_backend:
|
% if grid.paginated and grid.paginate_on_backend:
|
||||||
pagesize: this.perPage,
|
pagesize: this.perPage,
|
||||||
page: this.currentPage,
|
page: this.currentPage,
|
||||||
% endif
|
% endif
|
||||||
}
|
}
|
||||||
% if grid.sortable and grid.sort_on_backend:
|
|
||||||
for (let i = 1; i <= this.sorters.length; i++) {
|
|
||||||
params['sort'+i+'key'] = this.sorters[i-1].field
|
|
||||||
params['sort'+i+'dir'] = this.sorters[i-1].order
|
|
||||||
}
|
|
||||||
% endif
|
|
||||||
return params
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchData() {
|
async fetchData(params, success, failure) {
|
||||||
|
|
||||||
let params = new URLSearchParams(this.getBasicParams())
|
if (params === undefined || params === null) {
|
||||||
|
params = new URLSearchParams(this.getBasicParams())
|
||||||
|
} else {
|
||||||
|
params = new URLSearchParams(params)
|
||||||
|
}
|
||||||
if (!params.has('partial')) {
|
if (!params.has('partial')) {
|
||||||
params.append('partial', true)
|
params.append('partial', true)
|
||||||
}
|
}
|
||||||
|
@ -240,6 +156,8 @@
|
||||||
|
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.$http.get(`${request.path_url}?${'$'}{params}`).then(response => {
|
this.$http.get(`${request.path_url}?${'$'}{params}`).then(response => {
|
||||||
|
console.log(response)
|
||||||
|
console.log(response.data)
|
||||||
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
|
||||||
|
@ -247,6 +165,9 @@
|
||||||
this.pagerStats = response.data.pager_stats
|
this.pagerStats = response.data.pager_stats
|
||||||
% endif
|
% endif
|
||||||
this.loading = false
|
this.loading = false
|
||||||
|
if (success) {
|
||||||
|
success()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.$buefy.toast.open({
|
this.$buefy.toast.open({
|
||||||
message: data.error,
|
message: data.error,
|
||||||
|
@ -254,6 +175,9 @@
|
||||||
duration: 2000, // 4 seconds
|
duration: 2000, // 4 seconds
|
||||||
})
|
})
|
||||||
this.loading = false
|
this.loading = false
|
||||||
|
if (failure) {
|
||||||
|
failure()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
@ -262,71 +186,13 @@
|
||||||
this.pagerStats = {}
|
this.pagerStats = {}
|
||||||
% endif
|
% endif
|
||||||
this.loading = false
|
this.loading = false
|
||||||
|
if (failure) {
|
||||||
|
failure()
|
||||||
|
}
|
||||||
throw error
|
throw error
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
% if grid.sortable and grid.sort_on_backend:
|
|
||||||
|
|
||||||
onSort(field, order, event) {
|
|
||||||
|
|
||||||
## nb. buefy passes field name; oruga passes field object
|
|
||||||
% if request.use_oruga:
|
|
||||||
field = field.field
|
|
||||||
% endif
|
|
||||||
|
|
||||||
% if grid.sort_multiple:
|
|
||||||
|
|
||||||
// did user ctrl-click the column header?
|
|
||||||
if (event.ctrlKey) {
|
|
||||||
|
|
||||||
// toggle direction for existing, or add new sorter
|
|
||||||
const sorter = this.sorters.filter(s => s.field === field)[0]
|
|
||||||
if (sorter) {
|
|
||||||
sorter.order = sorter.order === 'desc' ? 'asc' : 'desc'
|
|
||||||
} else {
|
|
||||||
this.sorters.push({field, order})
|
|
||||||
}
|
|
||||||
|
|
||||||
// apply multi-column sorting
|
|
||||||
this.sortingPriority = this.sorters
|
|
||||||
|
|
||||||
} else {
|
|
||||||
|
|
||||||
% endif
|
|
||||||
|
|
||||||
// sort by single column only
|
|
||||||
this.sorters = [{field, order}]
|
|
||||||
|
|
||||||
% if grid.sort_multiple:
|
|
||||||
// multi-column sort not engaged
|
|
||||||
this.sortingPriority = []
|
|
||||||
}
|
|
||||||
% endif
|
|
||||||
|
|
||||||
// nb. always reset to first page when sorting changes
|
|
||||||
this.currentPage = 1
|
|
||||||
this.fetchData()
|
|
||||||
},
|
|
||||||
|
|
||||||
% if grid.sort_multiple:
|
|
||||||
|
|
||||||
sortingPriorityRemoved(field) {
|
|
||||||
|
|
||||||
// prune from active sorters
|
|
||||||
this.sorters = this.sorters.filter(s => s.field !== field)
|
|
||||||
|
|
||||||
// nb. even though we might have just one sorter
|
|
||||||
// now, we are still technically in multi-sort mode
|
|
||||||
this.sortingPriority = this.sorters
|
|
||||||
|
|
||||||
this.fetchData()
|
|
||||||
},
|
|
||||||
|
|
||||||
% endif
|
|
||||||
|
|
||||||
% endif
|
|
||||||
|
|
||||||
% if grid.paginated:
|
% if grid.paginated:
|
||||||
|
|
||||||
% if grid.paginate_on_backend:
|
% if grid.paginate_on_backend:
|
||||||
|
|
|
@ -181,37 +181,6 @@ class MasterView(View):
|
||||||
|
|
||||||
This is optional; see also :meth:`get_grid_columns()`.
|
This is optional; see also :meth:`get_grid_columns()`.
|
||||||
|
|
||||||
.. attribute:: sortable
|
|
||||||
|
|
||||||
Boolean indicating whether the grid for the :meth:`index()`
|
|
||||||
view should allow sorting of data. Default is ``True``.
|
|
||||||
|
|
||||||
This is used by :meth:`make_model_grid()` to set the grid's
|
|
||||||
:attr:`~wuttaweb.grids.base.Grid.sortable` flag.
|
|
||||||
|
|
||||||
See also :attr:`sort_on_backend` and :attr:`sort_defaults`.
|
|
||||||
|
|
||||||
.. attribute:: sort_on_backend
|
|
||||||
|
|
||||||
Boolean indicating whether the grid data for the
|
|
||||||
:meth:`index()` view should be sorted on the backend. Default
|
|
||||||
is ``True``.
|
|
||||||
|
|
||||||
This is used by :meth:`make_model_grid()` to set the grid's
|
|
||||||
:attr:`~wuttaweb.grids.base.Grid.sort_on_backend` flag.
|
|
||||||
|
|
||||||
Only relevant if :attr:`sortable` is true.
|
|
||||||
|
|
||||||
.. attribute:: sort_defaults
|
|
||||||
|
|
||||||
Optional list of default sorting info. Applicable for both
|
|
||||||
frontend and backend sorting.
|
|
||||||
|
|
||||||
This is used by :meth:`make_model_grid()` to set the grid's
|
|
||||||
:attr:`~wuttaweb.grids.base.Grid.sort_defaults`.
|
|
||||||
|
|
||||||
Only relevant if :attr:`sortable` is true.
|
|
||||||
|
|
||||||
.. attribute:: paginated
|
.. attribute:: paginated
|
||||||
|
|
||||||
Boolean indicating whether the grid data for the
|
Boolean indicating whether the grid data for the
|
||||||
|
@ -277,9 +246,6 @@ class MasterView(View):
|
||||||
# features
|
# features
|
||||||
listable = True
|
listable = True
|
||||||
has_grid = True
|
has_grid = True
|
||||||
sortable = True
|
|
||||||
sort_on_backend = True
|
|
||||||
sort_defaults = None
|
|
||||||
paginated = True
|
paginated = True
|
||||||
paginate_on_backend = True
|
paginate_on_backend = True
|
||||||
creatable = True
|
creatable = True
|
||||||
|
@ -1123,10 +1089,6 @@ class MasterView(View):
|
||||||
|
|
||||||
kwargs['actions'] = actions
|
kwargs['actions'] = actions
|
||||||
|
|
||||||
kwargs.setdefault('sortable', self.sortable)
|
|
||||||
kwargs.setdefault('sort_multiple', not self.request.use_oruga)
|
|
||||||
kwargs.setdefault('sort_on_backend', self.sort_on_backend)
|
|
||||||
kwargs.setdefault('sort_defaults', self.sort_defaults)
|
|
||||||
kwargs.setdefault('paginated', self.paginated)
|
kwargs.setdefault('paginated', self.paginated)
|
||||||
kwargs.setdefault('paginate_on_backend', self.paginate_on_backend)
|
kwargs.setdefault('paginate_on_backend', self.paginate_on_backend)
|
||||||
|
|
||||||
|
@ -1174,7 +1136,8 @@ class MasterView(View):
|
||||||
"""
|
"""
|
||||||
query = self.get_query(session=session)
|
query = self.get_query(session=session)
|
||||||
if query:
|
if query:
|
||||||
return query
|
return query.all()
|
||||||
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_query(self, session=None):
|
def get_query(self, session=None):
|
||||||
|
|
|
@ -45,7 +45,6 @@ class PersonView(MasterView):
|
||||||
model_class = Person
|
model_class = Person
|
||||||
model_title_plural = "People"
|
model_title_plural = "People"
|
||||||
route_prefix = 'people'
|
route_prefix = 'people'
|
||||||
sort_defaults = 'full_name'
|
|
||||||
|
|
||||||
grid_columns = [
|
grid_columns = [
|
||||||
'full_name',
|
'full_name',
|
||||||
|
@ -54,6 +53,13 @@ class PersonView(MasterView):
|
||||||
'last_name',
|
'last_name',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# 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.Person.full_name)
|
||||||
|
|
||||||
def configure_grid(self, g):
|
def configure_grid(self, g):
|
||||||
""" """
|
""" """
|
||||||
super().configure_grid(g)
|
super().configure_grid(g)
|
||||||
|
@ -61,12 +67,6 @@ class PersonView(MasterView):
|
||||||
# full_name
|
# full_name
|
||||||
g.set_link('full_name')
|
g.set_link('full_name')
|
||||||
|
|
||||||
# first_name
|
|
||||||
g.set_link('first_name')
|
|
||||||
|
|
||||||
# last_name
|
|
||||||
g.set_link('last_name')
|
|
||||||
|
|
||||||
# TODO: master should handle this?
|
# TODO: master should handle this?
|
||||||
def configure_form(self, f):
|
def configure_form(self, f):
|
||||||
""" """
|
""" """
|
||||||
|
|
|
@ -149,34 +149,20 @@ class SettingView(MasterView):
|
||||||
"""
|
"""
|
||||||
model_class = Setting
|
model_class = Setting
|
||||||
model_title = "Raw Setting"
|
model_title = "Raw Setting"
|
||||||
sort_defaults = 'name'
|
|
||||||
|
|
||||||
|
# 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.Setting.name)
|
||||||
|
|
||||||
|
# TODO: master should handle this (per column nullable)
|
||||||
def configure_form(self, f):
|
def configure_form(self, f):
|
||||||
""" """
|
""" """
|
||||||
super().configure_form(f)
|
super().configure_form(f)
|
||||||
|
|
||||||
# name
|
|
||||||
f.set_validator('name', self.unique_name)
|
|
||||||
|
|
||||||
# value
|
|
||||||
# TODO: master should handle this (per column nullable)
|
|
||||||
f.set_required('value', False)
|
f.set_required('value', False)
|
||||||
|
|
||||||
def unique_name(self, node, value):
|
|
||||||
""" """
|
|
||||||
model = self.app.model
|
|
||||||
session = self.Session()
|
|
||||||
|
|
||||||
query = session.query(model.Setting)\
|
|
||||||
.filter(model.Setting.name == value)
|
|
||||||
|
|
||||||
if self.editing:
|
|
||||||
name = self.request.matchdict['name']
|
|
||||||
query = query.filter(model.Setting.name != name)
|
|
||||||
|
|
||||||
if query.count():
|
|
||||||
node.raise_invalid("Setting name must be unique")
|
|
||||||
|
|
||||||
|
|
||||||
def defaults(config, **kwargs):
|
def defaults(config, **kwargs):
|
||||||
base = globals()
|
base = globals()
|
||||||
|
|
|
@ -3,13 +3,11 @@
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from sqlalchemy import orm
|
|
||||||
from paginate import Page
|
from paginate import Page
|
||||||
from paginate_sqlalchemy import SqlalchemyOrmPage
|
|
||||||
from pyramid import testing
|
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
|
||||||
from wuttaweb.forms import FieldList
|
from wuttaweb.forms import FieldList
|
||||||
from tests.util import WebTestCase
|
from tests.util import WebTestCase
|
||||||
|
|
||||||
|
@ -17,7 +15,7 @@ from tests.util import WebTestCase
|
||||||
class TestGrid(WebTestCase):
|
class TestGrid(WebTestCase):
|
||||||
|
|
||||||
def make_grid(self, request=None, **kwargs):
|
def make_grid(self, request=None, **kwargs):
|
||||||
return mod.Grid(request or self.request, **kwargs)
|
return base.Grid(request or self.request, **kwargs)
|
||||||
|
|
||||||
def test_constructor(self):
|
def test_constructor(self):
|
||||||
|
|
||||||
|
@ -32,59 +30,6 @@ class TestGrid(WebTestCase):
|
||||||
self.assertIsInstance(grid.columns, FieldList)
|
self.assertIsInstance(grid.columns, FieldList)
|
||||||
self.assertEqual(grid.columns, ['foo', 'bar'])
|
self.assertEqual(grid.columns, ['foo', 'bar'])
|
||||||
|
|
||||||
def test_constructor_sorting(self):
|
|
||||||
model = self.app.model
|
|
||||||
|
|
||||||
# defaults, not sortable
|
|
||||||
grid = self.make_grid()
|
|
||||||
self.assertFalse(grid.sortable)
|
|
||||||
self.assertTrue(grid.sort_on_backend)
|
|
||||||
self.assertEqual(grid.sorters, {})
|
|
||||||
self.assertEqual(grid.sort_defaults, [])
|
|
||||||
|
|
||||||
# defaults, sortable
|
|
||||||
grid = self.make_grid(sortable=True)
|
|
||||||
self.assertTrue(grid.sortable)
|
|
||||||
self.assertTrue(grid.sort_on_backend)
|
|
||||||
self.assertEqual(grid.sorters, {})
|
|
||||||
self.assertEqual(grid.sort_defaults, [])
|
|
||||||
|
|
||||||
# sorters may be pre-populated
|
|
||||||
grid = self.make_grid(model_class=model.Setting, sortable=True)
|
|
||||||
self.assertEqual(len(grid.sorters), 2)
|
|
||||||
self.assertIn('name', grid.sorters)
|
|
||||||
self.assertIn('value', grid.sorters)
|
|
||||||
self.assertEqual(grid.sort_defaults, [])
|
|
||||||
|
|
||||||
# sort defaults as str
|
|
||||||
grid = self.make_grid(model_class=model.Setting, sortable=True,
|
|
||||||
sort_defaults='name')
|
|
||||||
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
|
|
||||||
|
|
||||||
# sort defaults as tuple
|
|
||||||
grid = self.make_grid(model_class=model.Setting, sortable=True,
|
|
||||||
sort_defaults=('name', 'desc'))
|
|
||||||
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
|
|
||||||
|
|
||||||
# sort defaults as list w/ single tuple
|
|
||||||
grid = self.make_grid(model_class=model.Setting, sortable=True,
|
|
||||||
sort_defaults=[('name', 'desc')])
|
|
||||||
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
|
|
||||||
|
|
||||||
# multi-column defaults
|
|
||||||
grid = self.make_grid(model_class=model.Setting, sortable=True,
|
|
||||||
sort_multiple=True,
|
|
||||||
sort_defaults=[('name', 'desc'), ('value', 'asc')])
|
|
||||||
self.assertTrue(grid.sort_multiple)
|
|
||||||
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc'),
|
|
||||||
mod.SortInfo('value', 'asc')])
|
|
||||||
|
|
||||||
# multi-column sort disabled for oruga
|
|
||||||
self.request.use_oruga = True
|
|
||||||
grid = self.make_grid(model_class=model.Setting, sortable=True,
|
|
||||||
sort_multiple=True)
|
|
||||||
self.assertFalse(grid.sort_multiple)
|
|
||||||
|
|
||||||
def test_vue_tagname(self):
|
def test_vue_tagname(self):
|
||||||
grid = self.make_grid()
|
grid = self.make_grid()
|
||||||
self.assertEqual(grid.vue_tagname, 'wutta-grid')
|
self.assertEqual(grid.vue_tagname, 'wutta-grid')
|
||||||
|
@ -229,9 +174,6 @@ class TestGrid(WebTestCase):
|
||||||
##############################
|
##############################
|
||||||
|
|
||||||
def test_load_settings(self):
|
def test_load_settings(self):
|
||||||
model = self.app.model
|
|
||||||
|
|
||||||
# nb. first use a paging grid
|
|
||||||
grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True,
|
grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True,
|
||||||
pagesize=20, page=1)
|
pagesize=20, page=1)
|
||||||
|
|
||||||
|
@ -245,7 +187,7 @@ class TestGrid(WebTestCase):
|
||||||
|
|
||||||
# can skip the saving step
|
# can skip the saving step
|
||||||
self.request.GET = {'pagesize': '10', 'page': '3'}
|
self.request.GET = {'pagesize': '10', 'page': '3'}
|
||||||
grid.load_settings(persist=False)
|
grid.load_settings(store=False)
|
||||||
self.assertEqual(grid.page, 3)
|
self.assertEqual(grid.page, 3)
|
||||||
self.assertEqual(self.request.session['grid.foo.page'], 2)
|
self.assertEqual(self.request.session['grid.foo.page'], 2)
|
||||||
|
|
||||||
|
@ -254,169 +196,19 @@ class TestGrid(WebTestCase):
|
||||||
grid.load_settings()
|
grid.load_settings()
|
||||||
self.assertFalse(grid.paginated)
|
self.assertFalse(grid.paginated)
|
||||||
|
|
||||||
# nb. next use a sorting grid
|
|
||||||
grid = self.make_grid(key='settings', model_class=model.Setting,
|
|
||||||
sortable=True, sort_on_backend=True)
|
|
||||||
|
|
||||||
# settings are loaded, applied, saved
|
|
||||||
self.assertEqual(grid.sort_defaults, [])
|
|
||||||
self.assertFalse(hasattr(grid, 'active_sorters'))
|
|
||||||
self.request.GET = {'sort1key': 'name', 'sort1dir': 'desc'}
|
|
||||||
grid.load_settings()
|
|
||||||
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
|
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
|
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
|
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
|
|
||||||
|
|
||||||
# can skip the saving step
|
|
||||||
self.request.GET = {'sort1key': 'name', 'sort1dir': 'asc'}
|
|
||||||
grid.load_settings(persist=False)
|
|
||||||
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
|
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
|
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
|
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
|
|
||||||
|
|
||||||
# no error for non-sortable grid
|
|
||||||
grid = self.make_grid(key='foo', sortable=False)
|
|
||||||
grid.load_settings()
|
|
||||||
self.assertFalse(grid.sortable)
|
|
||||||
|
|
||||||
# with sort defaults
|
|
||||||
grid = self.make_grid(model_class=model.Setting, sortable=True,
|
|
||||||
sort_on_backend=True, sort_defaults='name')
|
|
||||||
self.assertFalse(hasattr(grid, 'active_sorters'))
|
|
||||||
grid.load_settings()
|
|
||||||
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
|
|
||||||
|
|
||||||
# with multi-column sort defaults
|
|
||||||
grid = self.make_grid(model_class=model.Setting, sortable=True,
|
|
||||||
sort_on_backend=True)
|
|
||||||
grid.sort_defaults = [
|
|
||||||
mod.SortInfo('name', 'asc'),
|
|
||||||
mod.SortInfo('value', 'desc'),
|
|
||||||
]
|
|
||||||
self.assertFalse(hasattr(grid, 'active_sorters'))
|
|
||||||
grid.load_settings()
|
|
||||||
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
|
|
||||||
|
|
||||||
# load settings from session when nothing is in request
|
|
||||||
self.request.GET = {}
|
|
||||||
self.request.session.invalidate()
|
|
||||||
self.assertNotIn('grid.settings.sorters.length', self.request.session)
|
|
||||||
self.request.session['grid.settings.sorters.length'] = 1
|
|
||||||
self.request.session['grid.settings.sorters.1.key'] = 'name'
|
|
||||||
self.request.session['grid.settings.sorters.1.dir'] = 'desc'
|
|
||||||
grid = self.make_grid(key='settings', model_class=model.Setting,
|
|
||||||
sortable=True, sort_on_backend=True,
|
|
||||||
paginated=True, paginate_on_backend=True)
|
|
||||||
self.assertFalse(hasattr(grid, 'active_sorters'))
|
|
||||||
grid.load_settings()
|
|
||||||
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'desc'}])
|
|
||||||
|
|
||||||
def test_request_has_settings(self):
|
def test_request_has_settings(self):
|
||||||
grid = self.make_grid(key='foo')
|
grid = self.make_grid(key='foo')
|
||||||
|
|
||||||
# paging
|
self.assertFalse(grid.request_has_settings())
|
||||||
self.assertFalse(grid.request_has_settings('page'))
|
|
||||||
with patch.object(self.request, 'GET', new={'pagesize': '20'}):
|
with patch.object(self.request, 'GET', new={'pagesize': '20'}):
|
||||||
self.assertTrue(grid.request_has_settings('page'))
|
self.assertTrue(grid.request_has_settings())
|
||||||
|
|
||||||
with patch.object(self.request, 'GET', new={'page': '1'}):
|
with patch.object(self.request, 'GET', new={'page': '1'}):
|
||||||
self.assertTrue(grid.request_has_settings('page'))
|
self.assertTrue(grid.request_has_settings())
|
||||||
|
|
||||||
# sorting
|
|
||||||
self.assertFalse(grid.request_has_settings('sort'))
|
|
||||||
with patch.object(self.request, 'GET', new={'sort1key': 'name'}):
|
|
||||||
self.assertTrue(grid.request_has_settings('sort'))
|
|
||||||
|
|
||||||
def test_get_setting(self):
|
|
||||||
grid = self.make_grid(key='foo')
|
|
||||||
settings = {}
|
|
||||||
|
|
||||||
# default is null
|
|
||||||
value = grid.get_setting(settings, 'pagesize')
|
|
||||||
self.assertIsNone(value)
|
|
||||||
|
|
||||||
# can read value from user session
|
|
||||||
self.request.session['grid.foo.pagesize'] = 15
|
|
||||||
value = grid.get_setting(settings, 'pagesize', src='session')
|
|
||||||
self.assertEqual(value, 15)
|
|
||||||
|
|
||||||
# string value not normalized
|
|
||||||
self.request.session['grid.foo.pagesize'] = '15'
|
|
||||||
value = grid.get_setting(settings, 'pagesize', src='session')
|
|
||||||
self.assertEqual(value, '15')
|
|
||||||
self.assertNotEqual(value, 15)
|
|
||||||
|
|
||||||
# but can be normalized
|
|
||||||
self.request.session['grid.foo.pagesize'] = '15'
|
|
||||||
value = grid.get_setting(settings, 'pagesize', src='session', normalize=int)
|
|
||||||
self.assertEqual(value, 15)
|
|
||||||
|
|
||||||
# can read value from request
|
|
||||||
self.request.GET = {'pagesize': '25'}
|
|
||||||
value = grid.get_setting(settings, 'pagesize', src='request', normalize=int)
|
|
||||||
self.assertEqual(value, 25)
|
|
||||||
|
|
||||||
# null when normalization fails
|
|
||||||
self.request.GET = {'pagesize': 'invalid'}
|
|
||||||
value = grid.get_setting(settings, 'pagesize', src='request', normalize=int)
|
|
||||||
self.assertIsNone(value)
|
|
||||||
|
|
||||||
# reset
|
|
||||||
del self.request.session['grid.foo.pagesize']
|
|
||||||
self.request.GET = {}
|
|
||||||
|
|
||||||
# value can come from provided settings
|
|
||||||
settings['pagesize'] = '35'
|
|
||||||
value = grid.get_setting(settings, 'pagesize', src='session', normalize=int)
|
|
||||||
self.assertEqual(value, 35)
|
|
||||||
|
|
||||||
def test_update_sort_settings(self):
|
|
||||||
model = self.app.model
|
|
||||||
|
|
||||||
# nothing happens if not sortable
|
|
||||||
grid = self.make_grid(key='settings', model_class=model.Setting)
|
|
||||||
settings = {'sorters.length': 0}
|
|
||||||
self.request.session['grid.settings.sorters.length'] = 1
|
|
||||||
self.request.session['grid.settings.sorters.1.key'] = 'name'
|
|
||||||
self.request.session['grid.settings.sorters.1.dir'] = 'asc'
|
|
||||||
grid.update_sort_settings(settings, src='session')
|
|
||||||
self.assertEqual(settings['sorters.length'], 0)
|
|
||||||
|
|
||||||
# nb. now use a sortable grid
|
|
||||||
grid = self.make_grid(key='settings', model_class=model.Setting,
|
|
||||||
sortable=True, sort_on_backend=True)
|
|
||||||
|
|
||||||
# settings are updated from session
|
|
||||||
settings = {'sorters.length': 1, 'sorters.1.key': 'name', 'sorters.1.dir': 'asc'}
|
|
||||||
self.request.session['grid.settings.sorters.length'] = 1
|
|
||||||
self.request.session['grid.settings.sorters.1.key'] = 'name'
|
|
||||||
self.request.session['grid.settings.sorters.1.dir'] = 'asc'
|
|
||||||
grid.update_sort_settings(settings, src='session')
|
|
||||||
self.assertEqual(settings['sorters.length'], 1)
|
|
||||||
self.assertEqual(settings['sorters.1.key'], 'name')
|
|
||||||
self.assertEqual(settings['sorters.1.dir'], 'asc')
|
|
||||||
|
|
||||||
# settings are updated from request
|
|
||||||
self.request.GET = {'sort1key': 'value', 'sort1dir': 'desc'}
|
|
||||||
grid.update_sort_settings(settings, src='request')
|
|
||||||
self.assertEqual(settings['sorters.length'], 1)
|
|
||||||
self.assertEqual(settings['sorters.1.key'], 'value')
|
|
||||||
self.assertEqual(settings['sorters.1.dir'], 'desc')
|
|
||||||
|
|
||||||
def test_update_page_settings(self):
|
def test_update_page_settings(self):
|
||||||
|
|
||||||
# nothing happens if not paginated
|
|
||||||
grid = self.make_grid(key='foo')
|
grid = self.make_grid(key='foo')
|
||||||
settings = {'pagesize': 20, 'page': 1}
|
|
||||||
self.request.session['grid.foo.pagesize'] = 10
|
|
||||||
self.request.session['grid.foo.page'] = 2
|
|
||||||
grid.update_page_settings(settings)
|
|
||||||
self.assertEqual(settings['pagesize'], 20)
|
|
||||||
self.assertEqual(settings['page'], 1)
|
|
||||||
|
|
||||||
# nb. now use a paginated grid
|
|
||||||
grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)
|
|
||||||
|
|
||||||
# settings are updated from session
|
# settings are updated from session
|
||||||
settings = {'pagesize': 20, 'page': 1}
|
settings = {'pagesize': 20, 'page': 1}
|
||||||
|
@ -433,410 +225,45 @@ class TestGrid(WebTestCase):
|
||||||
self.assertEqual(settings['page'], 4)
|
self.assertEqual(settings['page'], 4)
|
||||||
|
|
||||||
def test_persist_settings(self):
|
def test_persist_settings(self):
|
||||||
model = self.app.model
|
|
||||||
|
|
||||||
# nb. start out with paginated-only grid
|
|
||||||
grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)
|
grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)
|
||||||
|
|
||||||
# invalid dest
|
|
||||||
self.assertRaises(ValueError, grid.persist_settings, {}, dest='doesnotexist')
|
|
||||||
|
|
||||||
# nb. no error if empty settings, but it saves null values
|
# nb. no error if empty settings, but it saves null values
|
||||||
grid.persist_settings({}, dest='session')
|
grid.persist_settings({})
|
||||||
self.assertIsNone(self.request.session['grid.foo.page'])
|
self.assertIsNone(self.request.session['grid.foo.page'])
|
||||||
|
|
||||||
# provided values are saved
|
# provided values are saved
|
||||||
grid.persist_settings({'pagesize': 15, 'page': 3}, dest='session')
|
grid.persist_settings({'pagesize': 15, 'page': 3})
|
||||||
self.assertEqual(self.request.session['grid.foo.page'], 3)
|
self.assertEqual(self.request.session['grid.foo.page'], 3)
|
||||||
|
|
||||||
# nb. now switch to sortable-only grid
|
|
||||||
grid = self.make_grid(key='settings', model_class=model.Setting,
|
|
||||||
sortable=True, sort_on_backend=True)
|
|
||||||
|
|
||||||
# no error if empty settings; does not save values
|
|
||||||
grid.persist_settings({}, dest='session')
|
|
||||||
self.assertNotIn('grid.settings.sorters.length', self.request.session)
|
|
||||||
|
|
||||||
# provided values are saved
|
|
||||||
grid.persist_settings({'sorters.length': 2,
|
|
||||||
'sorters.1.key': 'name',
|
|
||||||
'sorters.1.dir': 'desc',
|
|
||||||
'sorters.2.key': 'value',
|
|
||||||
'sorters.2.dir': 'asc'},
|
|
||||||
dest='session')
|
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.length'], 2)
|
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
|
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
|
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.2.key'], 'value')
|
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.2.dir'], 'asc')
|
|
||||||
|
|
||||||
# old values removed when new are saved
|
|
||||||
grid.persist_settings({'sorters.length': 1,
|
|
||||||
'sorters.1.key': 'name',
|
|
||||||
'sorters.1.dir': 'desc'},
|
|
||||||
dest='session')
|
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.length'], 1)
|
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.1.key'], 'name')
|
|
||||||
self.assertEqual(self.request.session['grid.settings.sorters.1.dir'], 'desc')
|
|
||||||
self.assertNotIn('grid.settings.sorters.2.key', self.request.session)
|
|
||||||
self.assertNotIn('grid.settings.sorters.2.dir', self.request.session)
|
|
||||||
|
|
||||||
##############################
|
|
||||||
# sorting methods
|
|
||||||
##############################
|
|
||||||
|
|
||||||
def test_make_backend_sorters(self):
|
|
||||||
model = self.app.model
|
|
||||||
|
|
||||||
# default is empty
|
|
||||||
grid = self.make_grid()
|
|
||||||
sorters = grid.make_backend_sorters()
|
|
||||||
self.assertEqual(sorters, {})
|
|
||||||
|
|
||||||
# makes sorters if model class
|
|
||||||
grid = self.make_grid(model_class=model.Setting)
|
|
||||||
sorters = grid.make_backend_sorters()
|
|
||||||
self.assertEqual(len(sorters), 2)
|
|
||||||
self.assertIn('name', sorters)
|
|
||||||
self.assertIn('value', sorters)
|
|
||||||
|
|
||||||
# does not replace supplied sorters
|
|
||||||
grid = self.make_grid(model_class=model.Setting)
|
|
||||||
mysorters = {'value': 42}
|
|
||||||
sorters = grid.make_backend_sorters(mysorters)
|
|
||||||
self.assertEqual(len(sorters), 2)
|
|
||||||
self.assertIn('name', sorters)
|
|
||||||
self.assertIn('value', sorters)
|
|
||||||
self.assertEqual(sorters['value'], 42)
|
|
||||||
self.assertEqual(mysorters['value'], 42)
|
|
||||||
|
|
||||||
def test_make_sorter(self):
|
|
||||||
model = self.app.model
|
|
||||||
sample_data = [
|
|
||||||
{'name': 'foo1', 'value': 'ONE'},
|
|
||||||
{'name': 'foo2', 'value': 'two'},
|
|
||||||
{'name': 'foo3', 'value': 'three'},
|
|
||||||
{'name': 'foo4', 'value': 'four'},
|
|
||||||
{'name': 'foo5', 'value': 'five'},
|
|
||||||
{'name': 'foo6', 'value': 'six'},
|
|
||||||
{'name': 'foo7', 'value': 'seven'},
|
|
||||||
{'name': 'foo8', 'value': 'eight'},
|
|
||||||
{'name': 'foo9', 'value': 'nine'},
|
|
||||||
]
|
|
||||||
for setting in sample_data:
|
|
||||||
self.app.save_setting(self.session, setting['name'], setting['value'])
|
|
||||||
self.session.commit()
|
|
||||||
sample_query = self.session.query(model.Setting)
|
|
||||||
|
|
||||||
# plain data
|
|
||||||
grid = self.make_grid(columns=['name', 'value'])
|
|
||||||
sorter = grid.make_sorter('name')
|
|
||||||
sorted_data = sorter(sample_data, 'desc')
|
|
||||||
self.assertEqual(sorted_data[0], {'name': 'foo9', 'value': 'nine'})
|
|
||||||
sorted_data = sorter(sample_data, 'asc')
|
|
||||||
self.assertEqual(sorted_data[0], {'name': 'foo1', 'value': 'ONE'})
|
|
||||||
|
|
||||||
# model class, but still plain data
|
|
||||||
grid = self.make_grid(model_class=model.Setting)
|
|
||||||
sorter = grid.make_sorter('name')
|
|
||||||
sorted_data = sorter(sample_data, 'desc')
|
|
||||||
self.assertEqual(sorted_data[0], {'name': 'foo9', 'value': 'nine'})
|
|
||||||
sorted_data = sorter(sample_data, 'asc')
|
|
||||||
self.assertEqual(sorted_data[0], {'name': 'foo1', 'value': 'ONE'})
|
|
||||||
|
|
||||||
# repeat previous test, w/ model property
|
|
||||||
grid = self.make_grid(model_class=model.Setting)
|
|
||||||
sorter = grid.make_sorter(model.Setting.name)
|
|
||||||
sorted_data = sorter(sample_data, 'desc')
|
|
||||||
self.assertEqual(sorted_data[0], {'name': 'foo9', 'value': 'nine'})
|
|
||||||
sorted_data = sorter(sample_data, 'asc')
|
|
||||||
self.assertEqual(sorted_data[0], {'name': 'foo1', 'value': 'ONE'})
|
|
||||||
|
|
||||||
# sqlalchemy query
|
|
||||||
grid = self.make_grid(model_class=model.Setting)
|
|
||||||
sorter = grid.make_sorter('name')
|
|
||||||
sorted_query = sorter(sample_query, 'desc')
|
|
||||||
sorted_data = sorted_query.all()
|
|
||||||
self.assertEqual(dict(sorted_data[0]), {'name': 'foo9', 'value': 'nine'})
|
|
||||||
sorted_query = sorter(sample_query, 'asc')
|
|
||||||
sorted_data = sorted_query.all()
|
|
||||||
self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})
|
|
||||||
|
|
||||||
# repeat previous test, w/ model property
|
|
||||||
grid = self.make_grid(model_class=model.Setting)
|
|
||||||
sorter = grid.make_sorter(model.Setting.name)
|
|
||||||
sorted_query = sorter(sample_query, 'desc')
|
|
||||||
sorted_data = sorted_query.all()
|
|
||||||
self.assertEqual(dict(sorted_data[0]), {'name': 'foo9', 'value': 'nine'})
|
|
||||||
sorted_query = sorter(sample_query, 'asc')
|
|
||||||
sorted_data = sorted_query.all()
|
|
||||||
self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})
|
|
||||||
|
|
||||||
# sortfunc for "invalid" column will fail when called; however
|
|
||||||
# it can work for manual sort w/ custom keyfunc
|
|
||||||
grid = self.make_grid(model_class=model.Setting)
|
|
||||||
sorter = grid.make_sorter('doesnotexist')
|
|
||||||
self.assertRaises(TypeError, sorter, sample_query, 'desc')
|
|
||||||
self.assertRaises(KeyError, sorter, sample_data, 'desc')
|
|
||||||
sorter = grid.make_sorter('doesnotexist', keyfunc=lambda obj: obj['name'])
|
|
||||||
sorted_data = sorter(sample_data, 'desc')
|
|
||||||
self.assertEqual(len(sorted_data), 9)
|
|
||||||
sorted_data = sorter(sample_data, 'asc')
|
|
||||||
self.assertEqual(len(sorted_data), 9)
|
|
||||||
|
|
||||||
# case folding is on by default
|
|
||||||
grid = self.make_grid(model_class=model.Setting)
|
|
||||||
sorter = grid.make_sorter('value')
|
|
||||||
sorted_data = sorter(sample_data, 'desc')
|
|
||||||
self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'})
|
|
||||||
sorted_data = sorter(sample_data, 'asc')
|
|
||||||
self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
|
|
||||||
|
|
||||||
# results are different with case folding off
|
|
||||||
grid = self.make_grid(model_class=model.Setting)
|
|
||||||
sorter = grid.make_sorter('value', foldcase=False)
|
|
||||||
sorted_data = sorter(sample_data, 'desc')
|
|
||||||
self.assertEqual(dict(sorted_data[0]), {'name': 'foo2', 'value': 'two'})
|
|
||||||
sorted_data = sorter(sample_data, 'asc')
|
|
||||||
self.assertEqual(dict(sorted_data[0]), {'name': 'foo1', 'value': 'ONE'})
|
|
||||||
|
|
||||||
def test_set_sorter(self):
|
|
||||||
model = self.app.model
|
|
||||||
|
|
||||||
# explicit sortfunc
|
|
||||||
grid = self.make_grid()
|
|
||||||
self.assertEqual(grid.sorters, {})
|
|
||||||
sortfunc = lambda data, direction: data
|
|
||||||
grid.set_sorter('foo', sortfunc)
|
|
||||||
self.assertIs(grid.sorters['foo'], sortfunc)
|
|
||||||
|
|
||||||
# auto from model property
|
|
||||||
grid = self.make_grid(model_class=model.Setting, sortable=True, sorters={})
|
|
||||||
self.assertEqual(grid.sorters, {})
|
|
||||||
grid.set_sorter('name', model.Setting.name)
|
|
||||||
self.assertTrue(callable(grid.sorters['name']))
|
|
||||||
|
|
||||||
# auto from column name
|
|
||||||
grid = self.make_grid(model_class=model.Setting, sortable=True, sorters={})
|
|
||||||
self.assertEqual(grid.sorters, {})
|
|
||||||
grid.set_sorter('name', 'name')
|
|
||||||
self.assertTrue(callable(grid.sorters['name']))
|
|
||||||
|
|
||||||
# auto from key
|
|
||||||
grid = self.make_grid(model_class=model.Setting, sortable=True, sorters={})
|
|
||||||
self.assertEqual(grid.sorters, {})
|
|
||||||
grid.set_sorter('name')
|
|
||||||
self.assertTrue(callable(grid.sorters['name']))
|
|
||||||
|
|
||||||
def test_remove_sorter(self):
|
|
||||||
model = self.app.model
|
|
||||||
|
|
||||||
# basics
|
|
||||||
grid = self.make_grid(model_class=model.Setting, sortable=True)
|
|
||||||
self.assertEqual(len(grid.sorters), 2)
|
|
||||||
self.assertIn('name', grid.sorters)
|
|
||||||
self.assertIn('value', grid.sorters)
|
|
||||||
grid.remove_sorter('value')
|
|
||||||
self.assertNotIn('value', grid.sorters)
|
|
||||||
|
|
||||||
def test_set_sort_defaults(self):
|
|
||||||
model = self.app.model
|
|
||||||
grid = self.make_grid(model_class=model.Setting, sortable=True)
|
|
||||||
self.assertEqual(grid.sort_defaults, [])
|
|
||||||
|
|
||||||
# can set just sortkey
|
|
||||||
grid.set_sort_defaults('name')
|
|
||||||
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'asc')])
|
|
||||||
|
|
||||||
# can set sortkey, sortdir
|
|
||||||
grid.set_sort_defaults('name', 'desc')
|
|
||||||
self.assertEqual(grid.sort_defaults, [mod.SortInfo('name', 'desc')])
|
|
||||||
|
|
||||||
# can set sortkey, sortdir as tuple
|
|
||||||
grid.set_sort_defaults(('value', 'asc'))
|
|
||||||
self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')])
|
|
||||||
|
|
||||||
# can set as list
|
|
||||||
grid.sort_multiple = True
|
|
||||||
grid.set_sort_defaults([('value', 'asc'), ('name', 'desc')])
|
|
||||||
self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc'),
|
|
||||||
mod.SortInfo('name', 'desc')])
|
|
||||||
|
|
||||||
# list is pruned if multi-sort disabled
|
|
||||||
grid.sort_multiple = False
|
|
||||||
grid.set_sort_defaults([('value', 'asc'), ('name', 'desc')])
|
|
||||||
self.assertEqual(grid.sort_defaults, [mod.SortInfo('value', 'asc')])
|
|
||||||
|
|
||||||
# error if any other single arg
|
|
||||||
self.assertRaises(ValueError, grid.set_sort_defaults, 42)
|
|
||||||
|
|
||||||
# error if more than 2 args
|
|
||||||
self.assertRaises(ValueError, grid.set_sort_defaults, 'name', 'asc', 'value', 'desc')
|
|
||||||
|
|
||||||
def test_is_sortable(self):
|
|
||||||
model = self.app.model
|
|
||||||
|
|
||||||
# basics, frontend sorting
|
|
||||||
grid = self.make_grid(model_class=model.Setting, sortable=True, sort_on_backend=False)
|
|
||||||
self.assertTrue(grid.is_sortable('name'))
|
|
||||||
self.assertTrue(grid.is_sortable('value'))
|
|
||||||
grid.remove_sorter('value')
|
|
||||||
# nb. columns are always sortable for frontend, despite remove_sorter()
|
|
||||||
self.assertTrue(grid.is_sortable('value'))
|
|
||||||
# nb. when grid is not sortable, no column is either
|
|
||||||
grid.sortable = False
|
|
||||||
self.assertFalse(grid.is_sortable('name'))
|
|
||||||
|
|
||||||
# same test but with backend sorting
|
|
||||||
grid = self.make_grid(model_class=model.Setting, sortable=True, sort_on_backend=True)
|
|
||||||
self.assertTrue(grid.is_sortable('name'))
|
|
||||||
self.assertTrue(grid.is_sortable('value'))
|
|
||||||
grid.remove_sorter('value')
|
|
||||||
self.assertFalse(grid.is_sortable('value'))
|
|
||||||
# nb. when grid is not sortable, no column is either
|
|
||||||
grid.sortable = False
|
|
||||||
self.assertFalse(grid.is_sortable('name'))
|
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
# data methods
|
# data methods
|
||||||
##############################
|
##############################
|
||||||
|
|
||||||
def test_get_visible_data(self):
|
def test_get_visible_data(self):
|
||||||
model = self.app.model
|
data = [
|
||||||
sample_data = [
|
{'foo': 1, 'bar': 1},
|
||||||
{'name': 'foo1', 'value': 'ONE'},
|
{'foo': 2, 'bar': 2},
|
||||||
{'name': 'foo2', 'value': 'two'},
|
{'foo': 3, 'bar': 3},
|
||||||
{'name': 'foo3', 'value': 'three'},
|
{'foo': 4, 'bar': 4},
|
||||||
{'name': 'foo4', 'value': 'four'},
|
{'foo': 5, 'bar': 5},
|
||||||
{'name': 'foo5', 'value': 'five'},
|
{'foo': 6, 'bar': 6},
|
||||||
{'name': 'foo6', 'value': 'six'},
|
{'foo': 7, 'bar': 7},
|
||||||
{'name': 'foo7', 'value': 'seven'},
|
{'foo': 8, 'bar': 8},
|
||||||
{'name': 'foo8', 'value': 'eight'},
|
{'foo': 9, 'bar': 9},
|
||||||
{'name': 'foo9', 'value': 'nine'},
|
|
||||||
]
|
]
|
||||||
for setting in sample_data:
|
grid = self.make_grid(data=data,
|
||||||
self.app.save_setting(self.session, setting['name'], setting['value'])
|
columns=['foo', 'bar'],
|
||||||
self.session.commit()
|
|
||||||
sample_query = self.session.query(model.Setting)
|
|
||||||
|
|
||||||
# data is sorted and paginated
|
|
||||||
grid = self.make_grid(model_class=model.Setting,
|
|
||||||
data=sample_query,
|
|
||||||
sortable=True, sort_on_backend=True,
|
|
||||||
sort_defaults=('name', 'desc'),
|
|
||||||
paginated=True, paginate_on_backend=True,
|
paginated=True, paginate_on_backend=True,
|
||||||
pagesize=4, page=2)
|
pagesize=4, page=2)
|
||||||
grid.load_settings()
|
|
||||||
visible = grid.get_visible_data()
|
visible = grid.get_visible_data()
|
||||||
self.assertEqual([s.name for s in visible], ['foo5', 'foo4', 'foo3', 'foo2'])
|
self.assertEqual(len(visible), 4)
|
||||||
|
self.assertEqual(visible[0], {'foo': 5, 'bar': 5})
|
||||||
def test_sort_data(self):
|
|
||||||
model = self.app.model
|
|
||||||
sample_data = [
|
|
||||||
{'name': 'foo1', 'value': 'ONE'},
|
|
||||||
{'name': 'foo2', 'value': 'two'},
|
|
||||||
{'name': 'foo3', 'value': 'ggg'},
|
|
||||||
{'name': 'foo4', 'value': 'ggg'},
|
|
||||||
{'name': 'foo5', 'value': 'ggg'},
|
|
||||||
{'name': 'foo6', 'value': 'six'},
|
|
||||||
{'name': 'foo7', 'value': 'seven'},
|
|
||||||
{'name': 'foo8', 'value': 'eight'},
|
|
||||||
{'name': 'foo9', 'value': 'nine'},
|
|
||||||
]
|
|
||||||
for setting in sample_data:
|
|
||||||
self.app.save_setting(self.session, setting['name'], setting['value'])
|
|
||||||
self.session.commit()
|
|
||||||
sample_query = self.session.query(model.Setting)
|
|
||||||
|
|
||||||
grid = self.make_grid(model_class=model.Setting,
|
|
||||||
sortable=True, sort_on_backend=True,
|
|
||||||
sort_defaults=('name', 'desc'))
|
|
||||||
grid.load_settings()
|
|
||||||
|
|
||||||
# can sort a simple list of data
|
|
||||||
sorted_data = grid.sort_data(sample_data)
|
|
||||||
self.assertIsInstance(sorted_data, list)
|
|
||||||
self.assertEqual(len(sorted_data), 9)
|
|
||||||
self.assertEqual(sorted_data[0]['name'], 'foo9')
|
|
||||||
self.assertEqual(sorted_data[-1]['name'], 'foo1')
|
|
||||||
|
|
||||||
# can also sort a data query
|
|
||||||
sorted_query = grid.sort_data(sample_query)
|
|
||||||
self.assertIsInstance(sorted_query, orm.Query)
|
|
||||||
sorted_data = sorted_query.all()
|
|
||||||
self.assertEqual(len(sorted_data), 9)
|
|
||||||
self.assertEqual(sorted_data[0]['name'], 'foo9')
|
|
||||||
self.assertEqual(sorted_data[-1]['name'], 'foo1')
|
|
||||||
|
|
||||||
# cannot sort data if sorter missing in overrides
|
|
||||||
sorted_data = grid.sort_data(sample_data, sorters=[])
|
|
||||||
# nb. sorted data is in same order as original sample (not sorted)
|
|
||||||
self.assertEqual(sorted_data[0]['name'], 'foo1')
|
|
||||||
self.assertEqual(sorted_data[-1]['name'], 'foo9')
|
|
||||||
|
|
||||||
# multi-column sorting for list data
|
|
||||||
sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'},
|
|
||||||
{'key': 'name', 'dir': 'asc'}])
|
|
||||||
self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
|
|
||||||
self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'})
|
|
||||||
self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'})
|
|
||||||
self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'})
|
|
||||||
|
|
||||||
# multi-column sorting for query
|
|
||||||
sorted_query = grid.sort_data(sample_query, sorters=[{'key': 'value', 'dir': 'asc'},
|
|
||||||
{'key': 'name', 'dir': 'asc'}])
|
|
||||||
self.assertEqual(dict(sorted_data[0]), {'name': 'foo8', 'value': 'eight'})
|
|
||||||
self.assertEqual(dict(sorted_data[1]), {'name': 'foo3', 'value': 'ggg'})
|
|
||||||
self.assertEqual(dict(sorted_data[3]), {'name': 'foo5', 'value': 'ggg'})
|
|
||||||
self.assertEqual(dict(sorted_data[-1]), {'name': 'foo2', 'value': 'two'})
|
|
||||||
|
|
||||||
# cannot sort data if sortfunc is missing for column
|
|
||||||
grid.remove_sorter('name')
|
|
||||||
sorted_data = grid.sort_data(sample_data, sorters=[{'key': 'value', 'dir': 'asc'},
|
|
||||||
{'key': 'name', 'dir': 'asc'}])
|
|
||||||
# nb. sorted data is in same order as original sample (not sorted)
|
|
||||||
self.assertEqual(sorted_data[0]['name'], 'foo1')
|
|
||||||
self.assertEqual(sorted_data[-1]['name'], 'foo9')
|
|
||||||
|
|
||||||
def test_paginate_data(self):
|
def test_paginate_data(self):
|
||||||
model = self.app.model
|
grid = self.make_grid()
|
||||||
sample_data = [
|
pager = grid.paginate_data([])
|
||||||
{'name': 'foo1', 'value': 'ONE'},
|
|
||||||
{'name': 'foo2', 'value': 'two'},
|
|
||||||
{'name': 'foo3', 'value': 'three'},
|
|
||||||
{'name': 'foo4', 'value': 'four'},
|
|
||||||
{'name': 'foo5', 'value': 'five'},
|
|
||||||
{'name': 'foo6', 'value': 'six'},
|
|
||||||
{'name': 'foo7', 'value': 'seven'},
|
|
||||||
{'name': 'foo8', 'value': 'eight'},
|
|
||||||
{'name': 'foo9', 'value': 'nine'},
|
|
||||||
]
|
|
||||||
for setting in sample_data:
|
|
||||||
self.app.save_setting(self.session, setting['name'], setting['value'])
|
|
||||||
self.session.commit()
|
|
||||||
sample_query = self.session.query(model.Setting)
|
|
||||||
|
|
||||||
# basic list pager
|
|
||||||
grid = self.make_grid(paginated=True, paginate_on_backend=True)
|
|
||||||
pager = grid.paginate_data(sample_data)
|
|
||||||
self.assertIsInstance(pager, Page)
|
self.assertIsInstance(pager, Page)
|
||||||
|
|
||||||
# basic query pager
|
|
||||||
grid = self.make_grid(paginated=True, paginate_on_backend=True)
|
|
||||||
pager = grid.paginate_data(sample_query)
|
|
||||||
self.assertIsInstance(pager, SqlalchemyOrmPage)
|
|
||||||
|
|
||||||
# page is reset to 1 for empty data
|
|
||||||
self.request.session['grid.foo.page'] = 2
|
|
||||||
grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True)
|
|
||||||
grid.load_settings()
|
|
||||||
self.assertEqual(grid.page, 2)
|
|
||||||
self.assertEqual(self.request.session['grid.foo.page'], 2)
|
|
||||||
pager = grid.paginate_data(sample_data)
|
|
||||||
self.assertEqual(pager.page, 1)
|
|
||||||
self.assertEqual(grid.page, 1)
|
|
||||||
self.assertEqual(self.request.session['grid.foo.page'], 1)
|
|
||||||
|
|
||||||
##############################
|
##############################
|
||||||
# rendering methods
|
# rendering methods
|
||||||
##############################
|
##############################
|
||||||
|
@ -868,24 +295,6 @@ class TestGrid(WebTestCase):
|
||||||
self.assertEqual(first['field'], 'foo')
|
self.assertEqual(first['field'], 'foo')
|
||||||
self.assertEqual(first['label'], 'Foo')
|
self.assertEqual(first['label'], 'Foo')
|
||||||
|
|
||||||
def test_get_vue_active_sorters(self):
|
|
||||||
model = self.app.model
|
|
||||||
|
|
||||||
# empty
|
|
||||||
grid = self.make_grid(key='foo', sortable=True, sort_on_backend=True)
|
|
||||||
grid.load_settings()
|
|
||||||
sorters = grid.get_vue_active_sorters()
|
|
||||||
self.assertEqual(sorters, [])
|
|
||||||
|
|
||||||
# format is different
|
|
||||||
grid = self.make_grid(key='settings', model_class=model.Setting,
|
|
||||||
sortable=True, sort_on_backend=True,
|
|
||||||
sort_defaults='name')
|
|
||||||
grid.load_settings()
|
|
||||||
self.assertEqual(grid.active_sorters, [{'key': 'name', 'dir': 'asc'}])
|
|
||||||
sorters = grid.get_vue_active_sorters()
|
|
||||||
self.assertEqual(sorters, [{'field': 'name', 'order': 'asc'}])
|
|
||||||
|
|
||||||
def test_get_vue_data(self):
|
def test_get_vue_data(self):
|
||||||
|
|
||||||
# empty if no columns defined
|
# empty if no columns defined
|
||||||
|
@ -902,7 +311,7 @@ class TestGrid(WebTestCase):
|
||||||
self.assertEqual(data, [{'foo': 'bar'}])
|
self.assertEqual(data, [{'foo': 'bar'}])
|
||||||
|
|
||||||
# if grid has actions, that list may be supplemented
|
# if grid has actions, that list may be supplemented
|
||||||
grid.actions.append(mod.GridAction(self.request, 'view', url='/blarg'))
|
grid.actions.append(base.GridAction(self.request, 'view', url='/blarg'))
|
||||||
data = grid.get_vue_data()
|
data = grid.get_vue_data()
|
||||||
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'}])
|
||||||
|
@ -942,7 +351,7 @@ class TestGridAction(TestCase):
|
||||||
self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
|
self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False)
|
||||||
|
|
||||||
def make_action(self, key, **kwargs):
|
def make_action(self, key, **kwargs):
|
||||||
return mod.GridAction(self.request, key, **kwargs)
|
return base.GridAction(self.request, key, **kwargs)
|
||||||
|
|
||||||
def test_render_icon(self):
|
def test_render_icon(self):
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,6 @@ import functools
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from sqlalchemy import orm
|
|
||||||
from pyramid import testing
|
from pyramid import testing
|
||||||
from pyramid.response import Response
|
from pyramid.response import Response
|
||||||
from pyramid.httpexceptions import HTTPNotFound
|
from pyramid.httpexceptions import HTTPNotFound
|
||||||
|
@ -519,13 +518,11 @@ class TestMasterView(WebTestCase):
|
||||||
data = view.get_grid_data(session=self.session)
|
data = view.get_grid_data(session=self.session)
|
||||||
self.assertEqual(data, [])
|
self.assertEqual(data, [])
|
||||||
|
|
||||||
# grid with model class will produce data query
|
# basic logic with Setting model
|
||||||
with patch.multiple(master.MasterView, create=True,
|
with patch.multiple(master.MasterView, create=True,
|
||||||
model_class=model.Setting):
|
model_class=model.Setting):
|
||||||
view = master.MasterView(self.request)
|
view = master.MasterView(self.request)
|
||||||
query = view.get_grid_data(session=self.session)
|
data = view.get_grid_data(session=self.session)
|
||||||
self.assertIsInstance(query, orm.Query)
|
|
||||||
data = query.all()
|
|
||||||
self.assertEqual(len(data), 1)
|
self.assertEqual(len(data), 1)
|
||||||
self.assertIs(data[0], setting)
|
self.assertIs(data[0], setting)
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,9 @@
|
||||||
|
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import colander
|
|
||||||
from pyramid.httpexceptions import HTTPNotFound
|
from pyramid.httpexceptions import HTTPNotFound
|
||||||
|
|
||||||
from wuttaweb.views import settings as mod
|
from wuttaweb.views import settings
|
||||||
from tests.util import WebTestCase
|
from tests.util import WebTestCase
|
||||||
|
|
||||||
|
|
||||||
|
@ -16,7 +15,7 @@ class TestAppInfoView(WebTestCase):
|
||||||
self.pyramid_config.include('wuttaweb.views.essential')
|
self.pyramid_config.include('wuttaweb.views.essential')
|
||||||
|
|
||||||
def make_view(self):
|
def make_view(self):
|
||||||
return mod.AppInfoView(self.request)
|
return settings.AppInfoView(self.request)
|
||||||
|
|
||||||
def test_index(self):
|
def test_index(self):
|
||||||
# sanity/coverage check
|
# sanity/coverage check
|
||||||
|
@ -37,21 +36,19 @@ class TestAppInfoView(WebTestCase):
|
||||||
class TestSettingView(WebTestCase):
|
class TestSettingView(WebTestCase):
|
||||||
|
|
||||||
def make_view(self):
|
def make_view(self):
|
||||||
return mod.SettingView(self.request)
|
return settings.SettingView(self.request)
|
||||||
|
|
||||||
def test_get_grid_data(self):
|
def test_get_grid_data(self):
|
||||||
|
|
||||||
# empty data by default
|
# empty data by default
|
||||||
view = self.make_view()
|
view = self.make_view()
|
||||||
query = view.get_grid_data(session=self.session)
|
data = view.get_grid_data(session=self.session)
|
||||||
data = query.all()
|
|
||||||
self.assertEqual(len(data), 0)
|
self.assertEqual(len(data), 0)
|
||||||
|
|
||||||
# unless we save some settings
|
# unless we save some settings
|
||||||
self.app.save_setting(self.session, 'foo', 'bar')
|
self.app.save_setting(self.session, 'foo', 'bar')
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
query = view.get_grid_data(session=self.session)
|
data = view.get_grid_data(session=self.session)
|
||||||
data = query.all()
|
|
||||||
self.assertEqual(len(data), 1)
|
self.assertEqual(len(data), 1)
|
||||||
|
|
||||||
def test_configure_form(self):
|
def test_configure_form(self):
|
||||||
|
@ -61,23 +58,3 @@ class TestSettingView(WebTestCase):
|
||||||
view.configure_form(form)
|
view.configure_form(form)
|
||||||
self.assertIn('value', form.required_fields)
|
self.assertIn('value', form.required_fields)
|
||||||
self.assertFalse(form.required_fields['value'])
|
self.assertFalse(form.required_fields['value'])
|
||||||
|
|
||||||
def test_unique_name(self):
|
|
||||||
model = self.app.model
|
|
||||||
view = self.make_view()
|
|
||||||
|
|
||||||
setting = model.Setting(name='foo')
|
|
||||||
self.session.add(setting)
|
|
||||||
self.session.commit()
|
|
||||||
|
|
||||||
with patch.object(view, 'Session', return_value=self.session):
|
|
||||||
|
|
||||||
# invalid if same name in data
|
|
||||||
node = colander.SchemaNode(colander.String(), name='name')
|
|
||||||
self.assertRaises(colander.Invalid, view.unique_name, node, 'foo')
|
|
||||||
|
|
||||||
# but not if name belongs to current setting
|
|
||||||
view.editing = True
|
|
||||||
self.request.matchdict = {'name': 'foo'}
|
|
||||||
node = colander.SchemaNode(colander.String(), name='name')
|
|
||||||
self.assertIsNone(view.unique_name(node, 'foo'))
|
|
||||||
|
|
Loading…
Reference in a new issue