
hoping to eventually replace the 'default' view with this one, if all goes well. definitely needs more testing and is not exposed as an option yet, unless configured
1791 lines
66 KiB
Python
1791 lines
66 KiB
Python
# -*- coding: utf-8; -*-
|
|
################################################################################
|
|
#
|
|
# Rattail -- Retail Software Framework
|
|
# Copyright © 2010-2024 Lance Edgar
|
|
#
|
|
# This file is part of Rattail.
|
|
#
|
|
# Rattail is free software: you can redistribute it and/or modify it under the
|
|
# terms of the GNU General Public License as published by the Free Software
|
|
# Foundation, either version 3 of the License, or (at your option) any later
|
|
# version.
|
|
#
|
|
# Rattail is distributed in the hope that it will be useful, but WITHOUT ANY
|
|
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
|
|
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
|
|
# details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License along with
|
|
# Rattail. If not, see <http://www.gnu.org/licenses/>.
|
|
#
|
|
################################################################################
|
|
"""
|
|
Core Grid Classes
|
|
"""
|
|
|
|
from urllib.parse import urlencode
|
|
import warnings
|
|
import logging
|
|
|
|
import sqlalchemy as sa
|
|
from sqlalchemy import orm
|
|
|
|
from wuttjamaican.util import UNSPECIFIED
|
|
from rattail.db.types import GPCType
|
|
from rattail.util import prettify, pretty_boolean
|
|
|
|
from pyramid.renderers import render
|
|
from webhelpers2.html import HTML, tags
|
|
from paginate_sqlalchemy import SqlalchemyOrmPage
|
|
|
|
from wuttaweb.grids import Grid as WuttaGrid, GridAction as WuttaGridAction, SortInfo
|
|
from wuttaweb.util import FieldList
|
|
from . import filters as gridfilters
|
|
from tailbone.db import Session
|
|
from tailbone.util import raw_datetime
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class Grid(WuttaGrid):
|
|
"""
|
|
Base class for all grids.
|
|
|
|
This is now a subclass of
|
|
:class:`wuttaweb:wuttaweb.grids.base.Grid`, and exists to add
|
|
customizations which have traditionally been part of Tailbone.
|
|
|
|
Some of these customizations are still undocumented. Some will
|
|
eventually be moved to the upstream/parent class, and possibly
|
|
some will be removed outright. What docs we have, are shown here.
|
|
|
|
.. _Buefy docs: https://buefy.org/documentation/table/
|
|
|
|
.. attribute:: checkable
|
|
|
|
Optional callback to determine if a given row is checkable,
|
|
i.e. this allows hiding checkbox for certain rows if needed.
|
|
|
|
This may be either a Python callable, or string representing a
|
|
JS callable. If the latter, according to the `Buefy docs`_:
|
|
|
|
.. code-block:: none
|
|
|
|
Custom method to verify if a row is checkable, works when is
|
|
checkable.
|
|
|
|
Function (row: Object)
|
|
|
|
In other words this JS callback would be invoked for each data
|
|
row in the client-side grid.
|
|
|
|
But if a Python callable is used, then it will be invoked for
|
|
each row object in the server-side grid. For instance::
|
|
|
|
def checkable(obj):
|
|
if obj.some_property == True:
|
|
return True
|
|
return False
|
|
|
|
grid.checkable = checkable
|
|
|
|
.. attribute:: check_handler
|
|
|
|
Optional JS callback for the ``@check`` event of the underlying
|
|
Buefy table component. See the `Buefy docs`_ for more info,
|
|
but for convenience they say this (as of writing):
|
|
|
|
.. code-block:: none
|
|
|
|
Triggers when the checkbox in a row is clicked and/or when
|
|
the header checkbox is clicked
|
|
|
|
For instance, you might set ``grid.check_handler =
|
|
'rowChecked'`` and then define the handler within your template
|
|
(e.g. ``/widgets/index.mako``) like so:
|
|
|
|
.. code-block:: none
|
|
|
|
<%def name="modify_this_page_vars()">
|
|
${parent.modify_this_page_vars()}
|
|
<script type="text/javascript">
|
|
|
|
TailboneGrid.methods.rowChecked = function(checkedList, row) {
|
|
if (!row) {
|
|
console.log("no row, so header checkbox was clicked")
|
|
} else {
|
|
console.log(row)
|
|
if (checkedList.includes(row)) {
|
|
console.log("clicking row checkbox ON")
|
|
} else {
|
|
console.log("clicking row checkbox OFF")
|
|
}
|
|
}
|
|
console.log(checkedList)
|
|
}
|
|
|
|
</script>
|
|
</%def>
|
|
|
|
.. attribute:: raw_renderers
|
|
|
|
Dict of "raw" field renderers. See also
|
|
:meth:`set_raw_renderer()`.
|
|
|
|
When present, these are rendered "as-is" into the grid
|
|
template, whereas the more typical scenario involves rendering
|
|
each field "into" a span element, like:
|
|
|
|
.. code-block:: html
|
|
|
|
<span v-html="RENDERED-FIELD"></span>
|
|
|
|
So instead of injecting into a span, any "raw" fields defined
|
|
via this dict, will be injected as-is, like:
|
|
|
|
.. code-block:: html
|
|
|
|
RENDERED-FIELD
|
|
|
|
Note that each raw renderer is called only once, and *without*
|
|
any arguments. Likely the only use case for this, is to inject
|
|
a Vue component into the field. A basic example::
|
|
|
|
from webhelpers2.html import HTML
|
|
|
|
def myrender():
|
|
return HTML.tag('my-component', **{'v-model': 'props.row.myfield'})
|
|
|
|
grid = Grid(
|
|
# ..normal constructor args here..
|
|
|
|
raw_renderers={
|
|
'myfield': myrender,
|
|
},
|
|
)
|
|
|
|
.. attribute row_uuid_getter::
|
|
|
|
Optional callable to obtain the "UUID" (sic) value for each
|
|
data row. The default assumption as that each row object has a
|
|
``uuid`` attribute, but when that isn't the case, *and* the
|
|
grid needs to support checkboxes, we must "pretend" by
|
|
injecting some custom value to the ``uuid`` of the row data.
|
|
|
|
If necssary, set this to a callable like so::
|
|
|
|
def fake_uuid(row):
|
|
return row.some_custom_key
|
|
|
|
grid.row_uuid_getter = fake_uuid
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
request,
|
|
key=None,
|
|
data=None,
|
|
width='auto',
|
|
model_title=None,
|
|
model_title_plural=None,
|
|
enums={},
|
|
assume_local_times=False,
|
|
invisible=[],
|
|
raw_renderers={},
|
|
extra_row_class=None,
|
|
url='#',
|
|
joiners={},
|
|
filterable=False,
|
|
filters={},
|
|
use_byte_string_filters=False,
|
|
searchable={},
|
|
checkboxes=False,
|
|
checked=None,
|
|
check_handler=None,
|
|
check_all_handler=None,
|
|
checkable=None,
|
|
row_uuid_getter=None,
|
|
clicking_row_checks_box=False,
|
|
click_handlers=None,
|
|
main_actions=[],
|
|
more_actions=[],
|
|
delete_speedbump=False,
|
|
ajax_data_url=None,
|
|
expose_direct_link=False,
|
|
**kwargs,
|
|
):
|
|
if 'component' in kwargs:
|
|
warnings.warn("component param is deprecated for Grid(); "
|
|
"please use vue_tagname param instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
kwargs.setdefault('vue_tagname', kwargs.pop('component'))
|
|
|
|
if 'default_sortkey' in kwargs:
|
|
warnings.warn("default_sortkey param is deprecated for Grid(); "
|
|
"please use sort_defaults param instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
if 'default_sortdir' in kwargs:
|
|
warnings.warn("default_sortdir param is deprecated for Grid(); "
|
|
"please use sort_defaults param instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
if 'default_sortkey' in kwargs or 'default_sortdir' in kwargs:
|
|
sortkey = kwargs.pop('default_sortkey', None)
|
|
sortdir = kwargs.pop('default_sortdir', 'asc')
|
|
if sortkey:
|
|
kwargs.setdefault('sort_defaults', [(sortkey, sortdir)])
|
|
|
|
if 'pageable' in kwargs:
|
|
warnings.warn("pageable param is deprecated for Grid(); "
|
|
"please use vue_tagname param instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
kwargs.setdefault('paginated', kwargs.pop('pageable'))
|
|
|
|
if 'default_pagesize' in kwargs:
|
|
warnings.warn("default_pagesize param is deprecated for Grid(); "
|
|
"please use pagesize param instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
kwargs.setdefault('pagesize', kwargs.pop('default_pagesize'))
|
|
|
|
if 'default_page' in kwargs:
|
|
warnings.warn("default_page param is deprecated for Grid(); "
|
|
"please use page param instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
kwargs.setdefault('page', kwargs.pop('default_page'))
|
|
|
|
# TODO: this should not be needed once all templates correctly
|
|
# reference grid.vue_component etc.
|
|
kwargs.setdefault('vue_tagname', 'tailbone-grid')
|
|
|
|
kwargs['key'] = key
|
|
kwargs['data'] = data
|
|
super().__init__(request, **kwargs)
|
|
|
|
self.model_title = model_title
|
|
if not self.model_title and self.model_class and hasattr(self.model_class, 'get_model_title'):
|
|
self.model_title = self.model_class.get_model_title()
|
|
|
|
self.model_title_plural = model_title_plural
|
|
if not self.model_title_plural:
|
|
if self.model_class and hasattr(self.model_class, 'get_model_title_plural'):
|
|
self.model_title_plural = self.model_class.get_model_title_plural()
|
|
if not self.model_title_plural:
|
|
self.model_title_plural = '{}s'.format(self.model_title)
|
|
|
|
self.width = width
|
|
self.enums = enums or {}
|
|
self.assume_local_times = assume_local_times
|
|
self.renderers = self.make_default_renderers(self.renderers)
|
|
self.raw_renderers = raw_renderers or {}
|
|
self.invisible = invisible or []
|
|
self.extra_row_class = extra_row_class
|
|
self.url = url
|
|
self.joiners = joiners or {}
|
|
|
|
self.filterable = filterable
|
|
self.use_byte_string_filters = use_byte_string_filters
|
|
self.filters = self.make_filters(filters)
|
|
|
|
self.searchable = searchable or {}
|
|
|
|
self.checkboxes = checkboxes
|
|
self.checked = checked
|
|
if self.checked is None:
|
|
self.checked = lambda item: False
|
|
self.check_handler = check_handler
|
|
self.check_all_handler = check_all_handler
|
|
self.checkable = checkable
|
|
self.row_uuid_getter = row_uuid_getter
|
|
self.clicking_row_checks_box = clicking_row_checks_box
|
|
|
|
self.click_handlers = click_handlers or {}
|
|
|
|
self.delete_speedbump = delete_speedbump
|
|
|
|
if ajax_data_url:
|
|
self.ajax_data_url = ajax_data_url
|
|
elif self.request:
|
|
self.ajax_data_url = self.request.path_url
|
|
else:
|
|
self.ajax_data_url = ''
|
|
|
|
self.main_actions = main_actions or []
|
|
if self.main_actions:
|
|
warnings.warn("main_actions param is deprecated for Grdi(); "
|
|
"please use actions param instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
self.actions.extend(self.main_actions)
|
|
self.more_actions = more_actions or []
|
|
if self.more_actions:
|
|
warnings.warn("more_actions param is deprecated for Grdi(); "
|
|
"please use actions param instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
self.actions.extend(self.more_actions)
|
|
|
|
self.expose_direct_link = expose_direct_link
|
|
self._whgrid_kwargs = kwargs
|
|
|
|
@property
|
|
def component(self):
|
|
""" """
|
|
warnings.warn("Grid.component is deprecated; "
|
|
"please use vue_tagname instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
return self.vue_tagname
|
|
|
|
@property
|
|
def component_studly(self):
|
|
""" """
|
|
warnings.warn("Grid.component_studly is deprecated; "
|
|
"please use vue_component instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
return self.vue_component
|
|
|
|
def get_default_sortkey(self):
|
|
""" """
|
|
warnings.warn("Grid.default_sortkey is deprecated; "
|
|
"please use Grid.sort_defaults instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
if self.sort_defaults:
|
|
return self.sort_defaults[0].sortkey
|
|
|
|
def set_default_sortkey(self, value):
|
|
""" """
|
|
warnings.warn("Grid.default_sortkey is deprecated; "
|
|
"please use Grid.sort_defaults instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
if self.sort_defaults:
|
|
info = self.sort_defaults[0]
|
|
self.sort_defaults[0] = SortInfo(value, info.sortdir)
|
|
else:
|
|
self.sort_defaults = [SortInfo(value, 'asc')]
|
|
|
|
default_sortkey = property(get_default_sortkey, set_default_sortkey)
|
|
|
|
def get_default_sortdir(self):
|
|
""" """
|
|
warnings.warn("Grid.default_sortdir is deprecated; "
|
|
"please use Grid.sort_defaults instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
if self.sort_defaults:
|
|
return self.sort_defaults[0].sortdir
|
|
|
|
def set_default_sortdir(self, value):
|
|
""" """
|
|
warnings.warn("Grid.default_sortdir is deprecated; "
|
|
"please use Grid.sort_defaults instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
if self.sort_defaults:
|
|
info = self.sort_defaults[0]
|
|
self.sort_defaults[0] = SortInfo(info.sortkey, value)
|
|
else:
|
|
raise ValueError("cannot set default_sortdir without default_sortkey")
|
|
|
|
default_sortdir = property(get_default_sortdir, set_default_sortdir)
|
|
|
|
def get_pageable(self):
|
|
""" """
|
|
warnings.warn("Grid.pageable is deprecated; "
|
|
"please use Grid.paginated instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
return self.paginated
|
|
|
|
def set_pageable(self, value):
|
|
""" """
|
|
warnings.warn("Grid.pageable is deprecated; "
|
|
"please use Grid.paginated instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
self.paginated = value
|
|
|
|
pageable = property(get_pageable, set_pageable)
|
|
|
|
def hide_column(self, key):
|
|
"""
|
|
This *removes* a column from the grid, altogether.
|
|
|
|
This method is deprecated; use :meth:`remove()` instead.
|
|
"""
|
|
warnings.warn("Grid.hide_column() is deprecated; please use "
|
|
"Grid.remove() instead.",
|
|
DeprecationWarning, stacklevel=2)
|
|
self.remove(key)
|
|
|
|
def hide_columns(self, *keys):
|
|
"""
|
|
This *removes* columns from the grid, altogether.
|
|
|
|
This method is deprecated; use :meth:`remove()` instead.
|
|
"""
|
|
self.remove(*keys)
|
|
|
|
def set_invisible(self, key, invisible=True):
|
|
"""
|
|
Mark the given column as "invisible" (but do not remove it).
|
|
|
|
Use :meth:`remove()` if you actually want to remove it.
|
|
"""
|
|
if invisible:
|
|
if key not in self.invisible:
|
|
self.invisible.append(key)
|
|
else:
|
|
if key in self.invisible:
|
|
self.invisible.remove(key)
|
|
|
|
def insert_before(self, field, newfield):
|
|
self.columns.insert_before(field, newfield)
|
|
|
|
def insert_after(self, field, newfield):
|
|
self.columns.insert_after(field, newfield)
|
|
|
|
def replace(self, oldfield, newfield):
|
|
self.insert_after(oldfield, newfield)
|
|
self.remove(oldfield)
|
|
|
|
def set_joiner(self, key, joiner):
|
|
if joiner is None:
|
|
self.joiners.pop(key, None)
|
|
else:
|
|
self.joiners[key] = joiner
|
|
|
|
def set_sorter(self, key, *args, **kwargs):
|
|
""" """
|
|
|
|
if len(args) == 1:
|
|
if kwargs:
|
|
warnings.warn("kwargs are ignored for Grid.set_sorter(); "
|
|
"please refactor your code accordingly",
|
|
DeprecationWarning, stacklevel=2)
|
|
if args[0] is None:
|
|
warnings.warn("specifying None is deprecated for Grid.set_sorter(); "
|
|
"please use Grid.remove_sorter() instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
self.remove_sorter(key)
|
|
else:
|
|
super().set_sorter(key, args[0])
|
|
|
|
elif len(args) == 0:
|
|
super().set_sorter(key)
|
|
|
|
else:
|
|
warnings.warn("multiple args are deprecated for Grid.set_sorter(); "
|
|
"please refactor your code accordingly",
|
|
DeprecationWarning, stacklevel=2)
|
|
self.sorters[key] = self.make_sorter(*args, **kwargs)
|
|
|
|
def set_filter(self, key, *args, **kwargs):
|
|
if len(args) == 1 and args[0] is None:
|
|
self.remove_filter(key)
|
|
else:
|
|
if 'label' not in kwargs and key in self.labels:
|
|
kwargs['label'] = self.labels[key]
|
|
self.filters[key] = self.make_filter(key, *args, **kwargs)
|
|
|
|
def set_searchable(self, key, searchable=True):
|
|
if searchable:
|
|
self.searchable[key] = True
|
|
else:
|
|
self.searchable.pop(key, None)
|
|
|
|
def is_searchable(self, key):
|
|
return self.searchable.get(key, False)
|
|
|
|
def remove_filter(self, key):
|
|
self.filters.pop(key, None)
|
|
|
|
def set_label(self, key, label, column_only=False):
|
|
"""
|
|
Set/override the label for a column.
|
|
|
|
This overrides
|
|
:meth:`~wuttaweb:wuttaweb.grids.base.Grid.set_label()` to add
|
|
the following params:
|
|
|
|
:param column_only: Boolean indicating whether the label
|
|
should be applied *only* to the column header (if
|
|
``True``), vs. applying also to the filter (if ``False``).
|
|
"""
|
|
super().set_label(key, label)
|
|
|
|
if not column_only and key in self.filters:
|
|
self.filters[key].label = label
|
|
|
|
def set_click_handler(self, key, handler):
|
|
if handler:
|
|
self.click_handlers[key] = handler
|
|
else:
|
|
self.click_handlers.pop(key, None)
|
|
|
|
def has_click_handler(self, key):
|
|
return key in self.click_handlers
|
|
|
|
def set_raw_renderer(self, key, renderer):
|
|
"""
|
|
Set or remove the "raw" renderer for the given field.
|
|
|
|
See :attr:`raw_renderers` for more about these.
|
|
|
|
:param key: Field name.
|
|
|
|
:param renderer: Either a renderer callable, or ``None``.
|
|
"""
|
|
if renderer:
|
|
self.raw_renderers[key] = renderer
|
|
else:
|
|
self.raw_renderers.pop(key, None)
|
|
|
|
def set_type(self, key, type_):
|
|
if type_ == 'boolean':
|
|
self.set_renderer(key, self.render_boolean)
|
|
elif type_ == 'currency':
|
|
self.set_renderer(key, self.render_currency)
|
|
elif type_ == 'datetime':
|
|
self.set_renderer(key, self.render_datetime)
|
|
elif type_ == 'datetime_local':
|
|
self.set_renderer(key, self.render_datetime_local)
|
|
elif type_ == 'enum':
|
|
self.set_renderer(key, self.render_enum)
|
|
elif type_ == 'gpc':
|
|
self.set_renderer(key, self.render_gpc)
|
|
elif type_ == 'percent':
|
|
self.set_renderer(key, self.render_percent)
|
|
elif type_ == 'quantity':
|
|
self.set_renderer(key, self.render_quantity)
|
|
elif type_ == 'duration':
|
|
self.set_renderer(key, self.render_duration)
|
|
elif type_ == 'duration_hours':
|
|
self.set_renderer(key, self.render_duration_hours)
|
|
else:
|
|
raise ValueError("Unsupported type for column '{}': {}".format(key, type_))
|
|
|
|
def set_enum(self, key, enum):
|
|
if enum:
|
|
self.enums[key] = enum
|
|
self.set_type(key, 'enum')
|
|
if key in self.filters:
|
|
self.filters[key].set_value_renderer(gridfilters.EnumValueRenderer(enum))
|
|
else:
|
|
self.enums.pop(key, None)
|
|
|
|
def render_generic(self, obj, column_name):
|
|
return self.obtain_value(obj, column_name)
|
|
|
|
def render_boolean(self, obj, column_name):
|
|
value = self.obtain_value(obj, column_name)
|
|
return pretty_boolean(value)
|
|
|
|
def obtain_value(self, obj, column_name):
|
|
"""
|
|
Try to obtain and return the value from the given object, for
|
|
the given column name.
|
|
|
|
:returns: The value, or ``None`` if no value was found.
|
|
"""
|
|
# TODO: this seems a little hacky, is there a better way?
|
|
# nb. this may only be relevant for import/export batch view?
|
|
if isinstance(obj, sa.engine.Row):
|
|
return obj._mapping[column_name]
|
|
|
|
if isinstance(obj, dict):
|
|
return obj[column_name]
|
|
|
|
try:
|
|
return getattr(obj, column_name)
|
|
except AttributeError:
|
|
pass
|
|
return obj[column_name]
|
|
|
|
def render_currency(self, obj, column_name):
|
|
value = self.obtain_value(obj, column_name)
|
|
if value is None:
|
|
return ""
|
|
if value < 0:
|
|
return "(${:0,.2f})".format(0 - value)
|
|
return "${:0,.2f}".format(value)
|
|
|
|
def render_datetime(self, obj, column_name):
|
|
value = self.obtain_value(obj, column_name)
|
|
if value is None:
|
|
return ""
|
|
return raw_datetime(self.request.rattail_config, value)
|
|
|
|
def render_datetime_local(self, obj, column_name):
|
|
value = self.obtain_value(obj, column_name)
|
|
if value is None:
|
|
return ""
|
|
app = self.request.rattail_config.get_app()
|
|
value = app.localtime(value)
|
|
return raw_datetime(self.request.rattail_config, value)
|
|
|
|
def render_enum(self, obj, column_name):
|
|
value = self.obtain_value(obj, column_name)
|
|
if value is None:
|
|
return ""
|
|
enum = self.enums.get(column_name)
|
|
if enum and value in enum:
|
|
return str(enum[value])
|
|
return str(value)
|
|
|
|
def render_gpc(self, obj, column_name):
|
|
value = self.obtain_value(obj, column_name)
|
|
if value is None:
|
|
return ""
|
|
return value.pretty()
|
|
|
|
def render_percent(self, obj, column_name):
|
|
app = self.request.rattail_config.get_app()
|
|
value = self.obtain_value(obj, column_name)
|
|
return app.render_percent(value, places=3)
|
|
|
|
def render_quantity(self, obj, column_name):
|
|
value = self.obtain_value(obj, column_name)
|
|
app = self.request.rattail_config.get_app()
|
|
return app.render_quantity(value)
|
|
|
|
def render_duration(self, obj, column_name):
|
|
seconds = self.obtain_value(obj, column_name)
|
|
if seconds is None:
|
|
return ""
|
|
app = self.request.rattail_config.get_app()
|
|
return app.render_duration(seconds=seconds)
|
|
|
|
def render_duration_hours(self, obj, field):
|
|
value = self.obtain_value(obj, field)
|
|
if value is None:
|
|
return ""
|
|
app = self.request.rattail_config.get_app()
|
|
return app.render_duration(hours=value)
|
|
|
|
def set_url(self, url):
|
|
self.url = url
|
|
|
|
def make_url(self, obj, i=None):
|
|
if callable(self.url):
|
|
return self.url(obj)
|
|
return self.url
|
|
|
|
def make_default_renderers(self, renderers):
|
|
"""
|
|
Make the default set of column renderers for the grid.
|
|
|
|
We honor any existing renderers which have already been set, but then
|
|
we also try to supplement that by auto-assigning renderers based on
|
|
underlying column type. Note that this special logic only applies to
|
|
grids with a valid :attr:`model_class`.
|
|
"""
|
|
if self.model_class:
|
|
mapper = orm.class_mapper(self.model_class)
|
|
for prop in mapper.iterate_properties:
|
|
if isinstance(prop, orm.ColumnProperty) and not prop.key.endswith('uuid'):
|
|
if prop.key in self.columns and prop.key not in renderers:
|
|
if len(prop.columns) == 1:
|
|
coltype = prop.columns[0].type
|
|
renderers[prop.key] = self.get_renderer_for_column_type(coltype)
|
|
|
|
return renderers
|
|
|
|
def get_renderer_for_column_type(self, coltype):
|
|
"""
|
|
Returns an appropriate renderer according to the given SA column type.
|
|
"""
|
|
if isinstance(coltype, sa.Boolean):
|
|
return self.render_boolean
|
|
|
|
if isinstance(coltype, sa.DateTime):
|
|
if self.assume_local_times:
|
|
return self.render_datetime_local
|
|
else:
|
|
return self.render_datetime
|
|
|
|
if isinstance(coltype, GPCType):
|
|
return self.render_gpc
|
|
|
|
return self.render_generic
|
|
|
|
def checkbox_column_format(self, column_number, row_number, item):
|
|
return HTML.td(self.render_checkbox(item), class_='checkbox')
|
|
|
|
def actions_column_format(self, column_number, row_number, item):
|
|
return HTML.td(self.render_actions(item, row_number), class_='actions')
|
|
|
|
def get_default_filters(self):
|
|
"""
|
|
Returns the default set of filters provided by the grid.
|
|
"""
|
|
if hasattr(self, 'default_filters'):
|
|
if callable(self.default_filters):
|
|
return self.default_filters()
|
|
return self.default_filters
|
|
filters = gridfilters.GridFilterSet()
|
|
if self.model_class:
|
|
mapper = orm.class_mapper(self.model_class)
|
|
for prop in mapper.iterate_properties:
|
|
if not isinstance(prop, orm.ColumnProperty):
|
|
continue
|
|
if prop.key.endswith('uuid'):
|
|
continue
|
|
if len(prop.columns) != 1:
|
|
continue
|
|
column = prop.columns[0]
|
|
if isinstance(column.type, sa.LargeBinary):
|
|
continue
|
|
filters[prop.key] = self.make_filter(prop.key, column)
|
|
return filters
|
|
|
|
def make_filters(self, filters=None):
|
|
"""
|
|
Returns an initial set of filters which will be available to the grid.
|
|
The grid itself may or may not provide some default filters, and the
|
|
``filters`` kwarg may contain additions and/or overrides.
|
|
"""
|
|
if filters:
|
|
return filters
|
|
return self.get_default_filters()
|
|
|
|
def make_filter(self, key, column, **kwargs):
|
|
"""
|
|
Make a filter suitable for use with the given column.
|
|
"""
|
|
factory = kwargs.pop('factory', None)
|
|
if not factory:
|
|
factory = gridfilters.AlchemyGridFilter
|
|
if isinstance(column.type, sa.String):
|
|
factory = gridfilters.AlchemyStringFilter
|
|
elif isinstance(column.type, sa.Numeric):
|
|
factory = gridfilters.AlchemyNumericFilter
|
|
elif isinstance(column.type, sa.BigInteger):
|
|
factory = gridfilters.AlchemyBigIntegerFilter
|
|
elif isinstance(column.type, sa.Integer):
|
|
factory = gridfilters.AlchemyIntegerFilter
|
|
elif isinstance(column.type, sa.Boolean):
|
|
# TODO: check column for nullable here?
|
|
factory = gridfilters.AlchemyNullableBooleanFilter
|
|
elif isinstance(column.type, sa.Date):
|
|
factory = gridfilters.AlchemyDateFilter
|
|
elif isinstance(column.type, sa.DateTime):
|
|
if self.assume_local_times:
|
|
factory = gridfilters.AlchemyLocalDateTimeFilter
|
|
else:
|
|
factory = gridfilters.AlchemyDateTimeFilter
|
|
elif isinstance(column.type, GPCType):
|
|
factory = gridfilters.AlchemyGPCFilter
|
|
kwargs['column'] = column
|
|
kwargs.setdefault('config', self.request.rattail_config)
|
|
kwargs.setdefault('encode_values', self.use_byte_string_filters)
|
|
return factory(key, **kwargs)
|
|
|
|
def iter_filters(self):
|
|
"""
|
|
Iterate over all filters available to the grid.
|
|
"""
|
|
return self.filters.values()
|
|
|
|
def iter_active_filters(self):
|
|
"""
|
|
Iterate over all *active* filters for the grid. Whether a filter is
|
|
active is determined by current grid settings.
|
|
"""
|
|
for filtr in self.iter_filters():
|
|
if filtr.active:
|
|
yield filtr
|
|
|
|
def make_simple_sorter(self, key, foldcase=False):
|
|
""" """
|
|
warnings.warn("Grid.make_simple_sorter() is deprecated; "
|
|
"please use Grid.make_sorter() instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
return self.make_sorter(key, foldcase=foldcase)
|
|
|
|
def get_pagesize_options(self, default=None):
|
|
""" """
|
|
# let upstream check config
|
|
options = super().get_pagesize_options(default=UNSPECIFIED)
|
|
if options is not UNSPECIFIED:
|
|
return options
|
|
|
|
# fallback to legacy config
|
|
options = self.config.get_list('tailbone.grid.pagesize_options')
|
|
if options:
|
|
warnings.warn("tailbone.grid.pagesize_options setting is deprecated; "
|
|
"please set wuttaweb.grids.default_pagesize_options instead",
|
|
DeprecationWarning)
|
|
options = [int(size) for size in options
|
|
if size.isdigit()]
|
|
if options:
|
|
return options
|
|
|
|
if default:
|
|
return default
|
|
|
|
# use upstream default
|
|
return super().get_pagesize_options()
|
|
|
|
def get_pagesize(self, default=None):
|
|
""" """
|
|
# let upstream check config
|
|
pagesize = super().get_pagesize(default=UNSPECIFIED)
|
|
if pagesize is not UNSPECIFIED:
|
|
return pagesize
|
|
|
|
# fallback to legacy config
|
|
pagesize = self.config.get_int('tailbone.grid.default_pagesize')
|
|
if pagesize:
|
|
warnings.warn("tailbone.grid.default_pagesize setting is deprecated; "
|
|
"please use wuttaweb.grids.default_pagesize instead",
|
|
DeprecationWarning)
|
|
return pagesize
|
|
|
|
if default:
|
|
return default
|
|
|
|
# use upstream default
|
|
return super().get_pagesize()
|
|
|
|
def get_default_pagesize(self): # pragma: no cover
|
|
""" """
|
|
warnings.warn("Grid.get_default_pagesize() method is deprecated; "
|
|
"please use Grid.get_pagesize() of Grid.page instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
|
|
if self.default_pagesize:
|
|
return self.default_pagesize
|
|
|
|
return self.get_pagesize()
|
|
|
|
def load_settings(self, **kwargs):
|
|
""" """
|
|
if 'store' in kwargs:
|
|
warnings.warn("the 'store' param is deprecated for load_settings(); "
|
|
"please use the 'persist' param instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
kwargs.setdefault('persist', kwargs.pop('store'))
|
|
|
|
persist = kwargs.get('persist', True)
|
|
|
|
# initial default 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:
|
|
settings['pagesize'] = self.pagesize
|
|
settings['page'] = self.page
|
|
if self.filterable:
|
|
for filtr in self.iter_filters():
|
|
settings['filter.{}.active'.format(filtr.key)] = filtr.default_active
|
|
settings['filter.{}.verb'.format(filtr.key)] = filtr.default_verb
|
|
settings['filter.{}.value'.format(filtr.key)] = filtr.default_value
|
|
|
|
# If user has default settings on file, apply those first.
|
|
if self.user_has_defaults():
|
|
self.apply_user_defaults(settings)
|
|
|
|
# If request contains instruction to reset to default filters, then we
|
|
# can skip the rest of the request/session checks.
|
|
if self.request.GET.get('reset-to-default-filters') == 'true':
|
|
pass
|
|
|
|
# If request has filter settings, grab those, then grab sort/pager
|
|
# settings from request or session.
|
|
elif self.filterable and self.request_has_settings('filter'):
|
|
self.update_filter_settings(settings, 'request')
|
|
if self.request_has_settings('sort'):
|
|
self.update_sort_settings(settings, src='request')
|
|
else:
|
|
self.update_sort_settings(settings, src='session')
|
|
self.update_page_settings(settings)
|
|
|
|
# If request has no filter settings but does have sort settings, grab
|
|
# those, then grab filter settings from session, then grab pager
|
|
# settings from request or session.
|
|
elif self.request_has_settings('sort'):
|
|
self.update_sort_settings(settings, src='request')
|
|
self.update_filter_settings(settings, 'session')
|
|
self.update_page_settings(settings)
|
|
|
|
# NOTE: These next two are functionally equivalent, but are kept
|
|
# separate to maintain the narrative...
|
|
|
|
# If request has no filter/sort settings but does have pager settings,
|
|
# grab those, then grab filter/sort settings from session.
|
|
elif self.request_has_settings('page'):
|
|
self.update_page_settings(settings)
|
|
self.update_filter_settings(settings, 'session')
|
|
self.update_sort_settings(settings, src='session')
|
|
|
|
# If request has no settings, grab all from session.
|
|
elif self.session_has_settings():
|
|
self.update_filter_settings(settings, 'session')
|
|
self.update_sort_settings(settings, src='session')
|
|
self.update_page_settings(settings)
|
|
|
|
# If no settings were found in request or session, don't store result.
|
|
else:
|
|
persist = False
|
|
|
|
# Maybe store settings for next time.
|
|
if persist:
|
|
self.persist_settings(settings, dest='session')
|
|
|
|
# If request contained instruction to save current settings as defaults
|
|
# for the current user, then do that.
|
|
if self.request.GET.get('save-current-filters-as-defaults') == 'true':
|
|
self.persist_settings(settings, dest='defaults')
|
|
|
|
# update ourself to reflect settings
|
|
if self.filterable:
|
|
for filtr in self.iter_filters():
|
|
filtr.active = settings['filter.{}.active'.format(filtr.key)]
|
|
filtr.verb = settings['filter.{}.verb'.format(filtr.key)]
|
|
filtr.value = settings['filter.{}.value'.format(filtr.key)]
|
|
if self.sortable:
|
|
# and self.sort_on_backend:
|
|
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'],
|
|
})
|
|
if self.paginated:
|
|
self.pagesize = settings['pagesize']
|
|
self.page = settings['page']
|
|
|
|
def user_has_defaults(self):
|
|
"""
|
|
Check to see if the current user has default settings on file for this grid.
|
|
"""
|
|
user = self.request.user
|
|
if not user:
|
|
return False
|
|
|
|
# NOTE: we used to leverage `self.session` here, but sometimes we might
|
|
# be showing a grid of data from another system...so always use
|
|
# Tailbone Session now, for the settings. hopefully that didn't break
|
|
# anything...
|
|
session = Session()
|
|
if user not in session:
|
|
# TODO: pretty sure there is no need to *merge* here..
|
|
# but we shall see if any breakage happens maybe
|
|
#user = session.merge(user)
|
|
user = session.get(user.__class__, user.uuid)
|
|
|
|
app = self.request.rattail_config.get_app()
|
|
|
|
# user defaults should be all or nothing, so just check one key
|
|
key = f'tailbone.{user.uuid}.grid.{self.key}.sorters.length'
|
|
if app.get_setting(session, key) is not None:
|
|
return True
|
|
|
|
# TODO: this is deprecated but should work its way out of the
|
|
# system in a little while (?)..then can remove this entirely
|
|
key = f'tailbone.{user.uuid}.grid.{self.key}.sortkey'
|
|
if app.get_setting(session, key) is not None:
|
|
return True
|
|
|
|
return False
|
|
|
|
def apply_user_defaults(self, settings):
|
|
"""
|
|
Update the given settings dict with user defaults, if any exist.
|
|
"""
|
|
app = self.request.rattail_config.get_app()
|
|
session = Session()
|
|
prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
|
|
|
|
def merge(key, normalize=lambda v: v):
|
|
value = app.get_setting(session, f'{prefix}.{key}')
|
|
settings[key] = normalize(value)
|
|
|
|
if self.filterable:
|
|
for filtr in self.iter_filters():
|
|
merge('filter.{}.active'.format(filtr.key), lambda v: v == 'true')
|
|
merge('filter.{}.verb'.format(filtr.key))
|
|
merge('filter.{}.value'.format(filtr.key))
|
|
|
|
if self.sortable:
|
|
|
|
# first clear existing settings for *sorting* only
|
|
# nb. this is because number of sort settings will vary
|
|
for key in list(settings):
|
|
if key.startswith('sorters.'):
|
|
del settings[key]
|
|
|
|
# check for *deprecated* settings, and use those if present
|
|
# TODO: obviously should stop this, but must wait until
|
|
# all old settings have been flushed out. which in the
|
|
# case of user-persisted settings, could be a while...
|
|
sortkey = app.get_setting(session, f'{prefix}.sortkey')
|
|
if sortkey:
|
|
settings['sorters.length'] = 1
|
|
settings['sorters.1.key'] = sortkey
|
|
settings['sorters.1.dir'] = app.get_setting(session, f'{prefix}.sortdir')
|
|
|
|
# nb. re-persist these user settings per new
|
|
# convention, so deprecated settings go away and we
|
|
# can remove this logic after a while..
|
|
app = self.request.rattail_config.get_app()
|
|
model = app.model
|
|
prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
|
|
query = Session.query(model.Setting)\
|
|
.filter(sa.or_(
|
|
model.Setting.name.like(f'{prefix}.sorters.%'),
|
|
model.Setting.name == f'{prefix}.sortkey',
|
|
model.Setting.name == f'{prefix}.sortdir'))
|
|
for setting in query.all():
|
|
Session.delete(setting)
|
|
Session.flush()
|
|
|
|
def persist(key):
|
|
app.save_setting(Session(),
|
|
f'tailbone.{self.request.user.uuid}.grid.{self.key}.{key}',
|
|
settings[key])
|
|
|
|
persist('sorters.length')
|
|
persist('sorters.1.key')
|
|
persist('sorters.1.dir')
|
|
|
|
else: # the future
|
|
merge('sorters.length', int)
|
|
for i in range(1, settings['sorters.length'] + 1):
|
|
merge(f'sorters.{i}.key')
|
|
merge(f'sorters.{i}.dir')
|
|
|
|
if self.paginated:
|
|
merge('pagesize', int)
|
|
merge('page', int)
|
|
|
|
def request_has_settings(self, type_):
|
|
"""
|
|
Determine if the current request (GET query string) contains any
|
|
filter/sort settings for the grid.
|
|
"""
|
|
if type_ == 'filter':
|
|
for filtr in self.iter_filters():
|
|
if filtr.key in self.request.GET:
|
|
return True
|
|
if 'filter' in self.request.GET: # user may be applying empty filters
|
|
return True
|
|
|
|
elif type_ == 'sort':
|
|
|
|
# TODO: remove this eventually, but some links in the wild
|
|
# may still include these params, so leave it for now
|
|
for key in ['sortkey', 'sortdir']:
|
|
if key in self.request.GET:
|
|
return True
|
|
|
|
if 'sort1key' in self.request.GET:
|
|
return True
|
|
|
|
elif type_ == 'page':
|
|
for key in ['pagesize', 'page']:
|
|
if key in self.request.GET:
|
|
return True
|
|
|
|
return False
|
|
|
|
def session_has_settings(self):
|
|
"""
|
|
Determine if the current session contains any settings for the grid.
|
|
"""
|
|
# session should have all or nothing, so just check a few keys which
|
|
# should be guaranteed present if anything has been stashed
|
|
prefix = f'grid.{self.key}'
|
|
for key in ['page', 'sorters.length']:
|
|
if f'{prefix}.{key}' in self.request.session:
|
|
return True
|
|
return any([key.startswith(f'{prefix}.filter')
|
|
for key in self.request.session])
|
|
|
|
def update_filter_settings(self, settings, source):
|
|
"""
|
|
Updates a settings dictionary according to filter settings data found
|
|
in either the GET query string, or session storage.
|
|
|
|
:param settings: Dictionary of initial settings, which is to be updated.
|
|
|
|
:param source: String identifying the source to consult for settings
|
|
data. Must be one of: ``('request', 'session')``.
|
|
"""
|
|
if not self.filterable:
|
|
return
|
|
|
|
for filtr in self.iter_filters():
|
|
prefix = 'filter.{}'.format(filtr.key)
|
|
|
|
if source == 'request':
|
|
# consider filter active if query string contains a value for it
|
|
settings['{}.active'.format(prefix)] = filtr.key in self.request.GET
|
|
settings['{}.verb'.format(prefix)] = self.get_setting(
|
|
settings, f'{filtr.key}.verb', src='request', default='')
|
|
settings['{}.value'.format(prefix)] = self.get_setting(
|
|
settings, filtr.key, src='request', default='')
|
|
|
|
else: # source = session
|
|
settings['{}.active'.format(prefix)] = self.get_setting(
|
|
settings, f'{prefix}.active', src='session',
|
|
normalize=lambda v: str(v).lower() == 'true', default=False)
|
|
settings['{}.verb'.format(prefix)] = self.get_setting(
|
|
settings, f'{prefix}.verb', src='session', default='')
|
|
settings['{}.value'.format(prefix)] = self.get_setting(
|
|
settings, f'{prefix}.value', src='session', default='')
|
|
|
|
def update_page_settings(self, settings):
|
|
"""
|
|
Updates a settings dictionary according to pager settings data found in
|
|
either the GET query string, or session storage.
|
|
|
|
Note that due to how the actual pager functions, the effective settings
|
|
will often come from *both* the request and session. This is so that
|
|
e.g. the page size will remain constant (coming from the session) while
|
|
the user jumps between pages (which only provides the single setting).
|
|
|
|
:param settings: Dictionary of initial settings, which is to be updated.
|
|
"""
|
|
if not self.paginated:
|
|
return
|
|
|
|
pagesize = self.request.GET.get('pagesize')
|
|
if pagesize is not None:
|
|
if pagesize.isdigit():
|
|
settings['pagesize'] = int(pagesize)
|
|
else:
|
|
pagesize = self.request.session.get('grid.{}.pagesize'.format(self.key))
|
|
if pagesize is not None:
|
|
settings['pagesize'] = pagesize
|
|
|
|
page = self.request.GET.get('page')
|
|
if page is not None:
|
|
if page.isdigit():
|
|
settings['page'] = int(page)
|
|
else:
|
|
page = self.request.session.get('grid.{}.page'.format(self.key))
|
|
if page is not None:
|
|
settings['page'] = int(page)
|
|
|
|
def persist_settings(self, settings, dest='session'):
|
|
""" """
|
|
if dest not in ('defaults', 'session'):
|
|
raise ValueError(f"invalid dest identifier: {dest}")
|
|
|
|
app = self.request.rattail_config.get_app()
|
|
model = app.model
|
|
|
|
def persist(key, value=lambda k: settings.get(k)):
|
|
if dest == 'defaults':
|
|
skey = 'tailbone.{}.grid.{}.{}'.format(self.request.user.uuid, self.key, key)
|
|
app.save_setting(Session(), skey, value(key))
|
|
else: # dest == session
|
|
skey = 'grid.{}.{}'.format(self.key, key)
|
|
self.request.session[skey] = value(key)
|
|
|
|
if self.filterable:
|
|
for filtr in self.iter_filters():
|
|
persist('filter.{}.active'.format(filtr.key), value=lambda k: str(settings[k]).lower())
|
|
persist('filter.{}.verb'.format(filtr.key))
|
|
persist('filter.{}.value'.format(filtr.key))
|
|
|
|
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 == 'defaults':
|
|
prefix = f'tailbone.{self.request.user.uuid}.grid.{self.key}'
|
|
query = Session.query(model.Setting)\
|
|
.filter(sa.or_(
|
|
model.Setting.name.like(f'{prefix}.sorters.%'),
|
|
# TODO: remove these eventually,
|
|
# but probably should wait until
|
|
# all nodes have been upgraded for
|
|
# (quite) a while?
|
|
model.Setting.name == f'{prefix}.sortkey',
|
|
model.Setting.name == f'{prefix}.sortdir'))
|
|
for setting in query.all():
|
|
Session.delete(setting)
|
|
Session.flush()
|
|
|
|
else: # session
|
|
# remove sort settings from user session
|
|
prefix = f'grid.{self.key}'
|
|
for key in list(self.request.session):
|
|
if key.startswith(f'{prefix}.sorters.'):
|
|
del self.request.session[key]
|
|
# TODO: definitely will remove these, but leave for
|
|
# now so they don't monkey with current user sessions
|
|
# when next upgrade happens. so, remove after all are
|
|
# upgraded
|
|
self.request.session.pop(f'{prefix}.sortkey', None)
|
|
self.request.session.pop(f'{prefix}.sortdir', None)
|
|
|
|
# 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')
|
|
|
|
if self.paginated:
|
|
persist('pagesize')
|
|
persist('page')
|
|
|
|
def filter_data(self, data):
|
|
"""
|
|
Filter and return the given data set, according to current settings.
|
|
"""
|
|
for filtr in self.iter_active_filters():
|
|
|
|
# apply filter to data but save reference to original; if data is a
|
|
# SQLAlchemy query and wasn't modified, we don't need to bother
|
|
# with the underlying join (if there is one)
|
|
original = data
|
|
data = filtr.filter(data)
|
|
if filtr.key in self.joiners and filtr.key not in self.joined and (
|
|
not isinstance(data, orm.Query) or data is not original):
|
|
|
|
# this filter requires a join; apply that
|
|
data = self.joiners[filtr.key](data)
|
|
self.joined.add(filtr.key)
|
|
|
|
return data
|
|
|
|
def sort_data(self, data, sorters=None):
|
|
""" """
|
|
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
|
|
|
|
# join appropriate model if needed
|
|
if sortkey in self.joiners and sortkey not in self.joined:
|
|
data = self.joiners[sortkey](data)
|
|
self.joined.add(sortkey)
|
|
|
|
# invoke the sorter
|
|
data = sortfunc(data, sortdir)
|
|
|
|
return data
|
|
|
|
def paginate_data(self, data):
|
|
"""
|
|
Paginate the given data set according to current settings, and return
|
|
the result.
|
|
"""
|
|
# we of course assume our current page is correct, at first
|
|
pager = self.make_pager(data)
|
|
|
|
# if pager has detected that our current page is outside the valid
|
|
# range, we must re-orient ourself around the "new" (valid) page
|
|
if pager.page != self.page:
|
|
self.page = pager.page
|
|
self.request.session['grid.{}.page'.format(self.key)] = self.page
|
|
pager = self.make_pager(data)
|
|
|
|
return pager
|
|
|
|
def make_pager(self, data):
|
|
|
|
# TODO: this seems hacky..normally we expect `data` to be a
|
|
# query of course, but in some cases it may be a list instead.
|
|
# if so then we can't use ORM pager
|
|
if isinstance(data, list):
|
|
import paginate
|
|
return paginate.Page(data,
|
|
items_per_page=self.pagesize,
|
|
page=self.page)
|
|
|
|
return SqlalchemyOrmPage(data,
|
|
items_per_page=self.pagesize,
|
|
page=self.page,
|
|
url_maker=URLMaker(self.request))
|
|
|
|
def make_visible_data(self):
|
|
"""
|
|
Apply various settings to the raw data set, to produce a final data
|
|
set. This will page / sort / filter as necessary, according to the
|
|
grid's defaults and the current request etc.
|
|
"""
|
|
self.joined = set()
|
|
data = self.data
|
|
if self.filterable:
|
|
data = self.filter_data(data)
|
|
if self.sortable:
|
|
data = self.sort_data(data)
|
|
if self.paginated:
|
|
self.pager = self.paginate_data(data)
|
|
data = self.pager
|
|
return data
|
|
|
|
def render_vue_tag(self, master=None, **kwargs):
|
|
""" """
|
|
kwargs.setdefault('ref', 'grid')
|
|
kwargs.setdefault(':csrftoken', 'csrftoken')
|
|
|
|
if (master and master.deletable and master.has_perm('delete')
|
|
and master.delete_confirm == 'simple'):
|
|
kwargs.setdefault('@deleteActionClicked', 'deleteObject')
|
|
|
|
return HTML.tag(self.vue_tagname, **kwargs)
|
|
|
|
def render_vue_template(self, template='/grids/complete.mako', **context):
|
|
""" """
|
|
return self.render_complete(template=template, **context)
|
|
|
|
def render_complete(self, template='/grids/complete.mako', **kwargs):
|
|
"""
|
|
Render the grid, complete with filters. Note that this also
|
|
includes the context menu items and grid tools.
|
|
"""
|
|
if 'grid_columns' not in kwargs:
|
|
kwargs['grid_columns'] = self.get_table_columns()
|
|
|
|
if 'grid_data' not in kwargs:
|
|
kwargs['grid_data'] = self.get_table_data()
|
|
|
|
if 'static_data' not in kwargs:
|
|
kwargs['static_data'] = self.has_static_data()
|
|
|
|
if self.filterable and 'filters_data' not in kwargs:
|
|
kwargs['filters_data'] = self.get_filters_data()
|
|
|
|
if self.filterable and 'filters_sequence' not in kwargs:
|
|
kwargs['filters_sequence'] = self.get_filters_sequence()
|
|
|
|
context = kwargs
|
|
context['grid'] = self
|
|
context['request'] = self.request
|
|
context.setdefault('allow_save_defaults', True)
|
|
context.setdefault('view_click_handler', self.get_view_click_handler())
|
|
html = render(template, context)
|
|
return HTML.literal(html)
|
|
|
|
def render_buefy(self, **kwargs):
|
|
warnings.warn("Grid.render_buefy() is deprecated; "
|
|
"please use Grid.render_complete() instead",
|
|
DeprecationWarning, stacklevel=2)
|
|
return self.render_complete(**kwargs)
|
|
|
|
def render_table_element(self, template='/grids/b-table.mako',
|
|
data_prop='gridData', empty_labels=False,
|
|
**kwargs):
|
|
"""
|
|
This is intended for ad-hoc "small" grids with static data. Renders
|
|
just a ``<b-table>`` element instead of the typical "full" grid.
|
|
"""
|
|
context = dict(kwargs)
|
|
context['grid'] = self
|
|
context['request'] = self.request
|
|
context['data_prop'] = data_prop
|
|
context['empty_labels'] = empty_labels
|
|
if 'grid_columns' not in context:
|
|
context['grid_columns'] = self.get_table_columns()
|
|
context.setdefault('paginated', False)
|
|
if context['paginated']:
|
|
context.setdefault('per_page', 20)
|
|
context['view_click_handler'] = self.get_view_click_handler()
|
|
return render(template, context)
|
|
|
|
def get_view_click_handler(self):
|
|
""" """
|
|
# locate the 'view' action
|
|
# TODO: this should be easier, and/or moved elsewhere?
|
|
view = None
|
|
for action in self.actions:
|
|
if action.key == 'view':
|
|
return action.click_handler
|
|
|
|
def set_filters_sequence(self, filters, only=False):
|
|
"""
|
|
Explicitly set the sequence for grid filters, using the sequence
|
|
provided. If the grid currently has more filters than are mentioned in
|
|
the given sequence, the sequence will come first and all others will be
|
|
tacked on at the end.
|
|
|
|
:param filters: Sequence of filter keys, i.e. field names.
|
|
|
|
:param only: If true, then *only* those filters specified will
|
|
be kept, and all others discarded. If false then any
|
|
filters not specified will still be tacked onto the end, in
|
|
alphabetical order.
|
|
"""
|
|
new_filters = gridfilters.GridFilterSet()
|
|
for field in filters:
|
|
if field in self.filters:
|
|
new_filters[field] = self.filters.pop(field)
|
|
else:
|
|
log.warning("field '%s' is not in current filter set", field)
|
|
if not only:
|
|
for field in sorted(self.filters):
|
|
new_filters[field] = self.filters[field]
|
|
self.filters = new_filters
|
|
|
|
def get_filters_sequence(self):
|
|
"""
|
|
Returns a list of filter keys (strings) in the sequence with which they
|
|
should be displayed in the UI.
|
|
"""
|
|
return list(self.filters)
|
|
|
|
def get_filters_data(self):
|
|
"""
|
|
Returns a dict of current filters data, for use with index view.
|
|
"""
|
|
data = {}
|
|
for filtr in self.filters.values():
|
|
|
|
valueless = [v for v in filtr.valueless_verbs
|
|
if v in filtr.verbs]
|
|
|
|
multiple_values = [v for v in filtr.multiple_value_verbs
|
|
if v in filtr.verbs]
|
|
|
|
choices = []
|
|
choice_labels = {}
|
|
if filtr.choices:
|
|
choices = list(filtr.choices)
|
|
choice_labels = dict(filtr.choices)
|
|
elif self.enums and filtr.key in self.enums:
|
|
choices = list(self.enums[filtr.key])
|
|
choice_labels = self.enums[filtr.key]
|
|
|
|
data[filtr.key] = {
|
|
'key': filtr.key,
|
|
'label': filtr.label,
|
|
'active': filtr.active,
|
|
'visible': filtr.active,
|
|
'verbs': filtr.verbs,
|
|
'valueless_verbs': valueless,
|
|
'multiple_value_verbs': multiple_values,
|
|
'verb_labels': filtr.verb_labels,
|
|
'verb': filtr.verb or filtr.default_verb or filtr.verbs[0],
|
|
'value': str(filtr.value) if filtr.value is not None else "",
|
|
'data_type': filtr.data_type,
|
|
'choices': choices,
|
|
'choice_labels': choice_labels,
|
|
}
|
|
|
|
return data
|
|
|
|
def render_filters(self, template='/grids/filters.mako', **kwargs):
|
|
"""
|
|
Render the filters to a Unicode string, using the specified template.
|
|
Additional kwargs are passed along as context to the template.
|
|
"""
|
|
# Provide default data to filters form, so renderer can do some of the
|
|
# work for us.
|
|
data = {}
|
|
for filtr in self.iter_active_filters():
|
|
data['{}.active'.format(filtr.key)] = filtr.active
|
|
data['{}.verb'.format(filtr.key)] = filtr.verb
|
|
data[filtr.key] = filtr.value
|
|
|
|
form = gridfilters.GridFiltersForm(self.filters,
|
|
request=self.request,
|
|
defaults=data)
|
|
|
|
kwargs['request'] = self.request
|
|
kwargs['grid'] = self
|
|
kwargs['form'] = form
|
|
return render(template, kwargs)
|
|
|
|
def render_actions(self, row, i): # pragma: no cover
|
|
""" """
|
|
warnings.warn("grid.render_actions() is deprecated!",
|
|
DeprecationWarning, stacklevel=2)
|
|
|
|
actions = [self.render_action(a, row, i)
|
|
for a in self.actions]
|
|
actions = [a for a in actions if a]
|
|
return HTML.literal('').join(actions)
|
|
|
|
def render_action(self, action, row, i): # pragma: no cover
|
|
""" """
|
|
warnings.warn("grid.render_action() is deprecated!",
|
|
DeprecationWarning, stacklevel=2)
|
|
|
|
url = action.get_url(row, i)
|
|
if url:
|
|
kwargs = {'class_': action.key, 'target': action.target}
|
|
if action.icon:
|
|
icon = HTML.tag('span', class_='ui-icon ui-icon-{}'.format(action.icon))
|
|
return tags.link_to(icon + action.label, url, **kwargs)
|
|
return tags.link_to(action.label, url, **kwargs)
|
|
|
|
def get_row_key(self, item):
|
|
"""
|
|
Must return a unique key for the given data item's row.
|
|
"""
|
|
mapper = orm.object_mapper(item)
|
|
if len(mapper.primary_key) == 1:
|
|
return getattr(item, mapper.primary_key[0].key)
|
|
raise NotImplementedError
|
|
|
|
def checkbox(self, item):
|
|
"""
|
|
Returns boolean indicating whether a checkbox should be rendererd for
|
|
the given data item's row.
|
|
"""
|
|
return True
|
|
|
|
def render_checkbox(self, item):
|
|
"""
|
|
Renders a checkbox cell for the given item, if applicable.
|
|
"""
|
|
if not self.checkbox(item):
|
|
return ''
|
|
return tags.checkbox('checkbox-{}-{}'.format(self.key, self.get_row_key(item)),
|
|
checked=self.checked(item))
|
|
|
|
def has_static_data(self):
|
|
"""
|
|
Should return ``True`` if the grid data can be considered "static"
|
|
(i.e. a list of values). Will return ``False`` otherwise, e.g. if the
|
|
data is represented as a SQLAlchemy query.
|
|
"""
|
|
# TODO: should make this smarter?
|
|
if isinstance(self.data, list):
|
|
return True
|
|
return False
|
|
|
|
def get_vue_columns(self):
|
|
""" """
|
|
return self.get_table_columns()
|
|
|
|
def get_table_columns(self):
|
|
"""
|
|
Return a list of dicts representing all grid columns. Meant
|
|
for use with the client-side JS table.
|
|
"""
|
|
columns = []
|
|
for name in self.columns:
|
|
columns.append({
|
|
'field': name,
|
|
'label': self.get_label(name),
|
|
'sortable': self.is_sortable(name),
|
|
'visible': name not in self.invisible,
|
|
})
|
|
return columns
|
|
|
|
def get_uuid_for_row(self, rowobj):
|
|
|
|
# use custom getter if set
|
|
if self.row_uuid_getter:
|
|
return self.row_uuid_getter(rowobj)
|
|
|
|
# otherwise fallback to normal uuid, if present
|
|
if hasattr(rowobj, 'uuid'):
|
|
return rowobj.uuid
|
|
|
|
def get_vue_data(self):
|
|
""" """
|
|
table_data = self.get_table_data()
|
|
return table_data['data']
|
|
|
|
def get_table_data(self):
|
|
"""
|
|
Returns a list of data rows for the grid, for use with
|
|
client-side JS table.
|
|
"""
|
|
if hasattr(self, '_table_data'):
|
|
return self._table_data
|
|
|
|
# filter / sort / paginate to get "visible" data
|
|
raw_data = self.make_visible_data()
|
|
data = []
|
|
status_map = {}
|
|
checked = []
|
|
|
|
# we check for 'all' method and if so, assume we have a Query;
|
|
# otherwise we assume it's something we can use len() with, which could
|
|
# be a list or a Paginator
|
|
if hasattr(raw_data, 'all'):
|
|
count = raw_data.count()
|
|
else:
|
|
count = len(raw_data)
|
|
|
|
# iterate over data rows
|
|
checkable = self.checkboxes and self.checkable and callable(self.checkable)
|
|
for i in range(count):
|
|
rowobj = raw_data[i]
|
|
|
|
# nb. cache 0-based index on the row, in case client-side
|
|
# logic finds it useful
|
|
row = {'_index': i}
|
|
|
|
# if grid allows checkboxes, and we have logic to see if
|
|
# any given row is checkable, add data for that here
|
|
if checkable:
|
|
row['_checkable'] = self.checkable(rowobj)
|
|
|
|
# sometimes we need to include some "raw" data columns in our
|
|
# result set, even though the column is not displayed as part of
|
|
# the grid. this can be used for front-end editing of row data for
|
|
# instance, when the "display" version is different than raw data.
|
|
# here is the hack we use for that.
|
|
columns = list(self.columns)
|
|
if hasattr(self, 'raw_data_columns'):
|
|
columns.extend(self.raw_data_columns)
|
|
|
|
# iterate over data fields
|
|
for name in columns:
|
|
|
|
# leverage configured rendering logic where applicable;
|
|
# otherwise use "raw" data value as string
|
|
if self.renderers and name in self.renderers:
|
|
value = self.renderers[name](rowobj, name)
|
|
else:
|
|
value = self.obtain_value(rowobj, name)
|
|
if value is None:
|
|
value = ""
|
|
|
|
# this value will ultimately be inserted into table
|
|
# cell a la <td v-html="..."> so we must escape it
|
|
# here to be safe
|
|
row[name] = HTML.literal.escape(value)
|
|
|
|
# maybe add UUID for convenience
|
|
if 'uuid' not in self.columns:
|
|
uuid = self.get_uuid_for_row(rowobj)
|
|
if uuid:
|
|
row['uuid'] = uuid
|
|
|
|
# set action URL(s) for row, as needed
|
|
self.set_action_urls(row, rowobj, i)
|
|
|
|
# set extra row class if applicable
|
|
if self.extra_row_class:
|
|
status = self.extra_row_class(rowobj, i)
|
|
if status:
|
|
status_map[i] = status
|
|
|
|
# set checked flag if applicable
|
|
if self.checkboxes:
|
|
if self.checked(rowobj):
|
|
checked.append(i)
|
|
|
|
data.append(row)
|
|
|
|
results = {
|
|
'data': data,
|
|
'row_status_map': status_map,
|
|
}
|
|
|
|
if self.checkboxes:
|
|
results['checked_rows'] = checked
|
|
# TODO: this seems a bit hacky, but is required for now to
|
|
# initialize things on the client side...
|
|
var = '{}CurrentData'.format(self.vue_component)
|
|
results['checked_rows_code'] = '[{}]'.format(
|
|
', '.join(['{}[{}]'.format(var, i) for i in checked]))
|
|
|
|
if self.paginated and self.paginate_on_backend:
|
|
results['pager_stats'] = self.get_vue_pager_stats()
|
|
|
|
# TODO: is this actually needed now that we have pager_stats?
|
|
if self.paginated and self.pager is not None:
|
|
results['total_items'] = self.pager.item_count
|
|
results['per_page'] = self.pager.items_per_page
|
|
results['page'] = self.pager.page
|
|
results['pages'] = self.pager.page_count
|
|
results['first_item'] = self.pager.first_item
|
|
results['last_item'] = self.pager.last_item
|
|
else:
|
|
results['total_items'] = count
|
|
|
|
self._table_data = results
|
|
return self._table_data
|
|
|
|
def set_action_urls(self, row, rowobj, i):
|
|
"""
|
|
Pre-generate all action URLs for the given data row. Meant for use
|
|
with client-side table, since we can't generate URLs from JS.
|
|
"""
|
|
for action in self.actions:
|
|
url = action.get_url(rowobj, i)
|
|
row['_action_url_{}'.format(action.key)] = url
|
|
|
|
|
|
class GridAction(WuttaGridAction):
|
|
"""
|
|
Represents a "row action" hyperlink within a grid context.
|
|
|
|
This is a subclass of
|
|
:class:`wuttaweb:wuttaweb.grids.base.GridAction`.
|
|
|
|
.. warning::
|
|
|
|
This class remains for now, to retain compatibility with
|
|
existing code. But at some point the WuttaWeb class will
|
|
supersede this one entirely.
|
|
|
|
:param target: HTML "target" attribute for the ``<a>`` tag.
|
|
|
|
:param click_handler: Optional JS click handler for the action.
|
|
This value will be rendered as-is within the final grid
|
|
template, hence the JS string must be callable code. Note
|
|
that ``props.row`` will be available in the calling context,
|
|
so a couple of examples:
|
|
|
|
* ``deleteThisThing(props.row)``
|
|
* ``$emit('do-something', props.row)``
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
request,
|
|
key,
|
|
target=None,
|
|
click_handler=None,
|
|
**kwargs,
|
|
):
|
|
# TODO: previously url default was '#' - but i don't think we
|
|
# need that anymore? guess we'll see..
|
|
#kwargs.setdefault('url', '#')
|
|
|
|
super().__init__(request, key, **kwargs)
|
|
|
|
self.target = target
|
|
self.click_handler = click_handler
|
|
|
|
|
|
class URLMaker(object):
|
|
"""
|
|
URL constructor for use with SQLAlchemy grid pagers. Logic for this was
|
|
basically copied from the old `webhelpers.paginate` module
|
|
"""
|
|
|
|
def __init__(self, request):
|
|
self.request = request
|
|
|
|
def __call__(self, page):
|
|
params = self.request.GET.copy()
|
|
params["page"] = page
|
|
params["partial"] = "1"
|
|
qs = urlencode(params, True)
|
|
return '{}?{}'.format(self.request.path, qs)
|