1
0
Fork 0

feat: add basic "delete results" grid tool

this is done synchronously with no progress indicator yet
This commit is contained in:
Lance Edgar 2024-08-24 14:26:13 -05:00
parent 6650ee698e
commit 6fa8b0aeaa
8 changed files with 426 additions and 13 deletions

View file

@ -28,7 +28,7 @@ import functools
import json import json
import logging import logging
import warnings import warnings
from collections import namedtuple from collections import namedtuple, OrderedDict
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
@ -339,6 +339,16 @@ class Grid:
sorting. sorting.
See :meth:`set_joiner()` for more info. 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__( def __init__(
@ -369,6 +379,7 @@ class Grid:
filters=None, filters=None,
filter_defaults=None, filter_defaults=None,
joiners=None, joiners=None,
tools=None,
): ):
self.request = request self.request = request
self.vue_tagname = vue_tagname self.vue_tagname = vue_tagname
@ -386,6 +397,7 @@ class Grid:
self.app = self.config.get_app() self.app = self.config.get_app()
self.set_columns(columns or self.get_columns()) self.set_columns(columns or self.get_columns())
self.set_tools(tools)
# sorting # sorting
self.sortable = sortable self.sortable = sortable
@ -658,6 +670,33 @@ class Grid:
""" """
self.actions.append(GridAction(self.request, key, **kwargs)) 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 # joining methods
############################## ##############################

View file

@ -90,6 +90,19 @@
</form> </form>
% endif % endif
<div style="display: flex; flex-direction: column; justify-content: space-between;">
## nb. this is needed to force tools to bottom
## TODO: should we put a context menu here?
<div></div>
<div class="wutta-grid-tools-wrapper">
% for html in grid.tools.values():
${html}
% endfor
</div>
</div>
</div> </div>
<${b}-table :data="data" <${b}-table :data="data"
@ -290,6 +303,14 @@
template: '#${grid.vue_tagname}-template', template: '#${grid.vue_tagname}-template',
computed: { computed: {
recordCount() {
% if grid.paginated:
return this.pagerStats.item_count
% else:
return this.data.length
% endif
},
directLink() { directLink() {
const params = new URLSearchParams(this.getAllParams()) const params = new URLSearchParams(this.getAllParams())
return `${request.path_url}?${'$'}{params}` return `${request.path_url}?${'$'}{params}`

View file

@ -23,6 +23,41 @@
% endif % endif
</%def> </%def>
<%def name="modify_vue_vars()">
${parent.modify_vue_vars()}
% if master.deletable_bulk and master.has_perm('delete_bulk'):
<script>
${grid.vue_component}Data.deleteResultsSubmitting = false
${grid.vue_component}.computed.deleteResultsDisabled = function() {
if (this.deleteResultsSubmitting) {
return true
}
if (!this.recordCount) {
return true
}
return false
}
${grid.vue_component}.methods.deleteResultsSubmit = function() {
## TODO: should give a better dialog here
const msg = "You are about to delete "
+ this.recordCount.toLocaleString('en')
+ " records.\n\nAre you sure?"
if (!confirm(msg)) {
return
}
this.deleteResultsSubmitting = true
this.$refs.deleteResultsForm.submit()
}
</script>
% endif
</%def>
<%def name="make_vue_components()"> <%def name="make_vue_components()">
${parent.make_vue_components()} ${parent.make_vue_components()}
% if grid is not Undefined: % if grid is not Undefined:

View file

@ -154,6 +154,7 @@ class CommonView(View):
'settings.view', 'settings.view',
'settings.edit', 'settings.edit',
'settings.delete', 'settings.delete',
'settings.delete_bulk',
'upgrades.list', 'upgrades.list',
'upgrades.create', 'upgrades.create',
'upgrades.view', 'upgrades.view',

View file

@ -24,6 +24,8 @@
Base Logic for Master Views Base Logic for Master Views
""" """
import logging
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy import orm from sqlalchemy import orm
@ -31,11 +33,14 @@ from pyramid.renderers import render_to_response
from webhelpers2.html import HTML from webhelpers2.html import HTML
from wuttaweb.views import View 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 wuttaweb.db import Session
from wuttjamaican.util import get_class_hierarchy from wuttjamaican.util import get_class_hierarchy
log = logging.getLogger(__name__)
class MasterView(View): class MasterView(View):
""" """
Base class for "master" views. Base class for "master" views.
@ -284,6 +289,12 @@ class MasterView(View):
See also :meth:`is_deletable()`. 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 .. attribute:: form_fields
List of fields for the model form. List of fields for the model form.
@ -321,6 +332,7 @@ class MasterView(View):
viewable = True viewable = True
editable = True editable = True
deletable = True deletable = True
deletable_bulk = False
has_autocomplete = False has_autocomplete = False
configurable = False configurable = False
@ -622,6 +634,76 @@ class MasterView(View):
session = self.app.get_session(obj) session = self.app.get_session(obj)
session.delete(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 # autocomplete methods
############################## ##############################
@ -1168,6 +1250,64 @@ class MasterView(View):
return True return True
return False return False
def make_button(
self,
label,
variant=None,
primary=False,
**kwargs,
):
"""
Make and return a HTML ``<b-button>`` 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::
<b-button type="is-primary"
icon-pack="fas"
icon-left="hand-pointer">
Click Me
</b-button>
"""
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): def render_to_response(self, template, context):
""" """
Locate and render an appropriate template, with the given Locate and render an appropriate template, with the given
@ -1378,6 +1518,14 @@ class MasterView(View):
kwargs['actions'] = actions 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'): if hasattr(self, 'grid_row_class'):
kwargs.setdefault('row_class', self.grid_row_class) kwargs.setdefault('row_class', self.grid_row_class)
kwargs.setdefault('filterable', self.filterable) kwargs.setdefault('filterable', self.filterable)
@ -2084,17 +2232,6 @@ class MasterView(View):
f'{permission_prefix}.create', f'{permission_prefix}.create',
f"Create new {model_title}") 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 # edit
if cls.editable: if cls.editable:
instance_url_prefix = cls.get_instance_url_prefix() instance_url_prefix = cls.get_instance_url_prefix()
@ -2119,6 +2256,18 @@ class MasterView(View):
f'{permission_prefix}.delete', f'{permission_prefix}.delete',
f"Delete {model_title}") 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 # autocomplete
if cls.has_autocomplete: if cls.has_autocomplete:
config.add_route(f'{route_prefix}.autocomplete', config.add_route(f'{route_prefix}.autocomplete',
@ -2138,3 +2287,16 @@ class MasterView(View):
config.add_wutta_permission(permission_prefix, config.add_wutta_permission(permission_prefix,
f'{permission_prefix}.configure', f'{permission_prefix}.configure',
f"Configure {model_title_plural}") 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}")

View file

@ -202,6 +202,7 @@ class SettingView(MasterView):
""" """
model_class = Setting model_class = Setting
model_title = "Raw Setting" model_title = "Raw Setting"
deletable_bulk = True
filter_defaults = { filter_defaults = {
'name': {'active': True}, 'name': {'active': True},
} }

View file

@ -254,6 +254,42 @@ class TestGrid(WebTestCase):
self.assertEqual(len(grid.actions), 1) self.assertEqual(len(grid.actions), 1)
self.assertIsInstance(grid.actions[0], mod.GridAction) 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): def test_get_pagesize_options(self):
grid = self.make_grid() grid = self.make_grid()

View file

@ -27,6 +27,7 @@ class TestMasterView(WebTestCase):
with patch.multiple(mod.MasterView, create=True, with patch.multiple(mod.MasterView, create=True,
model_name='Widget', model_name='Widget',
model_key='uuid', model_key='uuid',
deletable_bulk=True,
has_autocomplete=True, has_autocomplete=True,
configurable=True): configurable=True):
mod.MasterView.defaults(self.pyramid_config) mod.MasterView.defaults(self.pyramid_config)
@ -400,6 +401,33 @@ class TestMasterView(WebTestCase):
self.assertTrue(view.has_any_perm('list', 'view')) self.assertTrue(view.has_any_perm('list', 'view'))
self.assertTrue(self.request.has_any_perm('settings.list', 'settings.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('<b-button ', html)
self.assertIn('click me', html)
self.assertNotIn('is-primary', html)
# primary as primary
html = view.make_button('click me', primary=True)
self.assertIn('<b-button ', html)
self.assertIn('click me', html)
self.assertIn('is-primary', html)
# primary as variant
html = view.make_button('click me', variant='is-primary')
self.assertIn('<b-button ', html)
self.assertIn('click me', html)
self.assertIn('is-primary', html)
# primary as type
html = view.make_button('click me', type='is-primary')
self.assertIn('<b-button ', html)
self.assertIn('click me', html)
self.assertIn('is-primary', html)
def test_render_to_response(self): def test_render_to_response(self):
self.pyramid_config.include('wuttaweb.views.common') self.pyramid_config.include('wuttaweb.views.common')
self.pyramid_config.include('wuttaweb.views.auth') self.pyramid_config.include('wuttaweb.views.auth')
@ -473,6 +501,7 @@ class TestMasterView(WebTestCase):
self.assertEqual(grid.labels, {'name': "SETTING NAME"}) self.assertEqual(grid.labels, {'name': "SETTING NAME"})
def test_make_model_grid(self): def test_make_model_grid(self):
self.pyramid_config.add_route('settings.delete_bulk', '/settings/delete-bulk')
model = self.app.model model = self.app.model
# no model class # no model class
@ -525,6 +554,20 @@ class TestMasterView(WebTestCase):
grid = view.make_model_grid(session=self.session) grid = view.make_model_grid(session=self.session)
self.assertEqual(len(grid.actions), 3) self.assertEqual(len(grid.actions), 3)
# no tools by default
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
grid = view.make_model_grid(session=self.session)
self.assertEqual(grid.tools, {})
# delete-results tool added if master/perms allow
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting,
deletable_bulk=True):
with patch.object(self.request, 'is_root', new=True):
grid = view.make_model_grid(session=self.session)
self.assertIn('delete-results', grid.tools)
def test_get_grid_data(self): def test_get_grid_data(self):
model = self.app.model model = self.app.model
self.app.save_setting(self.session, 'foo', 'bar') self.app.save_setting(self.session, 'foo', 'bar')
@ -1053,6 +1096,81 @@ class TestMasterView(WebTestCase):
self.session.commit() self.session.commit()
self.assertEqual(self.session.query(model.Setting).count(), 0) self.assertEqual(self.session.query(model.Setting).count(), 0)
def test_delete_bulk(self):
self.pyramid_config.add_route('settings', '/settings/')
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)
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
view = self.make_view()
# sanity check on sample data
grid = view.make_model_grid(session=self.session)
data = grid.get_visible_data()
self.assertEqual(len(data), 9)
# and then let's filter it a little
self.request.GET = {'value': 's', 'value.verb': 'contains'}
grid = view.make_model_grid(session=self.session)
self.assertEqual(len(grid.filters), 2)
self.assertEqual(len(grid.active_filters), 1)
data = grid.get_visible_data()
self.assertEqual(len(data), 2)
# okay now let's delete those (gets redirected)
with patch.object(view, 'make_model_grid', return_value=grid):
response = view.delete_bulk(session=self.session)
self.assertEqual(response.status_code, 302)
self.assertEqual(self.session.query(model.Setting).count(), 7)
def test_delete_bulk_data(self):
self.pyramid_config.add_route('settings', '/settings/')
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)
with patch.multiple(mod.MasterView, create=True,
model_class=model.Setting):
view = self.make_view()
# basic bulk delete
self.assertEqual(self.session.query(model.Setting).count(), 9)
settings = self.session.query(model.Setting)\
.filter(model.Setting.value.ilike('%s%'))\
.all()
self.assertEqual(len(settings), 2)
view.delete_bulk_data(settings, session=self.session)
self.session.commit()
self.assertEqual(self.session.query(model.Setting).count(), 7)
def test_autocomplete(self): def test_autocomplete(self):
model = self.app.model model = self.app.model