diff --git a/docs/api/wuttaweb/grids.base.rst b/docs/api/wuttaweb/grids.base.rst new file mode 100644 index 0000000..8b3cc38 --- /dev/null +++ b/docs/api/wuttaweb/grids.base.rst @@ -0,0 +1,6 @@ + +``wuttaweb.grids.base`` +======================= + +.. automodule:: wuttaweb.grids.base + :members: diff --git a/docs/api/wuttaweb/grids.rst b/docs/api/wuttaweb/grids.rst new file mode 100644 index 0000000..7430a21 --- /dev/null +++ b/docs/api/wuttaweb/grids.rst @@ -0,0 +1,6 @@ + +``wuttaweb.grids`` +================== + +.. automodule:: wuttaweb.grids + :members: diff --git a/docs/api/wuttaweb/index.rst b/docs/api/wuttaweb/index.rst index 5afd18b..1b1a61f 100644 --- a/docs/api/wuttaweb/index.rst +++ b/docs/api/wuttaweb/index.rst @@ -12,6 +12,8 @@ db forms forms.base + grids + grids.base handler helpers menus diff --git a/src/wuttaweb/forms/__init__.py b/src/wuttaweb/forms/__init__.py index 35102be..0f72808 100644 --- a/src/wuttaweb/forms/__init__.py +++ b/src/wuttaweb/forms/__init__.py @@ -26,6 +26,7 @@ Forms Library The ``wuttaweb.forms`` namespace contains the following: * :class:`~wuttaweb.forms.base.Form` +* :class:`~wuttaweb.forms.base.FieldList` """ -from .base import Form +from .base import Form, FieldList diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index 42abb31..e266a52 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -44,7 +44,8 @@ class FieldList(list): of :class:`python:list`. 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): diff --git a/src/wuttaweb/grids/__init__.py b/src/wuttaweb/grids/__init__.py new file mode 100644 index 0000000..5f4385a --- /dev/null +++ b/src/wuttaweb/grids/__init__.py @@ -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 . +# +################################################################################ +""" +Grids Library + +The ``wuttaweb.grids`` namespace contains the following: + +* :class:`~wuttaweb.grids.base.Grid` +""" + +from .base import Grid diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py new file mode 100644 index 0000000..ac18780 --- /dev/null +++ b/src/wuttaweb/grids/base.py @@ -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 . +# +################################################################################ +""" +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 + + + + 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 + + + + .. todo:: + + Why can't Sphinx render the above code block as 'html' ? + + It acts like it can't handle a `` + + diff --git a/src/wuttaweb/templates/master/index.mako b/src/wuttaweb/templates/master/index.mako index fd3d573..2b6f14b 100644 --- a/src/wuttaweb/templates/master/index.mako +++ b/src/wuttaweb/templates/master/index.mako @@ -3,11 +3,30 @@ <%def name="title()">${index_title} +## nb. avoid hero bar for index page <%def name="content_title()"> <%def name="page_content()"> -

TODO: index page content

+ % if grid is not Undefined: + ${grid.render_vue_tag()} + % endif +<%def name="render_this_page_template()"> + ${parent.render_this_page_template()} + % if grid is not Undefined: + ${grid.render_vue_template()} + % endif + + +<%def name="finalize_this_page_vars()"> + ${parent.finalize_this_page_vars()} + % if grid is not Undefined: + + % endif + ${parent.body()} diff --git a/src/wuttaweb/views/base.py b/src/wuttaweb/views/base.py index 94e22b2..bb46679 100644 --- a/src/wuttaweb/views/base.py +++ b/src/wuttaweb/views/base.py @@ -26,7 +26,7 @@ Base Logic for Views from pyramid import httpexceptions -from wuttaweb import forms +from wuttaweb import forms, grids class View: @@ -68,11 +68,21 @@ class View: Make and return a new :class:`~wuttaweb.forms.base.Form` instance, per the given ``kwargs``. - This is the "default" form factory which merely invokes - the constructor. + This is the "base" form factory which merely invokes the + constructor. """ 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): """ Convenience method, to raise a HTTP 404 Not Found exception:: diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py index 2cf719a..bb3767e 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -100,6 +100,13 @@ class MasterView(View): Code should not access this directly but instead call :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 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 ``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 Boolean indicating whether the master view supports @@ -151,9 +169,11 @@ class MasterView(View): # features listable = True + has_grid = True configurable = False # current action + listing = False configuring = False ############################## @@ -170,12 +190,101 @@ class MasterView(View): By default, this view is included only if :attr:`listable` is 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 = { - '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) + 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 ############################## @@ -738,6 +847,26 @@ class MasterView(View): 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 def get_config_title(cls): """ diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index bfe6830..d084ea1 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -26,8 +26,11 @@ Views for app settings from collections import OrderedDict +from wuttjamaican.db.model import Setting + from wuttaweb.views import MasterView from wuttaweb.util import get_libver, get_liburl +from wuttaweb.db import Session class AppInfoView(MasterView): @@ -38,10 +41,13 @@ class AppInfoView(MasterView): * ``/appinfo/`` * ``/appinfo/configure`` + + See also :class:`SettingView`. """ model_name = 'AppInfo' model_title_plural = "App Info" route_prefix = 'appinfo' + has_grid = False configurable = True def configure_get_simple_settings(self): @@ -103,8 +109,6 @@ class AppInfoView(MasterView): ('bb_vue_fontawesome', "(BB) @fortawesome/vue-fontawesome"), ]) - # import ipdb; ipdb.set_trace() - for key in weblibs: title = weblibs[key] weblibs[key] = { @@ -127,12 +131,53 @@ class AppInfoView(MasterView): 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): base = globals() AppInfoView = kwargs.get('AppInfoView', base['AppInfoView']) AppInfoView.defaults(config) + SettingView = kwargs.get('SettingView', base['SettingView']) + SettingView.defaults(config) + def includeme(config): defaults(config) diff --git a/tests/grids/__init__.py b/tests/grids/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py new file mode 100644 index 0000000..ee68b4e --- /dev/null +++ b/tests/grids/test_base.py @@ -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, '') + + 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('