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>
+              &nbsp;
+          % 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} &raquo; ${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')