From 754e0989e49c8d62fb81466f4bcebc0536576f6f Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 7 Aug 2024 14:00:53 -0500 Subject: [PATCH 1/2] feat: add basic `Grid` class, and /settings master view --- docs/api/wuttaweb/grids.base.rst | 6 + docs/api/wuttaweb/grids.rst | 6 + docs/api/wuttaweb/index.rst | 2 + src/wuttaweb/forms/__init__.py | 3 +- src/wuttaweb/forms/base.py | 3 +- src/wuttaweb/grids/__init__.py | 31 +++ src/wuttaweb/grids/base.py | 203 ++++++++++++++++++ src/wuttaweb/menus.py | 5 + .../templates/grids/vue_template.mako | 33 +++ src/wuttaweb/templates/master/index.mako | 21 +- src/wuttaweb/views/base.py | 16 +- src/wuttaweb/views/master.py | 131 ++++++++++- src/wuttaweb/views/settings.py | 49 ++++- tests/grids/__init__.py | 0 tests/grids/test_base.py | 77 +++++++ tests/views/test_base.py | 5 + tests/views/test_master.py | 33 +++ tests/views/test_settings.py | 28 ++- 18 files changed, 640 insertions(+), 12 deletions(-) create mode 100644 docs/api/wuttaweb/grids.base.rst create mode 100644 docs/api/wuttaweb/grids.rst create mode 100644 src/wuttaweb/grids/__init__.py create mode 100644 src/wuttaweb/grids/base.py create mode 100644 src/wuttaweb/templates/grids/vue_template.mako create mode 100644 tests/grids/__init__.py create mode 100644 tests/grids/test_base.py 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 <http://www.gnu.org/licenses/>. +# +################################################################################ +""" +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 <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 diff --git a/src/wuttaweb/menus.py b/src/wuttaweb/menus.py index 0fe780d..92e8162 100644 --- a/src/wuttaweb/menus.py +++ b/src/wuttaweb/menus.py @@ -122,6 +122,11 @@ class MenuHandler(GenericHandler): 'route': 'appinfo', 'perm': 'appinfo.list', }, + { + 'title': "Raw Settings", + 'route': 'settings', + 'perm': 'settings.list', + }, ], } diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako new file mode 100644 index 0000000..e4ead5d --- /dev/null +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -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> 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}</%def> +## nb. avoid hero bar for index page <%def name="content_title()"></%def> <%def name="page_content()"> - <p>TODO: index page content</p> + % if grid is not Undefined: + ${grid.render_vue_tag()} + % endif </%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()} 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, '<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') diff --git a/tests/views/test_base.py b/tests/views/test_base.py index 6e2f126..67f3f93 100644 --- a/tests/views/test_base.py +++ b/tests/views/test_base.py @@ -8,6 +8,7 @@ from pyramid.httpexceptions import HTTPFound, HTTPForbidden, HTTPNotFound from wuttjamaican.conf import WuttaConfig from wuttaweb.views import base from wuttaweb.forms import Form +from wuttaweb.grids import Grid class TestView(TestCase): @@ -31,6 +32,10 @@ class TestView(TestCase): form = self.view.make_form() self.assertIsInstance(form, Form) + def test_make_grid(self): + grid = self.view.make_grid() + self.assertIsInstance(grid, Grid) + def test_notfound(self): error = self.view.notfound() self.assertIsInstance(error, HTTPNotFound) diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 3380a8e..332ac5d 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -215,6 +215,37 @@ class TestMasterView(WebTestCase): self.assertEqual(master.MasterView.get_template_prefix(), '/machines') 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): # error by default (since no model class) @@ -296,11 +327,13 @@ class TestMasterView(WebTestCase): master.MasterView.model_name = 'AppInfo' master.MasterView.route_prefix = 'appinfo' master.MasterView.template_prefix = '/appinfo' + master.MasterView.grid_columns = ['foo', 'bar'] view = master.MasterView(self.request) response = view.index() del master.MasterView.model_name del master.MasterView.route_prefix del master.MasterView.template_prefix + del master.MasterView.grid_columns def test_configure(self): model = self.app.model diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py index 310c214..fc6c57b 100644 --- a/tests/views/test_settings.py +++ b/tests/views/test_settings.py @@ -7,17 +7,39 @@ from wuttaweb.views import settings class TestAppInfoView(WebTestCase): + def make_view(self): + return settings.AppInfoView(self.request) + def test_index(self): # sanity/coverage check - view = settings.AppInfoView(self.request) + view = self.make_view() response = view.index() def test_configure_get_simple_settings(self): # sanity/coverage check - view = settings.AppInfoView(self.request) + view = self.make_view() simple = view.configure_get_simple_settings() def test_configure_get_context(self): # sanity/coverage check - view = settings.AppInfoView(self.request) + view = self.make_view() 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) From 4c467f52674fcbf88dac881120b07f1b934ddb50 Mon Sep 17 00:00:00 2001 From: Lance Edgar <lance@edbob.org> Date: Wed, 7 Aug 2024 19:47:24 -0500 Subject: [PATCH 2/2] feat: add basic support for "view" part of CRUD still no SQLAlchemy yet, view must be explicit about data/model. but should support simple dict records, which will be needed in a few places anyway --- src/wuttaweb/forms/base.py | 82 +++++- src/wuttaweb/grids/__init__.py | 2 +- src/wuttaweb/grids/base.py | 151 +++++++++- .../templates/forms/vue_template.mako | 42 +-- .../templates/grids/vue_template.mako | 15 + src/wuttaweb/templates/master/form.mako | 5 + src/wuttaweb/templates/master/view.mako | 9 + src/wuttaweb/views/base.py | 14 +- src/wuttaweb/views/master.py | 267 ++++++++++++++++-- src/wuttaweb/views/settings.py | 37 ++- tests/forms/test_base.py | 27 +- tests/grids/test_base.py | 73 +++++ tests/views/test_master.py | 81 +++++- tests/views/test_settings.py | 22 ++ 14 files changed, 745 insertions(+), 82 deletions(-) create mode 100644 src/wuttaweb/templates/master/form.mako create mode 100644 src/wuttaweb/templates/master/view.mako diff --git a/src/wuttaweb/forms/base.py b/src/wuttaweb/forms/base.py index e266a52..89664e3 100644 --- a/src/wuttaweb/forms/base.py +++ b/src/wuttaweb/forms/base.py @@ -106,15 +106,48 @@ class Form: Form instances contain the following attributes: + .. attribute:: request + + Reference to current :term:`request` object. + .. attribute:: fields :class:`FieldList` instance containing string field names for the form. By default, fields will appear in the same order as they are in this list. - .. attribute:: request + .. attribute:: schema - Reference to current :term:`request` object. + Colander-based schema object for the form. This is optional; + if not specified an attempt will be made to construct one + automatically. + + See also :meth:`get_schema()`. + + .. attribute:: model_class + + Optional "class" for the model. If set, this usually would be + a SQLAlchemy mapped class. This may be used instead of + specifying the :attr:`schema`. + + .. attribute:: model_instance + + Optional instance from which initial form data should be + obtained. In simple cases this might be a dict, or maybe an + instance of :attr:`model_class`. + + Note that this also may be used instead of specifying the + :attr:`schema`, if the instance belongs to a class which is + SQLAlchemy-mapped. (In that case :attr:`model_class` can be + determined automatically.) + + .. attribute:: readonly + + Boolean indicating the form does not allow submit. In practice + this means there will not even be a ``<form>`` tag involved. + + Default for this is ``False`` in which case the ``<form>`` tag + will exist and submit is allowed. .. attribute:: action_url @@ -161,6 +194,9 @@ class Form: request, fields=None, schema=None, + model_class=None, + model_instance=None, + readonly=False, labels={}, action_url=None, vue_tagname='wutta-form', @@ -172,6 +208,7 @@ class Form: ): self.request = request self.schema = schema + self.readonly = readonly self.labels = labels or {} self.action_url = action_url self.vue_tagname = vue_tagname @@ -184,6 +221,9 @@ class Form: self.config = self.request.wutta_config self.app = self.config.get_app() + self.model_class = model_class + self.model_instance = model_instance + if fields is not None: self.set_fields(fields) elif self.schema: @@ -260,9 +300,22 @@ class Form: """ Return the :class:`colander:colander.Schema` object for the form, generating it automatically if necessary. + + Note that if :attr:`schema` is already set, that will be + returned as-is. """ if not self.schema: - raise NotImplementedError + + if self.fields: + schema = colander.Schema() + for name in self.fields: + schema.add(colander.SchemaNode( + colander.String(), + name=name)) + self.schema = schema + + else: # no fields + raise NotImplementedError return self.schema @@ -273,7 +326,12 @@ class Form: """ if not hasattr(self, 'deform_form'): schema = self.get_schema() - form = deform.Form(schema) + kwargs = {} + + if self.model_instance: + kwargs['appstruct'] = self.model_instance + + form = deform.Form(schema, **kwargs) self.deform_form = form return self.deform_form @@ -333,7 +391,7 @@ class Form: output = render(template, context) return HTML.literal(output) - def render_vue_field(self, fieldname): + def render_vue_field(self, fieldname, readonly=None): """ Render the given field completely, i.e. ``<b-field>`` wrapper with label and containing a widget. @@ -350,11 +408,19 @@ class Form: <!-- widget element(s) --> </b-field> """ - dform = self.get_deform() - field = dform[fieldname] + + if readonly is None: + readonly = self.readonly # render the field widget or whatever - html = field.serialize() + dform = self.get_deform() + field = dform[fieldname] + kw = {} + if readonly: + kw['readonly'] = True + html = field.serialize(**kw) + + # mark all that as safe html = HTML.literal(html) # render field label diff --git a/src/wuttaweb/grids/__init__.py b/src/wuttaweb/grids/__init__.py index 5f4385a..a28f02c 100644 --- a/src/wuttaweb/grids/__init__.py +++ b/src/wuttaweb/grids/__init__.py @@ -28,4 +28,4 @@ The ``wuttaweb.grids`` namespace contains the following: * :class:`~wuttaweb.grids.base.Grid` """ -from .base import Grid +from .base import Grid, GridAction diff --git a/src/wuttaweb/grids/base.py b/src/wuttaweb/grids/base.py index ac18780..17eaa3f 100644 --- a/src/wuttaweb/grids/base.py +++ b/src/wuttaweb/grids/base.py @@ -67,6 +67,11 @@ class Grid: model records) or else an object capable of producing such a list, e.g. SQLAlchemy query. + .. attribute:: actions + + List of :class:`GridAction` instances represenging action links + to be shown for each record in the grid. + .. attribute:: vue_tagname String name for Vue component tag. By default this is @@ -79,11 +84,13 @@ class Grid: key=None, columns=None, data=None, + actions=[], vue_tagname='wutta-grid', ): self.request = request self.key = key self.data = data + self.actions = actions or [] self.vue_tagname = vue_tagname self.config = self.request.wutta_config @@ -193,11 +200,145 @@ class Grid: """ 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? + This uses :attr:`data` as the basis, but may add some extra + values to each record for sake of action URLs etc. See also :meth:`get_vue_columns()`. """ - return self.data # TODO + # use data as-is unless we have actions + if not self.actions: + return self.data + + # we have action(s), so add URL(s) for each record in data + data = [] + for i, record in enumerate(self.data): + record = dict(record) + for action in self.actions: + url = action.get_url(record, i) + key = f'_action_url_{action.key}' + record[key] = url + data.append(record) + + return data + + +class GridAction: + """ + Represents a "row action" hyperlink within a grid context. + + All such actions are displayed as a group, in a dedicated + **Actions** column in the grid. So each row in the grid has its + own set of action links. + + A :class:`Grid` can have one (or zero) or more of these in its + :attr:`~Grid.actions` list. You can call + :meth:`~wuttaweb.views.base.View.make_grid_action()` to add custom + actions from within a view. + + :param request: Current :term:`request` object. + + .. note:: + + Some parameters are not explicitly described above. However + their corresponding attributes are described below. + + .. attribute:: key + + String key for the action (e.g. ``'edit'``), unique within the + grid. + + .. attribute:: label + + Label to be displayed for the action link. If not set, will be + generated from :attr:`key` by calling + :meth:`~wuttjamaican:wuttjamaican.app.AppHandler.make_title()`. + + See also :meth:`render_label()`. + + .. attribute:: url + + URL for the action link, if applicable. This *can* be a simple + string, however that will cause every row in the grid to have + the same URL for this action. + + A better way is to specify a callable which can return a unique + URL for each record. The callable should expect ``(obj, i)`` + args, for instance:: + + def myurl(obj, i): + return request.route_url('widgets.view', uuid=obj.uuid) + + action = GridAction(request, 'view', url=myurl) + + See also :meth:`get_url()`. + + .. attribute:: icon + + Name of icon to be shown for the action link. + + See also :meth:`render_icon()`. + """ + + def __init__( + self, + request, + key, + label=None, + url=None, + icon=None, + ): + self.request = request + self.config = self.request.wutta_config + self.app = self.config.get_app() + self.key = key + self.url = url + self.label = label or self.app.make_title(key) + self.icon = icon or key + + def render_icon(self): + """ + Render the HTML snippet for the action link icon. + + This uses :attr:`icon` to identify the named icon to be shown. + Output is something like (here ``'trash'`` is the icon name): + + .. code-block:: html + + <i class="fas fa-trash"></i> + """ + if self.request.use_oruga: + raise NotImplementedError + + return HTML.tag('i', class_=f'fas fa-{self.icon}') + + def render_label(self): + """ + Render the label text for the action link. + + Default behavior is to return :attr:`label` as-is. + """ + return self.label + + def get_url(self, obj, i=None): + """ + Returns the action link URL for the given object (model + instance). + + If :attr:`url` is a simple string, it is returned as-is. + + But if :attr:`url` is a callable (which is typically the most + useful), that will be called with the same ``(obj, i)`` args + passed along. + + :param obj: Model instance of whatever type the parent grid is + setup to use. + + :param i: Zero-based sequence for the object, within the + parent grid. + + See also :attr:`url`. + """ + if callable(self.url): + return self.url(obj, i) + + return self.url diff --git a/src/wuttaweb/templates/forms/vue_template.mako b/src/wuttaweb/templates/forms/vue_template.mako index bee0d55..a4e21ca 100644 --- a/src/wuttaweb/templates/forms/vue_template.mako +++ b/src/wuttaweb/templates/forms/vue_template.mako @@ -10,29 +10,31 @@ % endfor </section> - <div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: ${'end' if form.align_buttons_right else 'start'}; width: 100%; padding-left: 10rem;"> + % if not form.readonly: + <div style="margin-top: 1.5rem; display: flex; gap: 0.5rem; justify-content: ${'end' if form.align_buttons_right else 'start'}; width: 100%; padding-left: 10rem;"> - % if form.show_button_reset: - <b-button native-type="reset"> - Reset + % if form.show_button_reset: + <b-button native-type="reset"> + Reset + </b-button> + % endif + + <b-button type="is-primary" + native-type="submit" + % if form.auto_disable_submit: + :disabled="formSubmitting" + % endif + icon-pack="fas" + icon-left="${form.button_icon_submit}"> + % if form.auto_disable_submit: + {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }} + % else: + ${form.button_label_submit} + % endif </b-button> - % endif - <b-button type="is-primary" - native-type="submit" - % if form.auto_disable_submit: - :disabled="formSubmitting" - % endif - icon-pack="fas" - icon-left="${form.button_icon_submit}"> - % if form.auto_disable_submit: - {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }} - % else: - ${form.button_label_submit} - % endif - </b-button> - - </div> + </div> + % endif ${h.end_form()} </script> diff --git a/src/wuttaweb/templates/grids/vue_template.mako b/src/wuttaweb/templates/grids/vue_template.mako index e4ead5d..8429ef5 100644 --- a/src/wuttaweb/templates/grids/vue_template.mako +++ b/src/wuttaweb/templates/grids/vue_template.mako @@ -2,6 +2,7 @@ <script type="text/x-template" id="${grid.vue_tagname}-template"> <${b}-table :data="data" + hoverable :loading="loading"> % for column in grid.get_vue_columns(): @@ -13,6 +14,20 @@ </${b}-table-column> % endfor + % if grid.actions: + <${b}-table-column field="actions" + label="Actions" + v-slot="props"> + % for action in grid.actions: + <a :href="props.row._action_url_${action.key}"> + ${action.render_icon()} + ${action.render_label()} + </a> + + % endfor + </${b}-table-column> + % endif + </${b}-table> </script> diff --git a/src/wuttaweb/templates/master/form.mako b/src/wuttaweb/templates/master/form.mako new file mode 100644 index 0000000..72edaa2 --- /dev/null +++ b/src/wuttaweb/templates/master/form.mako @@ -0,0 +1,5 @@ +## -*- coding: utf-8; -*- +<%inherit file="/form.mako" /> + + +${parent.body()} diff --git a/src/wuttaweb/templates/master/view.mako b/src/wuttaweb/templates/master/view.mako new file mode 100644 index 0000000..b84ebc1 --- /dev/null +++ b/src/wuttaweb/templates/master/view.mako @@ -0,0 +1,9 @@ +## -*- coding: utf-8; -*- +<%inherit file="/master/form.mako" /> + +<%def name="title()">${index_title} » ${instance_title}</%def> + +<%def name="content_title()">${instance_title}</%def> + + +${parent.body()} diff --git a/src/wuttaweb/views/base.py b/src/wuttaweb/views/base.py index bb46679..ccdc749 100644 --- a/src/wuttaweb/views/base.py +++ b/src/wuttaweb/views/base.py @@ -68,7 +68,7 @@ class View: Make and return a new :class:`~wuttaweb.forms.base.Form` instance, per the given ``kwargs``. - This is the "base" form factory which merely invokes the + This is the "base" factory which merely invokes the constructor. """ return forms.Form(self.request, **kwargs) @@ -78,11 +78,21 @@ class View: 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 + This is the "base" factory which merely invokes the constructor. """ return grids.Grid(self.request, **kwargs) + def make_grid_action(self, key, **kwargs): + """ + Make and return a new :class:`~wuttaweb.grids.base.GridAction` + instance, per the given ``key`` and ``kwargs``. + + This is the "base" factory which merely invokes the + constructor. + """ + return grids.GridAction(self.request, key, **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 bb3767e..f884f7a 100644 --- a/src/wuttaweb/views/master.py +++ b/src/wuttaweb/views/master.py @@ -100,6 +100,18 @@ class MasterView(View): Code should not access this directly but instead call :meth:`get_model_title_plural()`. + .. attribute:: model_key + + Optional override for the view's "model key" - e.g. ``'id'`` + (string for simple case) or composite key such as + ``('id_field', 'name_field')``. + + If :attr:`model_class` is set to a SQLAlchemy mapped class, the + model key can be determined automatically. + + Code should not access this directly but instead call + :meth:`get_model_key()`. + .. attribute:: grid_key Optional override for the view's grid key, e.g. ``'widgets'``. @@ -156,6 +168,18 @@ class MasterView(View): This is optional; see also :meth:`index_get_grid_columns()`. + .. attribute:: viewable + + Boolean indicating whether the view model supports "viewing" - + i.e. it should have a :meth:`view()` view. Default value is + ``True``. + + .. attribute:: form_fields + + List of columns for the model form. + + This is optional; see also :meth:`get_form_fields()`. + .. attribute:: configurable Boolean indicating whether the master view supports @@ -170,10 +194,12 @@ class MasterView(View): # features listable = True has_grid = True + viewable = True configurable = False # current action listing = False + viewing = False configuring = False ############################## @@ -230,6 +256,16 @@ class MasterView(View): if 'data' not in kwargs: kwargs['data'] = self.index_get_grid_data() + if 'actions' not in kwargs: + actions = [] + + # TODO: should split this off into index_get_grid_actions() ? + if self.viewable: + actions.append(self.make_grid_action('view', icon='eye', + url=self.get_action_url_view)) + + kwargs['actions'] = actions + grid = self.make_grid(**kwargs) self.index_configure_grid(grid) return grid @@ -273,6 +309,21 @@ class MasterView(View): """ return [] + def get_action_url_view(self, obj, i): + """ + Returns the "view" grid action URL for the given object. + + Most typically this is like ``/widgets/XXX`` where ``XXX`` + represents the object's key/ID. + """ + route_prefix = self.get_route_prefix() + + kw = {} + for key in self.get_model_key(): + kw[key] = obj[key] + + return self.request.route_url(f'{route_prefix}.view', **kw) + def index_configure_grid(self, grid): """ Configure the grid for the :meth:`index()` view. @@ -285,6 +336,38 @@ class MasterView(View): based on request details etc. """ + ############################## + # view methods + ############################## + + def view(self): + """ + View to "view" details of an existing model record. + + This usually corresponds to a URL like ``/widgets/XXX`` + where ``XXX`` represents the key/ID for the record. + + By default, this view is included only if :attr:`viewable` is + true. + + The default view logic will show a read-only form with field + values displayed. + + See also related methods, which are called by this one: + + * :meth:`make_model_form()` + """ + self.viewing = True + instance = self.get_instance() + form = self.make_model_form(instance, readonly=True) + + context = { + 'instance': instance, + 'instance_title': self.get_instance_title(instance), + 'form': form, + } + return self.render_to_response('view', context) + ############################## # configure methods ############################## @@ -560,14 +643,12 @@ class MasterView(View): Save the given settings to the DB; this is called by :meth:`configure()`. - This method expected a list of name/value dicts and will - simply save each to the DB, with no "conversion" logic. + This method expects a list of name/value dicts and will simply + save each to the DB, with no "conversion" logic. :param settings: List of normalized setting definitions, as returned by :meth:`configure_gather_settings()`. """ - # app = self.get_rattail_app() - # nb. must avoid self.Session here in case that does not point # to our primary app DB session = Session() @@ -579,26 +660,6 @@ class MasterView(View): # support methods ############################## - def get_index_title(self): - """ - Returns the main index title for the master view. - - By default this returns the value from - :meth:`get_model_title_plural()`. Subclass may override as - needed. - """ - return self.get_model_title_plural() - - def get_index_url(self, **kwargs): - """ - Returns the URL for master's :meth:`index()` view. - - NB. this returns ``None`` if :attr:`listable` is false. - """ - if self.listable: - route_prefix = self.get_route_prefix() - return self.request.route_url(route_prefix, **kwargs) - def render_to_response(self, template, context): """ Locate and render an appropriate template, with the given @@ -677,6 +738,110 @@ class MasterView(View): """ return [f'/master/{template}.mako'] + def get_index_title(self): + """ + Returns the main index title for the master view. + + By default this returns the value from + :meth:`get_model_title_plural()`. Subclass may override as + needed. + """ + return self.get_model_title_plural() + + def get_index_url(self, **kwargs): + """ + Returns the URL for master's :meth:`index()` view. + + NB. this returns ``None`` if :attr:`listable` is false. + """ + if self.listable: + route_prefix = self.get_route_prefix() + return self.request.route_url(route_prefix, **kwargs) + + def get_instance(self): + """ + This should return the "current" model instance based on the + request details (e.g. route kwargs). + + If the instance cannot be found, this should raise a HTTP 404 + exception, i.e. :meth:`~wuttaweb.views.base.View.notfound()`. + + There is no "sane" default logic here; subclass *must* + override or else a ``NotImplementedError`` is raised. + """ + raise NotImplementedError("you must define get_instance() method " + f" for view class: {self.__class__}") + + def get_instance_title(self, instance): + """ + Return the human-friendly "title" for the instance, to be used + in the page title when viewing etc. + + Default logic returns the value from ``str(instance)``; + subclass may override if needed. + """ + return str(instance) + + def make_model_form(self, model_instance=None, **kwargs): + """ + Create and return a :class:`~wuttaweb.forms.base.Form` + for the view model. + + Note that this method is called for multiple "CRUD" views, + e.g.: + + * :meth:`view()` + + See also related methods, which are called by this one: + + * :meth:`get_form_fields()` + * :meth:`configure_form()` + """ + kwargs['model_instance'] = model_instance + + if 'fields' not in kwargs: + kwargs['fields'] = self.get_form_fields() + + form = self.make_form(**kwargs) + self.configure_form(form) + return form + + def get_form_fields(self): + """ + Returns the initial list of field names for the model form. + + This is called by :meth:`make_model_form()`; in the resulting + :class:`~wuttaweb.forms.base.Form` instance, this becomes + :attr:`~wuttaweb.forms.base.Form.fields`. + + This method may return ``None``, in which case the form may + (try to) generate its own default list. + + Subclass may define :attr:`form_fields` for simple cases, or + can override this method if needed. + + Note that :meth:`configure_form()` may be used to further + modify the final field list, regardless of what this method + returns. So a common pattern is to declare all "supported" + fields by setting :attr:`form_fields` but then optionally + remove or replace some in :meth:`configure_form()`. + """ + if hasattr(self, 'form_fields'): + return self.form_fields + + def configure_form(self, form): + """ + Configure the given model form, as needed. + + This is called by :meth:`make_model_form()` - for multiple + CRUD views. + + There is no default logic here; subclass should override if + needed. The ``form`` param will already be "complete" and + ready to use as-is, but this method can further modify it + based on request details etc. + """ + ############################## # class methods ############################## @@ -772,6 +937,32 @@ class MasterView(View): model_title = cls.get_model_title() return f"{model_title}s" + @classmethod + def get_model_key(cls): + """ + Returns the "model key" for the master view. + + This should return a tuple containing one or more "field + names" corresponding to the primary key for data records. + + In the most simple/common scenario, where the master view + represents a Wutta-based SQLAlchemy model, the return value + for this method is: ``('uuid',)`` + + But there is no "sane" default for other scenarios, in which + case subclass should define :attr:`model_key`. If the model + key cannot be determined, raises ``AttributeError``. + + :returns: Tuple of field names comprising the model key. + """ + if hasattr(cls, 'model_key'): + keys = cls.model_key + if isinstance(keys, str): + keys = [keys] + return tuple(keys) + + raise AttributeError(f"you must define model_key for view class: {cls}") + @classmethod def get_route_prefix(cls): """ @@ -822,6 +1013,27 @@ class MasterView(View): route_prefix = cls.get_route_prefix() return f'/{route_prefix}' + @classmethod + def get_instance_url_prefix(cls): + """ + Generate the URL prefix specific to an instance for this model + view. This will include model key param placeholders; it + winds up looking like: + + * ``/widgets/{uuid}`` + * ``/resources/{foo}|{bar}|{baz}`` + + The former being the most simple/common, and the latter + showing what a "composite" model key looks like, with pipe + symbols separating the key parts. + """ + prefix = cls.get_url_prefix() + '/' + for i, key in enumerate(cls.get_model_key()): + if i: + prefix += '|' + prefix += f'{{{key}}}' + return prefix + @classmethod def get_template_prefix(cls): """ @@ -923,6 +1135,13 @@ class MasterView(View): config.add_view(cls, attr='index', route_name=route_prefix) + # view + if cls.viewable: + instance_url_prefix = cls.get_instance_url_prefix() + config.add_route(f'{route_prefix}.view', instance_url_prefix) + config.add_view(cls, attr='view', + route_name=f'{route_prefix}.view') + # configure if cls.configurable: config.add_route(f'{route_prefix}.configure', diff --git a/src/wuttaweb/views/settings.py b/src/wuttaweb/views/settings.py index d084ea1..1082287 100644 --- a/src/wuttaweb/views/settings.py +++ b/src/wuttaweb/views/settings.py @@ -48,6 +48,9 @@ class AppInfoView(MasterView): model_title_plural = "App Info" route_prefix = 'appinfo' has_grid = False + viewable = False + editable = False + deletable = False configurable = True def configure_get_simple_settings(self): @@ -144,11 +147,15 @@ class SettingView(MasterView): model_class = Setting model_title = "Raw Setting" + # TODO: this should be deduced by master + model_key = 'name' + # TODO: try removing these grid_columns = [ 'name', 'value', ] + form_fields = list(grid_columns) # TODO: should define query, let master handle the rest def index_get_grid_data(self, session=None): @@ -161,13 +168,35 @@ class SettingView(MasterView): settings = [] for setting in query: - settings.append({ - 'name': setting.name, - 'value': setting.value, - }) + settings.append(self.normalize_setting(setting)) return settings + # TODO: master should handle this (but not as dict) + def normalize_setting(self, setting): + """ """ + return { + 'name': setting.name, + 'value': setting.value, + } + + # TODO: master should handle this + def get_instance(self, session=None): + """ """ + model = self.app.model + session = session or Session() + name = self.request.matchdict['name'] + setting = session.query(model.Setting).get(name) + if setting: + return self.normalize_setting(setting) + + return self.notfound() + + # TODO: master should handle this + def get_instance_title(self, setting): + """ """ + return setting['name'] + def defaults(config, **kwargs): base = globals() diff --git a/tests/forms/test_base.py b/tests/forms/test_base.py index 5e29ad6..5270f0f 100644 --- a/tests/forms/test_base.py +++ b/tests/forms/test_base.py @@ -59,8 +59,8 @@ class TestForm(TestCase): def tearDown(self): testing.tearDown() - def make_form(self, request=None, **kwargs): - return base.Form(request or self.request, **kwargs) + def make_form(self, **kwargs): + return base.Form(self.request, **kwargs) def make_schema(self): schema = colander.Schema(children=[ @@ -124,19 +124,33 @@ class TestForm(TestCase): self.assertIs(form.schema, schema) self.assertIs(form.get_schema(), schema) - # auto-generating schema not yet supported + # schema is auto-generated if fields provided form = self.make_form(fields=['foo', 'bar']) + schema = form.get_schema() + self.assertEqual(len(schema.children), 2) + self.assertEqual(schema['foo'].name, 'foo') + + # but auto-generating without fields is not supported + form = self.make_form() self.assertIsNone(form.schema) self.assertRaises(NotImplementedError, form.get_schema) def test_get_deform(self): schema = self.make_schema() + + # basic form = self.make_form(schema=schema) self.assertFalse(hasattr(form, 'deform_form')) dform = form.get_deform() self.assertIsInstance(dform, deform.Form) self.assertIs(form.deform_form, dform) + # with model instance / cstruct + myobj = {'foo': 'one', 'bar': 'two'} + form = self.make_form(schema=schema, model_instance=myobj) + dform = form.get_deform() + self.assertEqual(dform.cstruct, myobj) + def test_get_label(self): form = self.make_form(fields=['foo', 'bar']) self.assertEqual(form.get_label('foo'), "Foo") @@ -193,6 +207,13 @@ class TestForm(TestCase): # nb. no error message self.assertNotIn('message', html) + # readonly + html = form.render_vue_field('foo', readonly=True) + self.assertIn('<b-field :horizontal="true" label="Foo">', html) + self.assertNotIn('<b-input name="foo"', html) + # nb. no error message + self.assertNotIn('message', html) + # with single "static" error dform['foo'].error = MagicMock(msg="something is wrong") html = form.render_vue_field('foo') diff --git a/tests/grids/test_base.py b/tests/grids/test_base.py index ee68b4e..faab631 100644 --- a/tests/grids/test_base.py +++ b/tests/grids/test_base.py @@ -75,3 +75,76 @@ class TestGrid(TestCase): first = columns[0] self.assertEqual(first['field'], 'foo') self.assertEqual(first['label'], 'Foo') + + def test_get_vue_data(self): + + # null by default + grid = self.make_grid() + data = grid.get_vue_data() + self.assertIsNone(data) + + # is usually a list + mydata = [ + {'foo': 'bar'}, + ] + grid = self.make_grid(data=mydata) + data = grid.get_vue_data() + self.assertIs(data, mydata) + self.assertEqual(data, [{'foo': 'bar'}]) + + # if grid has actions, that list may be supplemented + grid.actions.append(base.GridAction(self.request, 'view', url='/blarg')) + data = grid.get_vue_data() + self.assertIsNot(data, mydata) + self.assertEqual(data, [{'foo': 'bar', '_action_url_view': '/blarg'}]) + + +class TestGridAction(TestCase): + + def setUp(self): + self.config = WuttaConfig() + self.request = testing.DummyRequest(wutta_config=self.config, use_oruga=False) + + def make_action(self, key, **kwargs): + return base.GridAction(self.request, key, **kwargs) + + def test_render_icon(self): + + # icon is derived from key by default + action = self.make_action('blarg') + html = action.render_icon() + self.assertIn('<i class="fas fa-blarg">', html) + + # oruga not yet supported + self.request.use_oruga = True + self.assertRaises(NotImplementedError, action.render_icon) + + def test_render_label(self): + + # label is derived from key by default + action = self.make_action('blarg') + label = action.render_label() + self.assertEqual(label, "Blarg") + + # otherwise use what caller provides + action = self.make_action('foo', label="Bar") + label = action.render_label() + self.assertEqual(label, "Bar") + + def test_get_url(self): + obj = {'foo': 'bar'} + + # null by default + action = self.make_action('blarg') + url = action.get_url(obj) + self.assertIsNone(url) + + # or can be "static" + action = self.make_action('blarg', url='/foo') + url = action.get_url(obj) + self.assertEqual(url, '/foo') + + # or can be "dynamic" + action = self.make_action('blarg', url=lambda o, i: '/yeehaw') + url = action.get_url(obj) + self.assertEqual(url, '/yeehaw') diff --git a/tests/views/test_master.py b/tests/views/test_master.py index 332ac5d..f7894ba 100644 --- a/tests/views/test_master.py +++ b/tests/views/test_master.py @@ -19,8 +19,9 @@ class TestMasterView(WebTestCase): def test_defaults(self): master.MasterView.model_name = 'Widget' - # TODO: should inspect pyramid routes after this, to be certain - master.MasterView.defaults(self.pyramid_config) + with patch.object(master.MasterView, 'viewable', new=False): + # TODO: should inspect pyramid routes after this, to be certain + master.MasterView.defaults(self.pyramid_config) del master.MasterView.model_name ############################## @@ -122,6 +123,16 @@ class TestMasterView(WebTestCase): self.assertEqual(master.MasterView.get_model_title_plural(), "Dinosaurs") del master.MasterView.model_class + def test_get_model_key(self): + + # error by default (since no model class) + self.assertRaises(AttributeError, master.MasterView.get_model_key) + + # subclass may specify model key + master.MasterView.model_key = 'uuid' + self.assertEqual(master.MasterView.get_model_key(), ('uuid',)) + del master.MasterView.model_key + def test_get_route_prefix(self): # error by default (since no model class) @@ -179,6 +190,25 @@ class TestMasterView(WebTestCase): self.assertEqual(master.MasterView.get_url_prefix(), '/machines') del master.MasterView.model_class + def test_get_instance_url_prefix(self): + + # error by default (since no model class) + self.assertRaises(AttributeError, master.MasterView.get_instance_url_prefix) + + # typical example with url_prefix and simple key + master.MasterView.url_prefix = '/widgets' + master.MasterView.model_key = 'uuid' + self.assertEqual(master.MasterView.get_instance_url_prefix(), '/widgets/{uuid}') + del master.MasterView.url_prefix + del master.MasterView.model_key + + # typical example with composite key + master.MasterView.url_prefix = '/widgets' + master.MasterView.model_key = ('foo', 'bar') + self.assertEqual(master.MasterView.get_instance_url_prefix(), '/widgets/{foo}|{bar}') + del master.MasterView.url_prefix + del master.MasterView.model_key + def test_get_template_prefix(self): # error by default (since no model class) @@ -281,12 +311,6 @@ class TestMasterView(WebTestCase): # support methods ############################## - def test_get_index_title(self): - master.MasterView.model_title_plural = "Wutta Widgets" - view = master.MasterView(self.request) - self.assertEqual(view.get_index_title(), "Wutta Widgets") - del master.MasterView.model_title_plural - def test_render_to_response(self): def widgets(request): return {} @@ -317,24 +341,51 @@ class TestMasterView(WebTestCase): self.assertRaises(IOError, view.render_to_response, 'nonexistent', {}) del master.MasterView.model_name + def test_get_index_title(self): + master.MasterView.model_title_plural = "Wutta Widgets" + view = master.MasterView(self.request) + self.assertEqual(view.get_index_title(), "Wutta Widgets") + del master.MasterView.model_title_plural + + def test_get_instance(self): + view = master.MasterView(self.request) + self.assertRaises(NotImplementedError, view.get_instance) + ############################## # view methods ############################## def test_index(self): - # basic sanity check using /appinfo - master.MasterView.model_name = 'AppInfo' - master.MasterView.route_prefix = 'appinfo' - master.MasterView.template_prefix = '/appinfo' - master.MasterView.grid_columns = ['foo', 'bar'] + # sanity/coverage check using /settings/ + master.MasterView.model_name = 'Setting' + master.MasterView.model_key = 'name' + master.MasterView.grid_columns = ['name', 'value'] view = master.MasterView(self.request) response = view.index() + # then again with data, to include view action url + data = [{'name': 'foo', 'value': 'bar'}] + with patch.object(view, 'index_get_grid_data', return_value=data): + response = view.index() del master.MasterView.model_name - del master.MasterView.route_prefix - del master.MasterView.template_prefix + del master.MasterView.model_key del master.MasterView.grid_columns + def test_view(self): + + # sanity/coverage check using /settings/XXX + master.MasterView.model_name = 'Setting' + master.MasterView.grid_columns = ['name', 'value'] + master.MasterView.form_fields = ['name', 'value'] + view = master.MasterView(self.request) + setting = {'name': 'foo.bar', 'value': 'baz'} + self.request.matchdict = {'name': 'foo.bar'} + with patch.object(view, 'get_instance', return_value=setting): + response = view.view() + del master.MasterView.model_name + del master.MasterView.grid_columns + del master.MasterView.form_fields + def test_configure(self): model = self.app.model diff --git a/tests/views/test_settings.py b/tests/views/test_settings.py index fc6c57b..81d95f3 100644 --- a/tests/views/test_settings.py +++ b/tests/views/test_settings.py @@ -2,6 +2,8 @@ from tests.views.utils import WebTestCase +from pyramid.httpexceptions import HTTPNotFound + from wuttaweb.views import settings @@ -43,3 +45,23 @@ class TestSettingView(WebTestCase): self.session.commit() data = view.index_get_grid_data(session=self.session) self.assertEqual(len(data), 1) + + def test_get_instance(self): + view = self.make_view() + self.request.matchdict = {'name': 'foo'} + + # setting not found + setting = view.get_instance(session=self.session) + self.assertIsInstance(setting, HTTPNotFound) + + # setting is returned + self.app.save_setting(self.session, 'foo', 'bar') + self.session.commit() + setting = view.get_instance(session=self.session) + self.assertEqual(setting, {'name': 'foo', 'value': 'bar'}) + + def test_get_instance_title(self): + setting = {'name': 'foo', 'value': 'bar'} + view = self.make_view() + title = view.get_instance_title(setting) + self.assertEqual(title, 'foo')