diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63497e1..4500527 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,29 @@ All notable changes to wuttaweb will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
 and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
 
+## v0.17.0 (2024-12-15)
+
+### Feat
+
+- add basic support for batch execution
+- add basic support for rows grid for master, batch views
+- add basic master view class for batches
+
+### Fix
+
+- add handling for decimal values and lists, in `make_json_safe()`
+- fix behavior when editing Roles for a User
+- add basic views for raw Permissions
+- improve support for date, datetime fields in grids, forms
+- add way to set field widgets using pseudo-type
+- add support for date, datetime form fields
+- make dropdown widgets as wide as other text fields in main form
+- add fallback instance title
+- display "global" errors at top of form, if present
+- add `make_form()` and `make_grid()` methods on web handler
+- correct "empty option" behavior for `ObjectRef` schema type
+- use fanstatic to serve built-in images by default
+
 ## v0.16.2 (2024-12-10)
 
 ### Fix
diff --git a/docs/api/wuttaweb.views.batch.rst b/docs/api/wuttaweb.views.batch.rst
new file mode 100644
index 0000000..8adc64b
--- /dev/null
+++ b/docs/api/wuttaweb.views.batch.rst
@@ -0,0 +1,6 @@
+
+``wuttaweb.views.batch``
+========================
+
+.. automodule:: wuttaweb.views.batch
+   :members:
diff --git a/docs/index.rst b/docs/index.rst
index 7ece535..ce74ae6 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -49,6 +49,7 @@ the narrative docs are pretty scant.  That will eventually change.
    api/wuttaweb.views
    api/wuttaweb.views.auth
    api/wuttaweb.views.base
+   api/wuttaweb.views.batch
    api/wuttaweb.views.common
    api/wuttaweb.views.essential
    api/wuttaweb.views.master
diff --git a/pyproject.toml b/pyproject.toml
index 0a0435c..5aed35f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "WuttaWeb"
-version = "0.16.2"
+version = "0.17.0"
 description = "Web App for Wutta Framework"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}]
@@ -32,6 +32,7 @@ requires-python = ">= 3.8"
 dependencies = [
         "ColanderAlchemy",
         "humanize",
+        "markdown",
         "paginate",
         "paginate_sqlalchemy",
         "pyramid>=2",
@@ -42,7 +43,7 @@ dependencies = [
         "pyramid_tm",
         "waitress",
         "WebHelpers2",
-        "WuttJamaican[db]>=0.17.1",
+        "WuttJamaican[db]>=0.18.0",
         "zope.sqlalchemy>=1.5",
 ]
 
diff --git a/src/wuttaweb/forms/widgets.py b/src/wuttaweb/forms/widgets.py
index 2c8a944..f90768a 100644
--- a/src/wuttaweb/forms/widgets.py
+++ b/src/wuttaweb/forms/widgets.py
@@ -104,7 +104,7 @@ class ObjectRefWidget(SelectWidget):
         # add url, only if rendering readonly
         readonly = kw.get('readonly', self.readonly)
         if readonly:
-            if 'url' not in values and self.url and hasattr(field.schema, 'model_instance'):
+            if 'url' not in values and self.url and getattr(field.schema, 'model_instance', None):
                 values['url'] = self.url(field.schema.model_instance)
 
         return values
@@ -421,3 +421,22 @@ class PermissionsWidget(WuttaCheckboxChoiceWidget):
             kw['values'] = values
 
         return super().serialize(field, cstruct, **kw)
+
+
+class BatchIdWidget(Widget):
+    """
+    Widget for use with the
+    :attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin.id`
+    field of a :term:`batch` model.
+
+    This widget is "always" read-only and renders the Batch ID as
+    zero-padded 8-char string
+    """
+
+    def serialize(self, field, cstruct, **kw):
+        """ """
+        if cstruct is colander.null:
+            return colander.null
+
+        batch_id = int(cstruct)
+        return f'{batch_id:08d}'
diff --git a/src/wuttaweb/templates/base.mako b/src/wuttaweb/templates/base.mako
index a33d3ca..df05cbb 100644
--- a/src/wuttaweb/templates/base.mako
+++ b/src/wuttaweb/templates/base.mako
@@ -201,6 +201,10 @@
         width: 100%;
     }
 
+    .tool-panels-wrapper {
+        padding: 1rem;
+    }
+
   </style>
 </%def>
 
diff --git a/src/wuttaweb/templates/batch/view.mako b/src/wuttaweb/templates/batch/view.mako
new file mode 100644
index 0000000..569af5b
--- /dev/null
+++ b/src/wuttaweb/templates/batch/view.mako
@@ -0,0 +1,124 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/master/view.mako" />
+
+<%def name="extra_styles()">
+  ${parent.extra_styles()}
+  <style>
+
+    ## TODO: should we do something like this site-wide?
+    ## (so far this is the only place we use markdown)
+    .markdown p {
+        margin-bottom: 1.5rem;
+    }
+
+  </style>
+</%def>
+
+<%def name="tool_panels()">
+  ${parent.tool_panels()}
+  ${self.tool_panel_execution()}
+</%def>
+
+<%def name="tool_panel_execution()">
+  <wutta-tool-panel heading="Execution">
+    % if batch.executed:
+        <b-notification :closable="false">
+          <p class="block">
+            Batch was executed<br />
+            ${app.render_time_ago(batch.executed)}
+            by ${batch.executed_by}.
+          </p>
+        </b-notification>
+    % elif why_not_execute:
+        <b-notification type="is-warning" :closable="false">
+          <p class="block">
+            Batch cannot be executed:
+          </p>
+          <p class="block">
+            ${why_not_execute}
+          </p>
+        </b-notification>
+    % else:
+        % if master.has_perm('execute'):
+            <b-notification type="is-success" :closable="false">
+              <p class="block">
+                Batch can be executed.
+              </p>
+              <b-button type="is-primary"
+                        @click="executeInit()"
+                        icon-pack="fas"
+                        icon-left="arrow-circle-right">
+                Execute Batch
+              </b-button>
+
+              <b-modal has-modal-card
+                       :active.sync="executeShowDialog">
+                <div class="modal-card">
+
+                  <header class="modal-card-head">
+                    <p class="modal-card-title">Execute ${model_title}</p>
+                  </header>
+
+                  ## TODO: forcing black text b/c of b-notification
+                  ## wrapping button, which has white text
+                  <section class="modal-card-body has-text-black">
+                    <p class="block has-text-weight-bold">
+                      What will happen when this batch is executed?
+                    </p>
+                    <div class="markdown">
+                      ${execution_described|n}
+                    </div>
+                    ${h.form(master.get_action_url('execute', batch), ref='executeForm')}
+                    ${h.csrf_token(request)}
+                    ${h.end_form()}
+                  </section>
+
+                  <footer class="modal-card-foot">
+                    <b-button @click="executeShowDialog = false">
+                      Cancel
+                    </b-button>
+                    <b-button type="is-primary"
+                              @click="executeSubmit()"
+                              icon-pack="fas"
+                              icon-left="arrow-circle-right"
+                              :disabled="executeSubmitting">
+                      {{ executeSubmitting ? "Working, please wait..." : "Execute Batch" }}
+                    </b-button>
+                  </footer>
+
+                </div>
+              </b-modal>
+            </b-notification>
+
+        % else:
+            <b-notification type="is-warning" :closable="false">
+              <p class="block">
+                Batch may be executed,<br />
+                but you do not have permission.
+              </p>
+            </b-notification>
+        % endif
+    % endif
+  </wutta-tool-panel>
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  % if not batch.executed and not why_not_execute and master.has_perm('execute'):
+      <script>
+
+        ThisPageData.executeShowDialog = false
+        ThisPageData.executeSubmitting = false
+
+        ThisPage.methods.executeInit = function() {
+            this.executeShowDialog = true
+        }
+
+        ThisPage.methods.executeSubmit = function() {
+            this.executeSubmitting = true
+            this.$refs.executeForm.submit()
+        }
+
+      </script>
+  % endif
+</%def>
diff --git a/src/wuttaweb/templates/form.mako b/src/wuttaweb/templates/form.mako
index de7209a..1a4fe2d 100644
--- a/src/wuttaweb/templates/form.mako
+++ b/src/wuttaweb/templates/form.mako
@@ -1,6 +1,19 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/page.mako" />
 
+<%def name="page_layout()">
+  <div style="display: flex; justify-content: space-between;">
+
+    ## main form
+    <div style="flex-grow: 1;">
+      ${self.page_content()}
+    </div>
+
+    ## tool panels
+    ${self.tool_panels_wrapper()}
+  </div>
+</%def>
+
 <%def name="page_content()">
   % if form is not Undefined:
       <div class="wutta-form-wrapper">
@@ -9,6 +22,14 @@
   % endif
 </%def>
 
+<%def name="tool_panels_wrapper()">
+  <div class="tool-panels-wrapper">
+    ${self.tool_panels()}
+  </div>
+</%def>
+
+<%def name="tool_panels()"></%def>
+
 <%def name="render_vue_template_form()">
   % if form is not Undefined:
       ${form.render_vue_template()}
diff --git a/src/wuttaweb/templates/master/view.mako b/src/wuttaweb/templates/master/view.mako
index b84ebc1..b4db013 100644
--- a/src/wuttaweb/templates/master/view.mako
+++ b/src/wuttaweb/templates/master/view.mako
@@ -5,5 +5,48 @@
 
 <%def name="content_title()">${instance_title}</%def>
 
+<%def name="page_layout()">
 
-${parent.body()}
+  % if master.has_rows:
+      <div style="display: flex; flex-direction: column;">
+        <div style="display: flex; justify-content: space-between;">
+
+          ## main form
+          <div style="flex-grow: 1;">
+            ${self.page_content()}
+          </div>
+
+          ## tool panels
+          ${self.tool_panels_wrapper()}
+
+        </div>
+
+        ## rows grid
+        <br />
+        <h4 class="block is-size-4">${master.get_rows_title() or ''}</h4>
+        ${rows_grid.render_vue_tag()}
+      </div>
+
+  % else:
+      ## no rows, just main form + tool panels
+      ${parent.page_layout()}
+  % endif
+</%def>
+
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  % if master.has_rows:
+      ${self.render_vue_template_rows_grid()}
+  % endif
+</%def>
+
+<%def name="render_vue_template_rows_grid()">
+  ${rows_grid.render_vue_template()}
+</%def>
+
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  % if master.has_rows:
+      ${rows_grid.render_vue_finalize()}
+  % endif
+</%def>
diff --git a/src/wuttaweb/templates/page.mako b/src/wuttaweb/templates/page.mako
index 218e9f4..c23ce90 100644
--- a/src/wuttaweb/templates/page.mako
+++ b/src/wuttaweb/templates/page.mako
@@ -1,6 +1,10 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/base.mako" />
 
+<%def name="page_layout()">
+  ${self.page_content()}
+</%def>
+
 <%def name="page_content()"></%def>
 
 <%def name="render_vue_templates()">
@@ -12,7 +16,7 @@
 <%def name="render_vue_template_this_page()">
   <script type="text/x-template" id="this-page-template">
     <div class="wutta-page-content-wrapper">
-      ${self.page_content()}
+      ${self.page_layout()}
     </div>
   </script>
 </%def>
diff --git a/src/wuttaweb/templates/wutta-components.mako b/src/wuttaweb/templates/wutta-components.mako
index 6030840..5664933 100644
--- a/src/wuttaweb/templates/wutta-components.mako
+++ b/src/wuttaweb/templates/wutta-components.mako
@@ -6,6 +6,7 @@
   ${self.make_wutta_timepicker_component()}
   ${self.make_wutta_filter_component()}
   ${self.make_wutta_filter_value_component()}
+  ${self.make_wutta_tool_panel_component()}
 </%def>
 
 <%def name="make_wutta_request_mixin()">
@@ -477,3 +478,28 @@
 
   </script>
 </%def>
+
+<%def name="make_wutta_tool_panel_component()">
+  <script type="text/x-template" id="wutta-tool-panel-template">
+    <nav class="panel tool-panel">
+      <p class="panel-heading">{{ heading }}</p>
+      <div class="panel-block">
+        <div style="display: flex; flex-direction: column; gap: 0.5rem;">
+          <slot />
+        </div>
+      </div>
+    </nav>
+  </script>
+  <script>
+
+    const WuttaToolPanel = {
+        template: '#wutta-tool-panel-template',
+        props: {
+            heading: String,
+        },
+    }
+
+    Vue.component('wutta-tool-panel', WuttaToolPanel)
+
+  </script>
+</%def>
diff --git a/src/wuttaweb/util.py b/src/wuttaweb/util.py
index c0069d4..0697f03 100644
--- a/src/wuttaweb/util.py
+++ b/src/wuttaweb/util.py
@@ -24,6 +24,7 @@
 Web Utilities
 """
 
+import decimal
 import importlib
 import json
 import logging
@@ -525,17 +526,28 @@ def make_json_safe(value, key=None, warn=True):
     if value is colander.null:
         return None
 
-    # recursively convert dict
-    if isinstance(value, dict):
+    elif isinstance(value, dict):
+        # recursively convert dict
         parent = dict(value)
         for key, value in parent.items():
             parent[key] = make_json_safe(value, key=key, warn=warn)
         value = parent
 
-    # convert UUID to str
-    if isinstance(value, _uuid.UUID):
+    elif isinstance(value, list):
+        # recursively convert list
+        parent = list(value)
+        for i, value in enumerate(parent):
+            parent[i] = make_json_safe(value, key=key, warn=warn)
+        value = parent
+
+    elif isinstance(value, _uuid.UUID):
+        # convert UUID to str
         value = value.hex
 
+    elif isinstance(value, decimal.Decimal):
+        # convert decimal to float
+        value = float(value)
+
     # ensure JSON-compatibility, warn if problems
     try:
         json.dumps(value)
diff --git a/src/wuttaweb/views/batch.py b/src/wuttaweb/views/batch.py
new file mode 100644
index 0000000..1645455
--- /dev/null
+++ b/src/wuttaweb/views/batch.py
@@ -0,0 +1,404 @@
+# -*- 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 logic for Batch Master views
+"""
+
+import logging
+import threading
+import time
+
+import markdown
+from sqlalchemy import orm
+
+from wuttaweb.views import MasterView
+from wuttaweb.forms.schema import UserRef
+from wuttaweb.forms.widgets import BatchIdWidget
+
+
+log = logging.getLogger(__name__)
+
+
+class BatchMasterView(MasterView):
+    """
+    Base class for all "batch master" views.
+
+    .. attribute:: batch_handler
+
+       Reference to the :term:`batch handler` for use with the view.
+
+       This is set when the view is first created, using return value
+       from :meth:`get_batch_handler()`.
+    """
+
+    labels = {
+        'id': "Batch ID",
+        'status_code': "Batch Status",
+    }
+
+    sort_defaults = ('id', 'desc')
+
+    has_rows = True
+    rows_title = "Batch Rows"
+    rows_sort_defaults = 'sequence'
+
+    def __init__(self, request, context=None):
+        super().__init__(request, context=context)
+        self.batch_handler = self.get_batch_handler()
+
+    def get_batch_handler(self):
+        """
+        Must return the :term:`batch handler` for use with this view.
+
+        There is no default logic; subclass must override.
+        """
+        raise NotImplementedError
+
+    def get_fallback_templates(self, template):
+        """
+        We override the default logic here, to prefer "batch"
+        templates over the "master" templates.
+
+        So for instance the "view batch" page will by default use the
+        ``/batch/view.mako`` template - which does inherit from
+        ``/master/view.mako`` but adds extra features specific to
+        batches.
+        """
+        templates = super().get_fallback_templates(template)
+        templates.insert(0, f'/batch/{template}.mako')
+        return templates
+
+    def render_to_response(self, template, context):
+        """
+        We override the default logic here, to inject batch-related
+        context for the
+        :meth:`~wuttaweb.views.master.MasterView.view()` template
+        specifically.  These values are used in the template file,
+        ``/batch/view.mako``.
+
+        * ``batch`` - reference to the current :term:`batch`
+        * ``batch_handler`` reference to :attr:`batch_handler`
+        * ``why_not_execute`` - text of reason (if any) not to execute batch
+        * ``execution_described`` - HTML (rendered from markdown) describing batch execution
+        """
+        if template == 'view':
+            batch = context['instance']
+            context['batch'] = batch
+            context['batch_handler'] = self.batch_handler
+            context['why_not_execute'] = self.batch_handler.why_not_execute(batch)
+
+            description = (self.batch_handler.describe_execution(batch)
+                           or "Handler does not say!  Your guess is as good as mine.")
+            context['execution_described'] = markdown.markdown(
+                description, extensions=['fenced_code', 'codehilite'])
+
+        return super().render_to_response(template, context)
+
+    def configure_grid(self, g):
+        """ """
+        super().configure_grid(g)
+        model = self.app.model
+
+        # created_by
+        CreatedBy = orm.aliased(model.User)
+        g.set_joiner('created_by',
+                     lambda q: q.join(CreatedBy,
+                                      CreatedBy.uuid == self.model_class.created_by_uuid))
+        g.set_sorter('created_by', CreatedBy.username)
+        # g.set_filter('created_by', CreatedBy.username, label="Created By Username")
+
+        # id
+        g.set_renderer('id', self.render_batch_id)
+        g.set_link('id')
+
+        # description
+        g.set_link('description')
+
+    def render_batch_id(self, batch, key, value):
+        """ """
+        if value:
+            batch_id = int(value)
+            return f'{batch_id:08d}'
+
+    def get_instance_title(self, batch):
+        """ """
+        if batch.description:
+            return f"{batch.id_str} {batch.description}"
+        return batch.id_str
+
+    def configure_form(self, f):
+        """ """
+        super().configure_form(f)
+        batch = f.model_instance
+
+        # id
+        if self.creating:
+            f.remove('id')
+        else:
+            f.set_readonly('id')
+            f.set_widget('id', BatchIdWidget())
+
+        # notes
+        f.set_widget('notes', 'notes')
+
+        # rows
+        f.remove('rows')
+        if self.creating:
+            f.remove('row_count')
+        else:
+            f.set_readonly('row_count')
+
+        # status
+        f.remove('status_text')
+        if self.creating:
+            f.remove('status_code')
+        else:
+            f.set_readonly('status_code')
+
+        # created
+        if self.creating:
+            f.remove('created')
+        else:
+            f.set_readonly('created')
+
+        # created_by
+        f.remove('created_by_uuid')
+        if self.creating:
+            f.remove('created_by')
+        else:
+            f.set_node('created_by', UserRef(self.request))
+            f.set_readonly('created_by')
+
+        # executed
+        if self.creating or not batch.executed:
+            f.remove('executed')
+        else:
+            f.set_readonly('executed')
+
+        # executed_by
+        f.remove('executed_by_uuid')
+        if self.creating or not batch.executed:
+            f.remove('executed_by')
+        else:
+            f.set_node('executed_by', UserRef(self.request))
+            f.set_readonly('executed_by')
+
+    def objectify(self, form):
+        """
+        We override the default logic here, to invoke
+        :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.make_batch()`
+        on the batch handler - when creating.  Parent/default logic is
+        used when updating.
+        """
+        if self.creating:
+
+            # first get the "normal" objectified batch.  this will have
+            # all attributes set correctly per the form data, but will
+            # not yet belong to the db session.  we ultimately discard it.
+            schema = form.get_schema()
+            batch = schema.objectify(form.validated, context=form.model_instance)
+
+            # then we collect attributes from the new batch
+            kwargs = dict([(key, getattr(batch, key))
+                           for key in form.validated
+                           if hasattr(batch, key)])
+
+            # and set attribute for user creating the batch
+            kwargs['created_by'] = self.request.user
+
+            # finally let batch handler make the "real" batch
+            return self.batch_handler.make_batch(self.Session(), **kwargs)
+
+        # when not creating, normal logic is fine
+        return super().objectify(form)
+
+    def redirect_after_create(self, batch):
+        """
+        If the new batch requires initial population, we launch a
+        thread for that and show the "progress" page.
+
+        Otherwise this will do the normal thing of redirecting to the
+        "view" page for the new batch.
+        """
+        # just view batch if should not populate
+        if not self.batch_handler.should_populate(batch):
+            return self.redirect(self.get_action_url('view', batch))
+
+        # setup thread to populate batch
+        route_prefix = self.get_route_prefix()
+        key = f'{route_prefix}.populate'
+        progress = self.make_progress(key, success_url=self.get_action_url('view', batch))
+        thread = threading.Thread(target=self.populate_thread,
+                                  args=(batch.uuid,),
+                                  kwargs=dict(progress=progress))
+
+        # start thread and show progress page
+        thread.start()
+        return self.render_progress(progress)
+
+    def delete_instance(self, batch):
+        """
+        Delete the given batch instance.
+
+        This calls
+        :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_delete()`
+        on the :attr:`batch_handler`.
+        """
+        self.batch_handler.do_delete(batch, self.request.user)
+
+    ##############################
+    # populate methods
+    ##############################
+
+    def populate_thread(self, batch_uuid, progress=None):
+        """
+        Thread target for populating new object with progress indicator.
+
+        When a new batch is created, and the batch handler says it
+        should also be populated, then this thread is launched to do
+        so outside of the main request/response cycle.  Progress bar
+        is then shown to the user until it completes.
+
+        This method mostly just calls
+        :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_populate()`
+        on the :term:`batch handler`.
+        """
+        # nb. must use our own session in separate thread
+        session = self.app.make_session()
+
+        # nb. main web request which created the batch, must complete
+        # before that session is committed.  until that happens we
+        # will not be able to see the new batch.  hence this loop,
+        # where we wait for the batch to appear.
+        batch = None
+        tries = 0
+        while not batch:
+            batch = session.get(self.model_class, batch_uuid)
+            tries += 1
+            if tries > 10:
+                raise RuntimeError("can't find the batch")
+            time.sleep(0.1)
+
+        try:
+            # populate the batch
+            self.batch_handler.do_populate(batch, progress=progress)
+            session.flush()
+
+        except Exception as error:
+            session.rollback()
+            log.warning("failed to populate %s: %s",
+                        self.get_model_title(), batch,
+                        exc_info=True)
+            if progress:
+                progress.handle_error(error)
+
+        else:
+            session.commit()
+            if progress:
+                progress.handle_success()
+
+        finally:
+            session.close()
+
+    ##############################
+    # execute methods
+    ##############################
+
+    def execute(self):
+        """
+        View to execute the current :term:`batch`.
+
+        Eventually this should show a progress indicator etc., but for
+        now it simply calls
+        :meth:`~wuttjamaican:wuttjamaican.batch.BatchHandler.do_execute()`
+        on the :attr:`batch_handler` and waits for it to complete,
+        then redirects user back to the "view batch" page.
+        """
+        self.executing = True
+        batch = self.get_instance()
+
+        try:
+            self.batch_handler.do_execute(batch, self.request.user)
+        except Exception as error:
+            log.warning("failed to execute batch: %s", batch, exc_info=True)
+            self.request.session.flash(f"Execution failed!: {error}", 'error')
+
+        return self.redirect(self.get_action_url('view', batch))
+
+    ##############################
+    # row methods
+    ##############################
+
+    @classmethod
+    def get_row_model_class(cls):
+        """ """
+        if hasattr(cls, 'row_model_class'):
+            return cls.row_model_class
+
+        Batch = cls.get_model_class()
+        return Batch.__row_class__
+
+    def get_row_grid_data(self, batch):
+        """
+        Returns the base query for the batch
+        :attr:`~wuttjamaican:wuttjamaican.db.model.batch.BatchMixin.rows`
+        data.
+        """
+        BatchRow = self.get_row_model_class()
+        query = self.Session.query(BatchRow)\
+                            .filter(BatchRow.batch == batch)
+        return query
+
+    def configure_row_grid(self, g):
+        """ """
+        super().configure_row_grid(g)
+
+        g.set_label('sequence', "Seq.", column_only=True)
+
+    ##############################
+    # configuration
+    ##############################
+
+    @classmethod
+    def defaults(cls, config):
+        """ """
+        cls._defaults(config)
+        cls._batch_defaults(config)
+
+    @classmethod
+    def _batch_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        permission_prefix = cls.get_permission_prefix()
+        model_title = cls.get_model_title()
+        instance_url_prefix = cls.get_instance_url_prefix()
+
+        # execute
+        config.add_route(f'{route_prefix}.execute',
+                         f'{instance_url_prefix}/execute',
+                         request_method='POST')
+        config.add_view(cls, attr='execute',
+                        route_name=f'{route_prefix}.execute',
+                        permission=f'{permission_prefix}.execute')
+        config.add_wutta_permission(permission_prefix,
+                                    f'{permission_prefix}.execute',
+                                    f"Execute {model_title}")
diff --git a/src/wuttaweb/views/master.py b/src/wuttaweb/views/master.py
index 4ce9d2d..0030859 100644
--- a/src/wuttaweb/views/master.py
+++ b/src/wuttaweb/views/master.py
@@ -73,12 +73,12 @@ class MasterView(View):
 
     .. attribute:: model_class
 
-       Optional reference to a data model class.  While not strictly
-       required, most views will set this to a SQLAlchemy mapped
-       class,
+       Optional reference to a :term:`data model` class.  While not
+       strictly required, most views will set this to a SQLAlchemy
+       mapped class,
        e.g. :class:`~wuttjamaican:wuttjamaican.db.model.base.Person`.
 
-       Code should not access this directly but instead call
+       The base logic should not access this directly but instead call
        :meth:`get_model_class()`.
 
     .. attribute:: model_name
@@ -340,6 +340,38 @@ class MasterView(View):
        Boolean indicating whether the master view supports
        "configuring" - i.e. it should have a :meth:`configure()` view.
        Default value is ``False``.
+
+    **ROW FEATURES**
+
+    .. attribute:: has_rows
+
+       Whether the model has "rows" which should also be displayed
+       when viewing model records.
+
+       This the "master switch" for all row features; if this is turned
+       on then many other things kick in.
+
+       See also :attr:`row_model_class`.
+
+    .. attribute:: row_model_class
+
+       Reference to a :term:`data model` class for the rows.
+
+       The base logic should not access this directly but instead call
+       :meth:`get_row_model_class()`.
+
+    .. attribute:: rows_title
+
+       Display title for the rows grid.
+
+       The base logic should not access this directly but instead call
+       :meth:`get_rows_title()`.
+
+    .. attribute:: row_grid_columns
+
+       List of columns for the row grid.
+
+       This is optional; see also :meth:`get_row_grid_columns()`.
     """
 
     ##############################
@@ -368,6 +400,16 @@ class MasterView(View):
     execute_progress_template = None
     configurable = False
 
+    # row features
+    has_rows = False
+    rows_filterable = True
+    rows_filter_defaults = None
+    rows_sortable = True
+    rows_sort_on_backend = True
+    rows_sort_defaults = None
+    rows_paginated = True
+    rows_paginate_on_backend = True
+
     # current action
     listing = False
     creating = False
@@ -458,6 +500,7 @@ class MasterView(View):
         * :meth:`make_model_form()`
         * :meth:`configure_form()`
         * :meth:`create_save_form()`
+        * :meth:`redirect_after_create()`
         """
         self.creating = True
         form = self.make_model_form(cancel_url_fallback=self.get_index_url())
@@ -465,7 +508,7 @@ class MasterView(View):
         if form.validate():
             obj = self.create_save_form(form)
             self.Session.flush()
-            return self.redirect(self.get_action_url('view', obj))
+            return self.redirect_after_create(obj)
 
         context = {
             'form': form,
@@ -491,6 +534,16 @@ class MasterView(View):
         self.persist(obj)
         return obj
 
+    def redirect_after_create(self, obj):
+        """
+        Usually, this returns a redirect to which we send the user,
+        after a new model record has been created.  By default this
+        sends them to the "view" page for the record.
+
+        It is called automatically by :meth:`create()`.
+        """
+        return self.redirect(self.get_action_url('view', obj))
+
     ##############################
     # view methods
     ##############################
@@ -514,15 +567,40 @@ class MasterView(View):
 
         * :meth:`make_model_form()`
         * :meth:`configure_form()`
+        * :meth:`make_row_model_grid()` - if :attr:`has_rows` is true
         """
         self.viewing = True
-        instance = self.get_instance()
-        form = self.make_model_form(instance, readonly=True)
-
+        obj = self.get_instance()
+        form = self.make_model_form(obj, readonly=True)
         context = {
-            'instance': instance,
+            'instance': obj,
             'form': form,
         }
+
+        if self.has_rows:
+
+            # always make the grid first.  note that it already knows
+            # to "reset" its params when that is requested.
+            grid = self.make_row_model_grid(obj)
+
+            # but if user did request a "reset" then we want to
+            # redirect so the query string gets cleared out
+            if self.request.GET.get('reset-view'):
+
+                # nb. we want to preserve url hash if applicable
+                kw = {'_query': None,
+                      '_anchor': self.request.GET.get('hash')}
+                return self.redirect(self.request.current_route_url(**kw))
+
+            # so-called 'partial' requests get just the grid data
+            if self.request.params.get('partial'):
+                context = grid.get_vue_context()
+                if grid.paginated and grid.paginate_on_backend:
+                    context['pager_stats'] = grid.get_vue_pager_stats()
+                return self.json_response(context)
+
+            context['rows_grid'] = grid
+
         return self.render_to_response('view', context)
 
     ##############################
@@ -1896,8 +1974,8 @@ class MasterView(View):
 
         This is called by :meth:`make_model_grid()`.
 
-        There is no default logic here; subclass should override as
-        needed.  The ``grid`` param will already be "complete" and
+        There is minimal 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.
         """
@@ -2230,6 +2308,182 @@ class MasterView(View):
             session = session or self.Session()
             session.add(obj)
 
+    ##############################
+    # row methods
+    ##############################
+
+    def get_rows_title(self):
+        """
+        Returns the display title for model **rows** grid, if
+        applicable/desired.  Only relevant if :attr:`has_rows` is
+        true.
+
+        There is no default here, but subclass may override by
+        assigning :attr:`rows_title`.
+        """
+        if hasattr(self, 'rows_title'):
+            return self.rows_title
+
+    def make_row_model_grid(self, obj, **kwargs):
+        """
+        Create and return a grid for a record's **rows** data, for use
+        in :meth:`view()`.  Only applicable if :attr:`has_rows` is
+        true.
+
+        :param obj: Current model instance for which rows data is
+           being displayed.
+
+        :returns: :class:`~wuttaweb.grids.base.Grid` instance for the
+           rows data.
+
+        See also related methods, which are called by this one:
+
+        * :meth:`get_row_grid_key()`
+        * :meth:`get_row_grid_columns()`
+        * :meth:`get_row_grid_data()`
+        * :meth:`configure_row_grid()`
+        """
+        if 'key' not in kwargs:
+            kwargs['key'] = self.get_row_grid_key()
+
+        if 'model_class' not in kwargs:
+            model_class = self.get_row_model_class()
+            if model_class:
+                kwargs['model_class'] = model_class
+
+        if 'columns' not in kwargs:
+            kwargs['columns'] = self.get_row_grid_columns()
+
+        if 'data' not in kwargs:
+            kwargs['data'] = self.get_row_grid_data(obj)
+
+        kwargs.setdefault('filterable', self.rows_filterable)
+        kwargs.setdefault('filter_defaults', self.rows_filter_defaults)
+        kwargs.setdefault('sortable', self.rows_sortable)
+        kwargs.setdefault('sort_multiple', not self.request.use_oruga)
+        kwargs.setdefault('sort_on_backend', self.rows_sort_on_backend)
+        kwargs.setdefault('sort_defaults', self.rows_sort_defaults)
+        kwargs.setdefault('paginated', self.rows_paginated)
+        kwargs.setdefault('paginate_on_backend', self.rows_paginate_on_backend)
+
+        grid = self.make_grid(**kwargs)
+        self.configure_row_grid(grid)
+        grid.load_settings()
+        return grid
+
+    def get_row_grid_key(self):
+        """
+        Returns the (presumably) unique key to be used for the
+        **rows** grid in :meth:`view()`.  Only relevant if
+        :attr:`has_rows` is true.
+
+        This is called from :meth:`make_row_model_grid()`; in the
+        resulting grid, this becomes
+        :attr:`~wuttaweb.grids.base.Grid.key`.
+
+        Whereas you can define :attr:`grid_key` for the main grid, the
+        row grid key is always generated dynamically.  This
+        incorporates the current record key (whose rows are in the
+        grid) so that the rows grid for each record is unique.
+        """
+        parts = [self.get_grid_key()]
+        for key in self.get_model_key():
+            parts.append(str(self.request.matchdict[key]))
+        return '.'.join(parts)
+
+    def get_row_grid_columns(self):
+        """
+        Returns the default list of column names for the **rows**
+        grid, for use in :meth:`view()`.  Only relevant if
+        :attr:`has_rows` is true.
+
+        This is called by :meth:`make_row_model_grid()`; in the
+        resulting grid, 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:`row_grid_columns` for simple cases,
+        or can override this method if needed.
+
+        Also note that :meth:`configure_row_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:`row_grid_columns` but
+        then optionally remove or replace some of those within
+        :meth:`configure_row_grid()`.
+        """
+        if hasattr(self, 'row_grid_columns'):
+            return self.row_grid_columns
+
+    def get_row_grid_data(self, obj):
+        """
+        Returns the data for the **rows** grid, for use in
+        :meth:`view()`.  Only relevant if :attr:`has_rows` is true.
+
+        This is called by :meth:`make_row_model_grid()`; in the
+        resulting grid, this becomes
+        :attr:`~wuttaweb.grids.base.Grid.data`.
+
+        Default logic not implemented; subclass must define this.
+        """
+        raise NotImplementedError
+
+    def configure_row_grid(self, grid):
+        """
+        Configure the **rows** grid for use in :meth:`view()`.  Only
+        relevant if :attr:`has_rows` is true.
+
+        This is called by :meth:`make_row_model_grid()`.
+
+        There is minimal 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.
+        """
+        grid.remove('uuid')
+        self.set_row_labels(grid)
+
+    def set_row_labels(self, obj):
+        """
+        Set label overrides on a **row** form or grid, based on what
+        is defined by the view class and its parent class(es).
+
+        This is called automatically from
+        :meth:`configure_row_grid()` and
+        :meth:`configure_row_form()`.
+
+        This calls :meth:`collect_row_labels()` to find everything,
+        then it assigns the labels using one of (based on ``obj``
+        type):
+
+        * :func:`wuttaweb.forms.base.Form.set_label()`
+        * :func:`wuttaweb.grids.base.Grid.set_label()`
+
+        :param obj: Either a :class:`~wuttaweb.grids.base.Grid` or a
+           :class:`~wuttaweb.forms.base.Form` instance.
+        """
+        labels = self.collect_row_labels()
+        for key, label in labels.items():
+            obj.set_label(key, label)
+
+    def collect_row_labels(self):
+        """
+        Collect all **row** labels defined within the view class
+        hierarchy.
+
+        This is called by :meth:`set_row_labels()`.
+
+        :returns: Dict of all labels found.
+        """
+        labels = {}
+        hierarchy = self.get_class_hierarchy()
+        for cls in hierarchy:
+            if hasattr(cls, 'row_labels'):
+                labels.update(cls.row_labels)
+        return labels
+
     ##############################
     # class methods
     ##############################
@@ -2515,6 +2769,18 @@ class MasterView(View):
 
         return cls.get_model_title_plural()
 
+    @classmethod
+    def get_row_model_class(cls):
+        """
+        Returns the **row** model class for the view, if defined.
+        Only relevant if :attr:`has_rows` is true.
+
+        There is no default here, but a subclass may override by
+        assigning :attr:`row_model_class`.
+        """
+        if hasattr(cls, 'row_model_class'):
+            return cls.row_model_class
+
     ##############################
     # configuration
     ##############################
diff --git a/tests/forms/test_widgets.py b/tests/forms/test_widgets.py
index a49bdf5..cd3c7c4 100644
--- a/tests/forms/test_widgets.py
+++ b/tests/forms/test_widgets.py
@@ -302,3 +302,23 @@ class TestPermissionsWidget(WebTestCase):
         # editable output always includes the perm
         html = widget.serialize(field, set())
         self.assertIn("Polish the widgets", html)
+
+
+class TestBatchIdWidget(WebTestCase):
+
+    def make_field(self, node, **kwargs):
+        # TODO: not sure why default renderer is in use even though
+        # pyramid_deform was included in setup?  but this works..
+        kwargs.setdefault('renderer', deform.Form.default_renderer)
+        return deform.Field(node, **kwargs)
+
+    def test_serialize(self):
+        node = colander.SchemaNode(colander.Integer())
+        field = self.make_field(node)
+        widget = mod.BatchIdWidget()
+
+        result = widget.serialize(field, colander.null)
+        self.assertIs(result, colander.null)
+
+        result = widget.serialize(field, 42)
+        self.assertEqual(result, '00000042')
diff --git a/tests/test_util.py b/tests/test_util.py
index 21de3a4..6946d65 100644
--- a/tests/test_util.py
+++ b/tests/test_util.py
@@ -1,5 +1,6 @@
 # -*- coding: utf-8; -*-
 
+import decimal
 import json
 import uuid as _uuid
 from unittest import TestCase
@@ -570,6 +571,12 @@ class TestMakeJsonSafe(TestCase):
         value = mod.make_json_safe(uuid)
         self.assertEqual(value, uuid.hex)
 
+    def test_decimal(self):
+        value = decimal.Decimal('42.42')
+        self.assertNotEqual(value, 42.42)
+        result = mod.make_json_safe(value)
+        self.assertEqual(result, 42.42)
+
     def test_dict(self):
         model = self.app.model
         person = model.Person(full_name="Betty Boop")
@@ -585,3 +592,21 @@ class TestMakeJsonSafe(TestCase):
             'foo': 'bar',
             'person': "Betty Boop",
         })
+
+    def test_list(self):
+        model = self.app.model
+        person = model.Person(full_name="Betty Boop")
+
+        data = [
+            'foo',
+            'bar',
+            person,
+        ]
+
+        self.assertRaises(TypeError, json.dumps, data)
+        value = mod.make_json_safe(data)
+        self.assertEqual(value, [
+            'foo',
+            'bar',
+            "Betty Boop",
+        ])
diff --git a/tests/views/test_batch.py b/tests/views/test_batch.py
new file mode 100644
index 0000000..a3a34fe
--- /dev/null
+++ b/tests/views/test_batch.py
@@ -0,0 +1,373 @@
+# -*- coding: utf-8; -*-
+
+import datetime
+from unittest.mock import patch, MagicMock
+
+from sqlalchemy import orm
+from pyramid.httpexceptions import HTTPFound
+
+from wuttjamaican.db import model
+from wuttjamaican.batch import BatchHandler
+from wuttaweb.views import MasterView, batch as mod
+from wuttaweb.progress import SessionProgress
+from tests.util import WebTestCase
+
+
+class MockBatch(model.BatchMixin, model.Base):
+    __tablename__ = 'testing_batch_mock'
+
+class MockBatchRow(model.BatchRowMixin, model.Base):
+    __tablename__ = 'testing_batch_mock_row'
+    __batch_class__ = MockBatch
+
+MockBatch.__row_class__ = MockBatchRow
+
+class MockBatchHandler(BatchHandler):
+    model_class = MockBatch
+
+
+class TestBatchMasterView(WebTestCase):
+
+    def setUp(self):
+        self.setup_web()
+
+        # nb. create MockBatch, MockBatchRow
+        model.Base.metadata.create_all(bind=self.session.bind)
+
+    def make_handler(self):
+        return MockBatchHandler(self.config)
+
+    def make_view(self):
+        return mod.BatchMasterView(self.request)
+
+    def test_get_batch_handler(self):
+        self.assertRaises(NotImplementedError, mod.BatchMasterView, self.request)
+
+        with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=42):
+            view = mod.BatchMasterView(self.request)
+            self.assertEqual(view.batch_handler, 42)
+
+    def test_get_fallback_templates(self):
+        handler = MockBatchHandler(self.config)
+        with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+            view = self.make_view()
+            templates = view.get_fallback_templates('view')
+            self.assertEqual(templates, [
+                '/batch/view.mako',
+                '/master/view.mako',
+            ])
+
+    def test_render_to_response(self):
+        model = self.app.model
+        handler = MockBatchHandler(self.config)
+
+        user = model.User(username='barney')
+        self.session.add(user)
+        batch = handler.make_batch(self.session, created_by=user)
+        self.session.add(batch)
+        self.session.flush()
+
+        with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+            with patch.object(MasterView, 'render_to_response') as render_to_response:
+                view = self.make_view()
+                response = view.render_to_response('view', {'instance': batch})
+                self.assertTrue(render_to_response.called)
+                context = render_to_response.call_args[0][1]
+                self.assertIs(context['batch'], batch)
+                self.assertIs(context['batch_handler'], handler)
+
+    def test_configure_grid(self):
+        handler = MockBatchHandler(self.config)
+        with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
+            with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+                view = mod.BatchMasterView(self.request)
+                grid = view.make_model_grid()
+                # nb. coverage only; tests nothing
+                view.configure_grid(grid)
+
+    def test_render_batch_id(self):
+        handler = MockBatchHandler(self.config)
+        with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+            view = mod.BatchMasterView(self.request)
+            batch = MockBatch(id=42)
+
+            result = view.render_batch_id(batch, 'id', 42)
+            self.assertEqual(result, '00000042')
+
+            result = view.render_batch_id(batch, 'id', None)
+            self.assertIsNone(result)
+
+    def test_get_instance_title(self):
+        handler = MockBatchHandler(self.config)
+        with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+            view = mod.BatchMasterView(self.request)
+
+            batch = MockBatch(id=42)
+            result = view.get_instance_title(batch)
+            self.assertEqual(result, "00000042")
+
+            batch = MockBatch(id=43, description="runnin some numbers")
+            result = view.get_instance_title(batch)
+            self.assertEqual(result, "00000043 runnin some numbers")
+
+    def test_configure_form(self):
+        handler = MockBatchHandler(self.config)
+        with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
+            with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+                view = mod.BatchMasterView(self.request)
+
+                # creating
+                with patch.object(view, 'creating', new=True):
+                    form = view.make_model_form(model_instance=None)
+                    view.configure_form(form)
+
+                batch = MockBatch(id=42)
+
+                # viewing
+                with patch.object(view, 'viewing', new=True):
+                    form = view.make_model_form(model_instance=batch)
+                    view.configure_form(form)
+
+                # editing
+                with patch.object(view, 'editing', new=True):
+                    form = view.make_model_form(model_instance=batch)
+                    view.configure_form(form)
+
+                # deleting
+                with patch.object(view, 'deleting', new=True):
+                    form = view.make_model_form(model_instance=batch)
+                    view.configure_form(form)
+
+                # viewing (executed)
+                batch.executed = datetime.datetime.now()
+                with patch.object(view, 'viewing', new=True):
+                    form = view.make_model_form(model_instance=batch)
+                    view.configure_form(form)
+
+    def test_objectify(self):
+        handler = MockBatchHandler(self.config)
+        with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
+            with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+                with patch.object(mod.BatchMasterView, 'Session', return_value=self.session):
+                    view = mod.BatchMasterView(self.request)
+
+                    # create batch
+                    with patch.object(view, 'creating', new=True):
+                        form = view.make_model_form(model_instance=None)
+                        form.validated = {}
+                        batch = view.objectify(form)
+                        self.assertIsInstance(batch.id, int)
+                        self.assertTrue(batch.id > 0)
+
+                    # edit batch
+                    with patch.object(view, 'editing', new=True):
+                        with patch.object(view.batch_handler, 'make_batch') as make_batch:
+                            form = view.make_model_form(model_instance=batch)
+                            form.validated = {'description': 'foo'}
+                            self.assertIsNone(batch.description)
+                            batch = view.objectify(form)
+                            self.assertEqual(batch.description, 'foo')
+
+    def test_redirect_after_create(self):
+        self.pyramid_config.add_route('mock_batches.view', '/batch/mock/{uuid}')
+        handler = MockBatchHandler(self.config)
+        with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+            with patch.multiple(mod.BatchMasterView, create=True,
+                                model_class=MockBatch,
+                                route_prefix='mock_batches'):
+                view = mod.BatchMasterView(self.request)
+                batch = MockBatch(id=42)
+
+                # typically redirect to view batch
+                result = view.redirect_after_create(batch)
+                self.assertIsInstance(result, HTTPFound)
+
+                # unless populating in which case thread is launched
+                self.request.session.id = 'abcdefghijk'
+                with patch.object(mod, 'threading') as threading:
+                    thread = MagicMock()
+                    threading.Thread.return_value = thread
+                    with patch.object(view.batch_handler, 'should_populate', return_value=True):
+                        with patch.object(view, 'render_progress') as render_progress:
+                            view.redirect_after_create(batch)
+                            self.assertTrue(threading.Thread.called)
+                            thread.start.assert_called_once_with()
+                            self.assertTrue(render_progress.called)
+
+    def test_delete_instance(self):
+        model = self.app.model
+        handler = self.make_handler()
+
+        user = model.User(username='barney')
+        self.session.add(user)
+
+        batch = handler.make_batch(self.session, created_by=user)
+        self.session.add(batch)
+        self.session.flush()
+
+        with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+            view = self.make_view()
+
+            self.assertEqual(self.session.query(MockBatch).count(), 1)
+            view.delete_instance(batch)
+            self.assertEqual(self.session.query(MockBatch).count(), 0)
+
+    def test_populate_thread(self):
+        model = self.app.model
+        handler = MockBatchHandler(self.config)
+        with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+            with patch.multiple(mod.BatchMasterView, create=True, model_class=MockBatch):
+                view = mod.BatchMasterView(self.request)
+                user = model.User(username='barney')
+                self.session.add(user)
+                batch = MockBatch(id=42, created_by=user)
+                self.session.add(batch)
+                self.session.commit()
+
+                # nb. use our session within thread method
+                with patch.object(self.app, 'make_session', return_value=self.session):
+
+                    # nb. prevent closing our session
+                    with patch.object(self.session, 'close') as close:
+
+                        # without progress
+                        view.populate_thread(batch.uuid)
+                        close.assert_called_once_with()
+                        close.reset_mock()
+
+                        # with progress
+                        self.request.session.id = 'abcdefghijk'
+                        view.populate_thread(batch.uuid,
+                                             progress=SessionProgress(self.request,
+                                                                      'populate_mock_batch'))
+                        close.assert_called_once_with()
+                        close.reset_mock()
+
+                        # failure to populate, without progress
+                        with patch.object(view.batch_handler, 'do_populate', side_effect=RuntimeError):
+                            view.populate_thread(batch.uuid)
+                            close.assert_called_once_with()
+                            close.reset_mock()
+
+                        # failure to populate, with progress
+                        with patch.object(view.batch_handler, 'do_populate', side_effect=RuntimeError):
+                            view.populate_thread(batch.uuid,
+                                                 progress=SessionProgress(self.request,
+                                                                          'populate_mock_batch'))
+                            close.assert_called_once_with()
+                            close.reset_mock()
+
+                        # failure for batch to appear
+                        self.session.delete(batch)
+                        self.session.commit()
+                        # nb. should give up waiting after 1 second
+                        self.assertRaises(RuntimeError, view.populate_thread, batch.uuid)
+
+    def test_execute(self):
+        self.pyramid_config.add_route('mock_batches.view', '/batch/mock/{uuid}')
+        model = self.app.model
+        handler = MockBatchHandler(self.config)
+
+        user = model.User(username='barney')
+        self.session.add(user)
+        batch = handler.make_batch(self.session, created_by=user)
+        self.session.add(batch)
+        self.session.commit()
+
+        with patch.multiple(mod.BatchMasterView, create=True,
+                            model_class=MockBatch,
+                            route_prefix='mock_batches',
+                            get_batch_handler=MagicMock(return_value=handler),
+                            get_instance=MagicMock(return_value=batch)):
+            view = self.make_view()
+
+            # batch executes okay
+            response = view.execute()
+            self.assertEqual(response.status_code, 302) # redirect to "view batch"
+            self.assertFalse(self.request.session.peek_flash('error'))
+
+            # but cannot be executed again
+            response = view.execute()
+            self.assertEqual(response.status_code, 302) # redirect to "view batch"
+            # nb. flash has error this time
+            self.assertTrue(self.request.session.peek_flash('error'))
+
+    def test_get_row_model_class(self):
+        handler = MockBatchHandler(self.config)
+        with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+            view = self.make_view()
+
+            self.assertRaises(AttributeError, view.get_row_model_class)
+
+            # row class determined from batch class
+            with patch.object(mod.BatchMasterView, 'model_class', new=MockBatch, create=True):
+                cls = view.get_row_model_class()
+                self.assertIs(cls, MockBatchRow)
+
+            self.assertRaises(AttributeError, view.get_row_model_class)
+
+            # view may specify row class
+            with patch.object(mod.BatchMasterView, 'row_model_class', new=MockBatchRow, create=True):
+                cls = view.get_row_model_class()
+                self.assertIs(cls, MockBatchRow)
+
+    def test_get_row_grid_data(self):
+        handler = MockBatchHandler(self.config)
+        model = self.app.model
+
+        user = model.User(username='barney')
+        self.session.add(user)
+
+        batch = handler.make_batch(self.session, created_by=user)
+        self.session.add(batch)
+        row = handler.make_row()
+        handler.add_row(batch, row)
+        self.session.flush()
+
+        with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+
+            view = self.make_view()
+            self.assertRaises(AttributeError, view.get_row_grid_data, batch)
+
+            Session = MagicMock(return_value=self.session)
+            Session.query.side_effect = lambda m: self.session.query(m)
+            with patch.multiple(mod.BatchMasterView, create=True,
+                                Session=Session,
+                                model_class=MockBatch):
+
+                view = self.make_view()
+                data = view.get_row_grid_data(batch)
+                self.assertIsInstance(data, orm.Query)
+                self.assertEqual(data.count(), 1)
+
+    def test_configure_row_grid(self):
+        handler = MockBatchHandler(self.config)
+        model = self.app.model
+
+        user = model.User(username='barney')
+        self.session.add(user)
+
+        batch = handler.make_batch(self.session, created_by=user)
+        self.session.add(batch)
+        row = handler.make_row()
+        handler.add_row(batch, row)
+        self.session.flush()
+
+        with patch.object(mod.BatchMasterView, 'get_batch_handler', return_value=handler):
+
+            Session = MagicMock(return_value=self.session)
+            Session.query.side_effect = lambda m: self.session.query(m)
+            with patch.multiple(mod.BatchMasterView, create=True,
+                                Session=Session,
+                                model_class=MockBatch):
+
+                with patch.object(self.request, 'matchdict', new={'uuid': batch.uuid}):
+                    view = self.make_view()
+                    grid = view.make_row_model_grid(batch)
+                    self.assertIn('sequence', grid.labels)
+                    self.assertEqual(grid.labels['sequence'], "Seq.")
+
+    def test_defaults(self):
+        # nb. coverage only
+        with patch.object(mod.BatchMasterView, 'model_class', new=MockBatch, create=True):
+            mod.BatchMasterView.defaults(self.pyramid_config)
diff --git a/tests/views/test_master.py b/tests/views/test_master.py
index 8e451ee..06d8ed1 100644
--- a/tests/views/test_master.py
+++ b/tests/views/test_master.py
@@ -334,6 +334,16 @@ class TestMasterView(WebTestCase):
                             model_class=MyModel):
             self.assertEqual(mod.MasterView.get_config_title(), "Dinosaurs")
 
+    def test_get_row_model_class(self):
+        model = self.app.model
+
+        # no default
+        self.assertIsNone(mod.MasterView.get_row_model_class())
+
+        # class may specify
+        with patch.object(mod.MasterView, 'row_model_class', create=True, new=model.User):
+            self.assertIs(mod.MasterView.get_row_model_class(), model.User)
+
     ##############################
     # support methods
     ##############################
@@ -1017,6 +1027,53 @@ class TestMasterView(WebTestCase):
             with patch.object(view, 'get_instance', return_value=setting):
                 response = view.view()
 
+    def test_view_with_rows(self):
+        self.pyramid_config.include('wuttaweb.views.common')
+        self.pyramid_config.include('wuttaweb.views.auth')
+        self.pyramid_config.add_route('people', '/people/')
+        model = self.app.model
+        person = model.Person(full_name="Whitney Houston")
+        self.session.add(person)
+        user = model.User(username='whitney', person=person)
+        self.session.add(user)
+        self.session.commit()
+
+        get_row_grid_data = MagicMock()
+        with patch.multiple(mod.MasterView, create=True,
+                            Session=MagicMock(return_value=self.session),
+                            model_class=model.Person,
+                            route_prefix='people',
+                            has_rows=True,
+                            row_model_class=model.User,
+                            get_row_grid_data=get_row_grid_data):
+            with patch.object(self.request, 'matchdict', new={'uuid': person.uuid}):
+                view = self.make_view()
+
+                # just for coverage
+                get_row_grid_data.return_value = []
+                response = view.view()
+                self.assertEqual(response.status_code, 200)
+                self.assertEqual(response.content_type, 'text/html')
+
+                # now with data...
+                get_row_grid_data.return_value = [user]
+                response = view.view()
+                self.assertEqual(response.status_code, 200)
+                self.assertEqual(response.content_type, 'text/html')
+
+                # then once more as 'partial' - aka. data only
+                with patch.dict(self.request.GET, {'partial': 1}):
+                    response = view.view()
+                    self.assertEqual(response.status_code, 200)
+                    self.assertEqual(response.content_type, 'application/json')
+
+                # redirects when view is reset
+                with patch.dict(self.request.GET, {'reset-view': '1', 'hash': 'foo'}):
+                    # nb. mock current route
+                    with patch.object(self.request, 'current_route_url'):
+                        response = view.view()
+                        self.assertEqual(response.status_code, 302)
+
     def test_edit(self):
         self.pyramid_config.include('wuttaweb.views.common')
         self.pyramid_config.include('wuttaweb.views.auth')
@@ -1501,3 +1558,103 @@ class TestMasterView(WebTestCase):
                     # should now have 0 settings
                     count = self.session.query(model.Setting).count()
                     self.assertEqual(count, 0)
+
+    ##############################
+    # row methods
+    ##############################
+
+    def test_collect_row_labels(self):
+
+        # default labels
+        view = self.make_view()
+        labels = view.collect_row_labels()
+        self.assertEqual(labels, {})
+
+        # labels come from all classes; subclass wins
+        with patch.object(View, 'row_labels', create=True, new={'foo': "Foo", 'bar': "Bar"}):
+            with patch.object(mod.MasterView, 'row_labels', create=True, new={'foo': "FOO FIGHTERS"}):
+                view = self.make_view()
+                labels = view.collect_row_labels()
+                self.assertEqual(labels, {'foo': "FOO FIGHTERS", 'bar': "Bar"})
+
+    def test_set_row_labels(self):
+        model = self.app.model
+        person = model.Person(full_name="Fred Flintstone")
+        self.session.add(person)
+
+        with patch.multiple(mod.MasterView, create=True,
+                            model_class=model.Person,
+                            has_rows=True,
+                            row_model_class=model.User):
+
+            # no labels by default
+            view = self.make_view()
+            grid = view.make_row_model_grid(person, key='person.users', data=[])
+            view.set_row_labels(grid)
+            self.assertEqual(grid.labels, {})
+
+            # labels come from all classes; subclass wins
+            with patch.object(View, 'row_labels', create=True, new={'username': "USERNAME"}):
+                with patch.object(mod.MasterView, 'row_labels', create=True, new={'username': "UserName"}):
+                    view = self.make_view()
+                    grid = view.make_row_model_grid(person, key='person.users', data=[])
+                    view.set_row_labels(grid)
+                    self.assertEqual(grid.labels, {'username': "UserName"})
+
+    def test_get_row_grid_data(self):
+        model = self.app.model
+        person = model.Person(full_name="Fred Flintstone")
+        self.session.add(person)
+        view = self.make_view()
+        self.assertRaises(NotImplementedError, view.get_row_grid_data, person)
+
+    def test_get_row_grid_columns(self):
+
+        # no default
+        view = self.make_view()
+        self.assertIsNone(view.get_row_grid_columns())
+
+        # class may specify
+        with patch.object(view, 'row_grid_columns', create=True, new=['foo', 'bar']):
+            self.assertEqual(view.get_row_grid_columns(), ['foo', 'bar'])
+
+    def test_get_row_grid_key(self):
+        view = self.make_view()
+        with patch.multiple(mod.MasterView, create=True,
+                            model_key='id',
+                            grid_key='widgets'):
+
+            self.request.matchdict = {'id': 42}
+            self.assertEqual(view.get_row_grid_key(), 'widgets.42')
+
+    def test_make_row_model_grid(self):
+        model = self.app.model
+        person = model.Person(full_name="Barney Rubble")
+        self.session.add(person)
+        self.session.commit()
+
+        self.request.matchdict = {'uuid': person.uuid}
+        with patch.multiple(mod.MasterView, create=True,
+                            model_class=model.Person):
+            view = self.make_view()
+
+            # specify data
+            grid = view.make_row_model_grid(person, data=[])
+            self.assertIsNone(grid.model_class)
+            self.assertEqual(grid.data, [])
+
+            # fetch data
+            with patch.object(view, 'get_row_grid_data', return_value=[]):
+                grid = view.make_row_model_grid(person)
+                self.assertIsNone(grid.model_class)
+                self.assertEqual(grid.data, [])
+
+    def test_get_rows_title(self):
+        view = self.make_view()
+
+        # no default
+        self.assertIsNone(view.get_rows_title())
+
+        # class may specify
+        with patch.object(view, 'rows_title', create=True, new="Mock Rows"):
+            self.assertEqual(view.get_rows_title(), "Mock Rows")