diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e22370..dad38d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to wuttaweb will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## v0.9.0 (2024-08-16) + +### Feat + +- add backend pagination support for grids +- add initial/basic pagination for grids + ## v0.8.1 (2024-08-15) ### Fix diff --git a/pyproject.toml b/pyproject.toml index e07c98b..9e092c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.8.1" +version = "0.9.0" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -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..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 @@ -61,6 +63,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 @@ -82,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. @@ -106,15 +116,57 @@ 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 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`, 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 + + 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 +175,14 @@ class Grid: renderers={}, actions=[], linked_columns=[], - vue_tagname='wutta-grid', + paginated=False, + paginate_on_backend=True, + 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 +190,18 @@ 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.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 + def get_columns(self): """ Returns the official list of column names for the grid, or @@ -340,6 +402,207 @@ 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] + + ############################## + # 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 + ############################## + def render_vue_tag(self, **kwargs): """ Render the Vue component tag for the grid. @@ -418,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..? @@ -479,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 5e60bab..e588450 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -2,8 +2,25 @@ diff --git a/src/wuttaweb/views/base.py b/src/wuttaweb/views/base.py index ccdc749..bc1e76c 100644 --- a/src/wuttaweb/views/base.py +++ b/src/wuttaweb/views/base.py @@ -25,6 +25,7 @@ Base Logic for Views """ from pyramid import httpexceptions +from pyramid.renderers import render_to_response from wuttaweb import forms, grids @@ -117,3 +118,13 @@ class View: correctly no matter what. """ return httpexceptions.HTTPFound(location=url, **kwargs) + + def json_response(self, context): + """ + Convenience method to return a JSON response. + + :param context: Context data to be rendered as JSON. + + :returns: A :term:`response` with JSON content type. + """ + return render_to_response('json', context, request=self.request) diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 1c7518d..8a72cc9 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -181,6 +181,23 @@ 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:: paginate_on_backend + + Boolean indicating whether the grid data for the + :meth:`index()` view should be paginated on the backend. + Default is ``True``. + + This is used by :meth:`make_model_grid()` to set the grid's + :attr:`~wuttaweb.grids.base.Grid.paginate_on_backend` flag. + .. attribute:: creatable Boolean indicating whether the view model supports "creating" - @@ -229,6 +246,8 @@ class MasterView(View): # features listable = True has_grid = True + paginated = True + paginate_on_backend = True creatable = True viewable = True editable = True @@ -275,7 +294,16 @@ class MasterView(View): } if self.has_grid: - context['grid'] = self.make_model_grid() + grid = self.make_model_grid() + + # so-called 'partial' requests get just data, no html + if self.request.GET.get('partial'): + context = {'data': grid.get_vue_data()} + if grid.paginated and grid.paginate_on_backend: + context['pager_stats'] = grid.get_vue_pager_stats() + return self.json_response(context) + + context['grid'] = grid return self.render_to_response('index', context) @@ -1061,8 +1089,12 @@ class MasterView(View): kwargs['actions'] = actions + kwargs.setdefault('paginated', self.paginated) + kwargs.setdefault('paginate_on_backend', self.paginate_on_backend) + grid = self.make_grid(**kwargs) self.configure_grid(grid) + grid.load_settings() return grid def get_grid_columns(self): diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index 2d15689..160b1a4 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -3,29 +3,16 @@ from unittest import TestCase from unittest.mock import patch +from paginate import Page 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 +131,143 @@ 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) + + ############################## + # configuration methods + ############################## + + def test_load_settings(self): + grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True, + pagesize=20, page=1) + + # settings are loaded, applied, saved + self.assertEqual(grid.page, 1) + self.assertNotIn('grid.foo.page', self.request.session) + self.request.GET = {'pagesize': '10', 'page': '2'} + grid.load_settings() + self.assertEqual(grid.page, 2) + self.assertEqual(self.request.session['grid.foo.page'], 2) + + # can skip the saving step + self.request.GET = {'pagesize': '10', 'page': '3'} + grid.load_settings(store=False) + self.assertEqual(grid.page, 3) + self.assertEqual(self.request.session['grid.foo.page'], 2) + + # no error for non-paginated grid + grid = self.make_grid(key='foo', paginated=False) + grid.load_settings() + self.assertFalse(grid.paginated) + + def test_request_has_settings(self): + grid = self.make_grid(key='foo') + + self.assertFalse(grid.request_has_settings()) + + with patch.object(self.request, 'GET', new={'pagesize': '20'}): + self.assertTrue(grid.request_has_settings()) + + with patch.object(self.request, 'GET', new={'page': '1'}): + self.assertTrue(grid.request_has_settings()) + + def test_update_page_settings(self): + grid = self.make_grid(key='foo') + + # settings are updated from session + settings = {'pagesize': 20, 'page': 1} + self.request.session['grid.foo.pagesize'] = 10 + self.request.session['grid.foo.page'] = 2 + grid.update_page_settings(settings) + self.assertEqual(settings['pagesize'], 10) + self.assertEqual(settings['page'], 2) + + # settings are updated from request + self.request.GET = {'pagesize': '15', 'page': '4'} + grid.update_page_settings(settings) + self.assertEqual(settings['pagesize'], 15) + self.assertEqual(settings['page'], 4) + + def test_persist_settings(self): + grid = self.make_grid(key='foo', paginated=True, paginate_on_backend=True) + + # nb. no error if empty settings, but it saves null values + grid.persist_settings({}) + self.assertIsNone(self.request.session['grid.foo.page']) + + # provided values are saved + grid.persist_settings({'pagesize': 15, 'page': 3}) + self.assertEqual(self.request.session['grid.foo.page'], 3) + + ############################## + # data methods + ############################## + + def test_get_visible_data(self): + data = [ + {'foo': 1, 'bar': 1}, + {'foo': 2, 'bar': 2}, + {'foo': 3, 'bar': 3}, + {'foo': 4, 'bar': 4}, + {'foo': 5, 'bar': 5}, + {'foo': 6, 'bar': 6}, + {'foo': 7, 'bar': 7}, + {'foo': 8, 'bar': 8}, + {'foo': 9, 'bar': 9}, + ] + grid = self.make_grid(data=data, + columns=['foo', 'bar'], + paginated=True, paginate_on_backend=True, + pagesize=4, page=2) + visible = grid.get_visible_data() + self.assertEqual(len(visible), 4) + self.assertEqual(visible[0], {'foo': 5, 'bar': 5}) + + def test_paginate_data(self): + grid = self.make_grid() + pager = grid.paginate_data([]) + self.assertIsInstance(pager, Page) + + ############################## + # rendering methods + ############################## + def test_render_vue_tag(self): grid = self.make_grid(columns=['foo', 'bar']) html = grid.render_vue_tag() @@ -197,6 +321,28 @@ class TestGrid(TestCase): data = grid.get_vue_data() self.assertEqual(data, [{'foo': 'blah blah', '_action_url_view': '/blarg'}]) + def test_get_vue_pager_stats(self): + data = [ + {'foo': 1, 'bar': 1}, + {'foo': 2, 'bar': 2}, + {'foo': 3, 'bar': 3}, + {'foo': 4, 'bar': 4}, + {'foo': 5, 'bar': 5}, + {'foo': 6, 'bar': 6}, + {'foo': 7, 'bar': 7}, + {'foo': 8, 'bar': 8}, + {'foo': 9, 'bar': 9}, + ] + + grid = self.make_grid(columns=['foo', 'bar'], pagesize=4, page=2) + grid.pager = grid.paginate_data(data) + stats = grid.get_vue_pager_stats() + self.assertEqual(stats['item_count'], 9) + self.assertEqual(stats['items_per_page'], 4) + self.assertEqual(stats['page'], 2) + self.assertEqual(stats['first_item'], 5) + self.assertEqual(stats['last_item'], 8) + class TestGridAction(TestCase): diff --git a/tests/views/test_base.py b/tests/views/test_base.py index 67f3f93..f86fc8f 100644 --- a/tests/views/test_base.py +++ b/tests/views/test_base.py @@ -1,46 +1,56 @@ # -*- coding: utf-8; -*- -from unittest import TestCase - -from pyramid import testing from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPNotFound -from wuttjamaican.conf import WuttaConfig -from wuttaweb.views import base +from wuttaweb.views import base as mod from wuttaweb.forms import Form -from wuttaweb.grids import Grid +from wuttaweb.grids import Grid, GridAction +from tests.util import WebTestCase -class TestView(TestCase): +class TestView(WebTestCase): - def setUp(self): - self.config = WuttaConfig() - self.app = self.config.get_app() - self.request = testing.DummyRequest(wutta_config=self.config) - self.view = base.View(self.request) + def make_view(self): + return mod.View(self.request) def test_basic(self): - self.assertIs(self.view.request, self.request) - self.assertIs(self.view.config, self.config) - self.assertIs(self.view.app, self.app) + view = self.make_view() + self.assertIs(view.request, self.request) + self.assertIs(view.config, self.config) + self.assertIs(view.app, self.app) def test_forbidden(self): - error = self.view.forbidden() + view = self.make_view() + error = view.forbidden() self.assertIsInstance(error, HTTPForbidden) def test_make_form(self): - form = self.view.make_form() + view = self.make_view() + form = view.make_form() self.assertIsInstance(form, Form) def test_make_grid(self): - grid = self.view.make_grid() + view = self.make_view() + grid = view.make_grid() self.assertIsInstance(grid, Grid) + def test_make_grid_action(self): + view = self.make_view() + action = view.make_grid_action('view') + self.assertIsInstance(action, GridAction) + def test_notfound(self): - error = self.view.notfound() + view = self.make_view() + error = view.notfound() self.assertIsInstance(error, HTTPNotFound) def test_redirect(self): - error = self.view.redirect('/') + view = self.make_view() + error = view.redirect('/') self.assertIsInstance(error, HTTPFound) self.assertEqual(error.location, '/') + + def test_json_response(self): + view = self.make_view() + response = view.json_response({'foo': 'bar'}) + self.assertEqual(response.status_code, 200) diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 8647b2a..719ddb8 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -747,6 +747,14 @@ class TestMasterView(WebTestCase): data = [{'name': 'foo', 'value': 'bar'}] with patch.object(view, 'get_grid_data', return_value=data): response = view.index() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, 'text/html') + + # then once more as 'partial' - aka. data only + self.request.GET = {'partial': '1'} + response = view.index() + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, 'application/json') def test_create(self): self.pyramid_config.include('wuttaweb.views.common')