From dd3d640b1c87f5bb9e0b15e6b27162645a173d0b Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 16 Aug 2024 18:19:24 -0500 Subject: [PATCH 1/3] feat: add initial/basic pagination for grids so far this is only for client-side pagination; which means *all* grid data is dumped to JSON for Vue access. backend pagination coming soon --- pyproject.toml | 1 + src/wuttaweb/grids/base.py | 109 +++++++++++++++++- .../templates/grids/vue_template.mako | 60 ++++++++-- src/wuttaweb/views/master.py | 11 ++ tests/grids/test_base.py | 56 ++++++--- 5 files changed, 207 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e07c98b..9570746 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ classifiers = [ requires-python = ">= 3.8" dependencies = [ "ColanderAlchemy", + "paginate", "pyramid>=2", "pyramid_beaker", "pyramid_deform", diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 740607c..5f5b34e 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -61,6 +61,11 @@ class Grid: Presumably unique key for the grid; used to track per-grid sort/filter settings etc. + .. attribute:: vue_tagname + + String name for Vue component tag. By default this is + ``'wutta-grid'``. See also :meth:`render_vue_tag()`. + .. attribute:: model_class Model class for the grid, if applicable. When set, this is @@ -106,15 +111,43 @@ class Grid: See also :meth:`set_link()` and :meth:`is_linked()`. - .. attribute:: vue_tagname + .. attribute:: paginated - String name for Vue component tag. By default this is - ``'wutta-grid'``. See also :meth:`render_vue_tag()`. + Boolean indicating whether the grid data should be paginated + vs. all data is shown at once. Default is ``False``. + + See also :attr:`pagesize` and :attr:`page`. + + .. attribute:: pagesize_options + + List of "page size" options for the grid. See also + :attr:`pagesize`. + + Only relevant if :attr:`paginated` is true. If not specified, + constructor will call :meth:`get_pagesize_options()` to get the + value. + + .. attribute:: pagesize + + Number of records to show in a data page. See also + :attr:`pagesize_options` and :attr:`page`. + + Only relevant if :attr:`paginated` is true. If not specified, + constructor will call :meth:`get_pagesize()` to get the value. + + .. attribute:: page + + The current page number (of data) to display in the grid. See + also :attr:`pagesize`. + + Only relevant if :attr:`paginated` is true. If not specified, + constructor will assume ``1`` (first page). """ def __init__( self, request, + vue_tagname='wutta-grid', model_class=None, key=None, columns=None, @@ -123,9 +156,13 @@ class Grid: renderers={}, actions=[], linked_columns=[], - vue_tagname='wutta-grid', + paginated=False, + pagesize_options=None, + pagesize=None, + page=1, ): self.request = request + self.vue_tagname = vue_tagname self.model_class = model_class self.key = key self.data = data @@ -133,13 +170,17 @@ class Grid: self.renderers = renderers or {} self.actions = actions or [] self.linked_columns = linked_columns or [] - self.vue_tagname = vue_tagname self.config = self.request.wutta_config self.app = self.config.get_app() self.set_columns(columns or self.get_columns()) + self.paginated = paginated + self.pagesize_options = pagesize_options or self.get_pagesize_options() + self.pagesize = pagesize or self.get_pagesize() + self.page = page + def get_columns(self): """ Returns the official list of column names for the grid, or @@ -340,6 +381,64 @@ class Grid: return True return False + ############################## + # paging methods + ############################## + + def get_pagesize_options(self, default=None): + """ + Returns a list of default page size options for the grid. + + It will check config but if no setting exists, will fall + back to:: + + [5, 10, 20, 50, 100, 200] + + :param default: Alternate default value to return if none is + configured. + + This method is intended for use in the constructor. Code can + instead access :attr:`pagesize_options` directly. + """ + options = self.config.get_list('wuttaweb.grids.default_pagesize_options') + if options: + options = [int(size) for size in options + if size.isdigit()] + if options: + return options + + return default or [5, 10, 20, 50, 100, 200] + + def get_pagesize(self, default=None): + """ + Returns the default page size for the grid. + + It will check config but if no setting exists, will fall back + to a value from :attr:`pagesize_options` (will return ``20`` if + that is listed; otherwise the "first" option). + + :param default: Alternate default value to return if none is + configured. + + This method is intended for use in the constructor. Code can + instead access :attr:`pagesize` directly. + """ + size = self.config.get_int('wuttaweb.grids.default_pagesize') + if size: + return size + + if default: + return default + + if 20 in self.pagesize_options: + return 20 + + return self.pagesize_options[0] + + ############################## + # rendering methods + ############################## + def render_vue_tag(self, **kwargs): """ Render the Vue component tag for the grid. diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index 5e60bab..0505c33 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -2,8 +2,20 @@ diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 1c7518d..0fb050d 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -181,6 +181,14 @@ class MasterView(View): This is optional; see also :meth:`get_grid_columns()`. + .. attribute:: paginated + + Boolean indicating whether the grid data for the + :meth:`index()` view should be paginated. Default is ``True``. + + This is used by :meth:`make_model_grid()` to set the grid's + :attr:`~wuttaweb.grids.base.Grid.paginated` flag. + .. attribute:: creatable Boolean indicating whether the view model supports "creating" - @@ -229,6 +237,7 @@ class MasterView(View): # features listable = True has_grid = True + paginated = True creatable = True viewable = True editable = True @@ -1061,6 +1070,8 @@ class MasterView(View): kwargs['actions'] = actions + kwargs.setdefault('paginated', self.paginated) + grid = self.make_grid(**kwargs) self.configure_grid(grid) return grid diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index 2d15689..9e23c02 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -8,24 +8,10 @@ from pyramid import testing from wuttjamaican.conf import WuttaConfig from wuttaweb.grids import base from wuttaweb.forms import FieldList +from tests.util import WebTestCase -class TestGrid(TestCase): - - def setUp(self): - self.config = WuttaConfig(defaults={ - 'wutta.web.menus.handler_spec': 'tests.util:NullMenuHandler', - }) - self.app = self.config.get_app() - - self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False) - - self.pyramid_config = testing.setUp(request=self.request, settings={ - 'mako.directories': ['wuttaweb:templates'], - }) - - def tearDown(self): - testing.tearDown() +class TestGrid(WebTestCase): def make_grid(self, request=None, **kwargs): return base.Grid(request or self.request, **kwargs) @@ -144,6 +130,44 @@ class TestGrid(TestCase): self.assertFalse(grid.is_linked('foo')) self.assertTrue(grid.is_linked('bar')) + def test_get_pagesize_options(self): + grid = self.make_grid() + + # default + options = grid.get_pagesize_options() + self.assertEqual(options, [5, 10, 20, 50, 100, 200]) + + # override default + options = grid.get_pagesize_options(default=[42]) + self.assertEqual(options, [42]) + + # from config + self.config.setdefault('wuttaweb.grids.default_pagesize_options', '1 2 3') + options = grid.get_pagesize_options() + self.assertEqual(options, [1, 2, 3]) + + def test_get_pagesize(self): + grid = self.make_grid() + + # default + size = grid.get_pagesize() + self.assertEqual(size, 20) + + # override default + size = grid.get_pagesize(default=42) + self.assertEqual(size, 42) + + # override default options + self.config.setdefault('wuttaweb.grids.default_pagesize_options', '10 15 30') + grid = self.make_grid() + size = grid.get_pagesize() + self.assertEqual(size, 10) + + # from config + self.config.setdefault('wuttaweb.grids.default_pagesize', '15') + size = grid.get_pagesize() + self.assertEqual(size, 15) + def test_render_vue_tag(self): grid = self.make_grid(columns=['foo', 'bar']) html = grid.render_vue_tag() From d151758c4851a5a967fa08725b7f3d06e387e74f Mon Sep 17 00:00:00 2001 From: Lance Edgar Date: Fri, 16 Aug 2024 22:52:24 -0500 Subject: [PATCH 2/3] feat: add backend pagination support for grids --- src/wuttaweb/grids/base.py | 193 +++++++++++++++++- .../templates/grids/vue_template.mako | 123 ++++++++++- src/wuttaweb/views/base.py | 11 + src/wuttaweb/views/master.py | 23 ++- tests/grids/test_base.py | 122 +++++++++++ tests/views/test_base.py | 50 +++-- tests/views/test_master.py | 8 + 7 files changed, 501 insertions(+), 29 deletions(-) diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index 5f5b34e..328637d 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -30,9 +30,11 @@ import logging import sqlalchemy as sa +import paginate from pyramid.renderers import render from webhelpers2.html import HTML +from wuttaweb.db import Session from wuttaweb.util import FieldList, get_model_fields, make_json_safe @@ -87,6 +89,9 @@ class Grid: model records) or else an object capable of producing such a list, e.g. SQLAlchemy query. + This is the "full" data set; see also + :meth:`get_visible_data()`. + .. attribute:: labels Dict of column label overrides. @@ -114,9 +119,23 @@ class Grid: .. attribute:: paginated Boolean indicating whether the grid data should be paginated - vs. all data is shown at once. Default is ``False``. + vs. all data shown at once. Default is ``False`` which means + the full set of grid data is sent for each request. - See also :attr:`pagesize` and :attr:`page`. + See also :attr:`pagesize` and :attr:`page`, and + :attr:`paginate_on_backend`. + + .. attribute:: paginate_on_backend + + Boolean indicating whether the grid data should be paginated on + the backend. Default is ``True`` which means only one "page" + of data is sent to the client-side component. + + If this is ``False``, the full set of grid data is sent for + each request, and the client-side Vue component will handle the + pagination. + + Only relevant if :attr:`paginated` is also true. .. attribute:: pagesize_options @@ -157,6 +176,7 @@ class Grid: actions=[], linked_columns=[], paginated=False, + paginate_on_backend=True, pagesize_options=None, pagesize=None, page=1, @@ -177,6 +197,7 @@ class Grid: self.set_columns(columns or self.get_columns()) self.paginated = paginated + self.paginate_on_backend = paginate_on_backend self.pagesize_options = pagesize_options or self.get_pagesize_options() self.pagesize = pagesize or self.get_pagesize() self.page = page @@ -435,6 +456,149 @@ class Grid: return self.pagesize_options[0] + ############################## + # configuration methods + ############################## + + def load_settings(self, store=True): + """ + Load all effective settings for the grid, from the following + places: + + * request params + * user session + + The first value found for a given setting will be applied to + the grid. + + .. note:: + + As of now, "pagination" settings are the only type + supported by this logic. Filter/sort coming soon... + + The overall logic for this method is as follows: + + * collect settings + * apply settings to current grid + * optionally save settings to user session + + Saving the settings to user session will allow the grid to + "remember" its current settings when user refreshes the page. + + :param store: Flag indicating whether the collected settings + should then be saved to the user session. + """ + + # initial default settings + settings = {} + if self.paginated and self.paginate_on_backend: + settings['pagesize'] = self.pagesize + settings['page'] = self.page + + # grab settings from request and/or user session + if self.paginated and self.paginate_on_backend: + self.update_page_settings(settings) + + else: + # no settings were found in request or user session, so + # nothing needs to be saved + store = False + + # maybe store settings in user session, for next time + if store: + self.persist_settings(settings) + + # update ourself to reflect settings + if self.paginated and self.paginate_on_backend: + self.pagesize = settings['pagesize'] + self.page = settings['page'] + + def request_has_settings(self): + """ """ + for key in ['pagesize', 'page']: + if key in self.request.GET: + return True + return False + + def update_page_settings(self, settings): + """ """ + # update the settings dict from request and/or user session + + # pagesize + pagesize = self.request.GET.get('pagesize') + if pagesize is not None: + if pagesize.isdigit(): + settings['pagesize'] = int(pagesize) + else: + pagesize = self.request.session.get(f'grid.{self.key}.pagesize') + if pagesize is not None: + settings['pagesize'] = pagesize + + # page + page = self.request.GET.get('page') + if page is not None: + if page.isdigit(): + settings['page'] = int(page) + else: + page = self.request.session.get(f'grid.{self.key}.page') + if page is not None: + settings['page'] = int(page) + + def persist_settings(self, settings): + """ """ + model = self.app.model + session = Session() + + # func to save a setting value to user session + def persist(key, value=lambda k: settings.get(k)): + skey = f'grid.{self.key}.{key}' + self.request.session[skey] = value(key) + + if self.paginated and self.paginate_on_backend: + persist('pagesize') + persist('page') + + ############################## + # data methods + ############################## + + def get_visible_data(self): + """ + Returns the "effective" visible data for the grid. + + This uses :attr:`data` as the starting point but may morph it + for pagination etc. per the grid settings. + + Code can either access :attr:`data` directly, or call this + method to get only the data for current view (e.g. assuming + pagination is used), depending on the need. + + See also these methods which may be called by this one: + + * :meth:`paginate_data()` + """ + data = self.data or [] + + if self.paginated and self.paginate_on_backend: + self.pager = self.paginate_data(data) + data = self.pager + + return data + + def paginate_data(self, data): + """ + Apply pagination to the given data set, based on grid settings. + + This returns a "pager" object which can then be used as a + "data replacement" in subsequent logic. + + This method is called by :meth:`get_visible_data()`. + """ + pager = paginate.Page(data, + items_per_page=self.pagesize, + page=self.page) + return pager + ############################## # rendering methods ############################## @@ -517,17 +681,18 @@ class Grid: """ Returns a list of Vue-compatible data records. - This uses :attr:`data` as the basis, but may add some extra - values to each record, e.g. URLs for :attr:`actions` etc. + This calls :meth:`get_visible_data()` but then may modify the + result, e.g. to add URLs for :attr:`actions` etc. Importantly, this also ensures each value in the dict is JSON-serializable, using :func:`~wuttaweb.util.make_json_safe()`. :returns: List of data record dicts for use with Vue table - component. + component. May be the full set of data, or just the + current page, per :attr:`paginate_on_backend`. """ - original_data = self.data or [] + original_data = self.get_visible_data() # TODO: at some point i thought it was useful to wrangle the # columns here, but now i can't seem to figure out why..? @@ -578,6 +743,22 @@ class Grid: return data + def get_vue_pager_stats(self): + """ + Returns a simple dict with current grid pager stats. + + This is used when :attr:`paginate_on_backend` is in effect. + """ + pager = self.pager + return { + 'item_count': pager.item_count, + 'items_per_page': pager.items_per_page, + 'page': pager.page, + 'page_count': pager.page_count, + 'first_item': pager.first_item, + 'last_item': pager.last_item, + } + class GridAction: """ diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index 0505c33..e588450 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -14,6 +14,11 @@ pagination-size="is-small" :per-page="perPage" :current-page="currentPage" + @page-change="onPageChange" + % if grid.paginate_on_backend: + backend-pagination + :total="pagerStats.item_count" + % endif % endif > @@ -53,8 +58,14 @@
showing + {{ renderNumber(pagerStats.first_item) }} + - {{ renderNumber(pagerStats.last_item) }} + of {{ renderNumber(pagerStats.item_count) }} results;