From 6fa8b0aeaaf1ffb2b5dfe0895d1c89b51e57e0e9 Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Sat, 24 Aug 2024 14:26:13 -0500 Subject: [PATCH] feat: add basic "delete results" grid tool this is done synchronously with no progress indicator yet --- src/wuttaweb/grids/base.py | 41 +++- .../templates/grids/vue_template.mako | 21 ++ src/wuttaweb/templates/master/index.mako | 35 ++++ src/wuttaweb/views/common.py | 1 + src/wuttaweb/views/master.py | 186 ++++++++++++++++-- src/wuttaweb/views/settings.py | 1 + tests/grids/test_base.py | 36 ++++ tests/views/test_master.py | 118 +++++++++++ 8 files changed, 426 insertions(+), 13 deletions(-) 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 name="modify_vue_vars()"> + ${parent.modify_vue_vars()} + % if master.deletable_bulk and master.has_perm('delete_bulk'): + + % endif + + <%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('