diff --git a/CHANGELOG.md b/CHANGELOG.md index dad38d5..2e22370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,6 @@ 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 9e092c6..e07c98b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaWeb" -version = "0.9.0" +version = "0.8.1" description = "Web App for Wutta Framework" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@edbob.org"}] @@ -31,7 +31,6 @@ 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 328637d..740607c 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -30,11 +30,9 @@ 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 @@ -63,11 +61,6 @@ 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 @@ -89,9 +82,6 @@ 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. @@ -116,57 +106,15 @@ class Grid: See also :meth:`set_link()` and :meth:`is_linked()`. - .. attribute:: paginated + .. attribute:: vue_tagname - 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). + String name for Vue component tag. By default this is + ``'wutta-grid'``. See also :meth:`render_vue_tag()`. """ def __init__( self, request, - vue_tagname='wutta-grid', model_class=None, key=None, columns=None, @@ -175,14 +123,9 @@ class Grid: renderers={}, actions=[], linked_columns=[], - paginated=False, - paginate_on_backend=True, - pagesize_options=None, - pagesize=None, - page=1, + vue_tagname='wutta-grid', ): self.request = request - self.vue_tagname = vue_tagname self.model_class = model_class self.key = key self.data = data @@ -190,18 +133,13 @@ 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 @@ -402,207 +340,6 @@ 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. @@ -681,18 +418,17 @@ class Grid: """ Returns a list of Vue-compatible data records. - This calls :meth:`get_visible_data()` but then may modify the - result, e.g. to add URLs for :attr:`actions` etc. + This uses :attr:`data` as the basis, but may add some extra + values to each record, e.g. 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. May be the full set of data, or just the - current page, per :attr:`paginate_on_backend`. + component. """ - original_data = self.get_visible_data() + original_data = self.data or [] # TODO: at some point i thought it was useful to wrangle the # columns here, but now i can't seem to figure out why..? @@ -743,22 +479,6 @@ 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 e588450..5e60bab 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -2,25 +2,8 @@ diff --git a/src/wuttaweb/views/base.py b/src/wuttaweb/views/base.py index bc1e76c..ccdc749 100644 --- a/src/wuttaweb/views/base.py +++ b/src/wuttaweb/views/base.py @@ -25,7 +25,6 @@ Base Logic for Views """ from pyramid import httpexceptions -from pyramid.renderers import render_to_response from wuttaweb import forms, grids @@ -118,13 +117,3 @@ 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 8a72cc9..1c7518d 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -181,23 +181,6 @@ 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" - @@ -246,8 +229,6 @@ class MasterView(View): # features listable = True has_grid = True - paginated = True - paginate_on_backend = True creatable = True viewable = True editable = True @@ -294,16 +275,7 @@ class MasterView(View): } if self.has_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 + context['grid'] = self.make_model_grid() return self.render_to_response('index', context) @@ -1089,12 +1061,8 @@ 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 160b1a4..2d15689 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -3,16 +3,29 @@ 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(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() def make_grid(self, request=None, **kwargs): return base.Grid(request or self.request, **kwargs) @@ -131,143 +144,6 @@ class TestGrid(WebTestCase): 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() @@ -321,28 +197,6 @@ class TestGrid(WebTestCase): 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 f86fc8f..67f3f93 100644 --- a/tests/views/test_base.py +++ b/tests/views/test_base.py @@ -1,56 +1,46 @@ # -*- coding: utf-8; -*- +from unittest import TestCase + +from pyramid import testing from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPNotFound -from wuttaweb.views import base as mod +from wuttjamaican.conf import WuttaConfig +from wuttaweb.views import base from wuttaweb.forms import Form -from wuttaweb.grids import Grid, GridAction -from tests.util import WebTestCase +from wuttaweb.grids import Grid -class TestView(WebTestCase): +class TestView(TestCase): - def make_view(self): - return mod.View(self.request) + 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 test_basic(self): - view = self.make_view() - self.assertIs(view.request, self.request) - self.assertIs(view.config, self.config) - self.assertIs(view.app, self.app) + self.assertIs(self.view.request, self.request) + self.assertIs(self.view.config, self.config) + self.assertIs(self.view.app, self.app) def test_forbidden(self): - view = self.make_view() - error = view.forbidden() + error = self.view.forbidden() self.assertIsInstance(error, HTTPForbidden) def test_make_form(self): - view = self.make_view() - form = view.make_form() + form = self.view.make_form() self.assertIsInstance(form, Form) def test_make_grid(self): - view = self.make_view() - grid = view.make_grid() + grid = self.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): - view = self.make_view() - error = view.notfound() + error = self.view.notfound() self.assertIsInstance(error, HTTPNotFound) def test_redirect(self): - view = self.make_view() - error = view.redirect('/') + error = self.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 719ddb8..8647b2a 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -747,14 +747,6 @@ 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')