diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py
index 51b0d7d..4ff990e 100644
--- a/src/wuttaweb/grids/base.py
+++ b/src/wuttaweb/grids/base.py
@@ -28,7 +28,7 @@ import functools
import json
import logging
import warnings
-from collections import namedtuple
+from collections import namedtuple, OrderedDict
import sqlalchemy as sa
from sqlalchemy import orm
@@ -339,6 +339,16 @@ class Grid:
sorting.
See :meth:`set_joiner()` for more info.
+
+ .. attribute:: tools
+
+ Dict of "tool" elements for the grid. Tools are usually buttons
+ (e.g. "Delete Results"), shown on top right of the grid.
+
+ The keys for this dict are somewhat arbitrary, defined by the
+ caller. Values should be HTML literal elements.
+
+ See also :meth:`add_tool()` and :meth:`set_tools()`.
"""
def __init__(
@@ -369,6 +379,7 @@ class Grid:
filters=None,
filter_defaults=None,
joiners=None,
+ tools=None,
):
self.request = request
self.vue_tagname = vue_tagname
@@ -386,6 +397,7 @@ class Grid:
self.app = self.config.get_app()
self.set_columns(columns or self.get_columns())
+ self.set_tools(tools)
# sorting
self.sortable = sortable
@@ -658,6 +670,33 @@ class Grid:
"""
self.actions.append(GridAction(self.request, key, **kwargs))
+ def set_tools(self, tools):
+ """
+ Set the :attr:`tools` attribute using the given tools collection.
+
+ This will normalize the list/dict to desired internal format.
+ """
+ if tools and isinstance(tools, list):
+ if not any([isinstance(t, (tuple, list)) for t in tools]):
+ tools = [(self.app.make_uuid(), t) for t in tools]
+ self.tools = OrderedDict(tools or [])
+
+ def add_tool(self, html, key=None):
+ """
+ Add a new HTML snippet to the :attr:`tools` dict.
+
+ :param html: HTML literal for the tool element.
+
+ :param key: Optional key to use when adding to the
+ :attr:`tools` dict. If not specified, a random string is
+ generated.
+
+ See also :meth:`set_tools()`.
+ """
+ if not key:
+ key = self.app.make_uuid()
+ self.tools[key] = html
+
##############################
# joining methods
##############################
diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako
index 1ed408e..e3a7a23 100644
--- a/src/wuttaweb/templates/grids/vue_template.mako
+++ b/src/wuttaweb/templates/grids/vue_template.mako
@@ -90,6 +90,19 @@
% endif
+
+
+ ## nb. this is needed to force tools to bottom
+ ## TODO: should we put a context menu here?
+
+
+
+ % for html in grid.tools.values():
+ ${html}
+ % endfor
+
+
+
<${b}-table :data="data"
@@ -290,6 +303,14 @@
template: '#${grid.vue_tagname}-template',
computed: {
+ recordCount() {
+ % if grid.paginated:
+ return this.pagerStats.item_count
+ % else:
+ return this.data.length
+ % endif
+ },
+
directLink() {
const params = new URLSearchParams(this.getAllParams())
return `${request.path_url}?${'$'}{params}`
diff --git a/src/wuttaweb/templates/master/index.mako b/src/wuttaweb/templates/master/index.mako
index a16aced..bf32c6f 100644
--- a/src/wuttaweb/templates/master/index.mako
+++ b/src/wuttaweb/templates/master/index.mako
@@ -23,6 +23,41 @@
% endif
%def>
+<%def name="modify_vue_vars()">
+ ${parent.modify_vue_vars()}
+ % if master.deletable_bulk and master.has_perm('delete_bulk'):
+
+ % endif
+%def>
+
<%def name="make_vue_components()">
${parent.make_vue_components()}
% if grid is not Undefined:
diff --git a/src/wuttaweb/views/common.py b/src/wuttaweb/views/common.py
index 2b6b084..8c6dc25 100644
--- a/src/wuttaweb/views/common.py
+++ b/src/wuttaweb/views/common.py
@@ -154,6 +154,7 @@ class CommonView(View):
'settings.view',
'settings.edit',
'settings.delete',
+ 'settings.delete_bulk',
'upgrades.list',
'upgrades.create',
'upgrades.view',
diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py
index bc6dbb7..d1e9bef 100644
--- a/src/wuttaweb/views/master.py
+++ b/src/wuttaweb/views/master.py
@@ -24,6 +24,8 @@
Base Logic for Master Views
"""
+import logging
+
import sqlalchemy as sa
from sqlalchemy import orm
@@ -31,11 +33,14 @@ from pyramid.renderers import render_to_response
from webhelpers2.html import HTML
from wuttaweb.views import View
-from wuttaweb.util import get_form_data, get_model_fields
+from wuttaweb.util import get_form_data, get_model_fields, render_csrf_token
from wuttaweb.db import Session
from wuttjamaican.util import get_class_hierarchy
+log = logging.getLogger(__name__)
+
+
class MasterView(View):
"""
Base class for "master" views.
@@ -284,6 +289,12 @@ class MasterView(View):
See also :meth:`is_deletable()`.
+ .. attribute:: deletable_bulk
+
+ Boolean indicating whether the view model supports "bulk
+ deleting" - i.e. it should have a :meth:`delete_bulk()` view.
+ Default value is ``False``.
+
.. attribute:: form_fields
List of fields for the model form.
@@ -321,6 +332,7 @@ class MasterView(View):
viewable = True
editable = True
deletable = True
+ deletable_bulk = False
has_autocomplete = False
configurable = False
@@ -622,6 +634,76 @@ class MasterView(View):
session = self.app.get_session(obj)
session.delete(obj)
+ def delete_bulk(self, session=None):
+ """
+ View to delete all records in the current :meth:`index()` grid
+ data set, i.e. those matching current query.
+
+ This usually corresponds to a URL like
+ ``/widgets/delete-bulk``.
+
+ By default, this view is included only if
+ :attr:`deletable_bulk` is true.
+
+ This view requires POST method. When it is finished deleting,
+ user is redirected back to :meth:`index()` view.
+
+ Subclass normally should not override this method, but rather
+ one of the related methods which are called (in)directly by
+ this one:
+
+ * :meth:`delete_bulk_data()`
+ """
+ # get current data set from grid
+ # nb. this must *not* be paginated, we need it all
+ grid = self.make_model_grid(paginated=False)
+ data = grid.get_visible_data()
+
+ # delete it all and go back to listing
+ self.delete_bulk_data(data, session=session)
+ return self.redirect(self.get_index_url())
+
+ def delete_bulk_data(self, data, session=None):
+ """
+ This method performs the actual bulk deletion, for the given
+ data set.
+
+ Default logic will call :meth:`is_deletable()` for every data
+ record, and if that returns true then it calls
+ :meth:`delete_instance()`.
+
+ As of now there is no progress indicator or async; caller must
+ simply wait until delete is finished.
+ """
+ session = session or self.Session()
+
+ for obj in data:
+ if self.is_deletable(obj):
+ self.delete_instance(obj)
+
+ def delete_bulk_make_button(self):
+ """ """
+ route_prefix = self.get_route_prefix()
+
+ label = HTML.literal(
+ '{{ deleteResultsSubmitting ? "Working, please wait..." : "Delete Results" }}')
+ button = self.make_button(label,
+ variant='is-danger',
+ icon_left='trash',
+ **{'@click': 'deleteResultsSubmit()',
+ ':disabled': 'deleteResultsDisabled'})
+
+ form = HTML.tag('form',
+ method='post',
+ action=self.request.route_url(f'{route_prefix}.delete_bulk'),
+ ref='deleteResultsForm',
+ class_='control',
+ c=[
+ render_csrf_token(self.request),
+ button,
+ ])
+ return form
+
##############################
# autocomplete methods
##############################
@@ -1168,6 +1250,64 @@ class MasterView(View):
return True
return False
+ def make_button(
+ self,
+ label,
+ variant=None,
+ primary=False,
+ **kwargs,
+ ):
+ """
+ Make and return a HTML ```` literal.
+
+ :param label: Text label for the button.
+
+ :param variant: This is the "Buefy type" (or "Oruga variant")
+ for the button. Buefy and Oruga represent this differently
+ but this logic expects the Buefy format
+ (e.g. ``is-danger``) and *not* the Oruga format
+ (e.g. ``danger``), despite the param name matching Oruga's
+ terminology.
+
+ :param type: This param is not advertised in the method
+ signature, but if caller specifies ``type`` instead of
+ ``variant`` it should work the same.
+
+ :param primary: If neither ``variant`` nor ``type`` are
+ specified, this flag may be used to automatically set the
+ Buefy type to ``is-primary``.
+
+ This is the preferred method where applicable, since it
+ avoids the Buefy vs. Oruga confusion, and the
+ implementation can change in the future.
+
+ :param \**kwargs: All remaining kwargs are passed to the
+ underlying ``HTML.tag()`` call, so will be rendered as
+ attributes on the button tag.
+
+ :returns: HTML literal for the button element. Will be something
+ along the lines of:
+
+ .. code-block::
+
+
+ Click Me
+
+ """
+ btn_kw = kwargs
+ btn_kw.setdefault('c', label)
+ btn_kw.setdefault('icon_pack', 'fas')
+
+ if 'type' not in btn_kw:
+ if variant:
+ btn_kw['type'] = variant
+ elif primary:
+ btn_kw['type'] = 'is-primary'
+
+ return HTML.tag('b-button', **btn_kw)
+
def render_to_response(self, template, context):
"""
Locate and render an appropriate template, with the given
@@ -1378,6 +1518,14 @@ class MasterView(View):
kwargs['actions'] = actions
+ if 'tools' not in kwargs:
+ tools = []
+
+ if self.deletable_bulk and self.has_perm('delete_bulk'):
+ tools.append(('delete-results', self.delete_bulk_make_button()))
+
+ kwargs['tools'] = tools
+
if hasattr(self, 'grid_row_class'):
kwargs.setdefault('row_class', self.grid_row_class)
kwargs.setdefault('filterable', self.filterable)
@@ -2084,17 +2232,6 @@ class MasterView(View):
f'{permission_prefix}.create',
f"Create new {model_title}")
- # view
- if cls.viewable:
- instance_url_prefix = cls.get_instance_url_prefix()
- config.add_route(f'{route_prefix}.view', instance_url_prefix)
- config.add_view(cls, attr='view',
- route_name=f'{route_prefix}.view',
- permission=f'{permission_prefix}.view')
- config.add_wutta_permission(permission_prefix,
- f'{permission_prefix}.view',
- f"View {model_title}")
-
# edit
if cls.editable:
instance_url_prefix = cls.get_instance_url_prefix()
@@ -2119,6 +2256,18 @@ class MasterView(View):
f'{permission_prefix}.delete',
f"Delete {model_title}")
+ # bulk delete
+ if cls.deletable_bulk:
+ config.add_route(f'{route_prefix}.delete_bulk',
+ f'{url_prefix}/delete-bulk',
+ request_method='POST')
+ config.add_view(cls, attr='delete_bulk',
+ route_name=f'{route_prefix}.delete_bulk',
+ permission=f'{permission_prefix}.delete_bulk')
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.delete_bulk',
+ f"Delete {model_title_plural} in bulk")
+
# autocomplete
if cls.has_autocomplete:
config.add_route(f'{route_prefix}.autocomplete',
@@ -2138,3 +2287,16 @@ class MasterView(View):
config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.configure',
f"Configure {model_title_plural}")
+
+ # view
+ # nb. always register this one last, so it does not take
+ # priority over model-wide action routes, e.g. delete_bulk
+ if cls.viewable:
+ instance_url_prefix = cls.get_instance_url_prefix()
+ config.add_route(f'{route_prefix}.view', instance_url_prefix)
+ config.add_view(cls, attr='view',
+ route_name=f'{route_prefix}.view',
+ permission=f'{permission_prefix}.view')
+ config.add_wutta_permission(permission_prefix,
+ f'{permission_prefix}.view',
+ f"View {model_title}")
diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py
index aa28416..43b4687 100644
--- a/src/wuttaweb/views/settings.py
+++ b/src/wuttaweb/views/settings.py
@@ -202,6 +202,7 @@ class SettingView(MasterView):
"""
model_class = Setting
model_title = "Raw Setting"
+ deletable_bulk = True
filter_defaults = {
'name': {'active': True},
}
diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py
index 840715e..f532ddf 100644
--- a/tests/grids/test_base.py
+++ b/tests/grids/test_base.py
@@ -254,6 +254,42 @@ class TestGrid(WebTestCase):
self.assertEqual(len(grid.actions), 1)
self.assertIsInstance(grid.actions[0], mod.GridAction)
+ def test_set_tools(self):
+ grid = self.make_grid()
+ self.assertEqual(grid.tools, {})
+
+ # null
+ grid.set_tools(None)
+ self.assertEqual(grid.tools, {})
+
+ # empty
+ grid.set_tools({})
+ self.assertEqual(grid.tools, {})
+
+ # full dict is replaced
+ grid.tools = {'foo': 'bar'}
+ self.assertEqual(grid.tools, {'foo': 'bar'})
+ grid.set_tools({'bar': 'baz'})
+ self.assertEqual(grid.tools, {'bar': 'baz'})
+
+ # can specify as list of html elements
+ grid.set_tools(['foo', 'bar'])
+ self.assertEqual(len(grid.tools), 2)
+ self.assertEqual(list(grid.tools.values()), ['foo', 'bar'])
+
+ def test_add_tool(self):
+ grid = self.make_grid()
+ self.assertEqual(grid.tools, {})
+
+ # with key
+ grid.add_tool('foo', key='foo')
+ self.assertEqual(grid.tools, {'foo': 'foo'})
+
+ # without key
+ grid.add_tool('bar')
+ self.assertEqual(len(grid.tools), 2)
+ self.assertEqual(list(grid.tools.values()), ['foo', 'bar'])
+
def test_get_pagesize_options(self):
grid = self.make_grid()
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
index 2e5f8a7..0224468 100644
--- a/tests/views/test_master.py
+++ b/tests/views/test_master.py
@@ -27,6 +27,7 @@ class TestMasterView(WebTestCase):
with patch.multiple(mod.MasterView, create=True,
model_name='Widget',
model_key='uuid',
+ deletable_bulk=True,
has_autocomplete=True,
configurable=True):
mod.MasterView.defaults(self.pyramid_config)
@@ -400,6 +401,33 @@ class TestMasterView(WebTestCase):
self.assertTrue(view.has_any_perm('list', 'view'))
self.assertTrue(self.request.has_any_perm('settings.list', 'settings.view'))
+ def test_make_button(self):
+ view = self.make_view()
+
+ # normal
+ html = view.make_button('click me')
+ self.assertIn('