2
0
Fork 0

feat: add backend pagination support for grids

This commit is contained in:
Lance Edgar 2024-08-16 22:52:24 -05:00
parent dd3d640b1c
commit d151758c48
7 changed files with 501 additions and 29 deletions

View file

@ -30,9 +30,11 @@ import logging
import sqlalchemy as sa import sqlalchemy as sa
import paginate
from pyramid.renderers import render from pyramid.renderers import render
from webhelpers2.html import HTML from webhelpers2.html import HTML
from wuttaweb.db import Session
from wuttaweb.util import FieldList, get_model_fields, make_json_safe 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 model records) or else an object capable of producing such a
list, e.g. SQLAlchemy query. list, e.g. SQLAlchemy query.
This is the "full" data set; see also
:meth:`get_visible_data()`.
.. attribute:: labels .. attribute:: labels
Dict of column label overrides. Dict of column label overrides.
@ -114,9 +119,23 @@ class Grid:
.. attribute:: paginated .. attribute:: paginated
Boolean indicating whether the grid data should be 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 .. attribute:: pagesize_options
@ -157,6 +176,7 @@ class Grid:
actions=[], actions=[],
linked_columns=[], linked_columns=[],
paginated=False, paginated=False,
paginate_on_backend=True,
pagesize_options=None, pagesize_options=None,
pagesize=None, pagesize=None,
page=1, page=1,
@ -177,6 +197,7 @@ class Grid:
self.set_columns(columns or self.get_columns()) self.set_columns(columns or self.get_columns())
self.paginated = paginated self.paginated = paginated
self.paginate_on_backend = paginate_on_backend
self.pagesize_options = pagesize_options or self.get_pagesize_options() self.pagesize_options = pagesize_options or self.get_pagesize_options()
self.pagesize = pagesize or self.get_pagesize() self.pagesize = pagesize or self.get_pagesize()
self.page = page self.page = page
@ -435,6 +456,149 @@ class Grid:
return self.pagesize_options[0] 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 # rendering methods
############################## ##############################
@ -517,17 +681,18 @@ class Grid:
""" """
Returns a list of Vue-compatible data records. Returns a list of Vue-compatible data records.
This uses :attr:`data` as the basis, but may add some extra This calls :meth:`get_visible_data()` but then may modify the
values to each record, e.g. URLs for :attr:`actions` etc. result, e.g. to add URLs for :attr:`actions` etc.
Importantly, this also ensures each value in the dict is Importantly, this also ensures each value in the dict is
JSON-serializable, using JSON-serializable, using
:func:`~wuttaweb.util.make_json_safe()`. :func:`~wuttaweb.util.make_json_safe()`.
:returns: List of data record dicts for use with Vue table :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 # TODO: at some point i thought it was useful to wrangle the
# columns here, but now i can't seem to figure out why..? # columns here, but now i can't seem to figure out why..?
@ -578,6 +743,22 @@ class Grid:
return data 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: class GridAction:
""" """

View file

@ -14,6 +14,11 @@
pagination-size="is-small" pagination-size="is-small"
:per-page="perPage" :per-page="perPage"
:current-page="currentPage" :current-page="currentPage"
@page-change="onPageChange"
% if grid.paginate_on_backend:
backend-pagination
:total="pagerStats.item_count"
% endif
% endif % endif
> >
@ -53,8 +58,14 @@
<div style="display: flex; gap: 0.5rem; align-items: center;"> <div style="display: flex; gap: 0.5rem; align-items: center;">
<span> <span>
showing showing
{{ renderNumber(pagerStats.first_item) }}
- {{ renderNumber(pagerStats.last_item) }}
of {{ renderNumber(pagerStats.item_count) }} results;
</span> </span>
<b-select v-model="perPage" <b-select v-model="perPage"
% if grid.paginate_on_backend:
@input="onPageSizeChange"
% endif
size="is-small"> size="is-small">
<option v-for="size in pageSizeOptions" <option v-for="size in pageSizeOptions"
:value="size"> :value="size">
@ -74,7 +85,7 @@
<script> <script>
const ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_data())|n} let ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_data())|n}
const ${grid.vue_component}Data = { const ${grid.vue_component}Data = {
data: ${grid.vue_component}CurrentData, data: ${grid.vue_component}CurrentData,
@ -85,12 +96,120 @@
pageSizeOptions: ${json.dumps(grid.pagesize_options)|n}, pageSizeOptions: ${json.dumps(grid.pagesize_options)|n},
perPage: ${json.dumps(grid.pagesize)|n}, perPage: ${json.dumps(grid.pagesize)|n},
currentPage: ${json.dumps(grid.page)|n}, currentPage: ${json.dumps(grid.page)|n},
% if grid.paginate_on_backend:
pagerStats: ${json.dumps(grid.get_vue_pager_stats())|n},
% endif
% endif % endif
} }
const ${grid.vue_component} = { const ${grid.vue_component} = {
template: '#${grid.vue_tagname}-template', template: '#${grid.vue_tagname}-template',
methods: {}, computed: {
% if not grid.paginate_on_backend:
pagerStats() {
let last = this.currentPage * this.perPage
let first = last - this.perPage + 1
if (last > this.data.length) {
last = this.data.length
}
return {
'item_count': this.data.length,
'items_per_page': this.perPage,
'page': this.currentPage,
'first_item': first,
'last_item': last,
}
},
% endif
},
methods: {
renderNumber(value) {
if (value != undefined) {
return value.toLocaleString('en')
}
},
getBasicParams() {
return {
% if grid.paginated and grid.paginate_on_backend:
pagesize: this.perPage,
page: this.currentPage,
% endif
}
},
async fetchData(params, success, failure) {
if (params === undefined || params === null) {
params = new URLSearchParams(this.getBasicParams())
} else {
params = new URLSearchParams(params)
}
if (!params.has('partial')) {
params.append('partial', true)
}
params = params.toString()
this.loading = true
this.$http.get(`${request.path_url}?${'$'}{params}`).then(response => {
console.log(response)
console.log(response.data)
if (!response.data.error) {
${grid.vue_component}CurrentData = response.data.data
this.data = ${grid.vue_component}CurrentData
% if grid.paginated and grid.paginate_on_backend:
this.pagerStats = response.data.pager_stats
% endif
this.loading = false
if (success) {
success()
}
} else {
this.$buefy.toast.open({
message: data.error,
type: 'is-danger',
duration: 2000, // 4 seconds
})
this.loading = false
if (failure) {
failure()
}
}
})
.catch((error) => {
this.data = []
% if grid.paginated and grid.paginate_on_backend:
this.pagerStats = {}
% endif
this.loading = false
if (failure) {
failure()
}
throw error
})
},
% if grid.paginated:
% if grid.paginate_on_backend:
onPageSizeChange(size) {
this.fetchData()
},
% endif
onPageChange(page) {
this.currentPage = page
% if grid.paginate_on_backend:
this.fetchData()
% endif
},
% endif
},
} }
</script> </script>

View file

@ -25,6 +25,7 @@ Base Logic for Views
""" """
from pyramid import httpexceptions from pyramid import httpexceptions
from pyramid.renderers import render_to_response
from wuttaweb import forms, grids from wuttaweb import forms, grids
@ -117,3 +118,13 @@ class View:
correctly no matter what. correctly no matter what.
""" """
return httpexceptions.HTTPFound(location=url, **kwargs) 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)

View file

@ -189,6 +189,15 @@ class MasterView(View):
This is used by :meth:`make_model_grid()` to set the grid's This is used by :meth:`make_model_grid()` to set the grid's
:attr:`~wuttaweb.grids.base.Grid.paginated` flag. :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 .. attribute:: creatable
Boolean indicating whether the view model supports "creating" - Boolean indicating whether the view model supports "creating" -
@ -238,6 +247,7 @@ class MasterView(View):
listable = True listable = True
has_grid = True has_grid = True
paginated = True paginated = True
paginate_on_backend = True
creatable = True creatable = True
viewable = True viewable = True
editable = True editable = True
@ -284,7 +294,16 @@ class MasterView(View):
} }
if self.has_grid: 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) return self.render_to_response('index', context)
@ -1071,9 +1090,11 @@ class MasterView(View):
kwargs['actions'] = actions kwargs['actions'] = actions
kwargs.setdefault('paginated', self.paginated) kwargs.setdefault('paginated', self.paginated)
kwargs.setdefault('paginate_on_backend', self.paginate_on_backend)
grid = self.make_grid(**kwargs) grid = self.make_grid(**kwargs)
self.configure_grid(grid) self.configure_grid(grid)
grid.load_settings()
return grid return grid
def get_grid_columns(self): def get_grid_columns(self):

View file

@ -3,6 +3,7 @@
from unittest import TestCase from unittest import TestCase
from unittest.mock import patch from unittest.mock import patch
from paginate import Page
from pyramid import testing from pyramid import testing
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
@ -168,6 +169,105 @@ class TestGrid(WebTestCase):
size = grid.get_pagesize() size = grid.get_pagesize()
self.assertEqual(size, 15) 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): def test_render_vue_tag(self):
grid = self.make_grid(columns=['foo', 'bar']) grid = self.make_grid(columns=['foo', 'bar'])
html = grid.render_vue_tag() html = grid.render_vue_tag()
@ -221,6 +321,28 @@ class TestGrid(WebTestCase):
data = grid.get_vue_data() data = grid.get_vue_data()
self.assertEqual(data, [{'foo': 'blah blah', '_action_url_view': '/blarg'}]) 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): class TestGridAction(TestCase):

View file

@ -1,46 +1,56 @@
# -*- coding: utf-8; -*- # -*- coding: utf-8; -*-
from unittest import TestCase
from pyramid import testing
from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPNotFound from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPNotFound
from wuttjamaican.conf import WuttaConfig from wuttaweb.views import base as mod
from wuttaweb.views import base
from wuttaweb.forms import Form 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): def make_view(self):
self.config = WuttaConfig() return mod.View(self.request)
self.app = self.config.get_app()
self.request = testing.DummyRequest(wutta_config=self.config)
self.view = base.View(self.request)
def test_basic(self): def test_basic(self):
self.assertIs(self.view.request, self.request) view = self.make_view()
self.assertIs(self.view.config, self.config) self.assertIs(view.request, self.request)
self.assertIs(self.view.app, self.app) self.assertIs(view.config, self.config)
self.assertIs(view.app, self.app)
def test_forbidden(self): def test_forbidden(self):
error = self.view.forbidden() view = self.make_view()
error = view.forbidden()
self.assertIsInstance(error, HTTPForbidden) self.assertIsInstance(error, HTTPForbidden)
def test_make_form(self): def test_make_form(self):
form = self.view.make_form() view = self.make_view()
form = view.make_form()
self.assertIsInstance(form, Form) self.assertIsInstance(form, Form)
def test_make_grid(self): def test_make_grid(self):
grid = self.view.make_grid() view = self.make_view()
grid = view.make_grid()
self.assertIsInstance(grid, 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): def test_notfound(self):
error = self.view.notfound() view = self.make_view()
error = view.notfound()
self.assertIsInstance(error, HTTPNotFound) self.assertIsInstance(error, HTTPNotFound)
def test_redirect(self): def test_redirect(self):
error = self.view.redirect('/') view = self.make_view()
error = view.redirect('/')
self.assertIsInstance(error, HTTPFound) self.assertIsInstance(error, HTTPFound)
self.assertEqual(error.location, '/') 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)

View file

@ -747,6 +747,14 @@ class TestMasterView(WebTestCase):
data = [{'name': 'foo', 'value': 'bar'}] data = [{'name': 'foo', 'value': 'bar'}]
with patch.object(view, 'get_grid_data', return_value=data): with patch.object(view, 'get_grid_data', return_value=data):
response = view.index() 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): def test_create(self):
self.pyramid_config.include('wuttaweb.views.common') self.pyramid_config.include('wuttaweb.views.common')