2
0
Fork 0

feat: add basic Grid class, and /settings master view

This commit is contained in:
Lance Edgar 2024-08-07 14:00:53 -05:00
parent 2ad1ae9c49
commit 754e0989e4
18 changed files with 640 additions and 12 deletions

View file

@ -0,0 +1,6 @@
``wuttaweb.grids.base``
=======================
.. automodule:: wuttaweb.grids.base
:members:

View file

@ -0,0 +1,6 @@
``wuttaweb.grids``
==================
.. automodule:: wuttaweb.grids
:members:

View file

@ -12,6 +12,8 @@
db db
forms forms
forms.base forms.base
grids
grids.base
handler handler
helpers helpers
menus menus

View file

@ -26,6 +26,7 @@ Forms Library
The ``wuttaweb.forms`` namespace contains the following: The ``wuttaweb.forms`` namespace contains the following:
* :class:`~wuttaweb.forms.base.Form` * :class:`~wuttaweb.forms.base.Form`
* :class:`~wuttaweb.forms.base.FieldList`
""" """
from .base import Form from .base import Form, FieldList

View file

@ -44,7 +44,8 @@ class FieldList(list):
of :class:`python:list`. of :class:`python:list`.
You normally would not need to instantiate this yourself, but it You normally would not need to instantiate this yourself, but it
is used under the hood for e.g. :attr:`Form.fields`. is used under the hood for :attr:`Form.fields` as well as
:attr:`~wuttaweb.grids.base.Grid.columns`.
""" """
def insert_before(self, field, newfield): def insert_before(self, field, newfield):

View file

@ -0,0 +1,31 @@
# -*- coding: utf-8; -*-
################################################################################
#
# wuttaweb -- Web App for Wutta Framework
# Copyright © 2024 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Grids Library
The ``wuttaweb.grids`` namespace contains the following:
* :class:`~wuttaweb.grids.base.Grid`
"""
from .base import Grid

203
src/wuttaweb/grids/base.py Normal file
View file

@ -0,0 +1,203 @@
# -*- coding: utf-8; -*-
################################################################################
#
# wuttaweb -- Web App for Wutta Framework
# Copyright © 2024 Lance Edgar
#
# This file is part of Wutta Framework.
#
# Wutta Framework is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# Wutta Framework is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# Wutta Framework. If not, see <http://www.gnu.org/licenses/>.
#
################################################################################
"""
Base grid classes
"""
from pyramid.renderers import render
from webhelpers2.html import HTML
from wuttaweb.forms import FieldList
class Grid:
"""
Base class for all grids.
:param request: Reference to current :term:`request` object.
:param columns: List of column names for the grid. This is
optional; if not specified an attempt will be made to deduce
the list automatically. See also :attr:`columns`.
.. note::
Some parameters are not explicitly described above. However
their corresponding attributes are described below.
Grid instances contain the following attributes:
.. attribute:: key
Presumably unique key for the grid; used to track per-grid
sort/filter settings etc.
.. attribute:: columns
:class:`~wuttaweb.forms.base.FieldList` instance containing
string column names for the grid. Columns will appear in the
same order as they are in this list.
See also :meth:`set_columns()`.
.. attribute:: data
Data set for the grid. This should either be a list of dicts
(or objects with dict-like access to fields, corresponding to
model records) or else an object capable of producing such a
list, e.g. SQLAlchemy query.
.. attribute:: vue_tagname
String name for Vue component tag. By default this is
``'wutta-grid'``. See also :meth:`render_vue_tag()`.
"""
def __init__(
self,
request,
key=None,
columns=None,
data=None,
vue_tagname='wutta-grid',
):
self.request = request
self.key = key
self.data = data
self.vue_tagname = vue_tagname
self.config = self.request.wutta_config
self.app = self.config.get_app()
if columns is not None:
self.set_columns(columns)
else:
self.columns = None
@property
def vue_component(self):
"""
String name for the Vue component, e.g. ``'WuttaGrid'``.
This is a generated value based on :attr:`vue_tagname`.
"""
words = self.vue_tagname.split('-')
return ''.join([word.capitalize() for word in words])
def set_columns(self, columns):
"""
Explicitly set the list of grid columns.
This will overwrite :attr:`columns` with a new
:class:`~wuttaweb.forms.base.FieldList` instance.
:param columns: List of string column names.
"""
self.columns = FieldList(columns)
def render_vue_tag(self, **kwargs):
"""
Render the Vue component tag for the grid.
By default this simply returns:
.. code-block:: html
<wutta-grid></wutta-grid>
The actual output will depend on various grid attributes, in
particular :attr:`vue_tagname`.
"""
return HTML.tag(self.vue_tagname, **kwargs)
def render_vue_template(
self,
template='/grids/vue_template.mako',
**context):
"""
Render the Vue template block for the grid.
This returns something like:
.. code-block:: none
<script type="text/x-template" id="wutta-grid-template">
<b-table>
<!-- columns etc. -->
</b-table>
</script>
.. todo::
Why can't Sphinx render the above code block as 'html' ?
It acts like it can't handle a ``<script>`` tag at all?
Actual output will of course depend on grid attributes,
:attr:`vue_tagname` and :attr:`columns` etc.
:param template: Path to Mako template which is used to render
the output.
"""
context['grid'] = self
context.setdefault('request', self.request)
output = render(template, context)
return HTML.literal(output)
def get_vue_columns(self):
"""
Returns a list of Vue-compatible column definitions.
This uses :attr:`columns` as the basis; each definition
returned will be a dict in this format::
{
'field': 'foo',
'label': "Foo",
}
See also :meth:`get_vue_data()`.
"""
if not self.columns:
raise ValueError(f"you must define columns for the grid! key = {self.key}")
columns = []
for name in self.columns:
columns.append({
'field': name,
'label': self.app.make_title(name),
})
return columns
def get_vue_data(self):
"""
Returns a list of Vue-compatible data records.
This uses :attr:`data` as the basis.
TODO: not clear yet how/where "non-simple" data should be
converted?
See also :meth:`get_vue_columns()`.
"""
return self.data # TODO

View file

@ -122,6 +122,11 @@ class MenuHandler(GenericHandler):
'route': 'appinfo', 'route': 'appinfo',
'perm': 'appinfo.list', 'perm': 'appinfo.list',
}, },
{
'title': "Raw Settings",
'route': 'settings',
'perm': 'settings.list',
},
], ],
} }

View file

@ -0,0 +1,33 @@
## -*- coding: utf-8; -*-
<script type="text/x-template" id="${grid.vue_tagname}-template">
<${b}-table :data="data"
:loading="loading">
% for column in grid.get_vue_columns():
<${b}-table-column field="${column['field']}"
label="${column['label']}"
v-slot="props"
cell-class="c_${column['field']}">
<span v-html="props.row.${column['field']}"></span>
</${b}-table-column>
% endfor
</${b}-table>
</script>
<script>
let ${grid.vue_component} = {
template: '#${grid.vue_tagname}-template',
methods: {},
}
let ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_data())|n}
let ${grid.vue_component}Data = {
data: ${grid.vue_component}CurrentData,
loading: false,
}
</script>

View file

@ -3,11 +3,30 @@
<%def name="title()">${index_title}</%def> <%def name="title()">${index_title}</%def>
## nb. avoid hero bar for index page
<%def name="content_title()"></%def> <%def name="content_title()"></%def>
<%def name="page_content()"> <%def name="page_content()">
<p>TODO: index page content</p> % if grid is not Undefined:
${grid.render_vue_tag()}
% endif
</%def> </%def>
<%def name="render_this_page_template()">
${parent.render_this_page_template()}
% if grid is not Undefined:
${grid.render_vue_template()}
% endif
</%def>
<%def name="finalize_this_page_vars()">
${parent.finalize_this_page_vars()}
% if grid is not Undefined:
<script>
${grid.vue_component}.data = function() { return ${grid.vue_component}Data }
Vue.component('${grid.vue_tagname}', ${grid.vue_component})
</script>
% endif
</%def>
${parent.body()} ${parent.body()}

View file

@ -26,7 +26,7 @@ Base Logic for Views
from pyramid import httpexceptions from pyramid import httpexceptions
from wuttaweb import forms from wuttaweb import forms, grids
class View: class View:
@ -68,11 +68,21 @@ class View:
Make and return a new :class:`~wuttaweb.forms.base.Form` Make and return a new :class:`~wuttaweb.forms.base.Form`
instance, per the given ``kwargs``. instance, per the given ``kwargs``.
This is the "default" form factory which merely invokes This is the "base" form factory which merely invokes the
the constructor. constructor.
""" """
return forms.Form(self.request, **kwargs) return forms.Form(self.request, **kwargs)
def make_grid(self, **kwargs):
"""
Make and return a new :class:`~wuttaweb.grids.base.Grid`
instance, per the given ``kwargs``.
This is the "base" grid factory which merely invokes the
constructor.
"""
return grids.Grid(self.request, **kwargs)
def notfound(self): def notfound(self):
""" """
Convenience method, to raise a HTTP 404 Not Found exception:: Convenience method, to raise a HTTP 404 Not Found exception::

View file

@ -100,6 +100,13 @@ class MasterView(View):
Code should not access this directly but instead call Code should not access this directly but instead call
:meth:`get_model_title_plural()`. :meth:`get_model_title_plural()`.
.. attribute:: grid_key
Optional override for the view's grid key, e.g. ``'widgets'``.
Code should not access this directly but instead call
:meth:`get_grid_key()`.
.. attribute:: config_title .. attribute:: config_title
Optional override for the view's "config" title, e.g. ``"Wutta Optional override for the view's "config" title, e.g. ``"Wutta
@ -138,6 +145,17 @@ class MasterView(View):
i.e. it should have an :meth:`index()` view. Default value is i.e. it should have an :meth:`index()` view. Default value is
``True``. ``True``.
.. attribute:: has_grid
Boolean indicating whether the :meth:`index()` view should
include a grid. Default value is ``True``.
.. attribute:: grid_columns
List of columns for the :meth:`index()` view grid.
This is optional; see also :meth:`index_get_grid_columns()`.
.. attribute:: configurable .. attribute:: configurable
Boolean indicating whether the master view supports Boolean indicating whether the master view supports
@ -151,9 +169,11 @@ class MasterView(View):
# features # features
listable = True listable = True
has_grid = True
configurable = False configurable = False
# current action # current action
listing = False
configuring = False configuring = False
############################## ##############################
@ -170,12 +190,101 @@ class MasterView(View):
By default, this view is included only if :attr:`listable` is By default, this view is included only if :attr:`listable` is
true. true.
The default view logic will show a "grid" (table) with the
model data (unless :attr:`has_grid` is false).
See also related methods, which are called by this one:
* :meth:`index_make_grid()`
""" """
self.listing = True
context = { context = {
'index_url': None, # avoid title link since this *is* the index 'index_url': None, # nb. avoid title link since this *is* the index
} }
if self.has_grid:
context['grid'] = self.index_make_grid()
return self.render_to_response('index', context) return self.render_to_response('index', context)
def index_make_grid(self, **kwargs):
"""
Create and return a :class:`~wuttaweb.grids.base.Grid`
instance for use with the :meth:`index()` view.
See also related methods, which are called by this one:
* :meth:`get_grid_key()`
* :meth:`index_get_grid_columns()`
* :meth:`index_get_grid_data()`
* :meth:`index_configure_grid()`
"""
if 'key' not in kwargs:
kwargs['key'] = self.get_grid_key()
if 'columns' not in kwargs:
kwargs['columns'] = self.index_get_grid_columns()
if 'data' not in kwargs:
kwargs['data'] = self.index_get_grid_data()
grid = self.make_grid(**kwargs)
self.index_configure_grid(grid)
return grid
def index_get_grid_columns(self):
"""
Returns the default list of grid column names, for the
:meth:`index()` view.
This is called by :meth:`index_make_grid()`; in the resulting
:class:`~wuttaweb.grids.base.Grid` instance, this becomes
:attr:`~wuttaweb.grids.base.Grid.columns`.
This method may return ``None``, in which case the grid may
(try to) generate its own default list.
Subclass may define :attr:`grid_columns` for simple cases, or
can override this method if needed.
Also note that :meth:`index_configure_grid()` may be used to
further modify the final column set, regardless of what this
method returns. So a common pattern is to declare all
"supported" columns by setting :attr:`grid_columns` but then
optionally remove or replace some of those within
:meth:`index_configure_grid()`.
"""
if hasattr(self, 'grid_columns'):
return self.grid_columns
def index_get_grid_data(self):
"""
Returns the grid data for the :meth:`index()` view.
This is called by :meth:`index_make_grid()`; in the resulting
:class:`~wuttaweb.grids.base.Grid` instance, this becomes
:attr:`~wuttaweb.grids.base.Grid.data`.
As of now there is not yet a "sane" default for this method;
it simply returns an empty list. Subclass should override as
needed.
"""
return []
def index_configure_grid(self, grid):
"""
Configure the grid for the :meth:`index()` view.
This is called by :meth:`index_make_grid()`.
There is no default logic here; subclass should override as
needed. The ``grid`` param will already be "complete" and
ready to use as-is, but this method can further modify it
based on request details etc.
"""
############################## ##############################
# configure methods # configure methods
############################## ##############################
@ -738,6 +847,26 @@ class MasterView(View):
return cls.get_url_prefix() return cls.get_url_prefix()
@classmethod
def get_grid_key(cls):
"""
Returns the (presumably) unique key to be used for the primary
grid in the :meth:`index()` view. This key may also be used
as the basis (key prefix) for secondary grids.
This is called from :meth:`index_make_grid()`; in the
resulting :class:`~wuttaweb.grids.base.Grid` instance, this
becomes :attr:`~wuttaweb.grids.base.Grid.key`.
The default logic for this method will call
:meth:`get_route_prefix()` and return that value as-is. A
subclass may override by assigning :attr:`grid_key`.
"""
if hasattr(cls, 'grid_key'):
return cls.grid_key
return cls.get_route_prefix()
@classmethod @classmethod
def get_config_title(cls): def get_config_title(cls):
""" """

View file

@ -26,8 +26,11 @@ Views for app settings
from collections import OrderedDict from collections import OrderedDict
from wuttjamaican.db.model import Setting
from wuttaweb.views import MasterView from wuttaweb.views import MasterView
from wuttaweb.util import get_libver, get_liburl from wuttaweb.util import get_libver, get_liburl
from wuttaweb.db import Session
class AppInfoView(MasterView): class AppInfoView(MasterView):
@ -38,10 +41,13 @@ class AppInfoView(MasterView):
* ``/appinfo/`` * ``/appinfo/``
* ``/appinfo/configure`` * ``/appinfo/configure``
See also :class:`SettingView`.
""" """
model_name = 'AppInfo' model_name = 'AppInfo'
model_title_plural = "App Info" model_title_plural = "App Info"
route_prefix = 'appinfo' route_prefix = 'appinfo'
has_grid = False
configurable = True configurable = True
def configure_get_simple_settings(self): def configure_get_simple_settings(self):
@ -103,8 +109,6 @@ class AppInfoView(MasterView):
('bb_vue_fontawesome', "(BB) @fortawesome/vue-fontawesome"), ('bb_vue_fontawesome', "(BB) @fortawesome/vue-fontawesome"),
]) ])
# import ipdb; ipdb.set_trace()
for key in weblibs: for key in weblibs:
title = weblibs[key] title = weblibs[key]
weblibs[key] = { weblibs[key] = {
@ -127,12 +131,53 @@ class AppInfoView(MasterView):
return context return context
class SettingView(MasterView):
"""
Master view for the "raw" settings table.
Notable URLs provided by this class:
* ``/settings/``
See also :class:`AppInfoView`.
"""
model_class = Setting
model_title = "Raw Setting"
# TODO: try removing these
grid_columns = [
'name',
'value',
]
# TODO: should define query, let master handle the rest
def index_get_grid_data(self, session=None):
""" """
model = self.app.model
session = session or Session()
query = session.query(model.Setting)\
.order_by(model.Setting.name)
settings = []
for setting in query:
settings.append({
'name': setting.name,
'value': setting.value,
})
return settings
def defaults(config, **kwargs): def defaults(config, **kwargs):
base = globals() base = globals()
AppInfoView = kwargs.get('AppInfoView', base['AppInfoView']) AppInfoView = kwargs.get('AppInfoView', base['AppInfoView'])
AppInfoView.defaults(config) AppInfoView.defaults(config)
SettingView = kwargs.get('SettingView', base['SettingView'])
SettingView.defaults(config)
def includeme(config): def includeme(config):
defaults(config) defaults(config)

0
tests/grids/__init__.py Normal file
View file

77
tests/grids/test_base.py Normal file
View file

@ -0,0 +1,77 @@
# -*- coding: utf-8; -*-
from unittest import TestCase
from pyramid import testing
from wuttjamaican.conf import WuttaConfig
from wuttaweb.grids import base
from wuttaweb.forms import FieldList
class TestGrid(TestCase):
def setUp(self):
self.config = WuttaConfig(defaults={
'wutta.web.menus.handler_spec': 'tests.utils:NullMenuHandler',
})
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)
def test_constructor(self):
# empty
grid = self.make_grid()
self.assertIsNone(grid.key)
self.assertIsNone(grid.columns)
self.assertIsNone(grid.data)
# now with columns
grid = self.make_grid(columns=['foo', 'bar'])
self.assertIsInstance(grid.columns, FieldList)
self.assertEqual(grid.columns, ['foo', 'bar'])
def test_vue_tagname(self):
grid = self.make_grid()
self.assertEqual(grid.vue_tagname, 'wutta-grid')
def test_vue_component(self):
grid = self.make_grid()
self.assertEqual(grid.vue_component, 'WuttaGrid')
def test_render_vue_tag(self):
grid = self.make_grid(columns=['foo', 'bar'])
html = grid.render_vue_tag()
self.assertEqual(html, '<wutta-grid></wutta-grid>')
def test_render_vue_template(self):
self.pyramid_config.include('pyramid_mako')
self.pyramid_config.add_subscriber('wuttaweb.subscribers.before_render',
'pyramid.events.BeforeRender')
grid = self.make_grid(columns=['foo', 'bar'])
html = grid.render_vue_template()
self.assertIn('<script type="text/x-template" id="wutta-grid-template">', html)
def test_get_vue_columns(self):
# error if no columns are set
grid = self.make_grid()
self.assertRaises(ValueError, grid.get_vue_columns)
# otherwise get back field/label dicts
grid = self.make_grid(columns=['foo', 'bar'])
columns = grid.get_vue_columns()
first = columns[0]
self.assertEqual(first['field'], 'foo')
self.assertEqual(first['label'], 'Foo')

View file

@ -8,6 +8,7 @@ from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPNotFound
from wuttjamaican.conf import WuttaConfig from wuttjamaican.conf import WuttaConfig
from wuttaweb.views import base from wuttaweb.views import base
from wuttaweb.forms import Form from wuttaweb.forms import Form
from wuttaweb.grids import Grid
class TestView(TestCase): class TestView(TestCase):
@ -31,6 +32,10 @@ class TestView(TestCase):
form = self.view.make_form() form = self.view.make_form()
self.assertIsInstance(form, Form) self.assertIsInstance(form, Form)
def test_make_grid(self):
grid = self.view.make_grid()
self.assertIsInstance(grid, Grid)
def test_notfound(self): def test_notfound(self):
error = self.view.notfound() error = self.view.notfound()
self.assertIsInstance(error, HTTPNotFound) self.assertIsInstance(error, HTTPNotFound)

View file

@ -215,6 +215,37 @@ class TestMasterView(WebTestCase):
self.assertEqual(master.MasterView.get_template_prefix(), '/machines') self.assertEqual(master.MasterView.get_template_prefix(), '/machines')
del master.MasterView.model_class del master.MasterView.model_class
def test_get_grid_key(self):
# error by default (since no model class)
self.assertRaises(AttributeError, master.MasterView.get_grid_key)
# subclass may specify grid key
master.MasterView.grid_key = 'widgets'
self.assertEqual(master.MasterView.get_grid_key(), 'widgets')
del master.MasterView.grid_key
# or it may specify route prefix
master.MasterView.route_prefix = 'trucks'
self.assertEqual(master.MasterView.get_grid_key(), 'trucks')
del master.MasterView.route_prefix
# or it may specify *normalized* model name
master.MasterView.model_name_normalized = 'blaster'
self.assertEqual(master.MasterView.get_grid_key(), 'blasters')
del master.MasterView.model_name_normalized
# or it may specify *standard* model name
master.MasterView.model_name = 'Dinosaur'
self.assertEqual(master.MasterView.get_grid_key(), 'dinosaurs')
del master.MasterView.model_name
# or it may specify model class
MyModel = MagicMock(__name__='Machine')
master.MasterView.model_class = MyModel
self.assertEqual(master.MasterView.get_grid_key(), 'machines')
del master.MasterView.model_class
def test_get_config_title(self): def test_get_config_title(self):
# error by default (since no model class) # error by default (since no model class)
@ -296,11 +327,13 @@ class TestMasterView(WebTestCase):
master.MasterView.model_name = 'AppInfo' master.MasterView.model_name = 'AppInfo'
master.MasterView.route_prefix = 'appinfo' master.MasterView.route_prefix = 'appinfo'
master.MasterView.template_prefix = '/appinfo' master.MasterView.template_prefix = '/appinfo'
master.MasterView.grid_columns = ['foo', 'bar']
view = master.MasterView(self.request) view = master.MasterView(self.request)
response = view.index() response = view.index()
del master.MasterView.model_name del master.MasterView.model_name
del master.MasterView.route_prefix del master.MasterView.route_prefix
del master.MasterView.template_prefix del master.MasterView.template_prefix
del master.MasterView.grid_columns
def test_configure(self): def test_configure(self):
model = self.app.model model = self.app.model

View file

@ -7,17 +7,39 @@ from wuttaweb.views import settings
class TestAppInfoView(WebTestCase): class TestAppInfoView(WebTestCase):
def make_view(self):
return settings.AppInfoView(self.request)
def test_index(self): def test_index(self):
# sanity/coverage check # sanity/coverage check
view = settings.AppInfoView(self.request) view = self.make_view()
response = view.index() response = view.index()
def test_configure_get_simple_settings(self): def test_configure_get_simple_settings(self):
# sanity/coverage check # sanity/coverage check
view = settings.AppInfoView(self.request) view = self.make_view()
simple = view.configure_get_simple_settings() simple = view.configure_get_simple_settings()
def test_configure_get_context(self): def test_configure_get_context(self):
# sanity/coverage check # sanity/coverage check
view = settings.AppInfoView(self.request) view = self.make_view()
context = view.configure_get_context() context = view.configure_get_context()
class TestSettingView(WebTestCase):
def make_view(self):
return settings.SettingView(self.request)
def test_index_get_grid_data(self):
# empty data by default
view = self.make_view()
data = view.index_get_grid_data(session=self.session)
self.assertEqual(len(data), 0)
# unless we save some settings
self.app.save_setting(self.session, 'foo', 'bar')
self.session.commit()
data = view.index_get_grid_data(session=self.session)
self.assertEqual(len(data), 1)