From 15ab0c959244c4de7a515e647fb60b8dd22d64b0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 19 Aug 2024 13:48:18 -0500
Subject: [PATCH 01/85] fix: add pager stats to all grid vue data (fixes view
 history)

also various other tweaks to modernize
---
 tailbone/grids/core.py                 |  6 +++++-
 tailbone/templates/grids/complete.mako |  2 +-
 tailbone/templates/master/view.mako    | 30 +++++++++-----------------
 tailbone/views/master.py               | 11 +++++-----
 4 files changed, 21 insertions(+), 28 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 3caf909c..6ec55987 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -237,7 +237,7 @@ class Grid(WuttaGrid):
                 kwargs.setdefault('sort_defaults', [(sortkey, sortdir)])
 
         if kwargs.get('pageable'):
-            warnings.warn("component param is deprecated for Grid(); "
+            warnings.warn("pageable param is deprecated for Grid(); "
                           "please use vue_tagname param instead",
                           DeprecationWarning, stacklevel=2)
             kwargs.setdefault('paginated', kwargs.pop('pageable'))
@@ -1703,6 +1703,10 @@ class Grid(WuttaGrid):
             results['checked_rows_code'] = '[{}]'.format(
                 ', '.join(['{}[{}]'.format(var, i) for i in checked]))
 
+        if self.paginated and self.paginate_on_backend:
+            results['pager_stats'] = self.get_vue_pager_stats()
+
+        # TODO: is this actually needed now that we have pager_stats?
         if self.paginated and self.pager is not None:
             results['total_items'] = self.pager.item_count
             results['per_page'] = self.pager.items_per_page
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index 8dc2d6dc..c136273b 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -115,7 +115,7 @@
        ## paging
        % if grid.paginated:
            paginated
-           pagination-size="is-small"
+           pagination-size="${'small' if request.use_oruga else 'is-small'}"
            :per-page="perPage"
            :current-page="currentPage"
            @page-change="onPageChange"
diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako
index a61020f3..37f57237 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -120,9 +120,7 @@
           </p>
         </div>
 
-        <versions-grid ref="versionsGrid"
-                       @view-revision="viewRevision">
-        </versions-grid>
+        ${versions_grid.render_vue_tag(ref='versionsGrid', **{'@view-revision': 'viewRevision'})}
 
         <${b}-modal :width="1200"
                     % if request.use_oruga:
@@ -237,17 +235,16 @@
 </%def>
 
 <%def name="render_row_grid_component()">
-  <tailbone-grid ref="rowGrid" id="rowGrid"></tailbone-grid>
+  ${rows_grid.render_vue_tag(id='rowGrid', ref='rowGrid')}
 </%def>
 
 <%def name="render_this_page_template()">
   % if getattr(master, 'has_rows', False):
-      ## TODO: stop using |n filter
-      ${rows_grid.render_complete(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))|n}
+      ${rows_grid.render_vue_template(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))}
   % endif
   ${parent.render_this_page_template()}
   % if expose_versions:
-      ${versions_grid.render_complete()|n}
+      ${versions_grid.render_vue_template()}
   % endif
 </%def>
 
@@ -338,19 +335,12 @@
 
 <%def name="finalize_this_page_vars()">
   ${parent.finalize_this_page_vars()}
-  <script type="text/javascript">
-
-    % if getattr(master, 'has_rows', False):
-        TailboneGrid.data = function() { return TailboneGridData }
-        Vue.component('tailbone-grid', TailboneGrid)
-    % endif
-
-    % if expose_versions:
-        VersionsGrid.data = function() { return VersionsGridData }
-        Vue.component('versions-grid', VersionsGrid)
-    % endif
-
-  </script>
+  % if getattr(master, 'has_rows', False):
+      ${rows_grid.render_vue_finalize()}
+  % endif
+  % if expose_versions:
+      ${versions_grid.render_vue_finalize()}
+  % endif
 </%def>
 
 
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index dde72106..ac74a070 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -347,8 +347,6 @@ class MasterView(View):
         # return grid data only, if partial page was requested
         if self.request.GET.get('partial'):
             context = grid.get_table_data()
-            if grid.paginated and grid.paginate_on_backend:
-                context['pager_stats'] = grid.get_vue_pager_stats()
             return self.json_response(context)
 
         context = {
@@ -587,7 +585,8 @@ class MasterView(View):
             'filterable': self.rows_filterable,
             'use_byte_string_filters': self.use_byte_string_filters,
             'sortable': self.rows_sortable,
-            'pageable': self.rows_pageable,
+            'sort_multiple': not self.request.use_oruga,
+            'paginated': self.rows_pageable,
             'extra_row_class': self.row_grid_extra_class,
             'url': lambda obj: self.get_row_action_url('view', obj),
         }
@@ -675,7 +674,7 @@ class MasterView(View):
         defaults = {
             'model_class': continuum.transaction_class(self.get_model_class()),
             'width': 'full',
-            'pageable': True,
+            'paginated': True,
             'url': lambda txn: self.request.route_url(route, uuid=instance.uuid, txnid=txn.id),
         }
         if 'actions' not in kwargs:
@@ -1387,8 +1386,8 @@ class MasterView(View):
             'vue_tagname': 'versions-grid',
             'ajax_data_url': self.get_action_url('revisions_data', obj),
             'sortable': True,
-            'default_sortkey': 'changed',
-            'default_sortdir': 'desc',
+            'sort_multiple': not self.request.use_oruga,
+            'sort_defaults': ('changed', 'desc'),
             'actions': [
                 self.make_action('view', icon='eye', url='#',
                                  click_handler='viewRevision(props.row)'),

From b762a0782a1b677817166609ee8b94bca872a7e2 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 19 Aug 2024 13:57:36 -0500
Subject: [PATCH 02/85] =?UTF-8?q?bump:=20version=200.19.2=20=E2=86=92=200.?=
 =?UTF-8?q?19.3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 6 ++++++
 pyproject.toml | 4 ++--
 2 files changed, 8 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1fe71f3f..c8017445 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to Tailbone 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.19.3 (2024-08-19)
+
+### Fix
+
+- add pager stats to all grid vue data (fixes view history)
+
 ## v0.19.2 (2024-08-19)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 8f840642..3e07abaa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.19.2"
+version = "0.19.3"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@@ -59,7 +59,7 @@ dependencies = [
         "transaction",
         "waitress",
         "WebHelpers2",
-        "WuttaWeb>=0.10.1",
+        "WuttaWeb>=0.10.2",
         "zope.sqlalchemy>=1.5",
 ]
 

From d29b8403435237effd5ca2d122a9fb00ff6896b2 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 19 Aug 2024 14:38:41 -0500
Subject: [PATCH 03/85] fix: avoid deprecated reference to app db engine

---
 tailbone/app.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tailbone/app.py b/tailbone/app.py
index 626c9206..ad9663cf 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -63,8 +63,8 @@ def make_rattail_config(settings):
     settings['wutta_config'] = rattail_config
 
     # configure database sessions
-    if hasattr(rattail_config, 'rattail_engine'):
-        tailbone.db.Session.configure(bind=rattail_config.rattail_engine)
+    if hasattr(rattail_config, 'appdb_engine'):
+        tailbone.db.Session.configure(bind=rattail_config.appdb_engine)
     if hasattr(rattail_config, 'trainwreck_engine'):
         tailbone.db.TrainwreckSession.configure(bind=rattail_config.trainwreck_engine)
     if hasattr(rattail_config, 'tempmon_engine'):

From 1ec1eba49681867aac1e24e11d3b89ed8bba060e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 19 Aug 2024 21:30:58 -0500
Subject: [PATCH 04/85] feat: refactor templates to simplify base/page/form
 structure

to mimic what has been done in wuttaweb
---
 tailbone/templates/appinfo/configure.mako     |   9 +-
 tailbone/templates/appinfo/index.mako         |  11 +-
 tailbone/templates/appsettings.mako           |  20 +-
 tailbone/templates/base.mako                  | 164 +++++----
 tailbone/templates/batch/index.mako           |  36 +-
 .../batch/inventory/desktop_form.mako         |  11 +-
 tailbone/templates/batch/pos/view.mako        |  10 +-
 .../batch/vendorcatalog/configure.mako        |  11 +-
 .../templates/batch/vendorcatalog/create.mako |   9 +-
 tailbone/templates/batch/view.mako            |  58 ++--
 tailbone/templates/configure-menus.mako       |   9 +-
 tailbone/templates/configure.mako             |   9 +-
 tailbone/templates/customers/configure.mako   |   9 +-
 .../templates/customers/pending/view.mako     |   8 +-
 tailbone/templates/customers/view.mako        |   8 +-
 tailbone/templates/custorders/create.mako     |  18 +-
 tailbone/templates/custorders/items/view.mako |   8 +-
 .../templates/datasync/changes/index.mako     |   9 +-
 tailbone/templates/datasync/configure.mako    |   9 +-
 tailbone/templates/datasync/status.mako       |   8 +-
 tailbone/templates/departments/view.mako      |  10 +-
 tailbone/templates/form.mako                  |  20 +-
 tailbone/templates/generate_feature.mako      |   9 +-
 tailbone/templates/importing/configure.mako   |   9 +-
 tailbone/templates/importing/runjob.mako      |   8 +-
 tailbone/templates/login.mako                 |   8 +-
 tailbone/templates/luigi/configure.mako       |   9 +-
 tailbone/templates/luigi/index.mako           |   9 +-
 tailbone/templates/master/clone.mako          |   9 +-
 tailbone/templates/master/delete.mako         |   7 +-
 tailbone/templates/master/form.mako           |   9 +-
 tailbone/templates/master/index.mako          |  44 +--
 tailbone/templates/master/merge.mako          |  23 +-
 tailbone/templates/master/versions.mako       |  31 +-
 tailbone/templates/master/view.mako           |  54 ++-
 tailbone/templates/members/configure.mako     |   9 +-
 tailbone/templates/messages/create.mako       |  13 +-
 tailbone/templates/messages/index.mako        |  17 +-
 tailbone/templates/messages/view.mako         |  15 +-
 tailbone/templates/ordering/view.mako         |  21 +-
 tailbone/templates/ordering/worksheet.mako    |  25 +-
 tailbone/templates/page.mako                  |  96 +++---
 tailbone/templates/people/index.mako          |   8 +-
 .../templates/people/merge-requests/view.mako |   8 +-
 tailbone/templates/people/view.mako           |  30 +-
 tailbone/templates/people/view_profile.mako   | 317 +++++++++---------
 tailbone/templates/poser/reports/view.mako    |  20 +-
 tailbone/templates/poser/setup.mako           |  11 +-
 .../templates/principal/find_by_perm.mako     |  53 ++-
 tailbone/templates/products/batch.mako        |   9 +-
 tailbone/templates/products/configure.mako    |   9 +-
 tailbone/templates/products/index.mako        |   9 +-
 tailbone/templates/products/pending/view.mako |  23 +-
 tailbone/templates/products/view.mako         |   9 +-
 .../templates/purchases/credits/index.mako    |   9 +-
 tailbone/templates/receiving/view.mako        |  26 +-
 tailbone/templates/receiving/view_row.mako    |   9 +-
 .../templates/reports/generated/choose.mako   |  13 +-
 .../templates/reports/generated/delete.mako   |  11 +-
 .../templates/reports/generated/view.mako     |  11 +-
 tailbone/templates/reports/inventory.mako     |  11 +-
 tailbone/templates/reports/ordering.mako      |   9 +-
 tailbone/templates/reports/problems/view.mako |   9 +-
 tailbone/templates/roles/create.mako          |  12 +-
 tailbone/templates/roles/edit.mako            |  12 +-
 tailbone/templates/roles/view.mako            |   8 +-
 .../templates/settings/email/configure.mako   |   9 +-
 tailbone/templates/settings/email/index.mako  |   8 +-
 tailbone/templates/settings/email/view.mako   |  21 +-
 tailbone/templates/tables/create.mako         |   9 +-
 .../templates/tempmon/appliances/view.mako    |  11 +-
 tailbone/templates/tempmon/clients/view.mako  |  11 +-
 tailbone/templates/tempmon/dashboard.mako     |   9 +-
 tailbone/templates/tempmon/probes/graph.mako  |   9 +-
 .../templates/themes/butterball/base.mako     | 100 ++++--
 .../trainwreck/transactions/configure.mako    |  11 +-
 .../trainwreck/transactions/rollover.mako     |  11 +-
 .../trainwreck/transactions/view.mako         |  10 +-
 .../trainwreck/transactions/view_row.mako     |  11 +-
 .../templates/units-of-measure/index.mako     |  19 +-
 tailbone/templates/upgrades/configure.mako    |   9 +-
 tailbone/templates/upgrades/view.mako         |  21 +-
 tailbone/templates/users/preferences.mako     |  11 +-
 tailbone/templates/users/view.mako            |   9 +-
 tailbone/templates/vendors/configure.mako     |  11 +-
 tailbone/templates/views/model/create.mako    |   9 +-
 tailbone/templates/workorders/view.mako       |   9 +-
 87 files changed, 818 insertions(+), 1045 deletions(-)

diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako
index aab180c4..4794f00b 100644
--- a/tailbone/templates/appinfo/configure.mako
+++ b/tailbone/templates/appinfo/configure.mako
@@ -213,9 +213,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.weblibs = ${json.dumps(weblibs)|n}
 
@@ -245,6 +245,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako
index 73f53920..68244300 100644
--- a/tailbone/templates/appinfo/index.mako
+++ b/tailbone/templates/appinfo/index.mako
@@ -114,14 +114,9 @@
   </${b}-collapse>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(request.rattail_config.prioritized_files, 1)])|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/appsettings.mako b/tailbone/templates/appsettings.mako
index 4f935956..ba667e0e 100644
--- a/tailbone/templates/appsettings.mako
+++ b/tailbone/templates/appsettings.mako
@@ -15,8 +15,8 @@
   <app-settings :groups="groups" :showing-group="showingGroup"></app-settings>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   <script type="text/x-template" id="app-settings-template">
 
     <div class="form">
@@ -150,19 +150,18 @@
   </script>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
 
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.groups = ${json.dumps(settings_data)|n}
     ThisPageData.showingGroup = ${json.dumps(current_group or '')|n}
-
   </script>
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  <script type="text/javascript">
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
 
     Vue.component('app-settings', {
         template: '#app-settings-template',
@@ -193,6 +192,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index 8e3b7785..a0e58e22 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -34,17 +34,21 @@
   </head>
 
   <body>
-    ${declare_formposter_mixin()}
-
-    ${self.body()}
-
-    <div id="whole-page-app">
+    <div id="app" style="height: 100%;">
       <whole-page></whole-page>
     </div>
 
-    ${self.render_whole_page_template()}
-    ${self.make_whole_page_component()}
-    ${self.make_whole_page_app()}
+    ## TODO: this must come before the self.body() call..but why?
+    ${declare_formposter_mixin()}
+
+    ## content body from derived/child template
+    ${self.body()}
+
+    ## Vue app
+    ${self.render_vue_templates()}
+    ${self.modify_vue_vars()}
+    ${self.make_vue_components()}
+    ${self.make_vue_app()}
   </body>
 </html>
 
@@ -181,7 +185,7 @@
 
 <%def name="head_tags()"></%def>
 
-<%def name="render_whole_page_template()">
+<%def name="render_vue_template_whole_page()">
   <script type="text/x-template" id="whole-page-template">
     <div>
       <header>
@@ -749,11 +753,8 @@
   % endif
 </%def>
 
-<%def name="declare_whole_page_vars()">
-  ${page_help.declare_vars()}
-  ${multi_file_upload.declare_vars()}
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))}
-  <script type="text/javascript">
+<%def name="render_vue_script_whole_page()">
+  <script>
 
     let WholePage = {
         template: '#whole-page-template',
@@ -889,57 +890,6 @@
   </script>
 </%def>
 
-<%def name="modify_whole_page_vars()">
-  <script type="text/javascript">
-
-    % if request.user:
-    FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n}
-    FeedbackFormData.userName = ${json.dumps(str(request.user))|n}
-    % endif
-
-  </script>
-</%def>
-
-<%def name="finalize_whole_page_vars()">
-  ## NOTE: if you override this, must use <script> tags
-</%def>
-
-<%def name="make_whole_page_component()">
-
-  ${make_grid_filter_components()}
-
-  ${self.declare_whole_page_vars()}
-  ${self.modify_whole_page_vars()}
-  ${self.finalize_whole_page_vars()}
-
-  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))}
-
-  ${page_help.make_component()}
-  ${multi_file_upload.make_component()}
-
-  <script type="text/javascript">
-
-    FeedbackForm.data = function() { return FeedbackFormData }
-
-    Vue.component('feedback-form', FeedbackForm)
-
-    WholePage.data = function() { return WholePageData }
-
-    Vue.component('whole-page', WholePage)
-
-  </script>
-</%def>
-
-<%def name="make_whole_page_app()">
-  <script type="text/javascript">
-
-    new Vue({
-        el: '#whole-page-app'
-    })
-
-  </script>
-</%def>
-
 <%def name="wtfield(form, name, **kwargs)">
   <div class="field-wrapper${' error' if form[name].errors else ''}">
     <label for="${name}">${form[name].label}</label>
@@ -961,3 +911,87 @@
     </div>
   </div>
 </%def>
+
+##############################
+## vue components + app
+##############################
+
+<%def name="render_vue_templates()">
+  ${page_help.declare_vars()}
+  ${multi_file_upload.declare_vars()}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.feedback.js') + '?ver={}'.format(tailbone.__version__))}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.autocomplete.js') + '?ver={}'.format(tailbone.__version__))}
+
+  ## DEPRECATED; called for back-compat
+  ${self.render_whole_page_template()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="render_whole_page_template()">
+  ${self.render_vue_template_whole_page()}
+  ${self.declare_whole_page_vars()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="declare_whole_page_vars()">
+  ${self.render_vue_script_whole_page()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ## DEPRECATED; called for back-compat
+  ${self.modify_whole_page_vars()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="modify_whole_page_vars()">
+  <script>
+
+    % if request.user:
+    FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n}
+    FeedbackFormData.userName = ${json.dumps(str(request.user))|n}
+    % endif
+
+  </script>
+</%def>
+
+<%def name="make_vue_components()">
+  ${make_grid_filter_components()}
+  ${page_help.make_component()}
+  ${multi_file_upload.make_component()}
+  <script>
+    FeedbackForm.data = function() { return FeedbackFormData }
+    Vue.component('feedback-form', FeedbackForm)
+  </script>
+
+  ## DEPRECATED; called for back-compat
+  ${self.finalize_whole_page_vars()}
+  ${self.make_whole_page_component()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="make_whole_page_component()">
+  <script>
+    WholePage.data = function() { return WholePageData }
+    Vue.component('whole-page', WholePage)
+  </script>
+</%def>
+
+<%def name="make_vue_app()">
+  ## DEPRECATED; called for back-compat
+  ${self.make_whole_page_app()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="make_whole_page_app()">
+  <script>
+    new Vue({
+        el: '#app'
+    })
+  </script>
+</%def>
+
+##############################
+## DEPRECATED
+##############################
+
+<%def name="finalize_whole_page_vars()"></%def>
diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako
index a7808590..a1b11b89 100644
--- a/tailbone/templates/batch/index.mako
+++ b/tailbone/templates/batch/index.mako
@@ -64,10 +64,17 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  % if master.results_executable and master.has_perm('execute_multiple'):
+      ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n}
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if master.results_refreshable and master.has_perm('refresh'):
-      <script type="text/javascript">
+      <script>
 
         TailboneGridData.refreshResultsButtonText = "Refresh Results"
         TailboneGridData.refreshResultsButtonDisabled = false
@@ -81,7 +88,7 @@
       </script>
   % endif
   % if master.results_executable and master.has_perm('execute_multiple'):
-      <script type="text/javascript">
+      <script>
 
         ${execute_form.vue_component}.methods.submit = function() {
             this.$refs.actualExecuteForm.submit()
@@ -118,25 +125,12 @@
   % endif
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
   % if master.results_executable and master.has_perm('execute_multiple'):
-      <script type="text/javascript">
-
+      <script>
         ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data }
-
-        Vue.component('${execute_form.component}', ${execute_form.vue_component})
-
+        Vue.component('${execute_form.vue_tagname}', ${execute_form.vue_component})
       </script>
   % endif
 </%def>
-
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-  % if master.results_executable and master.has_perm('execute_multiple'):
-      ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n}
-  % endif
-</%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/batch/inventory/desktop_form.mako b/tailbone/templates/batch/inventory/desktop_form.mako
index 8ca32ce0..cddaa2c5 100644
--- a/tailbone/templates/batch/inventory/desktop_form.mako
+++ b/tailbone/templates/batch/inventory/desktop_form.mako
@@ -297,14 +297,9 @@
   </script>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.toggleCompleteSubmitting = false
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/batch/pos/view.mako b/tailbone/templates/batch/pos/view.mako
index bdb8709d..5ecabd4d 100644
--- a/tailbone/templates/batch/pos/view.mako
+++ b/tailbone/templates/batch/pos/view.mako
@@ -1,13 +1,9 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/batch/view.mako" />
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ${form.vue_component}Data.taxesData = ${json.dumps(taxes_data)|n}
-
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/batch/vendorcatalog/configure.mako b/tailbone/templates/batch/vendorcatalog/configure.mako
index 0d57053e..4f91cb02 100644
--- a/tailbone/templates/batch/vendorcatalog/configure.mako
+++ b/tailbone/templates/batch/vendorcatalog/configure.mako
@@ -39,14 +39,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.catalogParsers = ${json.dumps(catalog_parsers_data)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/batch/vendorcatalog/create.mako b/tailbone/templates/batch/vendorcatalog/create.mako
index 63865bd5..d9d62bd1 100644
--- a/tailbone/templates/batch/vendorcatalog/create.mako
+++ b/tailbone/templates/batch/vendorcatalog/create.mako
@@ -1,9 +1,9 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/batch/create.mako" />
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ${form.vue_component}Data.parsers = ${json.dumps(parsers_data)|n}
 
@@ -37,6 +37,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako
index bef18cd4..cdfa9ba7 100644
--- a/tailbone/templates/batch/view.mako
+++ b/tailbone/templates/batch/view.mako
@@ -149,12 +149,6 @@
   </nav>
 </%def>
 
-<%def name="render_form_template()">
-  ## TODO: should use self.render_form_buttons()
-  ## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n}
-  ${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n}
-</%def>
-
 <%def name="render_this_page()">
   ${parent.render_this_page()}
 
@@ -197,16 +191,6 @@
 
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-  % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
-      ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n}
-  % endif
-  % if master.handler.executable(batch) and master.has_perm('execute'):
-      ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n}
-  % endif
-</%def>
-
 <%def name="render_form()">
   <div class="form">
     <${form.component} @show-upload="showUploadDialog = true">
@@ -267,9 +251,27 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
+      ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n}
+  % endif
+  % if master.handler.executable(batch) and master.has_perm('execute'):
+      ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n}
+  % endif
+</%def>
+
+## DEPRECATED; remains for back-compat
+## nb. this is called by parent template, /form.mako
+<%def name="render_form_template()">
+  ## TODO: should use self.render_form_buttons()
+  ## ${form.render_deform(form_id='batch-form', buttons=capture(self.render_form_buttons))|n}
+  ${form.render_deform(form_id='batch-form', buttons=capture(buttons))|n}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.statusBreakdownData = ${json.dumps(status_breakdown_data)|n}
 
@@ -340,28 +342,18 @@
   </script>
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
   % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
-      <script type="text/javascript">
-
-        ## UploadForm
+      <script>
         ${upload_worksheet_form.vue_component}.data = function() { return ${upload_worksheet_form.vue_component}Data }
         Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.vue_component})
-
       </script>
   % endif
-
   % if execute_enabled and master.has_perm('execute'):
-      <script type="text/javascript">
-
-        ## ExecuteForm
+      <script>
         ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data }
         Vue.component('${execute_form.component}', ${execute_form.vue_component})
-
       </script>
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/configure-menus.mako b/tailbone/templates/configure-menus.mako
index c0200912..c7f46d21 100644
--- a/tailbone/templates/configure-menus.mako
+++ b/tailbone/templates/configure-menus.mako
@@ -208,9 +208,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.menuSequence = ${json.dumps([m['key'] for m in menus])|n}
 
@@ -443,6 +443,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako
index f33779c8..272aadce 100644
--- a/tailbone/templates/configure.mako
+++ b/tailbone/templates/configure.mako
@@ -205,9 +205,9 @@
   ${h.end_form()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if simple_settings is not Undefined:
         ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n}
@@ -293,6 +293,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/customers/configure.mako b/tailbone/templates/customers/configure.mako
index e68f4543..1a6dca8b 100644
--- a/tailbone/templates/customers/configure.mako
+++ b/tailbone/templates/customers/configure.mako
@@ -88,9 +88,9 @@
 
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPage.methods.getLabelForKey = function(key) {
         switch (key) {
@@ -111,6 +111,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/customers/pending/view.mako b/tailbone/templates/customers/pending/view.mako
index e9e54c99..1cea9d1f 100644
--- a/tailbone/templates/customers/pending/view.mako
+++ b/tailbone/templates/customers/pending/view.mako
@@ -106,9 +106,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.resolvePersonShowDialog = false
     ThisPageData.resolvePersonUUID = null
@@ -139,5 +139,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/customers/view.mako b/tailbone/templates/customers/view.mako
index bbca9580..490e4757 100644
--- a/tailbone/templates/customers/view.mako
+++ b/tailbone/templates/customers/view.mako
@@ -16,9 +16,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if expose_shoppers:
     ${form.vue_component}Data.shoppers = ${json.dumps(shoppers_data)|n}
@@ -36,5 +36,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/custorders/create.mako b/tailbone/templates/custorders/create.mako
index 63505422..382a121f 100644
--- a/tailbone/templates/custorders/create.mako
+++ b/tailbone/templates/custorders/create.mako
@@ -47,10 +47,9 @@
   </div>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   ${product_lookup.tailbone_product_lookup_template()}
-
   <script type="text/x-template" id="customer-order-creator-template">
     <div>
 
@@ -1265,12 +1264,7 @@
 
     </div>
   </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  ${product_lookup.tailbone_product_lookup_component()}
-  <script type="text/javascript">
+  <script>
 
     const CustomerOrderCreator = {
         template: '#customer-order-creator-template',
@@ -2406,5 +2400,7 @@
   </script>
 </%def>
 
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  ${product_lookup.tailbone_product_lookup_component()}
+</%def>
diff --git a/tailbone/templates/custorders/items/view.mako b/tailbone/templates/custorders/items/view.mako
index 8eaee69a..4cc92bbf 100644
--- a/tailbone/templates/custorders/items/view.mako
+++ b/tailbone/templates/custorders/items/view.mako
@@ -291,9 +291,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ${form.vue_component}Data.eventsData = ${json.dumps(events_data)|n}
 
@@ -448,5 +448,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/datasync/changes/index.mako b/tailbone/templates/datasync/changes/index.mako
index 6d171619..86f5c121 100644
--- a/tailbone/templates/datasync/changes/index.mako
+++ b/tailbone/templates/datasync/changes/index.mako
@@ -26,9 +26,9 @@
 
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if request.has_perm('datasync.restart'):
         TailboneGridData.restartDatasyncFormSubmitting = false
@@ -50,6 +50,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako
index 7922d189..3651d0c4 100644
--- a/tailbone/templates/datasync/configure.mako
+++ b/tailbone/templates/datasync/configure.mako
@@ -599,9 +599,9 @@
 
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.showConfigFilesNote = false
     ThisPageData.profilesData = ${json.dumps(profiles_data)|n}
@@ -982,6 +982,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/datasync/status.mako b/tailbone/templates/datasync/status.mako
index c782dec6..e14686f8 100644
--- a/tailbone/templates/datasync/status.mako
+++ b/tailbone/templates/datasync/status.mako
@@ -115,8 +115,9 @@
     </${b}-table>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.processInfo = ${json.dumps(process_info)|n}
 
@@ -171,6 +172,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/departments/view.mako b/tailbone/templates/departments/view.mako
index f892f333..c5c39cbb 100644
--- a/tailbone/templates/departments/view.mako
+++ b/tailbone/templates/departments/view.mako
@@ -1,13 +1,9 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ${form.vue_component}Data.employeesData = ${json.dumps(employees_data)|n}
-
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako
index fec721fd..3bb04257 100644
--- a/tailbone/templates/form.mako
+++ b/tailbone/templates/form.mako
@@ -90,15 +90,15 @@
 
 <%def name="before_object_helpers()"></%def>
 
-<%def name="render_this_page_template()">
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   % if form is not Undefined:
       ${self.render_form_template()}
   % endif
-  ${parent.render_this_page_template()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if main_form_collapsible:
       <script>
         ThisPageData.mainFormPanelOpen = ${'false' if main_form_autocollapse else 'true'}
@@ -106,18 +106,12 @@
   % endif
 </%def>
 
-<%def name="finalize_this_page_vars()">
-  ${parent.finalize_this_page_vars()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
   % if form is not Undefined:
-      <script type="text/javascript">
-
+      <script>
         ${form.vue_component}.data = function() { return ${form.vue_component}Data }
-
         Vue.component('${form.vue_tagname}', ${form.vue_component})
-
       </script>
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/generate_feature.mako b/tailbone/templates/generate_feature.mako
index 18a26f58..0f2a9f7b 100644
--- a/tailbone/templates/generate_feature.mako
+++ b/tailbone/templates/generate_feature.mako
@@ -276,9 +276,9 @@
 
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.featureType = ${json.dumps(feature_type)|n}
     ThisPageData.resultGenerated = ${json.dumps(bool(result))|n}
@@ -385,6 +385,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/importing/configure.mako b/tailbone/templates/importing/configure.mako
index 0396745a..2445341d 100644
--- a/tailbone/templates/importing/configure.mako
+++ b/tailbone/templates/importing/configure.mako
@@ -144,9 +144,9 @@
   </b-modal>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.handlersData = ${json.dumps(handlers_data)|n}
 
@@ -203,6 +203,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/importing/runjob.mako b/tailbone/templates/importing/runjob.mako
index 23526ed2..a9625bc3 100644
--- a/tailbone/templates/importing/runjob.mako
+++ b/tailbone/templates/importing/runjob.mako
@@ -63,9 +63,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ${form.vue_component}Data.submittingRun = false
     ${form.vue_component}Data.submittingExplain = false
@@ -86,5 +86,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako
index f898660f..3eb46403 100644
--- a/tailbone/templates/login.mako
+++ b/tailbone/templates/login.mako
@@ -57,8 +57,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ${form.vue_component}Data.usernameInput = null
 
@@ -81,6 +82,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/luigi/configure.mako b/tailbone/templates/luigi/configure.mako
index 49060ceb..de364828 100644
--- a/tailbone/templates/luigi/configure.mako
+++ b/tailbone/templates/luigi/configure.mako
@@ -297,9 +297,9 @@
 
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.overnightTasks = ${json.dumps(overnight_tasks)|n}
     ThisPageData.overnightTaskShowDialog = false
@@ -425,6 +425,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/luigi/index.mako b/tailbone/templates/luigi/index.mako
index b5134c25..0dd72d01 100644
--- a/tailbone/templates/luigi/index.mako
+++ b/tailbone/templates/luigi/index.mako
@@ -255,9 +255,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if master.has_perm('restart_scheduler'):
 
@@ -374,6 +374,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/master/clone.mako b/tailbone/templates/master/clone.mako
index 59d6aea2..4c7e4662 100644
--- a/tailbone/templates/master/clone.mako
+++ b/tailbone/templates/master/clone.mako
@@ -34,9 +34,9 @@
   ${h.end_form()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     TailboneFormData.formSubmitting = false
     TailboneFormData.submitButtonText = "Yes, please clone away"
@@ -48,6 +48,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/master/delete.mako b/tailbone/templates/master/delete.mako
index c6187d55..d2f517d9 100644
--- a/tailbone/templates/master/delete.mako
+++ b/tailbone/templates/master/delete.mako
@@ -33,8 +33,8 @@
   ${h.end_form()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   <script>
 
     ${form.vue_component}Data.formSubmitting = false
@@ -45,6 +45,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/master/form.mako b/tailbone/templates/master/form.mako
index fac18ee2..17063c21 100644
--- a/tailbone/templates/master/form.mako
+++ b/tailbone/templates/master/form.mako
@@ -1,9 +1,9 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/form.mako" />
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ## declare extra data needed by form
     % if form is not Undefined and getattr(form, 'json_data', None):
@@ -28,6 +28,3 @@
   % endif
 
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/master/index.mako b/tailbone/templates/master/index.mako
index 81c11213..a2d26c60 100644
--- a/tailbone/templates/master/index.mako
+++ b/tailbone/templates/master/index.mako
@@ -265,6 +265,11 @@
 
 </%def>
 
+## DEPRECATED; remains for back-compat
+<%def name="render_this_page()">
+  ${self.page_content()}
+</%def>
+
 <%def name="page_content()">
 
   % if download_results_path:
@@ -290,34 +295,28 @@
   % endif
 </%def>
 
-<%def name="make_grid_component()">
-  ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())}
-</%def>
-
 <%def name="render_grid_component()">
   ${grid.render_vue_tag()}
 </%def>
 
-<%def name="make_this_page_component()">
+##############################
+## vue components
+##############################
 
-  ## define grid
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+
+  ## DEPRECATED; called for back-compat
   ${self.make_grid_component()}
-
-  ${parent.make_this_page_component()}
-
-  ## finalize grid
-  <script>
-    ${grid.vue_component}.data = function() { return ${grid.vue_component}Data }
-    Vue.component('${grid.vue_tagname}', ${grid.vue_component})
-  </script>
 </%def>
 
-<%def name="render_this_page()">
-  ${self.page_content()}
+## DEPRECATED; remains for back-compat
+<%def name="make_grid_component()">
+  ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   <script type="text/javascript">
 
     % if getattr(master, 'supports_grid_totals', False):
@@ -624,5 +623,10 @@
   </script>
 </%def>
 
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
+    ${grid.vue_component}.data = function() { return ${grid.vue_component}Data }
+    Vue.component('${grid.vue_tagname}', ${grid.vue_component})
+  </script>
+</%def>
diff --git a/tailbone/templates/master/merge.mako b/tailbone/templates/master/merge.mako
index 5d90043f..487d258d 100644
--- a/tailbone/templates/master/merge.mako
+++ b/tailbone/templates/master/merge.mako
@@ -109,8 +109,8 @@
   <merge-buttons></merge-buttons>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
 
   <script type="text/x-template" id="merge-buttons-template">
     <div class="level" style="margin-top: 2em;">
@@ -147,11 +147,7 @@
       </div>
     </div>
   </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  <script type="text/javascript">
+  <script>
 
     const MergeButtons = {
         template: '#merge-buttons-template',
@@ -175,12 +171,13 @@
         }
     }
 
-    Vue.component('merge-buttons', MergeButtons)
-
-    <% request.register_component('merge-buttons', 'MergeButtons') %>
-
   </script>
 </%def>
 
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
+    Vue.component('merge-buttons', MergeButtons)
+    <% request.register_component('merge-buttons', 'MergeButtons') %>
+  </script>
+</%def>
diff --git a/tailbone/templates/master/versions.mako b/tailbone/templates/master/versions.mako
index 307674b8..a6bb14f0 100644
--- a/tailbone/templates/master/versions.mako
+++ b/tailbone/templates/master/versions.mako
@@ -16,27 +16,16 @@
   ${self.page_content()}
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  <script type="text/javascript">
-
-    TailboneGrid.data = function() { return TailboneGridData }
-
-    Vue.component('tailbone-grid', TailboneGrid)
-
-  </script>
-</%def>
-
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-
-  ## TODO: stop using |n filter
-  ${grid.render_complete()|n}
-</%def>
-
 <%def name="page_content()">
-  <tailbone-grid :csrftoken="csrftoken">
-  </tailbone-grid>
+  ${grid.render_vue_tag(**{':csrftoken': 'csrftoken'})}
 </%def>
 
-${parent.body()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  ${grid.render_vue_template()}
+</%def>
+
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  ${grid.render_vue_finalize()}
+</%def>
diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako
index 37f57237..0a1f9c62 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -238,21 +238,34 @@
   ${rows_grid.render_vue_tag(id='rowGrid', ref='rowGrid')}
 </%def>
 
-<%def name="render_this_page_template()">
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   % if getattr(master, 'has_rows', False):
       ${rows_grid.render_vue_template(allow_save_defaults=False, tools=capture(self.render_row_grid_tools))}
   % endif
-  ${parent.render_this_page_template()}
   % if expose_versions:
       ${versions_grid.render_vue_template()}
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  % if expose_versions:
-      <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
+    % if getattr(master, 'touchable', False) and master.has_perm('touch'):
+
+        WholePageData.touchSubmitting = false
+
+        WholePage.methods.touchRecord = function() {
+            this.touchSubmitting = true
+            location.href = '${master.get_action_url('touch', instance)}'
+        }
+
+    % endif
+
+    % if expose_versions:
+
+        WholePageData.viewingHistory = false
         ThisPage.props.viewingHistory = Boolean
 
         ThisPageData.gettingRevisions = false
@@ -307,34 +320,12 @@
             this.viewVersionShowAllFields = !this.viewVersionShowAllFields
         }
 
-      </script>
-  % endif
-</%def>
-
-<%def name="modify_whole_page_vars()">
-  ${parent.modify_whole_page_vars()}
-  <script type="text/javascript">
-
-    % if getattr(master, 'touchable', False) and master.has_perm('touch'):
-
-        WholePageData.touchSubmitting = false
-
-        WholePage.methods.touchRecord = function() {
-            this.touchSubmitting = true
-            location.href = '${master.get_action_url('touch', instance)}'
-        }
-
     % endif
-
-    % if expose_versions:
-        WholePageData.viewingHistory = false
-    % endif
-
   </script>
 </%def>
 
-<%def name="finalize_this_page_vars()">
-  ${parent.finalize_this_page_vars()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
   % if getattr(master, 'has_rows', False):
       ${rows_grid.render_vue_finalize()}
   % endif
@@ -342,6 +333,3 @@
       ${versions_grid.render_vue_finalize()}
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/members/configure.mako b/tailbone/templates/members/configure.mako
index 465bf611..f1f0e39f 100644
--- a/tailbone/templates/members/configure.mako
+++ b/tailbone/templates/members/configure.mako
@@ -52,9 +52,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPage.methods.getLabelForKey = function(key) {
         switch (key) {
@@ -75,6 +75,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/messages/create.mako b/tailbone/templates/messages/create.mako
index 4a15573b..39236f75 100644
--- a/tailbone/templates/messages/create.mako
+++ b/tailbone/templates/messages/create.mako
@@ -32,14 +32,14 @@
   % endif
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   ${message_recipients_template()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     TailboneFormData.possibleRecipients = new Map(${json.dumps(available_recipients)|n})
     TailboneFormData.recipientDisplayMap = ${json.dumps(recipient_display_map)|n}
@@ -59,6 +59,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/messages/index.mako b/tailbone/templates/messages/index.mako
index 3fc82fd3..eaa4b6c9 100644
--- a/tailbone/templates/messages/index.mako
+++ b/tailbone/templates/messages/index.mako
@@ -22,15 +22,15 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if request.matched_route.name in ('messages.inbox', 'messages.archive'):
-      <script type="text/javascript">
+      <script>
 
-        TailboneGridData.moveMessagesSubmitting = false
-        TailboneGridData.moveMessagesText = null
+        ${grid.vue_component}Data.moveMessagesSubmitting = false
+        ${grid.vue_component}Data.moveMessagesText = null
 
-        TailboneGrid.computed.moveMessagesTextCurrent = function() {
+        ${grid.vue_component}.computed.moveMessagesTextCurrent = function() {
             if (this.moveMessagesText) {
                 return this.moveMessagesText
             }
@@ -38,7 +38,7 @@
             return "Move " + count.toString() + " selected to ${'Archive' if request.matched_route.name == 'messages.inbox' else 'Inbox'}"
         }
 
-        TailboneGrid.methods.moveMessagesSubmit = function() {
+        ${grid.vue_component}.methods.moveMessagesSubmit = function() {
             this.moveMessagesSubmitting = true
             this.moveMessagesText = "Working, please wait..."
         }
@@ -46,6 +46,3 @@
       </script>
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/messages/view.mako b/tailbone/templates/messages/view.mako
index 2e2baa60..36418698 100644
--- a/tailbone/templates/messages/view.mako
+++ b/tailbone/templates/messages/view.mako
@@ -82,22 +82,19 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    TailboneFormData.showingAllRecipients = false
+    ${form.vue_component}Data.showingAllRecipients = false
 
-    TailboneForm.methods.showMoreRecipients = function() {
+    ${form.vue_component}.methods.showMoreRecipients = function() {
         this.showingAllRecipients = true
     }
 
-    TailboneForm.methods.hideMoreRecipients = function() {
+    ${form.vue_component}.methods.hideMoreRecipients = function() {
         this.showingAllRecipients = false
     }
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako
index aed6fd75..584559c1 100644
--- a/tailbone/templates/ordering/view.mako
+++ b/tailbone/templates/ordering/view.mako
@@ -21,8 +21,8 @@
   % endif
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   % if not batch.executed and not batch.complete and master.has_perm('edit_row'):
       <script type="text/x-template" id="ordering-scanner-template">
         <div>
@@ -185,10 +185,10 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if not batch.executed and not batch.complete and master.has_perm('edit_row'):
-      <script type="text/javascript">
+      <script>
 
         let OrderingScanner = {
             template: '#ordering-scanner-template',
@@ -408,16 +408,11 @@
   % endif
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
   % if not batch.executed and not batch.complete and master.has_perm('edit_row'):
-      <script type="text/javascript">
-
+      <script>
         Vue.component('ordering-scanner', OrderingScanner)
-
       </script>
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako
index ca1abf6e..cb98c48f 100644
--- a/tailbone/templates/ordering/worksheet.mako
+++ b/tailbone/templates/ordering/worksheet.mako
@@ -199,9 +199,8 @@
   <ordering-worksheet></ordering-worksheet>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   <script type="text/x-template" id="ordering-worksheet-template">
     <div>
       <div class="form-wrapper">
@@ -239,11 +238,7 @@
       ${self.order_form_grid()}
     </div>
   </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  <script type="text/javascript">
+  <script>
 
     const OrderingWorksheet = {
         template: '#ordering-worksheet-template',
@@ -298,14 +293,12 @@
         },
     }
 
-    Vue.component('ordering-worksheet', OrderingWorksheet)
-
   </script>
 </%def>
 
-
-##############################
-## page body
-##############################
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
+    Vue.component('ordering-worksheet', OrderingWorksheet)
+  </script>
+</%def>
diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako
index 17d87c9a..54b47278 100644
--- a/tailbone/templates/page.mako
+++ b/tailbone/templates/page.mako
@@ -1,42 +1,26 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/base.mako" />
 
-<%def name="context_menu_items()">
-  % if context_menu_list_items is not Undefined:
-      % for item in context_menu_list_items:
-          <li>${item}</li>
-      % endfor
-  % endif
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  ${self.render_vue_template_this_page()}
 </%def>
 
-<%def name="page_content()"></%def>
-
-<%def name="render_this_page()">
-  <div style="display: flex;">
-
-    <div class="this-page-content" style="flex-grow: 1;">
-      ${self.page_content()}
-    </div>
-
-    <ul id="context-menu">
-      ${self.context_menu_items()}
-    </ul>
-
-  </div>
+<%def name="render_vue_template_this_page()">
+  ## DEPRECATED; called for back-compat
+  ${self.render_this_page_template()}
 </%def>
 
 <%def name="render_this_page_template()">
   <script type="text/x-template" id="this-page-template">
     <div>
+      ## DEPRECATED; called for back-compat
       ${self.render_this_page()}
     </div>
   </script>
-</%def>
+  <script>
 
-<%def name="declare_this_page_vars()">
-  <script type="text/javascript">
-
-    let ThisPage = {
+    const ThisPage = {
         template: '#this-page-template',
         mixins: [SimpleRequestMixin],
         props: {
@@ -52,7 +36,7 @@
         },
     }
 
-    let ThisPageData = {
+    const ThisPageData = {
         ## TODO: should find a better way to handle CSRF token
         csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
     }
@@ -60,29 +44,63 @@
   </script>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ## NOTE: if you override this, must use <script> tags
+## DEPRECATED; remains for back-compat
+<%def name="render_this_page()">
+  <div style="display: flex;">
+
+    <div class="this-page-content" style="flex-grow: 1;">
+      ${self.page_content()}
+    </div>
+
+    ## DEPRECATED; remains for back-compat
+    <ul id="context-menu">
+      ${self.context_menu_items()}
+    </ul>
+  </div>
 </%def>
 
-<%def name="finalize_this_page_vars()">
-  ## NOTE: if you override this, must use <script> tags
+## nb. this is the canonical block for page content!
+<%def name="page_content()"></%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="context_menu_items()">
+  % if context_menu_list_items is not Undefined:
+      % for item in context_menu_list_items:
+          <li>${item}</li>
+      % endfor
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+
+  ## DEPRECATED; called for back-compat
+  ${self.declare_this_page_vars()}
+  ${self.modify_this_page_vars()}
+</%def>
+
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+
+  ## DEPRECATED; called for back-compat
+  ${self.make_this_page_component()}
 </%def>
 
 <%def name="make_this_page_component()">
-  ${self.declare_this_page_vars()}
-  ${self.modify_this_page_vars()}
   ${self.finalize_this_page_vars()}
-
-  <script type="text/javascript">
-
+  <script>
     ThisPage.data = function() { return ThisPageData }
-
     Vue.component('this-page', ThisPage)
     <% request.register_component('this-page', 'ThisPage') %>
-
   </script>
 </%def>
 
+##############################
+## DEPRECATED
+##############################
 
-${self.render_this_page_template()}
-${self.make_this_page_component()}
+<%def name="declare_this_page_vars()"></%def>
+
+<%def name="modify_this_page_vars()"></%def>
+
+<%def name="finalize_this_page_vars()"></%def>
diff --git a/tailbone/templates/people/index.mako b/tailbone/templates/people/index.mako
index 6ce14633..cd6fddf1 100644
--- a/tailbone/templates/people/index.mako
+++ b/tailbone/templates/people/index.mako
@@ -61,9 +61,9 @@
   ${parent.grid_tools()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if getattr(master, 'mergeable', False) and master.has_perm('request_merge'):
 
@@ -100,5 +100,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/people/merge-requests/view.mako b/tailbone/templates/people/merge-requests/view.mako
index 9e8905cf..e2db1476 100644
--- a/tailbone/templates/people/merge-requests/view.mako
+++ b/tailbone/templates/people/merge-requests/view.mako
@@ -18,10 +18,10 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if not instance.merged and request.has_perm('people.merge'):
-      <script type="text/javascript">
+      <script>
 
         ThisPageData.mergeFormButtonText = "Perform Merge"
         ThisPageData.mergeFormSubmitting = false
@@ -34,5 +34,3 @@
       </script>
   % endif
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/people/view.mako b/tailbone/templates/people/view.mako
index d28d7558..15c669fa 100644
--- a/tailbone/templates/people/view.mako
+++ b/tailbone/templates/people/view.mako
@@ -2,6 +2,16 @@
 <%inherit file="/master/view.mako" />
 <%namespace file="/util.mako" import="view_profiles_helper" />
 
+<%def name="page_content()">
+  ${parent.page_content()}
+  % if not instance.users and request.has_perm('users.create'):
+      ${h.form(url('people.make_user'), ref='makeUserForm')}
+      ${h.csrf_token(request)}
+      ${h.hidden('person_uuid', value=instance.uuid)}
+      ${h.end_form()}
+  % endif
+</%def>
+
 <%def name="object_helpers()">
   ${parent.object_helpers()}
   ${view_profiles_helper([instance])}
@@ -13,9 +23,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ${form.vue_component}.methods.clickMakeUser = function(event) {
         this.$emit('make-user')
@@ -29,17 +39,3 @@
 
   </script>
 </%def>
-
-<%def name="page_content()">
-  ${parent.page_content()}
-  % if not instance.users and request.has_perm('users.create'):
-      ${h.form(url('people.make_user'), ref='makeUserForm')}
-      ${h.csrf_token(request)}
-      ${h.hidden('person_uuid', value=instance.uuid)}
-      ${h.end_form()}
-  % endif
-</%def>
-
-
-${parent.body()}
-
diff --git a/tailbone/templates/people/view_profile.mako b/tailbone/templates/people/view_profile.mako
index cdb6c5cc..6ca5a84c 100644
--- a/tailbone/templates/people/view_profile.mako
+++ b/tailbone/templates/people/view_profile.mako
@@ -1966,30 +1966,97 @@
 
     </div>
   </script>
-</%def>
+  <script>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-  ${self.render_personal_tab_template()}
+    let ProfileInfoData = {
+        activeTab: location.hash ? location.hash.substring(1) : 'personal',
+        tabchecks: ${json.dumps(tabchecks or {})|n},
+        today: '${rattail_app.today()}',
+        profileLastChanged: Date.now(),
+        person: ${json.dumps(person_data or {})|n},
+        phoneTypeOptions: ${json.dumps(phone_type_options or [])|n},
+        emailTypeOptions: ${json.dumps(email_type_options or [])|n},
+        maxLengths: ${json.dumps(max_lengths or {})|n},
 
-  % if expose_members:
-      ${self.render_member_tab_template()}
-  % endif
+        % if request.has_perm('people_profile.view_versions'):
+            loadingRevisions: false,
+            showingRevisionDialog: false,
+            revision: {},
+            revisionShowAllFields: false,
+        % endif
+    }
 
-  ${self.render_customer_tab_template()}
-  % if expose_customer_shoppers:
-      ${self.render_shopper_tab_template()}
-  % endif
-  ${self.render_employee_tab_template()}
-  ${self.render_notes_tab_template()}
+    let ProfileInfo = {
+        template: '#profile-info-template',
+        props: {
+            % if request.has_perm('people_profile.view_versions'):
+                viewingHistory: Boolean,
+                gettingRevisions: Boolean,
+                revisions: Array,
+                revisionVersionMap: null,
+            % endif
+        },
+        computed: {},
+        mounted() {
 
-  % if expose_transactions:
-      ${transactions_grid.render_complete(allow_save_defaults=False)|n}
-      ${self.render_transactions_tab_template()}
-  % endif
+            // auto-refresh whichever tab is shown first
+            ## TODO: how to not assume 'personal' is the default tab?
+            let tab = this.$refs['tab_' + (this.activeTab || 'personal')]
+            if (tab && tab.refreshTab) {
+                tab.refreshTab()
+            }
+        },
+        methods: {
 
-  ${self.render_user_tab_template()}
-  ${self.render_profile_info_template()}
+            profileChanged(data) {
+                this.$emit('change-content-title', data.person.dynamic_content_title)
+                this.person = data.person
+                this.tabchecks = data.tabchecks
+                this.profileLastChanged = Date.now()
+            },
+
+            activeTabChanged(value) {
+                location.hash = value
+                this.refreshTabIfNeeded(value)
+                this.activeTabChangedExtra(value)
+            },
+
+            refreshTabIfNeeded(key) {
+                // TODO: this is *always* refreshing, should be more selective (?)
+                let tab = this.$refs['tab_' + key]
+                if (tab && tab.refreshIfNeeded) {
+                    tab.refreshIfNeeded(this.profileLastChanged)
+                }
+            },
+
+            activeTabChangedExtra(value) {},
+
+            % if request.has_perm('people_profile.view_versions'):
+
+                viewRevision(row) {
+                    this.revision = this.revisionVersionMap[row.txnid]
+                    this.showingRevisionDialog = true
+                },
+
+                viewPrevRevision() {
+                    let txnid = this.revision.prev_txnid
+                    this.revision = this.revisionVersionMap[txnid]
+                },
+
+                viewNextRevision() {
+                    let txnid = this.revision.next_txnid
+                    this.revision = this.revisionVersionMap[txnid]
+                },
+
+                toggleVersionFields() {
+                    this.revisionShowAllFields = !this.revisionShowAllFields
+                },
+
+            % endif
+        },
+    }
+
+  </script>
 </%def>
 
 <%def name="declare_personal_tab_vars()">
@@ -3022,114 +3089,46 @@
   </script>
 </%def>
 
-<%def name="declare_profile_info_vars()">
-  <script type="text/javascript">
-
-    let ProfileInfoData = {
-        activeTab: location.hash ? location.hash.substring(1) : 'personal',
-        tabchecks: ${json.dumps(tabchecks or {})|n},
-        today: '${rattail_app.today()}',
-        profileLastChanged: Date.now(),
-        person: ${json.dumps(person_data or {})|n},
-        phoneTypeOptions: ${json.dumps(phone_type_options or [])|n},
-        emailTypeOptions: ${json.dumps(email_type_options or [])|n},
-        maxLengths: ${json.dumps(max_lengths or {})|n},
-
-        % if request.has_perm('people_profile.view_versions'):
-            loadingRevisions: false,
-            showingRevisionDialog: false,
-            revision: {},
-            revisionShowAllFields: false,
-        % endif
-    }
-
-    let ProfileInfo = {
-        template: '#profile-info-template',
-        props: {
-            % if request.has_perm('people_profile.view_versions'):
-                viewingHistory: Boolean,
-                gettingRevisions: Boolean,
-                revisions: Array,
-                revisionVersionMap: null,
-            % endif
-        },
-        computed: {},
-        mounted() {
-
-            // auto-refresh whichever tab is shown first
-            ## TODO: how to not assume 'personal' is the default tab?
-            let tab = this.$refs['tab_' + (this.activeTab || 'personal')]
-            if (tab && tab.refreshTab) {
-                tab.refreshTab()
-            }
-        },
-        methods: {
-
-            profileChanged(data) {
-                this.$emit('change-content-title', data.person.dynamic_content_title)
-                this.person = data.person
-                this.tabchecks = data.tabchecks
-                this.profileLastChanged = Date.now()
-            },
-
-            activeTabChanged(value) {
-                location.hash = value
-                this.refreshTabIfNeeded(value)
-                this.activeTabChangedExtra(value)
-            },
-
-            refreshTabIfNeeded(key) {
-                // TODO: this is *always* refreshing, should be more selective (?)
-                let tab = this.$refs['tab_' + key]
-                if (tab && tab.refreshIfNeeded) {
-                    tab.refreshIfNeeded(this.profileLastChanged)
-                }
-            },
-
-            activeTabChangedExtra(value) {},
-
-            % if request.has_perm('people_profile.view_versions'):
-
-                viewRevision(row) {
-                    this.revision = this.revisionVersionMap[row.txnid]
-                    this.showingRevisionDialog = true
-                },
-
-                viewPrevRevision() {
-                    let txnid = this.revision.prev_txnid
-                    this.revision = this.revisionVersionMap[txnid]
-                },
-
-                viewNextRevision() {
-                    let txnid = this.revision.next_txnid
-                    this.revision = this.revisionVersionMap[txnid]
-                },
-
-                toggleVersionFields() {
-                    this.revisionShowAllFields = !this.revisionShowAllFields
-                },
-
-            % endif
-        },
-    }
-
-  </script>
-</%def>
-
 <%def name="make_profile_info_component()">
-  ${self.declare_profile_info_vars()}
-  <script type="text/javascript">
 
+  ## DEPRECATED; called for back-compat
+  ${self.declare_profile_info_vars()}
+
+  <script>
     ProfileInfo.data = function() { return ProfileInfoData }
     Vue.component('profile-info', ProfileInfo)
     <% request.register_component('profile-info', 'ProfileInfo') %>
-
   </script>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+
+  ${self.render_personal_tab_template()}
+
+  % if expose_members:
+      ${self.render_member_tab_template()}
+  % endif
+
+  ${self.render_customer_tab_template()}
+  % if expose_customer_shoppers:
+      ${self.render_shopper_tab_template()}
+  % endif
+  ${self.render_employee_tab_template()}
+  ${self.render_notes_tab_template()}
+
+  % if expose_transactions:
+      ${transactions_grid.render_complete(allow_save_defaults=False)|n}
+      ${self.render_transactions_tab_template()}
+  % endif
+
+  ${self.render_user_tab_template()}
+  ${self.render_profile_info_template()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if request.has_perm('people_profile.view_versions'):
         ThisPage.props.viewingHistory = Boolean
@@ -3177,45 +3176,8 @@
         },
     }
 
-  </script>
-</%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
-  ${self.make_personal_tab_component()}
-
-  % if expose_members:
-      ${self.make_member_tab_component()}
-  % endif
-
-  ${self.make_customer_tab_component()}
-  % if expose_customer_shoppers:
-      ${self.make_shopper_tab_component()}
-  % endif
-  ${self.make_employee_tab_component()}
-  ${self.make_notes_tab_component()}
-
-  % if expose_transactions:
-      <script type="text/javascript">
-
-        TransactionsGrid.data = function() { return TransactionsGridData }
-        Vue.component('transactions-grid', TransactionsGrid)
-        ## TODO: why is this line not needed?
-        ## <% request.register_component('transactions-grid', 'TransactionsGrid') %>
-
-      </script>
-      ${self.make_transactions_tab_component()}
-  % endif
-
-  ${self.make_user_tab_component()}
-  ${self.make_profile_info_component()}
-</%def>
-
-<%def name="modify_whole_page_vars()">
-  ${parent.modify_whole_page_vars()}
-
-  % if request.has_perm('people_profile.view_versions'):
-      <script type="text/javascript">
+    % if request.has_perm('people_profile.view_versions'):
 
         WholePageData.viewingHistory = false
         WholePageData.gettingRevisions = false
@@ -3251,9 +3213,44 @@
             })
         }
 
-      </script>
-  % endif
+    % endif
+  </script>
 </%def>
 
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
 
-${parent.body()}
+  ${self.make_personal_tab_component()}
+
+  % if expose_members:
+      ${self.make_member_tab_component()}
+  % endif
+
+  ${self.make_customer_tab_component()}
+  % if expose_customer_shoppers:
+      ${self.make_shopper_tab_component()}
+  % endif
+  ${self.make_employee_tab_component()}
+  ${self.make_notes_tab_component()}
+
+  % if expose_transactions:
+      <script type="text/javascript">
+
+        TransactionsGrid.data = function() { return TransactionsGridData }
+        Vue.component('transactions-grid', TransactionsGrid)
+        ## TODO: why is this line not needed?
+        ## <% request.register_component('transactions-grid', 'TransactionsGrid') %>
+
+      </script>
+      ${self.make_transactions_tab_component()}
+  % endif
+
+  ${self.make_user_tab_component()}
+  ${self.make_profile_info_component()}
+</%def>
+
+##############################
+## DEPRECATED
+##############################
+
+<%def name="declare_profile_info_vars()"></%def>
diff --git a/tailbone/templates/poser/reports/view.mako b/tailbone/templates/poser/reports/view.mako
index 274a8806..cb8b51aa 100644
--- a/tailbone/templates/poser/reports/view.mako
+++ b/tailbone/templates/poser/reports/view.mako
@@ -62,19 +62,13 @@
   <br />
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if master.has_perm('replace'):
-  <script type="text/javascript">
-
-    ${form.vue_component}Data.showUploadForm = false
-
-    ${form.vue_component}Data.uploadFile = null
-
-    ${form.vue_component}Data.uploadSubmitting = false
-
-  </script>
+      <script>
+        ${form.vue_component}Data.showUploadForm = false
+        ${form.vue_component}Data.uploadFile = null
+        ${form.vue_component}Data.uploadSubmitting = false
+      </script>
   % endif
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/poser/setup.mako b/tailbone/templates/poser/setup.mako
index 8d01bb33..239e7db2 100644
--- a/tailbone/templates/poser/setup.mako
+++ b/tailbone/templates/poser/setup.mako
@@ -118,14 +118,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.setupSubmitting = false
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/principal/find_by_perm.mako b/tailbone/templates/principal/find_by_perm.mako
index 2ea289c8..ddc44e3d 100644
--- a/tailbone/templates/principal/find_by_perm.mako
+++ b/tailbone/templates/principal/find_by_perm.mako
@@ -10,8 +10,16 @@
   </find-principals>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="principal_table()">
+  <div
+    style="width: 50%;"
+    >
+    ${grid.render_table_element(data_prop='principalsData')|n}
+  </div>
+</%def>
+
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   <script type="text/x-template" id="find-principals-template">
     <div>
 
@@ -90,28 +98,6 @@
 
     </div>
   </script>
-</%def>
-
-<%def name="principal_table()">
-  <div
-    style="width: 50%;"
-    >
-    ${grid.render_table_element(data_prop='principalsData')|n}
-  </div>
-</%def>
-
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
-    ThisPageData.permissionGroups = ${json.dumps(perms_data)|n}
-    ThisPageData.sortedGroups = ${json.dumps(sorted_groups_data)|n}
-
-  </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
   <script type="text/javascript">
 
     const FindPrincipals = {
@@ -240,12 +226,21 @@
         }
     }
 
-    Vue.component('find-principals', FindPrincipals)
-
-    <% request.register_component('find-principals', 'FindPrincipals') %>
-
   </script>
 </%def>
 
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.permissionGroups = ${json.dumps(perms_data)|n}
+    ThisPageData.sortedGroups = ${json.dumps(sorted_groups_data)|n}
+  </script>
+</%def>
 
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
+    Vue.component('find-principals', FindPrincipals)
+    <% request.register_component('find-principals', 'FindPrincipals') %>
+  </script>
+</%def>
diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako
index 66e38028..9f969468 100644
--- a/tailbone/templates/products/batch.mako
+++ b/tailbone/templates/products/batch.mako
@@ -60,9 +60,9 @@
   </script>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako)
 
@@ -114,6 +114,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/products/configure.mako b/tailbone/templates/products/configure.mako
index 6121af67..a43a85d4 100644
--- a/tailbone/templates/products/configure.mako
+++ b/tailbone/templates/products/configure.mako
@@ -95,9 +95,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPage.methods.getTitleForKey = function(key) {
         switch (key) {
@@ -118,6 +118,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/products/index.mako b/tailbone/templates/products/index.mako
index b4731dee..5ffa9512 100644
--- a/tailbone/templates/products/index.mako
+++ b/tailbone/templates/products/index.mako
@@ -36,10 +36,10 @@
   </${grid.component}>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if label_profiles and master.has_perm('print_labels'):
-      <script type="text/javascript">
+      <script>
 
         ${grid.vue_component}Data.quickLabelProfile = ${json.dumps(label_profiles[0].uuid)|n}
         ${grid.vue_component}Data.quickLabelQuantity = 1
@@ -83,6 +83,3 @@
       </script>
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/products/pending/view.mako b/tailbone/templates/products/pending/view.mako
index 765c8838..72c9c76d 100644
--- a/tailbone/templates/products/pending/view.mako
+++ b/tailbone/templates/products/pending/view.mako
@@ -2,11 +2,6 @@
 <%inherit file="/master/view.mako" />
 <%namespace name="product_lookup" file="/products/lookup.mako" />
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
-  ${product_lookup.tailbone_product_lookup_template()}
-</%def>
-
 <%def name="page_content()">
   ${parent.page_content()}
 
@@ -67,9 +62,14 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  ${product_lookup.tailbone_product_lookup_template()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if master.has_perm('ignore_product') and instance.status_code in (enum.PENDING_PRODUCT_STATUS_PENDING, enum.PENDING_PRODUCT_STATUS_READY):
 
@@ -124,10 +124,7 @@
   </script>
 </%def>
 
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
   ${product_lookup.tailbone_product_lookup_component()}
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/products/view.mako b/tailbone/templates/products/view.mako
index bd4afc7f..66ca3128 100644
--- a/tailbone/templates/products/view.mako
+++ b/tailbone/templates/products/view.mako
@@ -282,9 +282,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.vendorSourcesData = ${json.dumps(vendor_sources['data'])|n}
     ThisPageData.lookupCodesData = ${json.dumps(lookup_codes['data'])|n}
@@ -411,6 +411,3 @@
     % endif
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/purchases/credits/index.mako b/tailbone/templates/purchases/credits/index.mako
index 0cfbc031..94028bdb 100644
--- a/tailbone/templates/purchases/credits/index.mako
+++ b/tailbone/templates/purchases/credits/index.mako
@@ -59,9 +59,9 @@
 
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ${grid.vue_component}Data.changeStatusShowDialog = false
     ${grid.vue_component}Data.changeStatusOptions = ${json.dumps(status_options)|n}
@@ -80,6 +80,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/receiving/view.mako b/tailbone/templates/receiving/view.mako
index 45a8d66b..710dec4a 100644
--- a/tailbone/templates/receiving/view.mako
+++ b/tailbone/templates/receiving/view.mako
@@ -139,9 +139,15 @@
   % endif
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="object_helpers()">
+  ${self.render_status_breakdown()}
+  ${self.render_po_vs_invoice_helper()}
+  ${self.render_execute_helper()}
+  ${self.render_tools_helper()}
+</%def>
 
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   % if allow_edit_catalog_unit_cost or allow_edit_invoice_unit_cost:
       <script type="text/x-template" id="receiving-cost-editor-template">
         <div>
@@ -162,16 +168,9 @@
   % endif
 </%def>
 
-<%def name="object_helpers()">
-  ${self.render_status_breakdown()}
-  ${self.render_po_vs_invoice_helper()}
-  ${self.render_execute_helper()}
-  ${self.render_tools_helper()}
-</%def>
-
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if allow_confirm_all_costs:
 
@@ -389,6 +388,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/receiving/view_row.mako b/tailbone/templates/receiving/view_row.mako
index 5077539c..086754c6 100644
--- a/tailbone/templates/receiving/view_row.mako
+++ b/tailbone/templates/receiving/view_row.mako
@@ -484,9 +484,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
 ##     ThisPage.methods.editUnitCost = function() {
 ##         alert("TODO: not yet implemented")
@@ -720,6 +720,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/reports/generated/choose.mako b/tailbone/templates/reports/generated/choose.mako
index a952fb6a..0921530c 100644
--- a/tailbone/templates/reports/generated/choose.mako
+++ b/tailbone/templates/reports/generated/choose.mako
@@ -53,13 +53,13 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    TailboneFormData.reportDescriptions = ${json.dumps(report_descriptions)|n}
+    ${form.vue_component}Data.reportDescriptions = ${json.dumps(report_descriptions)|n}
 
-    TailboneForm.methods.reportTypeChanged = function(reportType) {
+    ${form.vue_component}.methods.reportTypeChanged = function(reportType) {
         this.$emit('report-change', this.reportDescriptions[reportType])
     }
 
@@ -71,6 +71,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/reports/generated/delete.mako b/tailbone/templates/reports/generated/delete.mako
index bce54662..f60a9819 100644
--- a/tailbone/templates/reports/generated/delete.mako
+++ b/tailbone/templates/reports/generated/delete.mako
@@ -1,16 +1,11 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/delete.mako" />
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     % if params_data is not Undefined:
         ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n}
     % endif
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/reports/generated/view.mako b/tailbone/templates/reports/generated/view.mako
index e5bcc9e4..cce6f346 100644
--- a/tailbone/templates/reports/generated/view.mako
+++ b/tailbone/templates/reports/generated/view.mako
@@ -23,16 +23,11 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     % if params_data is not Undefined:
         ${form.vue_component}Data.paramsData = ${json.dumps(params_data)|n}
     % endif
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/reports/inventory.mako b/tailbone/templates/reports/inventory.mako
index f051959f..cc5adc10 100644
--- a/tailbone/templates/reports/inventory.mako
+++ b/tailbone/templates/reports/inventory.mako
@@ -48,15 +48,10 @@
   ${h.end_form()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.departments = ${json.dumps([{'uuid': d.uuid, 'name': d.name} for d in departments])|n}
     ThisPageData.excludeNotForSale = true
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/reports/ordering.mako b/tailbone/templates/reports/ordering.mako
index 1e526792..61ccdb16 100644
--- a/tailbone/templates/reports/ordering.mako
+++ b/tailbone/templates/reports/ordering.mako
@@ -81,9 +81,9 @@
 
 <%def name="extra_fields()"></%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.vendorUUID = null
     ThisPageData.departments = []
@@ -127,6 +127,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako
index 1d5cb14f..00ac1503 100644
--- a/tailbone/templates/reports/problems/view.mako
+++ b/tailbone/templates/reports/problems/view.mako
@@ -62,9 +62,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if weekdays_data is not Undefined:
         ${form.vue_component}Data.weekdaysData = ${json.dumps(weekdays_data)|n}
@@ -75,6 +75,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/roles/create.mako b/tailbone/templates/roles/create.mako
index 625b2675..89dd56c3 100644
--- a/tailbone/templates/roles/create.mako
+++ b/tailbone/templates/roles/create.mako
@@ -6,15 +6,11 @@
   ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     // TODO: this variable name should be more dynamic (?) since this is
     // connected to (and only here b/c of) the permissions field
-    TailboneFormData.showingPermissionGroup = ''
-
+    ${form.vue_component}Data.showingPermissionGroup = ''
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/roles/edit.mako b/tailbone/templates/roles/edit.mako
index 67f63013..e77cca33 100644
--- a/tailbone/templates/roles/edit.mako
+++ b/tailbone/templates/roles/edit.mako
@@ -6,15 +6,11 @@
   ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     // TODO: this variable name should be more dynamic (?) since this is
     // connected to (and only here b/c of) the permissions field
-    TailboneFormData.showingPermissionGroup = ''
-
+    ${form.vue_component}Data.showingPermissionGroup = ''
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/roles/view.mako b/tailbone/templates/roles/view.mako
index 0dc2956f..f5588695 100644
--- a/tailbone/templates/roles/view.mako
+++ b/tailbone/templates/roles/view.mako
@@ -6,9 +6,9 @@
   ${h.stylesheet_link(request.static_url('tailbone:static/css/perms.css'))}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     % if users_data is not Undefined:
         ${form.vue_component}Data.usersData = ${json.dumps(users_data)|n}
@@ -23,5 +23,3 @@
 
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/settings/email/configure.mako b/tailbone/templates/settings/email/configure.mako
index ef487809..f9c815c2 100644
--- a/tailbone/templates/settings/email/configure.mako
+++ b/tailbone/templates/settings/email/configure.mako
@@ -86,9 +86,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.testRecipient = ${json.dumps(user_email_address)|n}
     ThisPageData.sendingTest = false
@@ -137,6 +137,3 @@
     % endif
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/settings/email/index.mako b/tailbone/templates/settings/email/index.mako
index 050a5833..ab8d6fa4 100644
--- a/tailbone/templates/settings/email/index.mako
+++ b/tailbone/templates/settings/email/index.mako
@@ -15,10 +15,10 @@
   ${parent.render_grid_component()}
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if master.has_perm('configure'):
-      <script type="text/javascript">
+      <script>
 
         ThisPageData.showEmails = 'available'
 
@@ -65,5 +65,3 @@
       </script>
   % endif
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/settings/email/view.mako b/tailbone/templates/settings/email/view.mako
index c1bc5ed4..73ad7066 100644
--- a/tailbone/templates/settings/email/view.mako
+++ b/tailbone/templates/settings/email/view.mako
@@ -6,8 +6,8 @@
   <email-preview-tools></email-preview-tools>
 </%def>
 
-<%def name="render_this_page_template()">
-  ${parent.render_this_page_template()}
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
   <script type="text/x-template" id="email-preview-tools-template">
 
   ${h.form(url('email.preview'), **{'@submit': 'submitPreviewForm'})}
@@ -72,10 +72,6 @@
 
   ${h.end_form()}
   </script>
-</%def>
-
-<%def name="make_this_page_component()">
-  ${parent.make_this_page_component()}
   <script type="text/javascript">
 
     const EmailPreviewTools = {
@@ -100,12 +96,13 @@
         }
     }
 
-    Vue.component('email-preview-tools', EmailPreviewTools)
-
-    <% request.register_component('email-preview-tools', 'EmailPreviewTools') %>
-
   </script>
 </%def>
 
-
-${parent.body()}
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  <script>
+    Vue.component('email-preview-tools', EmailPreviewTools)
+    <% request.register_component('email-preview-tools', 'EmailPreviewTools') %>
+  </script>
+</%def>
diff --git a/tailbone/templates/tables/create.mako b/tailbone/templates/tables/create.mako
index 4fc2eb96..34844c5c 100644
--- a/tailbone/templates/tables/create.mako
+++ b/tailbone/templates/tables/create.mako
@@ -695,9 +695,9 @@
   </b-steps>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     // nb. for warning user they may lose changes if leaving page
     ThisPageData.dirty = false
@@ -983,6 +983,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/tempmon/appliances/view.mako b/tailbone/templates/tempmon/appliances/view.mako
index 7dd9314a..a55af922 100644
--- a/tailbone/templates/tempmon/appliances/view.mako
+++ b/tailbone/templates/tempmon/appliances/view.mako
@@ -8,14 +8,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/tempmon/clients/view.mako b/tailbone/templates/tempmon/clients/view.mako
index b1db423b..434da4c8 100644
--- a/tailbone/templates/tempmon/clients/view.mako
+++ b/tailbone/templates/tempmon/clients/view.mako
@@ -22,14 +22,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ${form.vue_component}Data.probesData = ${json.dumps(probes_data)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/tempmon/dashboard.mako b/tailbone/templates/tempmon/dashboard.mako
index 396b0e68..befaf8b4 100644
--- a/tailbone/templates/tempmon/dashboard.mako
+++ b/tailbone/templates/tempmon/dashboard.mako
@@ -59,9 +59,9 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.appliances = ${json.dumps(appliances_data)|n}
     ThisPageData.applianceUUID = ${json.dumps(appliance.uuid if appliance else None)|n}
@@ -118,6 +118,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/tempmon/probes/graph.mako b/tailbone/templates/tempmon/probes/graph.mako
index 412f25dd..94a440e0 100644
--- a/tailbone/templates/tempmon/probes/graph.mako
+++ b/tailbone/templates/tempmon/probes/graph.mako
@@ -66,9 +66,9 @@
   <canvas ref="tempchart" width="400" height="150"></canvas>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.currentTimeRange = ${json.dumps(current_time_range)|n}
     ThisPageData.chart = null
@@ -128,6 +128,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index 306b3430..14616474 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -20,38 +20,21 @@
   </head>
 
   <body>
-    <div id="app" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
+    <div id="app" style="height: 100%;">
       <whole-page></whole-page>
     </div>
 
     ## TODO: this must come before the self.body() call..but why?
     ${declare_formposter_mixin()}
 
-    ## global components used by various (but not all) pages
-    ${make_field_components()}
-    ${make_grid_filter_components()}
-
-    ## global components for buefy-based template compatibility
-    ${make_http_plugin()}
-    ${make_buefy_plugin()}
-    ${make_buefy_components()}
-
-    ## special global components, used by WholePage
-    ${self.make_menu_search_component()}
-    ${page_help.render_template()}
-    ${page_help.declare_vars()}
-    % if request.has_perm('common.feedback'):
-        ${self.make_feedback_component()}
-    % endif
-
-    ## WholePage component
-    ${self.make_whole_page_component()}
-
     ## content body from derived/child template
     ${self.body()}
 
     ## Vue app
-    ${self.make_whole_page_app()}
+    ${self.render_vue_templates()}
+    ${self.modify_vue_vars()}
+    ${self.make_vue_components()}
+    ${self.make_vue_app()}
   </body>
 </html>
 
@@ -596,7 +579,7 @@
   </script>
 </%def>
 
-<%def name="render_whole_page_template()">
+<%def name="render_vue_template_whole_page()">
   <script type="text/x-template" id="whole-page-template">
     <div id="whole-page" style="height: 100%; display: flex; flex-direction: column; justify-content: space-between;">
 
@@ -896,8 +879,6 @@
       </footer>
     </div>
   </script>
-
-##   ${multi_file_upload.render_template()}
 </%def>
 
 <%def name="render_this_page_component()">
@@ -1068,9 +1049,7 @@
   % endif
 </%def>
 
-<%def name="declare_whole_page_vars()">
-##   ${multi_file_upload.declare_vars()}
-
+<%def name="render_vue_script_whole_page()">
   <script>
 
     const WholePage = {
@@ -1172,26 +1151,71 @@
   </script>
 </%def>
 
-<%def name="modify_whole_page_vars()"></%def>
+##############################
+## vue components + app
+##############################
 
-## TODO: do we really need this?
-## <%def name="finalize_whole_page_vars()"></%def>
+<%def name="render_vue_templates()">
+##   ${multi_file_upload.render_template()}
+##   ${multi_file_upload.declare_vars()}
 
-<%def name="make_whole_page_component()">
+  ## global components used by various (but not all) pages
+  ${make_field_components()}
+  ${make_grid_filter_components()}
+
+  ## global components for buefy-based template compatibility
+  ${make_http_plugin()}
+  ${make_buefy_plugin()}
+  ${make_buefy_components()}
+
+  ## special global components, used by WholePage
+  ${self.make_menu_search_component()}
+  ${page_help.render_template()}
+  ${page_help.declare_vars()}
+  % if request.has_perm('common.feedback'):
+      ${self.make_feedback_component()}
+  % endif
+
+  ## DEPRECATED; called for back-compat
   ${self.render_whole_page_template()}
+
+  ## DEPRECATED; called for back-compat
   ${self.declare_whole_page_vars()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="render_whole_page_template()">
+  ${self.render_vue_template_whole_page()}
+  ${self.render_vue_script_whole_page()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ## DEPRECATED; called for back-compat
   ${self.modify_whole_page_vars()}
-##   ${self.finalize_whole_page_vars()}
+</%def>
 
+<%def name="make_vue_components()">
   ${page_help.make_component()}
-##   ${multi_file_upload.make_component()}
+  ## ${multi_file_upload.make_component()}
 
+  ## DEPRECATED; called for back-compat (?)
+  ${self.make_whole_page_component()}
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="make_whole_page_component()">
   <script>
     WholePage.data = () => { return WholePageData }
   </script>
   <% request.register_component('whole-page', 'WholePage') %>
 </%def>
 
+<%def name="make_vue_app()">
+  ## DEPRECATED; called for back-compat
+  ${self.make_whole_page_app()}
+</%def>
+
+## DEPRECATED; remains for back-compat
 <%def name="make_whole_page_app()">
   <script type="module">
     import {createApp} from 'vue'
@@ -1223,3 +1247,11 @@
     app.mount('#app')
   </script>
 </%def>
+
+##############################
+## DEPRECATED
+##############################
+
+<%def name="declare_whole_page_vars()"></%def>
+
+<%def name="modify_whole_page_vars()"></%def>
diff --git a/tailbone/templates/trainwreck/transactions/configure.mako b/tailbone/templates/trainwreck/transactions/configure.mako
index 4569759b..10c57e18 100644
--- a/tailbone/templates/trainwreck/transactions/configure.mako
+++ b/tailbone/templates/trainwreck/transactions/configure.mako
@@ -62,14 +62,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.hiddenDatabases = ${json.dumps(hidden_databases)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/trainwreck/transactions/rollover.mako b/tailbone/templates/trainwreck/transactions/rollover.mako
index b36e7bc3..f26515b5 100644
--- a/tailbone/templates/trainwreck/transactions/rollover.mako
+++ b/tailbone/templates/trainwreck/transactions/rollover.mako
@@ -48,14 +48,9 @@
   </b-table>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.engines = ${json.dumps(engines_data)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/trainwreck/transactions/view.mako b/tailbone/templates/trainwreck/transactions/view.mako
index 02950941..630950cf 100644
--- a/tailbone/templates/trainwreck/transactions/view.mako
+++ b/tailbone/templates/trainwreck/transactions/view.mako
@@ -1,15 +1,11 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view.mako" />
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     % if custorder_xref_markers_data is not Undefined:
         ${form.vue_component}Data.custorderXrefMarkersData = ${json.dumps(custorder_xref_markers_data)|n}
     % endif
-
   </script>
 </%def>
-
-${parent.body()}
diff --git a/tailbone/templates/trainwreck/transactions/view_row.mako b/tailbone/templates/trainwreck/transactions/view_row.mako
index 9c76f7bd..2507492e 100644
--- a/tailbone/templates/trainwreck/transactions/view_row.mako
+++ b/tailbone/templates/trainwreck/transactions/view_row.mako
@@ -1,16 +1,11 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/view_row.mako" />
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     % if discounts_data is not Undefined:
         ${form.vue_component}Data.discountsData = ${json.dumps(discounts_data)|n}
     % endif
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/units-of-measure/index.mako b/tailbone/templates/units-of-measure/index.mako
index 597cabfd..4815fc79 100644
--- a/tailbone/templates/units-of-measure/index.mako
+++ b/tailbone/templates/units-of-measure/index.mako
@@ -51,20 +51,17 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if master.has_perm('collect_wild_uoms'):
-  <script type="text/javascript">
+      <script>
 
-    TailboneGridData.showingCollectWildDialog = false
+        ${grid.vue_component}Data.showingCollectWildDialog = false
 
-    TailboneGrid.methods.collectFromWild = function() {
-        this.$refs['collect-wild-uoms-form'].submit()
-    }
+        ${grid.vue_component}.methods.collectFromWild = function() {
+            this.$refs['collect-wild-uoms-form'].submit()
+        }
 
-  </script>
+      </script>
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/upgrades/configure.mako b/tailbone/templates/upgrades/configure.mako
index f7af685c..9439f830 100644
--- a/tailbone/templates/upgrades/configure.mako
+++ b/tailbone/templates/upgrades/configure.mako
@@ -111,9 +111,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.upgradeSystems = ${json.dumps(upgrade_systems)|n}
     ThisPageData.upgradeSystemShowDialog = false
@@ -161,6 +161,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/upgrades/view.mako b/tailbone/templates/upgrades/view.mako
index 6ae110e0..c3fca81d 100644
--- a/tailbone/templates/upgrades/view.mako
+++ b/tailbone/templates/upgrades/view.mako
@@ -137,11 +137,11 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
-    TailboneFormData.showingPackages = 'diffs'
+    ${form.vue_component}Data.showingPackages = 'diffs'
 
     % if master.has_perm('execute'):
 
@@ -153,7 +153,7 @@
             // execute upgrade
             //////////////////////////////
 
-            TailboneForm.props.upgradeExecuting = {
+            ${form.vue_component}.props.upgradeExecuting = {
                 type: Boolean,
                 default: false,
             }
@@ -253,9 +253,9 @@
             // execute upgrade
             //////////////////////////////
 
-            TailboneFormData.formSubmitting = false
+            ${form.vue_component}Data.formSubmitting = false
 
-            TailboneForm.methods.submitForm = function() {
+            ${form.vue_component}.methods.submitForm = function() {
                 this.formSubmitting = true
             }
 
@@ -265,12 +265,12 @@
         // declare failure
         //////////////////////////////
 
-        TailboneForm.props.declareFailureSubmitting = {
+        ${form.vue_component}.props.declareFailureSubmitting = {
             type: Boolean,
             default: false,
         }
 
-        TailboneForm.methods.declareFailureClick = function() {
+        ${form.vue_component}.methods.declareFailureClick = function() {
             this.$emit('declare-failure-click')
         }
 
@@ -287,6 +287,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/users/preferences.mako b/tailbone/templates/users/preferences.mako
index c2e17396..ecfdd1c7 100644
--- a/tailbone/templates/users/preferences.mako
+++ b/tailbone/templates/users/preferences.mako
@@ -42,14 +42,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.themeStyleOptions = ${json.dumps(theme_style_options)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/users/view.mako b/tailbone/templates/users/view.mako
index 06087927..d1afd218 100644
--- a/tailbone/templates/users/view.mako
+++ b/tailbone/templates/users/view.mako
@@ -76,10 +76,10 @@
   % endif
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
   % if master.has_perm('manage_api_tokens'):
-    <script type="text/javascript">
+    <script>
 
       ${form.vue_component}.props.apiTokens = null
 
@@ -134,6 +134,3 @@
     </script>
   % endif
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/vendors/configure.mako b/tailbone/templates/vendors/configure.mako
index 79dad455..6b135346 100644
--- a/tailbone/templates/vendors/configure.mako
+++ b/tailbone/templates/vendors/configure.mako
@@ -44,14 +44,9 @@
   </div>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
-
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
     ThisPageData.supportedVendorSettings = ${json.dumps(supported_vendor_settings)|n}
-
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/views/model/create.mako b/tailbone/templates/views/model/create.mako
index c5e22cfb..e902fd48 100644
--- a/tailbone/templates/views/model/create.mako
+++ b/tailbone/templates/views/model/create.mako
@@ -259,9 +259,9 @@ def includeme(config):
   </b-steps>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.activeStep = 'enter-details'
 
@@ -334,6 +334,3 @@ def includeme(config):
 
   </script>
 </%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/workorders/view.mako b/tailbone/templates/workorders/view.mako
index 8740b4c9..432e011d 100644
--- a/tailbone/templates/workorders/view.mako
+++ b/tailbone/templates/workorders/view.mako
@@ -145,9 +145,9 @@
   </nav>
 </%def>
 
-<%def name="modify_this_page_vars()">
-  ${parent.modify_this_page_vars()}
-  <script type="text/javascript">
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
 
     ThisPageData.receiveButtonDisabled = false
     ThisPageData.receiveButtonText = "I've received the order from customer"
@@ -216,6 +216,3 @@
 
   </script>
 </%def>
-
-
-${parent.body()}

From 59bd58aca768f9e18a1e3db7447a576c48d29191 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 20 Aug 2024 13:46:40 -0500
Subject: [PATCH 05/85] feat: add new 'waterpark' theme, based on wuttaweb w/
 vue2 + buefy

hoping to eventually replace the 'default' view with this one, if all
goes well.  definitely needs more testing and is not exposed as an
option yet, unless configured
---
 tailbone/app.py                               |   3 +-
 tailbone/forms/core.py                        |  15 +-
 tailbone/grids/core.py                        |  14 +-
 tailbone/static/__init__.py                   |   5 +-
 tailbone/templates/appinfo/index.mako         |   4 +-
 tailbone/templates/base.mako                  |   2 +
 tailbone/templates/batch/index.mako           |   9 +-
 tailbone/templates/batch/view.mako            |  20 +-
 tailbone/templates/form.mako                  |   5 +-
 tailbone/templates/themes/waterpark/base.mako | 486 ++++++++++++++++++
 .../templates/themes/waterpark/configure.mako |   2 +
 tailbone/templates/themes/waterpark/form.mako |   2 +
 .../themes/waterpark/master/configure.mako    |   2 +
 .../themes/waterpark/master/create.mako       |   2 +
 .../themes/waterpark/master/delete.mako       |  46 ++
 .../themes/waterpark/master/edit.mako         |   2 +
 .../themes/waterpark/master/form.mako         |   2 +
 .../themes/waterpark/master/index.mako        | 294 +++++++++++
 .../themes/waterpark/master/view.mako         |   2 +
 tailbone/templates/themes/waterpark/page.mako |  48 ++
 tailbone/views/master.py                      |  12 +-
 tailbone/views/people.py                      |   2 +-
 tests/util.py                                 |   2 +-
 23 files changed, 937 insertions(+), 44 deletions(-)
 create mode 100644 tailbone/templates/themes/waterpark/base.mako
 create mode 100644 tailbone/templates/themes/waterpark/configure.mako
 create mode 100644 tailbone/templates/themes/waterpark/form.mako
 create mode 100644 tailbone/templates/themes/waterpark/master/configure.mako
 create mode 100644 tailbone/templates/themes/waterpark/master/create.mako
 create mode 100644 tailbone/templates/themes/waterpark/master/delete.mako
 create mode 100644 tailbone/templates/themes/waterpark/master/edit.mako
 create mode 100644 tailbone/templates/themes/waterpark/master/form.mako
 create mode 100644 tailbone/templates/themes/waterpark/master/index.mako
 create mode 100644 tailbone/templates/themes/waterpark/master/view.mako
 create mode 100644 tailbone/templates/themes/waterpark/page.mako

diff --git a/tailbone/app.py b/tailbone/app.py
index ad9663cf..b7262866 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -321,7 +321,8 @@ def main(global_config, **settings):
     """
     This function returns a Pyramid WSGI application.
     """
-    settings.setdefault('mako.directories', ['tailbone:templates'])
+    settings.setdefault('mako.directories', ['tailbone:templates',
+                                             'wuttaweb:templates'])
     rattail_config = make_rattail_config(settings)
     pyramid_config = make_pyramid_config(settings)
     pyramid_config.include('tailbone')
diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 2f1c9370..059b212a 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -905,7 +905,8 @@ class Form(object):
 
     def render_vue_template(self, template='/forms/deform.mako', **context):
         """ """
-        return self.render_deform(template=template, **context)
+        output = self.render_deform(template=template, **context)
+        return HTML.literal(output)
 
     def render_deform(self, dform=None, template=None, **kwargs):
         if not template:
@@ -1220,6 +1221,18 @@ class Form(object):
             # TODO: again, why does serialize() not return literal?
             return HTML.literal(field.serialize())
 
+    # TODO: this was copied from wuttaweb; can remove when we align
+    # Form class structure
+    def render_vue_finalize(self):
+        """ """
+        set_data = f"{self.vue_component}.data = function() {{ return {self.vue_component}Data }}"
+        make_component = f"Vue.component('{self.vue_tagname}', {self.vue_component})"
+        return HTML.tag('script', c=['\n',
+                                     HTML.literal(set_data),
+                                     '\n',
+                                     HTML.literal(make_component),
+                                     '\n'])
+
     def render_field_readonly(self, field_name, **kwargs):
         """
         Render the given field completely, but in read-only fashion.
diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 6ec55987..eada1041 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -216,39 +216,39 @@ class Grid(WuttaGrid):
             expose_direct_link=False,
             **kwargs,
     ):
-        if kwargs.get('component'):
+        if 'component' in kwargs:
             warnings.warn("component param is deprecated for Grid(); "
                           "please use vue_tagname param instead",
                           DeprecationWarning, stacklevel=2)
             kwargs.setdefault('vue_tagname', kwargs.pop('component'))
 
-        if kwargs.get('default_sortkey'):
+        if 'default_sortkey' in kwargs:
             warnings.warn("default_sortkey param is deprecated for Grid(); "
                           "please use sort_defaults param instead",
                           DeprecationWarning, stacklevel=2)
-        if kwargs.get('default_sortdir'):
+        if 'default_sortdir' in kwargs:
             warnings.warn("default_sortdir param is deprecated for Grid(); "
                           "please use sort_defaults param instead",
                           DeprecationWarning, stacklevel=2)
-        if kwargs.get('default_sortkey') or kwargs.get('default_sortdir'):
+        if 'default_sortkey' in kwargs or 'default_sortdir' in kwargs:
             sortkey = kwargs.pop('default_sortkey', None)
             sortdir = kwargs.pop('default_sortdir', 'asc')
             if sortkey:
                 kwargs.setdefault('sort_defaults', [(sortkey, sortdir)])
 
-        if kwargs.get('pageable'):
+        if 'pageable' in kwargs:
             warnings.warn("pageable param is deprecated for Grid(); "
                           "please use vue_tagname param instead",
                           DeprecationWarning, stacklevel=2)
             kwargs.setdefault('paginated', kwargs.pop('pageable'))
 
-        if kwargs.get('default_pagesize'):
+        if 'default_pagesize' in kwargs:
             warnings.warn("default_pagesize param is deprecated for Grid(); "
                           "please use pagesize param instead",
                           DeprecationWarning, stacklevel=2)
             kwargs.setdefault('pagesize', kwargs.pop('default_pagesize'))
 
-        if kwargs.get('default_page'):
+        if 'default_page' in kwargs:
             warnings.warn("default_page param is deprecated for Grid(); "
                           "please use page param instead",
                           DeprecationWarning, stacklevel=2)
diff --git a/tailbone/static/__init__.py b/tailbone/static/__init__.py
index 2ad5161a..57700b80 100644
--- a/tailbone/static/__init__.py
+++ b/tailbone/static/__init__.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2017 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,9 +24,8 @@
 Static Assets
 """
 
-from __future__ import unicode_literals, absolute_import
-
 
 def includeme(config):
+    config.include('wuttaweb.static')
     config.add_static_view('tailbone', 'tailbone:static')
     config.add_static_view('deform', 'deform:static')
diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako
index 68244300..75032c1f 100644
--- a/tailbone/templates/appinfo/index.mako
+++ b/tailbone/templates/appinfo/index.mako
@@ -1,7 +1,7 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="/master/index.mako" />
 
-<%def name="render_grid_component()">
+<%def name="page_content()">
 
   <div class="buttons">
 
@@ -108,7 +108,7 @@
 
     <div class="panel-block">
       <div style="width: 100%;">
-        ${parent.render_grid_component()}
+        ${grid.render_vue_tag()}
       </div>
     </div>
   </${b}-collapse>
diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index a0e58e22..eb950011 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -1,4 +1,5 @@
 ## -*- coding: utf-8; -*-
+<%namespace file="/wutta-components.mako" import="make_wutta_components" />
 <%namespace file="/grids/nav.mako" import="grid_index_nav" />
 <%namespace file="/autocomplete.mako" import="tailbone_autocomplete_template" />
 <%namespace name="base_meta" file="/base_meta.mako" />
@@ -955,6 +956,7 @@
 </%def>
 
 <%def name="make_vue_components()">
+  ${make_wutta_components()}
   ${make_grid_filter_components()}
   ${page_help.make_component()}
   ${multi_file_upload.make_component()}
diff --git a/tailbone/templates/batch/index.mako b/tailbone/templates/batch/index.mako
index a1b11b89..bea10a97 100644
--- a/tailbone/templates/batch/index.mako
+++ b/tailbone/templates/batch/index.mako
@@ -43,7 +43,7 @@
             <br />
             <div class="form-wrapper">
               <div class="form">
-                <${execute_form.component} ref="executeResultsForm"></${execute_form.component}>
+                ${execute_form.render_vue_tag(ref='executeResultsForm')}
               </div>
             </div>
           </section>
@@ -67,7 +67,7 @@
 <%def name="render_vue_templates()">
   ${parent.render_vue_templates()}
   % if master.results_executable and master.has_perm('execute_multiple'):
-      ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n}
+      ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)}
   % endif
 </%def>
 
@@ -128,9 +128,6 @@
 <%def name="make_vue_components()">
   ${parent.make_vue_components()}
   % if master.results_executable and master.has_perm('execute_multiple'):
-      <script>
-        ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data }
-        Vue.component('${execute_form.vue_tagname}', ${execute_form.vue_component})
-      </script>
+      ${execute_form.render_vue_finalize()}
   % endif
 </%def>
diff --git a/tailbone/templates/batch/view.mako b/tailbone/templates/batch/view.mako
index cdfa9ba7..7c81ab0e 100644
--- a/tailbone/templates/batch/view.mako
+++ b/tailbone/templates/batch/view.mako
@@ -119,8 +119,7 @@
                         <div class="markdown">
                           ${execution_described|n}
                         </div>
-                        <${execute_form.component} ref="executeBatchForm">
-                        </${execute_form.component}>
+                        ${execute_form.render_vue_tag(ref='executeBatchForm')}
                       </section>
 
                       <footer class="modal-card-foot">
@@ -168,8 +167,7 @@
               Please be certain to use the right one!
             </p>
             <br />
-            <${upload_worksheet_form.component} ref="uploadForm">
-            </${upload_worksheet_form.component}>
+            ${upload_worksheet_form.render_vue_tag(ref='uploadForm')}
           </section>
 
           <footer class="modal-card-foot">
@@ -254,10 +252,10 @@
 <%def name="render_vue_templates()">
   ${parent.render_vue_templates()}
   % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
-      ${upload_worksheet_form.render_deform(buttons=False, form_kwargs={'ref': 'actualUploadForm'})|n}
+      ${upload_worksheet_form.render_vue_template(buttons=False, form_kwargs={'ref': 'actualUploadForm'})}
   % endif
   % if master.handler.executable(batch) and master.has_perm('execute'):
-      ${execute_form.render_deform(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)|n}
+      ${execute_form.render_vue_template(form_kwargs={'ref': 'actualExecuteForm'}, buttons=False)}
   % endif
 </%def>
 
@@ -345,15 +343,9 @@
 <%def name="make_vue_components()">
   ${parent.make_vue_components()}
   % if master.has_worksheet_file and master.allow_worksheet(batch) and master.has_perm('worksheet'):
-      <script>
-        ${upload_worksheet_form.vue_component}.data = function() { return ${upload_worksheet_form.vue_component}Data }
-        Vue.component('${upload_worksheet_form.component}', ${upload_worksheet_form.vue_component})
-      </script>
+      ${upload_worksheet_form.render_vue_finalize()}
   % endif
   % if execute_enabled and master.has_perm('execute'):
-      <script>
-        ${execute_form.vue_component}.data = function() { return ${execute_form.vue_component}Data }
-        Vue.component('${execute_form.component}', ${execute_form.vue_component})
-      </script>
+      ${execute_form.render_vue_finalize()}
   % endif
 </%def>
diff --git a/tailbone/templates/form.mako b/tailbone/templates/form.mako
index 3bb04257..e3a4d5dc 100644
--- a/tailbone/templates/form.mako
+++ b/tailbone/templates/form.mako
@@ -109,9 +109,6 @@
 <%def name="make_vue_components()">
   ${parent.make_vue_components()}
   % if form is not Undefined:
-      <script>
-        ${form.vue_component}.data = function() { return ${form.vue_component}Data }
-        Vue.component('${form.vue_tagname}', ${form.vue_component})
-      </script>
+      ${form.render_vue_finalize()}
   % endif
 </%def>
diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako
new file mode 100644
index 00000000..15184f6e
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/base.mako
@@ -0,0 +1,486 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/base.mako" />
+<%namespace name="base_meta" file="/base_meta.mako" />
+<%namespace file="/formposter.mako" import="declare_formposter_mixin" />
+<%namespace file="/grids/filter-components.mako" import="make_grid_filter_components" />
+<%namespace name="page_help" file="/page_help.mako" />
+
+<%def name="base_styles()">
+  ${parent.base_styles()}
+  <style>
+
+    .filters .filter-fieldname .field,
+    .filters .filter-fieldname .field label {
+        width: 100%;
+    }
+
+    .filters .filter-fieldname,
+    .filters .filter-fieldname .field label,
+    .filters .filter-fieldname .button {
+        justify-content: left;
+    }
+
+    .filters .filter-verb .select,
+    .filters .filter-verb .select select {
+        width: 100%;
+    }
+
+    % if filter_fieldname_width is not Undefined:
+
+        .filters .filter-fieldname,
+        .filters .filter-fieldname .button {
+            min-width: ${filter_fieldname_width};
+        }
+
+        .filters .filter-verb {
+            min-width: ${filter_verb_width};
+        }
+
+    % endif
+
+  </style>
+</%def>
+
+<%def name="before_content()">
+  ## TODO: this must come before the self.body() call..but why?
+  ${declare_formposter_mixin()}
+</%def>
+
+<%def name="render_navbar_brand()">
+  <div class="navbar-brand">
+    <a class="navbar-item" href="${url('home')}"
+       v-show="!menuSearchActive">
+      ${base_meta.header_logo()}
+      <div id="global-header-title">
+        ${base_meta.global_title()}
+      </div>
+    </a>
+    <div v-show="menuSearchActive"
+         class="navbar-item">
+      <b-autocomplete ref="menuSearchAutocomplete"
+                      v-model="menuSearchTerm"
+                      :data="menuSearchFilteredData"
+                      field="label"
+                      open-on-focus
+                      keep-first
+                      icon-pack="fas"
+                      clearable
+                      @keydown.native="menuSearchKeydown"
+                      @select="menuSearchSelect">
+      </b-autocomplete>
+    </div>
+    <a role="button" class="navbar-burger" data-target="navbar-menu" aria-label="menu" aria-expanded="false">
+      <span aria-hidden="true"></span>
+      <span aria-hidden="true"></span>
+      <span aria-hidden="true"></span>
+    </a>
+  </div>
+</%def>
+
+<%def name="render_navbar_start()">
+  <div class="navbar-start">
+
+    <div v-if="menuSearchData.length"
+         class="navbar-item">
+      <b-button type="is-primary"
+                size="is-small"
+                @click="menuSearchInit()">
+        <span><i class="fa fa-search"></i></span>
+      </b-button>
+    </div>
+
+    % for topitem in menus:
+        % if topitem['is_link']:
+            ${h.link_to(topitem['title'], topitem['url'], target=topitem['target'], class_='navbar-item')}
+        % else:
+            <div class="navbar-item has-dropdown is-hoverable">
+              <a class="navbar-link">${topitem['title']}</a>
+              <div class="navbar-dropdown">
+                % for item in topitem['items']:
+                    % if item['is_menu']:
+                        <% item_hash = id(item) %>
+                        <% toggle = 'menu_{}_shown'.format(item_hash) %>
+                        <div>
+                          <a class="navbar-link" @click.prevent="toggleNestedMenu('${item_hash}')">
+                            ${item['title']}
+                          </a>
+                        </div>
+                        % for subitem in item['items']:
+                            % if subitem['is_sep']:
+                                <hr class="navbar-divider" v-show="${toggle}">
+                            % else:
+                                ${h.link_to("{}".format(subitem['title']), subitem['url'], class_='navbar-item nested', target=subitem['target'], **{'v-show': toggle})}
+                            % endif
+                        % endfor
+                    % else:
+                        % if item['is_sep']:
+                            <hr class="navbar-divider">
+                        % else:
+                            ${h.link_to(item['title'], item['url'], class_='navbar-item', target=item['target'])}
+                        % endif
+                    % endif
+                % endfor
+              </div>
+            </div>
+        % endif
+    % endfor
+
+  </div>
+</%def>
+
+<%def name="render_theme_picker()">
+  % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+      <div class="level-item">
+        ${h.form(url('change_theme'), method="post", ref='themePickerForm')}
+          ${h.csrf_token(request)}
+          <input type="hidden" name="referrer" :value="referrer" />
+          <div style="display: flex; align-items: center; gap: 0.5rem;">
+            <span>Theme:</span>
+            <b-select name="theme"
+                      v-model="globalTheme"
+                      @input="changeTheme()">
+              % for option in theme_picker_options:
+                  <option value="${option.value}">
+                    ${option.label}
+                  </option>
+              % endfor
+            </b-select>
+          </div>
+        ${h.end_form()}
+      </div>
+  % endif
+</%def>
+
+<%def name="render_feedback_button()">
+
+  <div class="level-item">
+    <page-help
+      % if can_edit_help:
+      @configure-fields-help="configureFieldsHelp = true"
+      % endif
+      />
+  </div>
+
+  % if request.has_perm('common.feedback'):
+      <feedback-form
+         action="${url('feedback')}"
+         :message="feedbackMessage">
+      </feedback-form>
+  % endif
+</%def>
+
+<%def name="render_this_page_component()">
+  <this-page @change-content-title="changeContentTitle"
+             % if can_edit_help:
+                 :configure-fields-help="configureFieldsHelp"
+             % endif
+             />
+</%def>
+
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+
+  ${page_help.render_template()}
+  ${page_help.declare_vars()}
+
+  % if request.has_perm('common.feedback'):
+      <script type="text/x-template" id="feedback-template">
+        <div>
+
+          <div class="level-item">
+            <b-button type="is-primary"
+                      @click="showFeedback()"
+                      icon-pack="fas"
+                      icon-left="comment">
+              Feedback
+            </b-button>
+          </div>
+
+          <b-modal has-modal-card
+                   :active.sync="showDialog">
+            <div class="modal-card">
+
+              <header class="modal-card-head">
+                <p class="modal-card-title">User Feedback</p>
+              </header>
+
+              <section class="modal-card-body">
+                <p class="block">
+                  Questions, suggestions, comments, complaints, etc.
+                  <span class="red">regarding this website</span> are
+                  welcome and may be submitted below.
+                </p>
+
+                <b-field label="User Name">
+                  <b-input v-model="userName"
+                           % if request.user:
+                               disabled
+                           % endif
+                           >
+                  </b-input>
+                </b-field>
+
+                <b-field label="Referring URL">
+                  <b-input
+                     v-model="referrer"
+                     disabled="true">
+                  </b-input>
+                </b-field>
+
+                <b-field label="Message">
+                  <b-input type="textarea"
+                           v-model="message"
+                           ref="textarea">
+                  </b-input>
+                </b-field>
+
+                % if config.get_bool('tailbone.feedback_allows_reply'):
+                    <div class="level">
+                      <div class="level-left">
+                        <div class="level-item">
+                          <b-checkbox v-model="pleaseReply"
+                                      @input="pleaseReplyChanged">
+                            Please email me back{{ pleaseReply ? " at: " : "" }}
+                          </b-checkbox>
+                        </div>
+                        <div class="level-item" v-show="pleaseReply">
+                          <b-input v-model="userEmail"
+                                   ref="userEmail">
+                          </b-input>
+                        </div>
+                      </div>
+                    </div>
+                % endif
+
+              </section>
+
+              <footer class="modal-card-foot">
+                <b-button @click="showDialog = false">
+                  Cancel
+                </b-button>
+                <b-button type="is-primary"
+                          icon-pack="fas"
+                          icon-left="paper-plane"
+                          @click="sendFeedback()"
+                          :disabled="sendingFeedback || !message.trim()">
+                  {{ sendingFeedback ? "Working, please wait..." : "Send Message" }}
+                </b-button>
+              </footer>
+            </div>
+          </b-modal>
+
+        </div>
+      </script>
+      <script>
+
+        const FeedbackForm = {
+            template: '#feedback-template',
+            mixins: [SimpleRequestMixin],
+            props: [
+                'action',
+                'message',
+            ],
+            methods: {
+
+                showFeedback() {
+                    this.referrer = location.href
+                    this.showDialog = true
+                    this.$nextTick(function() {
+                        this.$refs.textarea.focus()
+                    })
+                },
+
+                % if config.get_bool('tailbone.feedback_allows_reply'):
+                    pleaseReplyChanged(value) {
+                        this.$nextTick(() => {
+                            this.$refs.userEmail.focus()
+                        })
+                    },
+                % endif
+
+                sendFeedback() {
+                    this.sendingFeedback = true
+
+                    const params = {
+                        referrer: this.referrer,
+                        user: this.userUUID,
+                        user_name: this.userName,
+                        % if config.get_bool('tailbone.feedback_allows_reply'):
+                            please_reply_to: this.pleaseReply ? this.userEmail : null,
+                        % endif
+                        message: this.message.trim(),
+                    }
+
+                    this.simplePOST(this.action, params, response => {
+
+                        this.$buefy.toast.open({
+                            message: "Message sent!  Thank you for your feedback.",
+                            type: 'is-info',
+                            duration: 4000, // 4 seconds
+                        })
+
+                        this.showDialog = false
+                        // clear out message, in case they need to send another
+                        this.message = ""
+                        this.sendingFeedback = false
+
+                    }, response => { // failure
+                        this.sendingFeedback = false
+                    })
+                },
+            }
+        }
+
+        const FeedbackFormData = {
+            referrer: null,
+            userUUID: null,
+            userName: null,
+            userEmail: null,
+            % if config.get_bool('tailbone.feedback_allows_reply'):
+                pleaseReply: false,
+            % endif
+            showDialog: false,
+            sendingFeedback: false,
+        }
+
+      </script>
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ##############################
+    ## menu search
+    ##############################
+
+    WholePageData.menuSearchActive = false
+    WholePageData.menuSearchTerm = ''
+    WholePageData.menuSearchData = ${json.dumps(global_search_data or [])|n}
+
+    WholePage.computed.menuSearchFilteredData = function() {
+        if (!this.menuSearchTerm.length) {
+            return this.menuSearchData
+        }
+
+        const terms = []
+        for (let term of this.menuSearchTerm.toLowerCase().split(' ')) {
+            term = term.trim()
+            if (term) {
+                terms.push(term)
+            }
+        }
+        if (!terms.length) {
+            return this.menuSearchData
+        }
+
+        // all terms must match
+        return this.menuSearchData.filter((option) => {
+            const label = option.label.toLowerCase()
+            for (const term of terms) {
+                if (label.indexOf(term) < 0) {
+                    return false
+                }
+            }
+            return true
+        })
+    }
+
+    WholePage.methods.globalKey = function(event) {
+
+        // Ctrl+8 opens menu search
+        if (event.target.tagName == 'BODY') {
+            if (event.ctrlKey && event.key == '8') {
+                this.menuSearchInit()
+            }
+        }
+    }
+
+    WholePage.mounted = function() {
+        window.addEventListener('keydown', this.globalKey)
+        for (let hook of this.mountedHooks) {
+            hook(this)
+        }
+    }
+
+    WholePage.beforeDestroy = function() {
+        window.removeEventListener('keydown', this.globalKey)
+    }
+
+    WholePage.methods.menuSearchInit = function() {
+        this.menuSearchTerm = ''
+        this.menuSearchActive = true
+        this.$nextTick(() => {
+            this.$refs.menuSearchAutocomplete.focus()
+        })
+    }
+
+    WholePage.methods.menuSearchKeydown = function(event) {
+
+        // ESC will dismiss searchbox
+        if (event.which == 27) {
+            this.menuSearchActive = false
+        }
+    }
+
+    WholePage.methods.menuSearchSelect = function(option) {
+        location.href = option.url
+    }
+
+    ##############################
+    ## theme picker
+    ##############################
+
+    % if expose_theme_picker and request.has_perm('common.change_app_theme'):
+
+        WholePageData.globalTheme = ${json.dumps(theme or None)|n}
+        ## WholePageData.referrer = location.href
+
+        WholePage.methods.changeTheme = function() {
+            this.$refs.themePickerForm.submit()
+        }
+
+    % endif
+
+    ##############################
+    ## feedback
+    ##############################
+
+    % if request.has_perm('common.feedback'):
+
+        WholePageData.feedbackMessage = ""
+
+        % if request.user:
+            FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n}
+            FeedbackFormData.userName = ${json.dumps(str(request.user))|n}
+        % endif
+
+    % endif
+
+    ##############################
+    ## edit fields help
+    ##############################
+
+    % if can_edit_help:
+        WholePageData.configureFieldsHelp = false
+    % endif
+
+  </script>
+</%def>
+
+<%def name="make_vue_components()">
+  ${parent.make_vue_components()}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.datepicker.js') + f'?ver={tailbone.__version__}')}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.numericinput.js') + f'?ver={tailbone.__version__}')}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.oncebutton.js') + f'?ver={tailbone.__version__}')}
+  ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + f'?ver={tailbone.__version__}')}
+  ${make_grid_filter_components()}
+  ${page_help.make_component()}
+  % if request.has_perm('common.feedback'):
+      <script>
+        FeedbackForm.data = function() { return FeedbackFormData }
+        Vue.component('feedback-form', FeedbackForm)
+      </script>
+  % endif
+</%def>
diff --git a/tailbone/templates/themes/waterpark/configure.mako b/tailbone/templates/themes/waterpark/configure.mako
new file mode 100644
index 00000000..9ac9a5cd
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/configure.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/configure.mako" />
diff --git a/tailbone/templates/themes/waterpark/form.mako b/tailbone/templates/themes/waterpark/form.mako
new file mode 100644
index 00000000..cf1ddb8a
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/form.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/form.mako" />
diff --git a/tailbone/templates/themes/waterpark/master/configure.mako b/tailbone/templates/themes/waterpark/master/configure.mako
new file mode 100644
index 00000000..51da5b0a
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/configure.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/configure.mako" />
diff --git a/tailbone/templates/themes/waterpark/master/create.mako b/tailbone/templates/themes/waterpark/master/create.mako
new file mode 100644
index 00000000..23399b9e
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/create.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/create.mako" />
diff --git a/tailbone/templates/themes/waterpark/master/delete.mako b/tailbone/templates/themes/waterpark/master/delete.mako
new file mode 100644
index 00000000..a15dfaf8
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/delete.mako
@@ -0,0 +1,46 @@
+## -*- coding: utf-8; -*-
+<%inherit file="tailbone:templates/form.mako" />
+
+<%def name="title()">Delete ${model_title}: ${instance_title}</%def>
+
+<%def name="render_form()">
+  <br />
+  <b-notification type="is-danger" :closable="false">
+    You are about to delete the following ${model_title} and all associated data:
+  </b-notification>
+  ${parent.render_form()}
+</%def>
+
+<%def name="render_form_buttons()">
+  <br />
+  <b-notification type="is-danger" :closable="false">
+    Are you sure about this?
+  </b-notification>
+  <br />
+
+  ${h.form(request.current_route_url(), **{'@submit': 'submitForm'})}
+  ${h.csrf_token(request)}
+    <div class="buttons">
+      <wutta-button once tag="a" href="${form.cancel_url}"
+                    label="Whoops, nevermind..." />
+      <b-button type="is-primary is-danger"
+                native-type="submit"
+                :disabled="formSubmitting">
+        {{ formSubmitting ? "Working, please wait..." : "${form.button_label_submit}" }}
+      </b-button>
+    </div>
+  ${h.end_form()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ${form.vue_component}Data.formSubmitting = false
+
+    ${form.vue_component}.methods.submitForm = function() {
+        this.formSubmitting = true
+    }
+
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/waterpark/master/edit.mako b/tailbone/templates/themes/waterpark/master/edit.mako
new file mode 100644
index 00000000..18a2fa2f
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/edit.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/edit.mako" />
diff --git a/tailbone/templates/themes/waterpark/master/form.mako b/tailbone/templates/themes/waterpark/master/form.mako
new file mode 100644
index 00000000..db56843b
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/form.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/form.mako" />
diff --git a/tailbone/templates/themes/waterpark/master/index.mako b/tailbone/templates/themes/waterpark/master/index.mako
new file mode 100644
index 00000000..e3b5b42d
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/index.mako
@@ -0,0 +1,294 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/index.mako" />
+
+<%def name="grid_tools()">
+
+  ## grid totals
+  % if getattr(master, 'supports_grid_totals', False):
+      <div style="display: flex; align-items: center;">
+        <b-button v-if="gridTotalsDisplay == null"
+                  :disabled="gridTotalsFetching"
+                  @click="gridTotalsFetch()">
+          {{ gridTotalsFetching ? "Working, please wait..." : "Show Totals" }}
+        </b-button>
+        <div v-if="gridTotalsDisplay != null"
+             class="control">
+          Totals: {{ gridTotalsDisplay }}
+        </div>
+      </div>
+  % endif
+
+  ## download search results
+  % if getattr(master, 'results_downloadable', False) and master.has_perm('download_results'):
+      <div>
+        <b-button type="is-primary"
+                  icon-pack="fas"
+                  icon-left="download"
+                  @click="showDownloadResultsDialog = true"
+                  :disabled="!total">
+          Download Results
+        </b-button>
+
+        ${h.form(url('{}.download_results'.format(route_prefix)), ref='download_results_form')}
+        ${h.csrf_token(request)}
+        <input type="hidden" name="fmt" :value="downloadResultsFormat" />
+        <input type="hidden" name="fields" :value="downloadResultsFieldsIncluded" />
+        ${h.end_form()}
+
+        <b-modal :active.sync="showDownloadResultsDialog">
+          <div class="card">
+
+            <div class="card-content">
+              <p>
+                There are
+                <span class="is-size-4 has-text-weight-bold">
+                  {{ total.toLocaleString('en') }} ${model_title_plural}
+                </span>
+                matching your current filters.
+              </p>
+              <p>
+                You may download this set as a single data file if you like.
+              </p>
+              <br />
+
+              <b-notification type="is-warning" :closable="false"
+                              v-if="downloadResultsFormat == 'xlsx' && total >= 1000">
+                Excel downloads for large data sets can take a long time to
+                generate, and bog down the server in the meantime.  You are
+                encouraged to choose CSV for a large data set, even though
+                the end result (file size) may be larger with CSV.
+              </b-notification>
+
+              <div style="display: flex; justify-content: space-between">
+
+                <div>
+                  <b-field label="Format">
+                    <b-select v-model="downloadResultsFormat">
+                      % for key, label in master.download_results_supported_formats().items():
+                      <option value="${key}">${label}</option>
+                      % endfor
+                    </b-select>
+                  </b-field>
+                </div>
+
+                <div>
+
+                  <div v-show="downloadResultsFieldsMode != 'choose'"
+                       class="has-text-right">
+                    <p v-if="downloadResultsFieldsMode == 'default'">
+                      Will use DEFAULT fields.
+                    </p>
+                    <p v-if="downloadResultsFieldsMode == 'all'">
+                      Will use ALL fields.
+                    </p>
+                    <br />
+                  </div>
+
+                  <div class="buttons is-right">
+                    <b-button type="is-primary"
+                              v-show="downloadResultsFieldsMode != 'default'"
+                              @click="downloadResultsUseDefaultFields()">
+                      Use Default Fields
+                    </b-button>
+                    <b-button type="is-primary"
+                              v-show="downloadResultsFieldsMode != 'all'"
+                              @click="downloadResultsUseAllFields()">
+                      Use All Fields
+                    </b-button>
+                    <b-button type="is-primary"
+                              v-show="downloadResultsFieldsMode != 'choose'"
+                              @click="downloadResultsFieldsMode = 'choose'">
+                      Choose Fields
+                    </b-button>
+                  </div>
+
+                  <div v-show="downloadResultsFieldsMode == 'choose'">
+                    <div style="display: flex;">
+                      <div>
+                        <b-field label="Excluded Fields">
+                          <b-select multiple native-size="8"
+                                    expanded
+                                    v-model="downloadResultsExcludedFieldsSelected"
+                                    ref="downloadResultsExcludedFields">
+                            <option v-for="field in downloadResultsFieldsExcluded"
+                                    :key="field"
+                                    :value="field">
+                              {{ field }}
+                            </option>
+                          </b-select>
+                        </b-field>
+                      </div>
+                      <div>
+                        <br /><br />
+                        <b-button style="margin: 0.5rem;"
+                                  @click="downloadResultsExcludeFields()">
+                          &lt;
+                        </b-button>
+                        <br />
+                        <b-button style="margin: 0.5rem;"
+                                  @click="downloadResultsIncludeFields()">
+                          &gt;
+                        </b-button>
+                      </div>
+                      <div>
+                        <b-field label="Included Fields">
+                          <b-select multiple native-size="8"
+                                    expanded
+                                    v-model="downloadResultsIncludedFieldsSelected"
+                                    ref="downloadResultsIncludedFields">
+                            <option v-for="field in downloadResultsFieldsIncluded"
+                                    :key="field"
+                                    :value="field">
+                              {{ field }}
+                            </option>
+                          </b-select>
+                        </b-field>
+                      </div>
+                    </div>
+                  </div>
+
+                </div>
+              </div>
+            </div> <!-- card-content -->
+
+            <footer class="modal-card-foot">
+              <b-button @click="showDownloadResultsDialog = false">
+                Cancel
+              </b-button>
+              <once-button type="is-primary"
+                           @click="downloadResultsSubmit()"
+                           icon-pack="fas"
+                           icon-left="download"
+                           :disabled="!downloadResultsFieldsIncluded.length"
+                           text="Download Results">
+              </once-button>
+            </footer>
+          </div>
+        </b-modal>
+      </div>
+  % endif
+
+  ## download rows for search results
+  % if getattr(master, 'has_rows', False) and master.results_rows_downloadable and master.has_perm('download_results_rows'):
+      <b-button type="is-primary"
+                icon-pack="fas"
+                icon-left="download"
+                @click="downloadResultsRows()"
+                :disabled="downloadResultsRowsButtonDisabled">
+        {{ downloadResultsRowsButtonText }}
+      </b-button>
+      ${h.form(url('{}.download_results_rows'.format(route_prefix)), ref='downloadResultsRowsForm')}
+      ${h.csrf_token(request)}
+      ${h.end_form()}
+  % endif
+
+  ## merge 2 objects
+  % if getattr(master, 'mergeable', False) and request.has_perm('{}.merge'.format(permission_prefix)):
+
+      ${h.form(url('{}.merge'.format(route_prefix)), class_='control', **{'@submit': 'submitMergeForm'})}
+      ${h.csrf_token(request)}
+      <input type="hidden"
+             name="uuids"
+             :value="checkedRowUUIDs()" />
+      <b-button type="is-primary"
+                native-type="submit"
+                icon-pack="fas"
+                icon-left="object-ungroup"
+                :disabled="mergeFormSubmitting || checkedRows.length != 2">
+        {{ mergeFormButtonText }}
+      </b-button>
+      ${h.end_form()}
+  % endif
+
+  ## enable / disable selected objects
+  % if getattr(master, 'supports_set_enabled_toggle', False) and master.has_perm('enable_disable_set'):
+
+      ${h.form(url('{}.enable_set'.format(route_prefix)), class_='control', ref='enable_selected_form')}
+      ${h.csrf_token(request)}
+      ${h.hidden('uuids', v_model='selected_uuids')}
+      <b-button :disabled="enableSelectedDisabled"
+                @click="enableSelectedSubmit()">
+        {{ enableSelectedText }}
+      </b-button>
+      ${h.end_form()}
+
+      ${h.form(url('{}.disable_set'.format(route_prefix)), ref='disable_selected_form', class_='control')}
+      ${h.csrf_token(request)}
+      ${h.hidden('uuids', v_model='selected_uuids')}
+      <b-button :disabled="disableSelectedDisabled"
+                @click="disableSelectedSubmit()">
+        {{ disableSelectedText }}
+      </b-button>
+      ${h.end_form()}
+  % endif
+
+  ## delete selected objects
+  % if getattr(master, 'set_deletable', False) and master.has_perm('delete_set'):
+      ${h.form(url('{}.delete_set'.format(route_prefix)), ref='delete_selected_form', class_='control')}
+      ${h.csrf_token(request)}
+      ${h.hidden('uuids', v_model='selected_uuids')}
+      <b-button type="is-danger"
+                :disabled="deleteSelectedDisabled"
+                @click="deleteSelectedSubmit()"
+                icon-pack="fas"
+                icon-left="trash">
+        {{ deleteSelectedText }}
+      </b-button>
+      ${h.end_form()}
+  % endif
+
+  ## delete search results
+  % if getattr(master, 'bulk_deletable', False) and request.has_perm('{}.bulk_delete'.format(permission_prefix)):
+      ${h.form(url('{}.bulk_delete'.format(route_prefix)), ref='delete_results_form', class_='control')}
+      ${h.csrf_token(request)}
+      <b-button type="is-danger"
+                :disabled="deleteResultsDisabled"
+                :title="total ? null : 'There are no results to delete'"
+                @click="deleteResultsSubmit()"
+                icon-pack="fas"
+                icon-left="trash">
+        {{ deleteResultsText }}
+      </b-button>
+      ${h.end_form()}
+  % endif
+
+</%def>
+
+<%def name="render_vue_template_grid()">
+  ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    % if getattr(master, 'bulk_deletable', False) and master.has_perm('bulk_delete'):
+
+        ${grid.vue_component}Data.deleteResultsSubmitting = false
+        ${grid.vue_component}Data.deleteResultsText = "Delete Results"
+
+        ${grid.vue_component}.computed.deleteResultsDisabled = function() {
+            if (this.deleteResultsSubmitting) {
+                return true
+            }
+            if (!this.total) {
+                return true
+            }
+            return false
+        }
+
+        ${grid.vue_component}.methods.deleteResultsSubmit = function() {
+            // TODO: show "plural model title" here?
+            if (!confirm("You are about to delete " + this.total.toLocaleString('en') + " objects.\n\nAre you sure?")) {
+                return
+            }
+
+            this.deleteResultsSubmitting = true
+            this.deleteResultsText = "Working, please wait..."
+            this.$refs.delete_results_form.submit()
+        }
+
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/templates/themes/waterpark/master/view.mako b/tailbone/templates/themes/waterpark/master/view.mako
new file mode 100644
index 00000000..99194469
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/master/view.mako
@@ -0,0 +1,2 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/master/view.mako" />
diff --git a/tailbone/templates/themes/waterpark/page.mako b/tailbone/templates/themes/waterpark/page.mako
new file mode 100644
index 00000000..7e6851a7
--- /dev/null
+++ b/tailbone/templates/themes/waterpark/page.mako
@@ -0,0 +1,48 @@
+## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/page.mako" />
+
+<%def name="render_vue_template_this_page()">
+  <script type="text/x-template" id="this-page-template">
+    <div style="height: 100%;">
+      ## DEPRECATED; called for back-compat
+      ${self.render_this_page()}
+    </div>
+  </script>
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="render_this_page()">
+  <div style="display: flex;">
+
+    <div class="this-page-content" style="flex-grow: 1;">
+      ${self.page_content()}
+    </div>
+
+    ## DEPRECATED; remains for back-compat
+    <ul id="context-menu">
+      ${self.context_menu_items()}
+    </ul>
+  </div>
+</%def>
+
+## DEPRECATED; remains for back-compat
+<%def name="context_menu_items()">
+  % if context_menu_list_items is not Undefined:
+      % for item in context_menu_list_items:
+          <li>${item}</li>
+      % endfor
+  % endif
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ThisPageData.csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}
+
+    % if can_edit_help:
+        ThisPage.props.configureFieldsHelp = Boolean
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index ac74a070..a8365482 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -137,6 +137,7 @@ class MasterView(View):
     deleting = False
     executing = False
     cloning = False
+    configuring = False
     has_pk_fields = False
     has_image = False
     has_thumbnail = False
@@ -350,6 +351,7 @@ class MasterView(View):
             return self.json_response(context)
 
         context = {
+            'index_url': None, # nb. avoid title link since this *is* the index
             'grid': grid,
         }
 
@@ -380,7 +382,7 @@ class MasterView(View):
         grid contents etc.
         """
 
-    def make_grid(self, factory=None, key=None, data=None, columns=None, **kwargs):
+    def make_grid(self, factory=None, key=None, data=None, columns=None, session=None, **kwargs):
         """
         Creates a new grid instance
         """
@@ -389,7 +391,7 @@ class MasterView(View):
         if key is None:
             key = self.get_grid_key()
         if data is None:
-            data = self.get_data(session=kwargs.get('session'))
+            data = self.get_data(session=session)
         if columns is None:
             columns = self.get_grid_columns()
 
@@ -407,7 +409,7 @@ class MasterView(View):
         """
         if session is None:
             session = self.Session()
-        kwargs.setdefault('pageable', False)
+        kwargs.setdefault('paginated', False)
         grid = self.make_grid(session=session, **kwargs)
         return grid.make_visible_data()
 
@@ -1701,7 +1703,7 @@ class MasterView(View):
         """
         if session is None:
             session = self.Session()
-        kwargs.setdefault('pageable', False)
+        kwargs.setdefault('paginated', False)
         kwargs.setdefault('sortable', sort)
         grid = self.make_row_grid(session=session, **kwargs)
         return grid.make_visible_data()
@@ -1879,6 +1881,7 @@ class MasterView(View):
             return self.redirect(self.get_action_url('view', instance))
 
         form = self.make_form(instance)
+        form.save_label = "DELETE Forever"
 
         # TODO: Add better validation, ideally CSRF etc.
         if self.request.method == 'POST':
@@ -5119,6 +5122,7 @@ class MasterView(View):
         """
         Generic view for configuring some aspect of the software.
         """
+        self.configuring = True
         app = self.get_rattail_app()
         if self.request.method == 'POST':
             if self.request.POST.get('remove_settings'):
diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index 020babc5..b6a4c0b9 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -543,7 +543,7 @@ class PersonView(MasterView):
             },
             filterable=True,
             sortable=True,
-            pageable=True,
+            paginated=True,
             default_sortkey='end_time',
             default_sortdir='desc',
             component='transactions-grid',
diff --git a/tests/util.py b/tests/util.py
index 3aa04f5e..4277a7c3 100644
--- a/tests/util.py
+++ b/tests/util.py
@@ -24,7 +24,7 @@ class WebTestCase(DataTestCase):
         self.pyramid_config = testing.setUp(request=self.request, settings={
             'wutta_config': self.config,
             'rattail_config': self.config,
-            'mako.directories': ['tailbone:templates'],
+            'mako.directories': ['tailbone:templates', 'wuttaweb:templates'],
             # 'pyramid_deform.template_search_path': 'wuttaweb:templates/deform',
         })
 

From 83586ef90fd3c8acae6eda85bd7d44a5992464f5 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 20 Aug 2024 15:06:09 -0500
Subject: [PATCH 06/85] =?UTF-8?q?bump:=20version=200.19.3=20=E2=86=92=200.?=
 =?UTF-8?q?20.0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 11 +++++++++++
 pyproject.toml |  4 ++--
 2 files changed, 13 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c8017445..5840f59f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,17 @@ All notable changes to Tailbone 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.20.0 (2024-08-20)
+
+### Feat
+
+- add new 'waterpark' theme, based on wuttaweb w/ vue2 + buefy
+- refactor templates to simplify base/page/form structure
+
+### Fix
+
+- avoid deprecated reference to app db engine
+
 ## v0.19.3 (2024-08-19)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 3e07abaa..150544ba 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.19.3"
+version = "0.20.0"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@@ -59,7 +59,7 @@ dependencies = [
         "transaction",
         "waitress",
         "WebHelpers2",
-        "WuttaWeb>=0.10.2",
+        "WuttaWeb>=0.11.0",
         "zope.sqlalchemy>=1.5",
 ]
 

From 21f90f3f32f76d509b75348388445cc1a6dccd85 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 20 Aug 2024 16:02:35 -0500
Subject: [PATCH 07/85] fix: fix default filter verbs logic for workorder
 status

---
 tailbone/views/workorders.py | 22 +++++++++++++---------
 1 file changed, 13 insertions(+), 9 deletions(-)

diff --git a/tailbone/views/workorders.py b/tailbone/views/workorders.py
index a53037bc..d8094e4b 100644
--- a/tailbone/views/workorders.py
+++ b/tailbone/views/workorders.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -83,12 +83,12 @@ class WorkOrderView(MasterView):
     ]
 
     def __init__(self, request):
-        super(WorkOrderView, self).__init__(request)
+        super().__init__(request)
         app = self.get_rattail_app()
         self.workorder_handler = app.get_workorder_handler()
 
     def configure_grid(self, g):
-        super(WorkOrderView, self).configure_grid(g)
+        super().configure_grid(g)
         model = self.model
 
         # customer
@@ -113,7 +113,7 @@ class WorkOrderView(MasterView):
             return 'warning'
 
     def configure_form(self, f):
-        super(WorkOrderView, self).configure_form(f)
+        super().configure_form(f)
         model = self.model
         SelectWidget = forms.widgets.JQuerySelectWidget
 
@@ -208,7 +208,7 @@ class WorkOrderView(MasterView):
         return event.workorder
 
     def configure_row_grid(self, g):
-        super(WorkOrderView, self).configure_row_grid(g)
+        super().configure_row_grid(g)
         g.set_enum('type_code', self.enum.WORKORDER_EVENT)
         g.set_sort_defaults('occurred')
 
@@ -353,7 +353,7 @@ class WorkOrderView(MasterView):
 class StatusFilter(grids.filters.AlchemyIntegerFilter):
 
     def __init__(self, *args, **kwargs):
-        super(StatusFilter, self).__init__(*args, **kwargs)
+        super().__init__(*args, **kwargs)
 
         from drild import enum
 
@@ -369,14 +369,14 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter):
 
     @property
     def verb_labels(self):
-        labels = dict(super(StatusFilter, self).verb_labels)
+        labels = dict(super().verb_labels)
         labels['is_active'] = "Is Active"
         labels['not_active'] = "Is Not Active"
         return labels
 
     @property
     def valueless_verbs(self):
-        verbs = list(super(StatusFilter, self).valueless_verbs)
+        verbs = list(super().valueless_verbs)
         verbs.extend([
             'is_active',
             'not_active',
@@ -385,7 +385,11 @@ class StatusFilter(grids.filters.AlchemyIntegerFilter):
 
     @property
     def default_verbs(self):
-        verbs = list(super(StatusFilter, self).default_verbs)
+        verbs = super().default_verbs
+        if callable(verbs):
+            verbs = verbs()
+
+        verbs = list(verbs or [])
         verbs.insert(0, 'is_active')
         verbs.insert(1, 'not_active')
         return verbs

From 526c84dfa62cc88d2cd4ec28861e6caef70205e4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 20 Aug 2024 16:05:52 -0500
Subject: [PATCH 08/85] =?UTF-8?q?bump:=20version=200.20.0=20=E2=86=92=200.?=
 =?UTF-8?q?20.1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 6 ++++++
 pyproject.toml | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5840f59f..4e2b348a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to Tailbone 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.20.1 (2024-08-20)
+
+### Fix
+
+- fix default filter verbs logic for workorder status
+
 ## v0.20.0 (2024-08-20)
 
 ### Feat
diff --git a/pyproject.toml b/pyproject.toml
index 150544ba..90ecd953 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.20.0"
+version = "0.20.1"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From c8dc60cb68c72530b04df13fdc012a3ba382ba01 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 20 Aug 2024 16:37:58 -0500
Subject: [PATCH 09/85] fix: fix spacing for navbar logo/title in waterpark
 theme

---
 tailbone/templates/themes/waterpark/base.mako | 8 +++++---
 1 file changed, 5 insertions(+), 3 deletions(-)

diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako
index 15184f6e..878090dc 100644
--- a/tailbone/templates/themes/waterpark/base.mako
+++ b/tailbone/templates/themes/waterpark/base.mako
@@ -50,9 +50,11 @@
   <div class="navbar-brand">
     <a class="navbar-item" href="${url('home')}"
        v-show="!menuSearchActive">
-      ${base_meta.header_logo()}
-      <div id="global-header-title">
-        ${base_meta.global_title()}
+      <div style="display: flex; align-items: center;">
+        ${base_meta.header_logo()}
+        <div id="navbar-brand-title">
+          ${base_meta.global_title()}
+        </div>
       </div>
     </a>
     <div v-show="menuSearchActive"

From 07871188aa323331a4464c80021b4f25057dd54d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 20 Aug 2024 17:03:57 -0500
Subject: [PATCH 10/85] fix: fix master/index template rendering for waterpark
 theme

---
 tailbone/templates/themes/waterpark/master/index.mako | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/tailbone/templates/themes/waterpark/master/index.mako b/tailbone/templates/themes/waterpark/master/index.mako
index e3b5b42d..e6702599 100644
--- a/tailbone/templates/themes/waterpark/master/index.mako
+++ b/tailbone/templates/themes/waterpark/master/index.mako
@@ -254,6 +254,11 @@
 
 </%def>
 
+## DEPRECATED; remains for back-compat
+<%def name="render_this_page()">
+  ${self.page_content()}
+</%def>
+
 <%def name="render_vue_template_grid()">
   ${grid.render_vue_template(tools=capture(self.grid_tools).strip(), context_menu=capture(self.context_menu_items).strip())}
 </%def>

From 1def26a35bc36b399ff6783198a4687af206482e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 20 Aug 2024 19:09:56 -0500
Subject: [PATCH 11/85] feat: add "has output file templates" config option for
 master view

this is a bit hacky, a quick copy/paste job from the equivalent
feature for input file templates.

i assume this will get cleaned up when moved to wuttaweb..
---
 tailbone/templates/configure.mako             | 107 +++++++++-
 .../templates/themes/waterpark/configure.mako |  76 +++++++
 tailbone/views/master.py                      | 202 +++++++++++++++++-
 3 files changed, 381 insertions(+), 4 deletions(-)

diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako
index 272aadce..6d9c2261 100644
--- a/tailbone/templates/configure.mako
+++ b/tailbone/templates/configure.mako
@@ -143,6 +143,68 @@
   </div>
 </%def>
 
+<%def name="output_file_template_field(key)">
+    <% tmpl = output_file_templates[key] %>
+    <b-field grouped>
+
+      <b-field label="${tmpl['label']}">
+        <b-select name="${tmpl['setting_mode']}"
+                  v-model="outputFileTemplateSettings['${tmpl['setting_mode']}']"
+                  @input="settingsNeedSaved = true">
+          <option value="default">use default</option>
+          <option value="hosted">use uploaded file</option>
+        </b-select>
+      </b-field>
+
+      <b-field label="File"
+               v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted'"
+               :message="outputFileTemplateSettings['${tmpl['setting_file']}'] ? 'This file lives on disk at: ${output_file_option_dirs[tmpl['key']]}' : null">
+        <b-select name="${tmpl['setting_file']}"
+                  v-model="outputFileTemplateSettings['${tmpl['setting_file']}']"
+                  @input="settingsNeedSaved = true">
+          <option :value="null">-new-</option>
+          <option v-for="option in outputFileTemplateFileOptions['${tmpl['key']}']"
+                  :key="option"
+                  :value="option">
+            {{ option }}
+          </option>
+        </b-select>
+      </b-field>
+
+      <b-field label="Upload"
+               v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !outputFileTemplateSettings['${tmpl['setting_file']}']">
+
+        <b-field class="file is-primary"
+                 :class="{'has-name': !!outputFileTemplateSettings['${tmpl['setting_file']}']}">
+          <b-upload name="${tmpl['setting_file']}.upload"
+                    v-model="outputFileTemplateUploads['${tmpl['key']}']"
+                    class="file-label"
+                    @input="settingsNeedSaved = true">
+            <span class="file-cta">
+              <b-icon class="file-icon" pack="fas" icon="upload"></b-icon>
+              <span class="file-label">Click to upload</span>
+            </span>
+          </b-upload>
+          <span v-if="outputFileTemplateUploads['${tmpl['key']}']"
+                class="file-name">
+            {{ outputFileTemplateUploads['${tmpl['key']}'].name }}
+          </span>
+        </b-field>
+
+      </b-field>
+
+    </b-field>
+</%def>
+
+<%def name="output_file_templates_section()">
+  <h3 class="block is-size-3">Output File Templates</h3>
+  <div class="block" style="padding-left: 2rem;">
+    % for key in output_file_templates:
+        ${self.output_file_template_field(key)}
+    % endfor
+  </div>
+</%def>
+
 <%def name="form_content()"></%def>
 
 <%def name="page_content()">
@@ -229,6 +291,7 @@
     ThisPageData.settingsNeedSaved = false
     ThisPageData.undoChanges = false
     ThisPageData.savingSettings = false
+    ThisPageData.validators = []
 
     ThisPage.methods.purgeSettingsInit = function() {
         this.purgeSettingsShowDialog = true
@@ -260,7 +323,19 @@
     }
 
     ThisPage.methods.saveSettings = function() {
-        let msg = this.validateSettings()
+        let msg
+
+        // nb. this is the future
+        for (let validator of this.validators) {
+            msg = validator.call(this)
+            if (msg) {
+                alert(msg)
+                return
+            }
+        }
+
+        // nb. legacy method
+        msg = this.validateSettings()
         if (msg) {
             alert(msg)
             return
@@ -291,5 +366,35 @@
         window.addEventListener('beforeunload', this.beforeWindowUnload)
     }
 
+    ##############################
+    ## output file templates
+    ##############################
+
+    % if output_file_template_settings is not Undefined:
+
+        ThisPageData.outputFileTemplateSettings = ${json.dumps(output_file_template_settings)|n}
+        ThisPageData.outputFileTemplateFileOptions = ${json.dumps(output_file_options)|n}
+        ThisPageData.outputFileTemplateUploads = {
+            % for key in output_file_templates:
+                '${key}': null,
+            % endfor
+        }
+
+        ThisPage.methods.validateOutputFileTemplateSettings = function() {
+            % for tmpl in output_file_templates.values():
+                if (this.outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
+                    if (!this.outputFileTemplateSettings['${tmpl['setting_file']}']) {
+                        if (!this.outputFileTemplateUploads['${tmpl['key']}']) {
+                            return "You must provide a file to upload for the ${tmpl['label']} template."
+                        }
+                    }
+                }
+            % endfor
+        }
+
+        ThisPageData.validators.push(ThisPage.methods.validateOutputFileTemplateSettings)
+
+    % endif
+
   </script>
 </%def>
diff --git a/tailbone/templates/themes/waterpark/configure.mako b/tailbone/templates/themes/waterpark/configure.mako
index 9ac9a5cd..7a3e5261 100644
--- a/tailbone/templates/themes/waterpark/configure.mako
+++ b/tailbone/templates/themes/waterpark/configure.mako
@@ -1,2 +1,78 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="wuttaweb:templates/configure.mako" />
+<%namespace name="tailbone_base" file="tailbone:templates/configure.mako" />
+
+<%def name="input_file_templates_section()">
+  ${tailbone_base.input_file_templates_section()}
+</%def>
+
+<%def name="output_file_templates_section()">
+  ${tailbone_base.output_file_templates_section()}
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+
+    ##############################
+    ## input file templates
+    ##############################
+
+    % if input_file_template_settings is not Undefined:
+
+        ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n}
+        ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n}
+        ThisPageData.inputFileTemplateUploads = {
+            % for key in input_file_templates:
+                '${key}': null,
+            % endfor
+        }
+
+        ThisPage.methods.validateInputFileTemplateSettings = function() {
+            % for tmpl in input_file_templates.values():
+                if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
+                    if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) {
+                        if (!this.inputFileTemplateUploads['${tmpl['key']}']) {
+                            return "You must provide a file to upload for the ${tmpl['label']} template."
+                        }
+                    }
+                }
+            % endfor
+        }
+
+        ThisPageData.validators.push(ThisPage.methods.validateInputFileTemplateSettings)
+
+    % endif
+
+    ##############################
+    ## output file templates
+    ##############################
+
+    % if output_file_template_settings is not Undefined:
+
+        ThisPageData.outputFileTemplateSettings = ${json.dumps(output_file_template_settings)|n}
+        ThisPageData.outputFileTemplateFileOptions = ${json.dumps(output_file_options)|n}
+        ThisPageData.outputFileTemplateUploads = {
+            % for key in output_file_templates:
+                '${key}': null,
+            % endfor
+        }
+
+        ThisPage.methods.validateOutputFileTemplateSettings = function() {
+            % for tmpl in output_file_templates.values():
+                if (this.outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
+                    if (!this.outputFileTemplateSettings['${tmpl['setting_file']}']) {
+                        if (!this.outputFileTemplateUploads['${tmpl['key']}']) {
+                            return "You must provide a file to upload for the ${tmpl['label']} template."
+                        }
+                    }
+                }
+            % endfor
+        }
+
+        ThisPageData.validators.push(ThisPage.methods.validateOutputFileTemplateSettings)
+
+    % endif
+
+  </script>
+</%def>
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index a8365482..e4d6c3f6 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -117,6 +117,7 @@ class MasterView(View):
     supports_prev_next = False
     supports_import_batch_from_file = False
     has_input_file_templates = False
+    has_output_file_templates = False
     configurable = False
 
     # set to True to add "View *global* Objects" permission, and
@@ -1820,6 +1821,26 @@ class MasterView(View):
         path = os.path.join(basedir, filespec)
         return self.file_response(path)
 
+    def download_output_file_template(self):
+        """
+        View for downloading an output file template.
+        """
+        key = self.request.GET['key']
+        filespec = self.request.GET['file']
+
+        matches = [tmpl for tmpl in self.get_output_file_templates()
+                   if tmpl['key'] == key]
+        if not matches:
+            raise self.notfound()
+
+        template = matches[0]
+        templatesdir = os.path.join(self.rattail_config.datadir(),
+                                    'templates', 'output_files',
+                                    self.get_route_prefix())
+        basedir = os.path.join(templatesdir, template['key'])
+        path = os.path.join(basedir, filespec)
+        return self.file_response(path)
+
     def edit(self):
         """
         View for editing an existing model record.
@@ -2848,6 +2869,12 @@ class MasterView(View):
             kwargs['input_file_templates'] = OrderedDict([(tmpl['key'], tmpl)
                                                           for tmpl in templates])
 
+        # add info for downloadable output file templates, if any
+        if self.has_output_file_templates:
+            templates = self.normalize_output_file_templates()
+            kwargs['output_file_templates'] = OrderedDict([(tmpl['key'], tmpl)
+                                                           for tmpl in templates])
+
         return kwargs
 
     def get_input_file_templates(self):
@@ -2922,6 +2949,81 @@ class MasterView(View):
 
         return templates
 
+    def get_output_file_templates(self):
+        return []
+
+    def normalize_output_file_templates(self, templates=None,
+                                        include_file_options=False):
+        if templates is None:
+            templates = self.get_output_file_templates()
+
+        route_prefix = self.get_route_prefix()
+
+        if include_file_options:
+            templatesdir = os.path.join(self.rattail_config.datadir(),
+                                        'templates', 'output_files',
+                                        route_prefix)
+
+        for template in templates:
+
+            if 'config_section' not in template:
+                if hasattr(self, 'output_file_template_config_section'):
+                    template['config_section'] = self.output_file_template_config_section
+                else:
+                    template['config_section'] = route_prefix
+            section = template['config_section']
+
+            if 'config_prefix' not in template:
+                template['config_prefix'] = '{}.{}'.format(
+                    self.output_file_template_config_prefix,
+                    template['key'])
+            prefix = template['config_prefix']
+
+            for key in ('mode', 'file', 'url'):
+
+                if 'option_{}'.format(key) not in template:
+                    template['option_{}'.format(key)] = '{}.{}'.format(prefix, key)
+
+                if 'setting_{}'.format(key) not in template:
+                    template['setting_{}'.format(key)] = '{}.{}'.format(
+                        section,
+                        template['option_{}'.format(key)])
+
+                if key not in template:
+                    value = self.rattail_config.get(
+                        section,
+                        template['option_{}'.format(key)])
+                    if value is not None:
+                        template[key] = value
+
+            template.setdefault('mode', 'default')
+            template.setdefault('file', None)
+            template.setdefault('url', template['default_url'])
+
+            if include_file_options:
+                options = []
+                basedir = os.path.join(templatesdir, template['key'])
+                if os.path.exists(basedir):
+                    for name in sorted(os.listdir(basedir)):
+                        if len(name) == 4 and name.isdigit():
+                            files = os.listdir(os.path.join(basedir, name))
+                            if len(files) == 1:
+                                options.append(os.path.join(name, files[0]))
+                template['file_options'] = options
+                template['file_options_dir'] = basedir
+
+            if template['mode'] == 'external':
+                template['effective_url'] = template['url']
+            elif template['mode'] == 'hosted':
+                template['effective_url'] = self.request.route_url(
+                    '{}.download_output_file_template'.format(route_prefix),
+                    _query={'key': template['key'],
+                            'file': template['file']})
+            else:
+                template['effective_url'] = template['default_url']
+
+        return templates
+
     def template_kwargs_index(self, **kwargs):
         """
         Method stub, so subclass can always invoke super() for it.
@@ -2969,6 +3071,12 @@ class MasterView(View):
                     items.append(tags.link_to(f"Download {template['label']} Template",
                                               template['effective_url']))
 
+            if self.has_output_file_templates and self.has_perm('configure'):
+                templates = self.normalize_output_file_templates()
+                for template in templates:
+                    items.append(tags.link_to(f"Download {template['label']} Template",
+                                              template['effective_url']))
+
         # if self.viewing:
 
         #     # # TODO: either make this configurable, or just lose it.
@@ -5204,6 +5312,39 @@ class MasterView(View):
                     data[template['setting_file']] = os.path.join(numdir,
                                                                   info['filename'])
 
+        if self.has_output_file_templates:
+            templatesdir = os.path.join(self.rattail_config.datadir(),
+                                        'templates', 'output_files',
+                                        self.get_route_prefix())
+
+            def get_next_filedir(basedir):
+                nextid = 1
+                while True:
+                    path = os.path.join(basedir, '{:04d}'.format(nextid))
+                    if not os.path.exists(path):
+                        # this should fail if there happens to be a race
+                        # condition and someone else got to this id first
+                        os.mkdir(path)
+                        return path
+                    nextid += 1
+
+            for template in self.normalize_output_file_templates():
+                key = '{}.upload'.format(template['setting_file'])
+                if key in uploads:
+                    assert self.request.POST[template['setting_mode']] == 'hosted'
+                    assert not self.request.POST[template['setting_file']]
+                    info = uploads[key]
+                    basedir = os.path.join(templatesdir, template['key'])
+                    if not os.path.exists(basedir):
+                        os.makedirs(basedir)
+                    filedir = get_next_filedir(basedir)
+                    filepath = os.path.join(filedir, info['filename'])
+                    shutil.copyfile(info['filepath'], filepath)
+                    shutil.rmtree(info['filedir'])
+                    numdir = os.path.basename(filedir)
+                    data[template['setting_file']] = os.path.join(numdir,
+                                                                  info['filename'])
+
     def configure_get_simple_settings(self):
         """
         If you have some "simple" settings, each of which basically
@@ -5248,7 +5389,8 @@ class MasterView(View):
                               simple['option'])
 
     def configure_get_context(self, simple_settings=None,
-                              input_file_templates=True):
+                              input_file_templates=True,
+                              output_file_templates=True):
         """
         Returns the full context dict, for rendering the configure
         page template.
@@ -5305,10 +5447,27 @@ class MasterView(View):
             context['input_file_options'] = file_options
             context['input_file_option_dirs'] = file_option_dirs
 
+        # add settings for output file templates, if any
+        if output_file_templates and self.has_output_file_templates:
+            settings = {}
+            file_options = {}
+            file_option_dirs = {}
+            for template in self.normalize_output_file_templates(
+                    include_file_options=True):
+                settings[template['setting_mode']] = template['mode']
+                settings[template['setting_file']] = template['file']
+                settings[template['setting_url']] = template['url']
+                file_options[template['key']] = template['file_options']
+                file_option_dirs[template['key']] = template['file_options_dir']
+            context['output_file_template_settings'] = settings
+            context['output_file_options'] = file_options
+            context['output_file_option_dirs'] = file_option_dirs
+
         return context
 
     def configure_gather_settings(self, data, simple_settings=None,
-                                  input_file_templates=True):
+                                  input_file_templates=True,
+                                  output_file_templates=True):
         settings = []
 
         # maybe collect "simple" settings
@@ -5354,10 +5513,30 @@ class MasterView(View):
                 settings.append({'name': template['setting_url'],
                                  'value': data.get(template['setting_url'])})
 
+        # maybe also collect output file template settings
+        if output_file_templates and self.has_output_file_templates:
+            for template in self.normalize_output_file_templates():
+
+                # mode
+                settings.append({'name': template['setting_mode'],
+                                 'value': data.get(template['setting_mode'])})
+
+                # file
+                value = data.get(template['setting_file'])
+                if value:
+                    # nb. avoid saving if empty, so can remain "null"
+                    settings.append({'name': template['setting_file'],
+                                     'value': value})
+
+                # url
+                settings.append({'name': template['setting_url'],
+                                 'value': data.get(template['setting_url'])})
+
         return settings
 
     def configure_remove_settings(self, simple_settings=None,
-                                  input_file_templates=True):
+                                  input_file_templates=True,
+                                  output_file_templates=True):
         app = self.get_rattail_app()
         model = self.model
         names = []
@@ -5376,6 +5555,14 @@ class MasterView(View):
                     template['setting_url'],
                 ])
 
+        if output_file_templates and self.has_output_file_templates:
+            for template in self.normalize_output_file_templates():
+                names.extend([
+                    template['setting_mode'],
+                    template['setting_file'],
+                    template['setting_url'],
+                ])
+
         if names:
             # nb. using thread-local session here; we do not use
             # self.Session b/c it may not point to Rattail
@@ -5638,6 +5825,15 @@ class MasterView(View):
                             route_name='{}.download_input_file_template'.format(route_prefix),
                             permission='{}.create'.format(permission_prefix))
 
+        # download output file template
+        if cls.has_output_file_templates and cls.configurable:
+            config.add_route(f'{route_prefix}.download_output_file_template',
+                             f'{url_prefix}/download-output-file-template')
+            config.add_view(cls, attr='download_output_file_template',
+                            route_name=f'{route_prefix}.download_output_file_template',
+                            # TODO: this is different from input file, should change?
+                            permission=f'{permission_prefix}.configure')
+
         # view
         if cls.viewable:
             cls._defaults_view(config)

From b6a8e508bf2629d528b1bba3e1b12d6da83b1abf Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 20 Aug 2024 22:16:01 -0500
Subject: [PATCH 12/85] fix: prefer wuttaweb config for "home redirect to
 login" feature

---
 tailbone/views/common.py | 17 +++++++++++++++--
 1 file changed, 15 insertions(+), 2 deletions(-)

diff --git a/tailbone/views/common.py b/tailbone/views/common.py
index 7e9ddb09..26ef2626 100644
--- a/tailbone/views/common.py
+++ b/tailbone/views/common.py
@@ -25,6 +25,7 @@ Various common views
 """
 
 import os
+import warnings
 from collections import OrderedDict
 
 from rattail.batch import consume_batch_id
@@ -50,9 +51,21 @@ class CommonView(View):
         Home page view.
         """
         app = self.get_rattail_app()
+
+        # maybe auto-redirect anons to login
         if not self.request.user:
-            if self.rattail_config.getbool('tailbone', 'login_is_home', default=True):
-                raise self.redirect(self.request.route_url('login'))
+            redirect = self.config.get_bool('wuttaweb.home_redirect_to_login')
+            if redirect is None:
+                redirect = self.config.get_bool('tailbone.login_is_home')
+                if redirect is not None:
+                    warnings.warn("tailbone.login_is_home setting is deprecated; "
+                                  "please set wuttaweb.home_redirect_to_login instead",
+                                  DeprecationWarning)
+                else:
+                    # TODO: this is opposite of upstream default, should change
+                    redirect = True
+            if redirect:
+                return self.redirect(self.request.route_url('login'))
 
         image_url = self.rattail_config.get(
             'tailbone', 'main_image_url',

From 2ffc067097a7c979c4935eee1da4d697e7774845 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 20 Aug 2024 22:27:11 -0500
Subject: [PATCH 13/85] fix: inherit from wuttaweb for appinfo/index template

although for now, still must override for some link buttons
---
 tailbone/templates/appinfo/index.mako  | 95 +-------------------------
 tailbone/templates/grids/complete.mako | 14 ++++
 tailbone/views/settings.py             | 10 +++
 3 files changed, 26 insertions(+), 93 deletions(-)

diff --git a/tailbone/templates/appinfo/index.mako b/tailbone/templates/appinfo/index.mako
index 75032c1f..faaea935 100644
--- a/tailbone/templates/appinfo/index.mako
+++ b/tailbone/templates/appinfo/index.mako
@@ -1,8 +1,7 @@
 ## -*- coding: utf-8; -*-
-<%inherit file="/master/index.mako" />
+<%inherit file="wuttaweb:templates/appinfo/index.mako" />
 
 <%def name="page_content()">
-
   <div class="buttons">
 
     <once-button type="is-primary"
@@ -28,95 +27,5 @@
 
   </div>
 
-  <${b}-collapse class="panel" open>
-
-    <template #trigger="props">
-      <div class="panel-heading"
-           style="cursor: pointer;"
-           role="button">
-
-        ## TODO: for some reason buefy will "reuse" the icon
-        ## element in such a way that its display does not
-        ## refresh.  so to work around that, we use different
-        ## structure for the two icons, so buefy is forced to
-        ## re-draw
-
-        <b-icon v-if="props.open"
-                pack="fas"
-                icon="angle-down">
-        </b-icon>
-
-        <span v-if="!props.open">
-          <b-icon pack="fas"
-                  icon="angle-right">
-          </b-icon>
-        </span>
-
-        <span>Configuration Files</span>
-      </div>
-    </template>
-
-    <div class="panel-block">
-      <div style="width: 100%;">
-        <${b}-table :data="configFiles">
-          
-          <${b}-table-column field="priority"
-                          label="Priority"
-                          v-slot="props">
-            {{ props.row.priority }}
-          </${b}-table-column>
-
-          <${b}-table-column field="path"
-                          label="File Path"
-                          v-slot="props">
-            {{ props.row.path }}
-          </${b}-table-column>
-
-        </${b}-table>
-      </div>
-    </div>
-  </${b}-collapse>
-
-  <${b}-collapse class="panel"
-              :open="false">
-
-    <template #trigger="props">
-      <div class="panel-heading"
-           style="cursor: pointer;"
-           role="button">
-
-        ## TODO: for some reason buefy will "reuse" the icon
-        ## element in such a way that its display does not
-        ## refresh.  so to work around that, we use different
-        ## structure for the two icons, so buefy is forced to
-        ## re-draw
-
-        <b-icon v-if="props.open"
-                pack="fas"
-                icon="angle-down">
-        </b-icon>
-
-        <span v-if="!props.open">
-          <b-icon pack="fas"
-                  icon="angle-right">
-          </b-icon>
-        </span>
-
-        <strong>Installed Packages</strong>
-      </div>
-    </template>
-
-    <div class="panel-block">
-      <div style="width: 100%;">
-        ${grid.render_vue_tag()}
-      </div>
-    </div>
-  </${b}-collapse>
-</%def>
-
-<%def name="modify_vue_vars()">
-  ${parent.modify_vue_vars()}
-  <script>
-    ThisPageData.configFiles = ${json.dumps([dict(path=p, priority=i) for i, p in enumerate(request.rattail_config.prioritized_files, 1)])|n}
-  </script>
+  ${parent.page_content()}
 </%def>
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index c136273b..5d406512 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -257,6 +257,9 @@
       loading: false,
       ajaxDataUrl: ${json.dumps(getattr(grid, 'ajax_data_url', request.path_url))|n},
 
+      ## nb. this tracks whether grid.fetchFirstData() happened
+      fetchedFirstData: false,
+
       savingDefaults: false,
 
       data: ${grid.vue_component}CurrentData,
@@ -519,6 +522,17 @@
                       ...this.getFilterParams()}
           },
 
+          ## nb. this is meant to call for a grid which is hidden at
+          ## first, when it is first being shown to the user.  and if
+          ## it was initialized with empty data set.
+          async fetchFirstData() {
+              if (this.fetchedFirstData) {
+                  return
+              }
+              await this.loadAsyncData()
+              this.fetchedFirstData = true
+          },
+
           ## TODO: i noticed buefy docs show using `async` keyword here,
           ## so now i am too.  knowing nothing at all of if/how this is
           ## supposed to improve anything.  we shall see i guess
diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py
index bda62ccc..4d99cb2a 100644
--- a/tailbone/views/settings.py
+++ b/tailbone/views/settings.py
@@ -71,10 +71,20 @@ class AppInfoView(MasterView):
                                   app.get_title())
 
     def get_data(self, session=None):
+        """ """
+
+        # nb. init with empty data, only load it upon user request
+        if not self.request.GET.get('partial'):
+            return []
+
+        # TODO: pretty sure this is not cross-platform.  probably some
+        # sort of pip methods belong on the app handler?  or it should
+        # have a pip handler for all that?
         pip = os.path.join(sys.prefix, 'bin', 'pip')
         output = subprocess.check_output([pip, 'list', '--format=json'])
         data = json.loads(output.decode('utf_8').strip())
 
+        # must avoid null values for sort to work right
         for pkg in data:
             pkg.setdefault('editable_project_location', '')
 

From f7554602420eceb62d98fbde600c86aba0a944a3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 20 Aug 2024 23:23:23 -0500
Subject: [PATCH 14/85] feat: inherit from wuttaweb for AppInfoView,
 appinfo/configure template

---
 tailbone/menus.py                             |   2 +-
 tailbone/templates/appinfo/configure.mako     | 247 +-----------------
 .../themes/butterball/buefy-components.mako   |   9 +
 tailbone/views/settings.py                    | 202 +++-----------
 4 files changed, 48 insertions(+), 412 deletions(-)

diff --git a/tailbone/menus.py b/tailbone/menus.py
index abd0b58b..3ddee095 100644
--- a/tailbone/menus.py
+++ b/tailbone/menus.py
@@ -703,7 +703,7 @@ class TailboneMenuHandler(WuttaMenuHandler):
             },
             {'type': 'sep'},
             {
-                'title': "App Details",
+                'title': "App Info",
                 'route': 'appinfo',
                 'perm': 'appinfo.list',
             },
diff --git a/tailbone/templates/appinfo/configure.mako b/tailbone/templates/appinfo/configure.mako
index 4794f00b..9d866cea 100644
--- a/tailbone/templates/appinfo/configure.mako
+++ b/tailbone/templates/appinfo/configure.mako
@@ -1,247 +1,2 @@
 ## -*- coding: utf-8; -*-
-<%inherit file="/configure.mako" />
-
-<%def name="form_content()">
-
-  <h3 class="block is-size-3">Basics</h3>
-  <div class="block" style="padding-left: 2rem;">
-
-    <b-field grouped>
-
-      <b-field label="App Title">
-        <b-input name="rattail.app_title"
-                 v-model="simpleSettings['rattail.app_title']"
-                 @input="settingsNeedSaved = true">
-        </b-input>
-      </b-field>
-
-      <b-field label="Node Type">
-        ## TODO: should be a dropdown, app handler defines choices
-        <b-input name="rattail.node_type"
-                 v-model="simpleSettings['rattail.node_type']"
-                 @input="settingsNeedSaved = true">
-        </b-input>
-      </b-field>
-
-      <b-field label="Node Title">
-        <b-input name="rattail.node_title"
-                 v-model="simpleSettings['rattail.node_title']"
-                 @input="settingsNeedSaved = true">
-        </b-input>
-      </b-field>
-
-    </b-field>
-
-    <b-field>
-      <b-checkbox name="rattail.production"
-                  v-model="simpleSettings['rattail.production']"
-                  native-value="true"
-                  @input="settingsNeedSaved = true">
-        Production Mode
-      </b-checkbox>
-    </b-field>
-
-    <div class="level-left">
-      <div class="level-item">
-        <b-field>
-          <b-checkbox name="rattail.running_from_source"
-                      v-model="simpleSettings['rattail.running_from_source']"
-                      native-value="true"
-                      @input="settingsNeedSaved = true">
-            Running from Source
-          </b-checkbox>
-        </b-field>
-      </div>
-      <div class="level-item">
-        <b-field label="Top-Level Package" horizontal
-                 v-if="simpleSettings['rattail.running_from_source']">
-          <b-input name="rattail.running_from_source.rootpkg"
-                   v-model="simpleSettings['rattail.running_from_source.rootpkg']"
-                   @input="settingsNeedSaved = true">
-          </b-input>
-        </b-field>
-      </div>
-    </div>
-
-  </div>
-
-  <h3 class="block is-size-3">Display</h3>
-  <div class="block" style="padding-left: 2rem;">
-
-    <b-field grouped>
-
-      <b-field label="Background Color">
-        <b-input name="tailbone.background_color"
-                 v-model="simpleSettings['tailbone.background_color']"
-                 @input="settingsNeedSaved = true">
-        </b-input>
-      </b-field>
-
-    </b-field>
-
-  </div>
-
-  <h3 class="block is-size-3">Grids</h3>
-  <div class="block" style="padding-left: 2rem;">
-
-    <b-field grouped>
-
-      <b-field label="Default Page Size">
-        <b-input name="tailbone.grid.default_pagesize"
-                 v-model="simpleSettings['tailbone.grid.default_pagesize']"
-                 @input="settingsNeedSaved = true">
-        </b-input>
-      </b-field>
-
-    </b-field>
-
-  </div>
-
-  <h3 class="block is-size-3">Web Libraries</h3>
-  <div class="block" style="padding-left: 2rem;">
-
-    <${b}-table :data="weblibs">
-
-      <${b}-table-column field="title"
-                      label="Name"
-                      v-slot="props">
-        {{ props.row.title }}
-      </${b}-table-column>
-
-      <${b}-table-column field="configured_version"
-                      label="Version"
-                      v-slot="props">
-        {{ props.row.configured_version || props.row.default_version }}
-      </${b}-table-column>
-
-      <${b}-table-column field="configured_url"
-                      label="URL Override"
-                      v-slot="props">
-        {{ props.row.configured_url }}
-      </${b}-table-column>
-
-      <${b}-table-column field="live_url"
-                      label="Effective (Live) URL"
-                      v-slot="props">
-        <span v-if="props.row.modified"
-              class="has-text-warning">
-          save settings and refresh page to see new URL
-        </span>
-        <span v-if="!props.row.modified">
-          {{ props.row.live_url }}
-        </span>
-      </${b}-table-column>
-
-      <${b}-table-column field="actions"
-                      label="Actions"
-                      v-slot="props">
-        <a href="#"
-           @click.prevent="editWebLibraryInit(props.row)">
-          % if request.use_oruga:
-              <o-icon icon="edit" />
-          % else:
-              <i class="fas fa-edit"></i>
-          % endif
-          Edit
-        </a>
-      </${b}-table-column>
-
-    </${b}-table>
-
-    % for weblib in weblibs:
-        ${h.hidden('wuttaweb.libver.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.libver.{}']".format(weblib['key'])})}
-        ${h.hidden('wuttaweb.liburl.{}'.format(weblib['key']), **{':value': "simpleSettings['wuttaweb.liburl.{}']".format(weblib['key'])})}
-    % endfor
-
-    <${b}-modal has-modal-card
-                % if request.use_oruga:
-                    v-model:active="editWebLibraryShowDialog"
-                % else:
-                    :active.sync="editWebLibraryShowDialog"
-                % endif
-                >
-      <div class="modal-card">
-
-        <header class="modal-card-head">
-          <p class="modal-card-title">Web Library: {{ editWebLibraryRecord.title }}</p>
-        </header>
-
-        <section class="modal-card-body">
-
-          <b-field grouped>
-            
-            <b-field label="Default Version">
-              <b-input v-model="editWebLibraryRecord.default_version"
-                       disabled>
-              </b-input>
-            </b-field>
-
-            <b-field label="Override Version">
-              <b-input v-model="editWebLibraryVersion">
-              </b-input>
-            </b-field>
-
-          </b-field>
-
-          <b-field label="Override URL">
-            <b-input v-model="editWebLibraryURL"
-                     expanded />
-          </b-field>
-
-          <b-field label="Effective URL (as of last page load)">
-            <b-input v-model="editWebLibraryRecord.live_url"
-                     disabled
-                     expanded />
-          </b-field>
-
-        </section>
-
-        <footer class="modal-card-foot">
-          <b-button type="is-primary"
-                    @click="editWebLibrarySave()"
-                    icon-pack="fas"
-                    icon-left="save">
-            Save
-          </b-button>
-          <b-button @click="editWebLibraryShowDialog = false">
-            Cancel
-          </b-button>
-        </footer>
-      </div>
-    </${b}-modal>
-
-  </div>
-</%def>
-
-<%def name="modify_vue_vars()">
-  ${parent.modify_vue_vars()}
-  <script>
-
-    ThisPageData.weblibs = ${json.dumps(weblibs)|n}
-
-    ThisPageData.editWebLibraryShowDialog = false
-    ThisPageData.editWebLibraryRecord = {}
-    ThisPageData.editWebLibraryVersion = null
-    ThisPageData.editWebLibraryURL = null
-
-    ThisPage.methods.editWebLibraryInit = function(row) {
-        this.editWebLibraryRecord = row
-        this.editWebLibraryVersion = row.configured_version
-        this.editWebLibraryURL = row.configured_url
-        this.editWebLibraryShowDialog = true
-    }
-
-    ThisPage.methods.editWebLibrarySave = function() {
-        this.editWebLibraryRecord.configured_version = this.editWebLibraryVersion
-        this.editWebLibraryRecord.configured_url = this.editWebLibraryURL
-        this.editWebLibraryRecord.modified = true
-
-        this.simpleSettings[`wuttaweb.libver.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryVersion
-        this.simpleSettings[`wuttaweb.liburl.${'$'}{this.editWebLibraryRecord.key}`] = this.editWebLibraryURL
-
-        this.settingsNeedSaved = true
-        this.editWebLibraryShowDialog = false
-    }
-
-  </script>
-</%def>
+<%inherit file="wuttaweb:templates/appinfo/configure.mako" />
diff --git a/tailbone/templates/themes/butterball/buefy-components.mako b/tailbone/templates/themes/butterball/buefy-components.mako
index 51a0deb9..3a2cd798 100644
--- a/tailbone/templates/themes/butterball/buefy-components.mako
+++ b/tailbone/templates/themes/butterball/buefy-components.mako
@@ -666,6 +666,7 @@
 <%def name="make_b_tooltip_component()">
   <script type="text/x-template" id="b-tooltip-template">
     <o-tooltip :label="label"
+               :position="orugaPosition"
                :multiline="multilined">
       <slot />
     </o-tooltip>
@@ -676,6 +677,14 @@
         props: {
             label: String,
             multilined: Boolean,
+            position: String,
+        },
+        computed: {
+            orugaPosition() {
+                if (this.position) {
+                    return this.position.replace(/^is-/, '')
+                }
+            },
         },
     }
   </script>
diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py
index 4d99cb2a..099a77e1 100644
--- a/tailbone/views/settings.py
+++ b/tailbone/views/settings.py
@@ -25,11 +25,7 @@ Settings Views
 """
 
 import json
-import os
 import re
-import subprocess
-import sys
-from collections import OrderedDict
 
 import colander
 
@@ -37,201 +33,77 @@ from rattail.db.model import Setting
 from rattail.settings import Setting as AppSetting
 from rattail.util import import_module_path
 
-from tailbone import forms
+from tailbone import forms, grids
 from tailbone.db import Session
 from tailbone.views import MasterView, View
 from wuttaweb.util import get_libver, get_liburl
+from wuttaweb.views.settings import AppInfoView as WuttaAppInfoView
 
 
-class AppInfoView(MasterView):
-    """
-    Master view for the overall app, to show/edit config etc.
-    """
-    route_prefix = 'appinfo'
-    model_key = 'UNUSED'
-    model_title = "UNUSED"
-    model_title_plural = "App Details"
-    creatable = False
-    viewable = False
-    editable = False
-    deletable = False
-    filterable = False
-    pageable = False
-    configurable = True
+class AppInfoView(WuttaAppInfoView):
+    """ """
+    Session = Session
+    weblib_config_prefix = 'tailbone'
 
-    grid_columns = [
-        'name',
-        'version',
-        'editable_project_location',
-    ]
-
-    def get_index_title(self):
-        app = self.get_rattail_app()
-        return "{} for {}".format(self.get_model_title_plural(),
-                                  app.get_title())
-
-    def get_data(self, session=None):
+    # TODO: for now we override to get tailbone searchable grid
+    def make_grid(self, **kwargs):
         """ """
-
-        # nb. init with empty data, only load it upon user request
-        if not self.request.GET.get('partial'):
-            return []
-
-        # TODO: pretty sure this is not cross-platform.  probably some
-        # sort of pip methods belong on the app handler?  or it should
-        # have a pip handler for all that?
-        pip = os.path.join(sys.prefix, 'bin', 'pip')
-        output = subprocess.check_output([pip, 'list', '--format=json'])
-        data = json.loads(output.decode('utf_8').strip())
-
-        # must avoid null values for sort to work right
-        for pkg in data:
-            pkg.setdefault('editable_project_location', '')
-
-        return data
+        return grids.Grid(self.request, **kwargs)
 
     def configure_grid(self, g):
+        """ """
         super().configure_grid(g)
 
-        # sort on frontend
-        g.sort_on_backend = False
-        g.sort_multiple = False
-        g.set_sort_defaults('name')
-
         # name
         g.set_searchable('name')
 
         # editable_project_location
         g.set_searchable('editable_project_location')
 
-    def template_kwargs_index(self, **kwargs):
-        kwargs = super().template_kwargs_index(**kwargs)
-        kwargs['configure_button_title'] = "Configure App"
-        return kwargs
-
-    def get_weblibs(self):
-        """ """
-        return OrderedDict([
-            ('vue', "Vue"),
-            ('vue_resource', "vue-resource"),
-            ('buefy', "Buefy"),
-            ('buefy.css', "Buefy CSS"),
-            ('fontawesome', "FontAwesome"),
-            ('bb_vue', "(BB) vue"),
-            ('bb_oruga', "(BB) @oruga-ui/oruga-next"),
-            ('bb_oruga_bulma', "(BB) @oruga-ui/theme-bulma (JS)"),
-            ('bb_oruga_bulma_css', "(BB) @oruga-ui/theme-bulma (CSS)"),
-            ('bb_fontawesome_svg_core', "(BB) @fortawesome/fontawesome-svg-core"),
-            ('bb_free_solid_svg_icons', "(BB) @fortawesome/free-solid-svg-icons"),
-            ('bb_vue_fontawesome', "(BB) @fortawesome/vue-fontawesome"),
-        ])
-
     def configure_get_context(self, **kwargs):
         """ """
         context = super().configure_get_context(**kwargs)
         simple_settings = context['simple_settings']
-        weblibs = self.get_weblibs()
+        weblibs = context['weblibs']
 
-        for key in weblibs:
-            title = weblibs[key]
-            weblibs[key] = {
-                'key': key,
-                'title': title,
-
-                # nb. these values are exactly as configured, and are
-                # used for editing the settings
-                'configured_version': get_libver(self.request, key,
-                                                 prefix='tailbone',
-                                                 configured_only=True),
-                'configured_url': get_liburl(self.request, key,
-                                             prefix='tailbone',
-                                             configured_only=True),
-
-                # these are for informational purposes only
-                'default_version': get_libver(self.request, key,
-                                              prefix='tailbone',
-                                              default_only=True),
-                'live_url': get_liburl(self.request, key,
-                                       prefix='tailbone'),
-            }
+        for weblib in weblibs:
+            key = weblib['key']
 
             # TODO: this is only needed to migrate legacy settings to
-            # use the newer wutaweb setting names
+            # use the newer wuttaweb setting names
             url = simple_settings[f'wuttaweb.liburl.{key}']
-            if not url and weblibs[key]['configured_url']:
-                simple_settings[f'wuttaweb.liburl.{key}'] = weblibs[key]['configured_url']
+            if not url and weblib['configured_url']:
+                simple_settings[f'wuttaweb.liburl.{key}'] = weblib['configured_url']
 
-        context['weblibs'] = list(weblibs.values())
         return context
 
     def configure_get_simple_settings(self):
         """ """
-        simple_settings = [
+        simple_settings = super().configure_get_simple_settings()
 
-            # basics
-            {'section': 'rattail',
-             'option': 'app_title'},
-            {'section': 'rattail',
-             'option': 'node_type'},
-            {'section': 'rattail',
-             'option': 'node_title'},
-            {'section': 'rattail',
-             'option': 'production',
-             'type': bool},
-            {'section': 'rattail',
-             'option': 'running_from_source',
-             'type': bool},
-            {'section': 'rattail',
-             'option': 'running_from_source.rootpkg'},
+        # TODO: the update home page redirect setting is off by
+        # default for wuttaweb, but on for tailbone
+        for setting in simple_settings:
+            if setting['name'] == 'wuttaweb.home_redirect_to_login':
+                value = self.config.get_bool('wuttaweb.home_redirect_to_login')
+                if value is None:
+                    value = self.config.get_bool('tailbone.login_is_home', default=True)
+                setting['default'] = value
+                break
 
-            # display
-            {'section': 'tailbone',
-             'option': 'background_color'},
+        # nb. these are no longer used (deprecated), but we keep
+        # them defined here so the tool auto-deletes them
 
-            # grids
-            {'section': 'tailbone',
-             'option': 'grid.default_pagesize',
-             # TODO: seems like should enforce this, but validation is
-             # not setup yet
-             # 'type': int
-            },
+        simple_settings.extend([
+            {'name': 'tailbone.buefy_version'},
+            {'name': 'tailbone.vue_version'},
+        ])
 
-            # nb. these are no longer used (deprecated), but we keep
-            # them defined here so the tool auto-deletes them
-            {'section': 'tailbone',
-             'option': 'buefy_version'},
-            {'section': 'tailbone',
-             'option': 'vue_version'},
-
-        ]
-
-        def getval(key):
-            return self.config.get(f'tailbone.{key}')
-
-        weblibs = self.get_weblibs()
-        for key, title in weblibs.items():
-
-            simple_settings.append({
-                'section': 'wuttaweb',
-                'option': f"libver.{key}",
-                'default': getval(f"libver.{key}"),
-            })
-            simple_settings.append({
-                'section': 'wuttaweb',
-                'option': f"liburl.{key}",
-                'default': getval(f"liburl.{key}"),
-            })
-
-            # nb. these are no longer used (deprecated), but we keep
-            # them defined here so the tool auto-deletes them
-            simple_settings.append({
-                'section': 'tailbone',
-                'option': f"libver.{key}",
-            })
-            simple_settings.append({
-                'section': 'tailbone',
-                'option': f"liburl.{key}",
-            })
+        for key in self.get_weblibs():
+            simple_settings.extend([
+                {'name': f'tailbone.libver.{key}'},
+                {'name': f'tailbone.liburl.{key}'},
+            ])
 
         return simple_settings
 

From 71abbe06da0d08c4a285fbca2b583c570f3def4c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 21 Aug 2024 00:07:03 -0500
Subject: [PATCH 15/85] feat: inherit from wuttaweb templates for home, login
 pages

---
 tailbone/templates/base_meta.mako | 13 +-----
 tailbone/templates/home.mako      | 30 +-----------
 tailbone/templates/login.mako     | 77 ++-----------------------------
 tailbone/views/common.py          | 12 +++--
 4 files changed, 18 insertions(+), 114 deletions(-)

diff --git a/tailbone/templates/base_meta.mako b/tailbone/templates/base_meta.mako
index 00cfdfe9..b6376448 100644
--- a/tailbone/templates/base_meta.mako
+++ b/tailbone/templates/base_meta.mako
@@ -1,10 +1,7 @@
 ## -*- coding: utf-8; -*-
+<%inherit file="wuttaweb:templates/base_meta.mako" />
 
-<%def name="app_title()">${rattail_app.get_node_title()}</%def>
-
-<%def name="global_title()">${"[STAGE] " if not request.rattail_config.production() else ''}${self.app_title()}</%def>
-
-<%def name="extra_styles()"></%def>
+<%def name="app_title()">${app.get_node_title()}</%def>
 
 <%def name="favicon()">
   <link rel="icon" type="image/x-icon" href="${request.rattail_config.get('tailbone', 'favicon_url', default=request.static_url('tailbone:static/img/rattail.ico'))}" />
@@ -13,9 +10,3 @@
 <%def name="header_logo()">
   ${h.image(request.rattail_config.get('tailbone', 'header_image_url', default=request.static_url('tailbone:static/img/rattail.ico')), "Header Logo", style="height: 49px;")}
 </%def>
-
-<%def name="footer()">
-  <p class="has-text-centered">
-    powered by ${h.link_to("Rattail", url('about'))}
-  </p>
-</%def>
diff --git a/tailbone/templates/home.mako b/tailbone/templates/home.mako
index e4f7d072..54e44d57 100644
--- a/tailbone/templates/home.mako
+++ b/tailbone/templates/home.mako
@@ -1,33 +1,7 @@
 ## -*- coding: utf-8; -*-
-<%inherit file="/page.mako" />
-<%namespace name="base_meta" file="/base_meta.mako" />
-
-<%def name="title()">Home</%def>
-
-<%def name="extra_styles()">
-  ${parent.extra_styles()}
-  <style type="text/css">
-    .logo {
-        text-align: center;
-    }
-    .logo img {
-        margin: 3em auto;
-        max-height: 350px;
-        max-width: 800px;
-    }
-  </style>
-</%def>
+<%inherit file="wuttaweb:templates/home.mako" />
 
+## DEPRECATED; remains for back-compat
 <%def name="render_this_page()">
   ${self.page_content()}
 </%def>
-
-<%def name="page_content()">
-  <div class="logo">
-    ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))}
-    <h1>Welcome to ${base_meta.app_title()}</h1>
-  </div>
-</%def>
-
-
-${parent.body()}
diff --git a/tailbone/templates/login.mako b/tailbone/templates/login.mako
index 3eb46403..d2ea7828 100644
--- a/tailbone/templates/login.mako
+++ b/tailbone/templates/login.mako
@@ -1,84 +1,17 @@
 ## -*- coding: utf-8; -*-
-<%inherit file="/form.mako" />
-<%namespace name="base_meta" file="/base_meta.mako" />
-
-<%def name="title()">Login</%def>
+<%inherit file="wuttaweb:templates/auth/login.mako" />
 
+## TODO: this will not be needed with wuttaform
 <%def name="extra_styles()">
   ${parent.extra_styles()}
-  <style type="text/css">
-    .logo img {
-        display: block;
-        margin: 3rem auto;
-        max-height: 350px;
-        max-width: 800px;
-    }
-
-    /* must force a particular label with, in order to make sure */
-    /* the username and password inputs are the same size */
-    .field.is-horizontal .field-label .label {
-        text-align: left;
-        width: 6rem;
-    }
-
-    .buttons {
+  <style>
+    .card-content .buttons {
         justify-content: right;
     }
   </style>
 </%def>
 
-<%def name="logo()">
-  ${h.image(image_url, "{} logo".format(capture(base_meta.app_title)))}
-</%def>
-
-<%def name="login_form()">
-  <div class="form">
-    ${form.render_deform(form_kwargs={'data-ajax': 'false'})|n}
-  </div>
-</%def>
-
+## DEPRECATED; remains for back-compat
 <%def name="render_this_page()">
   ${self.page_content()}
 </%def>
-
-<%def name="page_content()">
-  <div class="logo">
-    ${self.logo()}
-  </div>
-
-  <div class="columns is-centered">
-    <div class="column is-narrow">
-      <div class="card">
-        <div class="card-content">
-          <tailbone-form></tailbone-form>
-        </div>
-      </div>
-    </div>
-  </div>
-</%def>
-
-<%def name="modify_vue_vars()">
-  ${parent.modify_vue_vars()}
-  <script>
-
-    ${form.vue_component}Data.usernameInput = null
-
-    ${form.vue_component}.mounted = function() {
-        this.$refs.username.focus()
-        this.usernameInput = this.$refs.username.$el.querySelector('input')
-        this.usernameInput.addEventListener('keydown', this.usernameKeydown)
-    }
-
-    ${form.vue_component}.beforeDestroy = function() {
-        this.usernameInput.removeEventListener('keydown', this.usernameKeydown)
-    }
-
-    ${form.vue_component}.methods.usernameKeydown = function(event) {
-        if (event.which == 13) {
-            event.preventDefault()
-            this.$refs.password.focus()
-        }
-    }
-
-  </script>
-</%def>
diff --git a/tailbone/views/common.py b/tailbone/views/common.py
index 26ef2626..f4d98c05 100644
--- a/tailbone/views/common.py
+++ b/tailbone/views/common.py
@@ -67,9 +67,15 @@ class CommonView(View):
             if redirect:
                 return self.redirect(self.request.route_url('login'))
 
-        image_url = self.rattail_config.get(
-            'tailbone', 'main_image_url',
-            default=self.request.static_url('tailbone:static/img/home_logo.png'))
+        image_url = self.config.get('wuttaweb.logo_url')
+        if not image_url:
+            image_url = self.config.get('tailbone.main_image_url')
+            if image_url:
+                warnings.warn("tailbone.main_image_url setting is deprecated; "
+                              "please set wuttaweb.logo_url instead",
+                              DeprecationWarning)
+            else:
+                image_url = self.request.static_url('tailbone:static/img/home_logo.png')
 
         context = {
             'image_url': image_url,

From 1d00fe994a069e366d67558d4f5f3709e103e991 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 21 Aug 2024 09:44:32 -0500
Subject: [PATCH 16/85] fix: use wuttaweb to get/render csrf token

---
 tailbone/helpers.py                           | 12 ++++-----
 tailbone/templates/formposter.mako            |  2 +-
 tailbone/templates/forms/deform.mako          |  2 +-
 tailbone/templates/ordering/view.mako         |  2 +-
 tailbone/templates/ordering/worksheet.mako    |  2 +-
 tailbone/templates/page.mako                  |  2 +-
 tailbone/templates/themes/waterpark/page.mako |  2 +-
 tailbone/util.py                              | 27 +++++++++----------
 8 files changed, 24 insertions(+), 27 deletions(-)

diff --git a/tailbone/helpers.py b/tailbone/helpers.py
index 23988423..50b38c30 100644
--- a/tailbone/helpers.py
+++ b/tailbone/helpers.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -24,6 +24,9 @@
 Template Context Helpers
 """
 
+# start off with all from wuttaweb
+from wuttaweb.helpers import *
+
 import os
 import datetime
 from decimal import Decimal
@@ -33,12 +36,7 @@ from rattail.time import localtime, make_utc
 from rattail.util import pretty_quantity, pretty_hours, hours_as_decimal
 from rattail.db.util import maxlen
 
-from webhelpers2.html import *
-from webhelpers2.html.tags import *
-
-from wuttaweb.util import get_liburl
-from tailbone.util import (csrf_token, get_csrf_token,
-                           pretty_datetime, raw_datetime,
+from tailbone.util import (pretty_datetime, raw_datetime,
                            render_markdown,
                            route_exists)
 
diff --git a/tailbone/templates/formposter.mako b/tailbone/templates/formposter.mako
index ab9c720d..d566a467 100644
--- a/tailbone/templates/formposter.mako
+++ b/tailbone/templates/formposter.mako
@@ -39,7 +39,7 @@
 
             simplePOST(action, params, success, failure) {
 
-                let csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}
+                let csrftoken = ${json.dumps(h.get_csrf_token(request))|n}
 
                 let headers = {
                     '${csrf_header_name}': csrftoken,
diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako
index 26c8b4ee..ea35ab17 100644
--- a/tailbone/templates/forms/deform.mako
+++ b/tailbone/templates/forms/deform.mako
@@ -180,7 +180,7 @@
   let ${form.vue_component}Data = {
 
       ## TODO: should find a better way to handle CSRF token
-      csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
+      csrftoken: ${json.dumps(h.get_csrf_token(request))|n},
 
       % if can_edit_help:
           fieldLabels: ${json.dumps(field_labels)|n},
diff --git a/tailbone/templates/ordering/view.mako b/tailbone/templates/ordering/view.mako
index 584559c1..34a6085f 100644
--- a/tailbone/templates/ordering/view.mako
+++ b/tailbone/templates/ordering/view.mako
@@ -204,7 +204,7 @@
                     saving: false,
 
                     ## TODO: should find a better way to handle CSRF token
-                    csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
+                    csrftoken: ${json.dumps(h.get_csrf_token(request))|n},
                 }
             },
             computed: {
diff --git a/tailbone/templates/ordering/worksheet.mako b/tailbone/templates/ordering/worksheet.mako
index cb98c48f..eb2077e7 100644
--- a/tailbone/templates/ordering/worksheet.mako
+++ b/tailbone/templates/ordering/worksheet.mako
@@ -250,7 +250,7 @@
                 submitting: false,
 
                 ## TODO: should find a better way to handle CSRF token
-                csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
+                csrftoken: ${json.dumps(h.get_csrf_token(request))|n},
             }
         },
         methods: {
diff --git a/tailbone/templates/page.mako b/tailbone/templates/page.mako
index 54b47278..43b0a266 100644
--- a/tailbone/templates/page.mako
+++ b/tailbone/templates/page.mako
@@ -38,7 +38,7 @@
 
     const ThisPageData = {
         ## TODO: should find a better way to handle CSRF token
-        csrftoken: ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n},
+        csrftoken: ${json.dumps(h.get_csrf_token(request))|n},
     }
 
   </script>
diff --git a/tailbone/templates/themes/waterpark/page.mako b/tailbone/templates/themes/waterpark/page.mako
index 7e6851a7..66ce47dc 100644
--- a/tailbone/templates/themes/waterpark/page.mako
+++ b/tailbone/templates/themes/waterpark/page.mako
@@ -38,7 +38,7 @@
   ${parent.modify_vue_vars()}
   <script>
 
-    ThisPageData.csrftoken = ${json.dumps(request.session.get_csrf_token() or request.session.new_csrf_token())|n}
+    ThisPageData.csrftoken = ${json.dumps(h.get_csrf_token(request))|n}
 
     % if can_edit_help:
         ThisPage.props.configureFieldsHelp = Boolean
diff --git a/tailbone/util.py b/tailbone/util.py
index 594fd69b..71aa35e3 100644
--- a/tailbone/util.py
+++ b/tailbone/util.py
@@ -41,7 +41,9 @@ from webhelpers2.html import HTML, tags
 
 from wuttaweb.util import (get_form_data as wutta_get_form_data,
                            get_libver as wutta_get_libver,
-                           get_liburl as wutta_get_liburl)
+                           get_liburl as wutta_get_liburl,
+                           get_csrf_token as wutta_get_csrf_token,
+                           render_csrf_token)
 
 
 log = logging.getLogger(__name__)
@@ -59,22 +61,19 @@ class SortColumn(object):
 
 
 def get_csrf_token(request):
-    """
-    Convenience function to retrieve the effective CSRF token for the given
-    request.
-    """
-    token = request.session.get_csrf_token()
-    if token is None:
-        token = request.session.new_csrf_token()
-    return token
+    """ """
+    warnings.warn("tailbone.util.get_csrf_token() is deprecated; "
+                  "please use wuttaweb.util.get_csrf_token() instead",
+                  DeprecationWarning, stacklevel=2)
+    return wutta_get_csrf_token(request)
 
 
 def csrf_token(request, name='_csrf'):
-    """
-    Convenience function. Returns CSRF hidden tag inside hidden DIV.
-    """
-    token = get_csrf_token(request)
-    return HTML.tag("div", tags.hidden(name, value=token), style="display:none;")
+    """ """
+    warnings.warn("tailbone.util.csrf_token() is deprecated; "
+                  "please use wuttaweb.util.render_csrf_token() instead",
+                  DeprecationWarning, stacklevel=2)
+    return render_csrf_token(request, name=name)
 
 
 def get_form_data(request):

From ffa724ef374ec59e90b51a2b14a83ee703bea5a0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 21 Aug 2024 15:50:55 -0500
Subject: [PATCH 17/85] fix: move "searchable columns" grid feature to wuttaweb

---
 tailbone/grids/core.py                 | 19 +++++++------------
 tailbone/templates/grids/complete.mako |  6 ++----
 tests/grids/test_core.py               |  6 ++++++
 3 files changed, 15 insertions(+), 16 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index eada1041..92452b31 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -200,7 +200,6 @@ class Grid(WuttaGrid):
             filterable=False,
             filters={},
             use_byte_string_filters=False,
-            searchable={},
             checkboxes=False,
             checked=None,
             check_handler=None,
@@ -254,6 +253,12 @@ class Grid(WuttaGrid):
                           DeprecationWarning, stacklevel=2)
             kwargs.setdefault('page', kwargs.pop('default_page'))
 
+        if 'searchable' in kwargs:
+            warnings.warn("searchable param is deprecated for Grid(); "
+                          "please use searchable_columns param instead",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('searchable_columns', kwargs.pop('searchable'))
+
         # TODO: this should not be needed once all templates correctly
         # reference grid.vue_component etc.
         kwargs.setdefault('vue_tagname', 'tailbone-grid')
@@ -287,8 +292,6 @@ class Grid(WuttaGrid):
         self.use_byte_string_filters = use_byte_string_filters
         self.filters = self.make_filters(filters)
 
-        self.searchable = searchable or {}
-
         self.checkboxes = checkboxes
         self.checked = checked
         if self.checked is None:
@@ -481,15 +484,6 @@ class Grid(WuttaGrid):
                 kwargs['label'] = self.labels[key]
             self.filters[key] = self.make_filter(key, *args, **kwargs)
 
-    def set_searchable(self, key, searchable=True):
-        if searchable:
-            self.searchable[key] = True
-        else:
-            self.searchable.pop(key, None)
-
-    def is_searchable(self, key):
-        return self.searchable.get(key, False)
-
     def remove_filter(self, key):
         self.filters.pop(key, None)
 
@@ -1587,6 +1581,7 @@ class Grid(WuttaGrid):
                 'field': name,
                 'label': self.get_label(name),
                 'sortable': self.is_sortable(name),
+                'searchable': self.is_searchable(name),
                 'visible': name not in self.invisible,
             })
         return columns
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index 5d406512..54ad0527 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -136,10 +136,8 @@
           <${b}-table-column field="${column['field']}"
                           label="${column['label']}"
                           v-slot="props"
-                          :sortable="${json.dumps(column.get('sortable', False))}"
-                          % if hasattr(grid, 'is_searchable') and grid.is_searchable(column['field']):
-                          searchable
-                          % endif
+                          :sortable="${json.dumps(column.get('sortable', False))|n}"
+                          :searchable="${json.dumps(column.get('searchable', False))|n}"
                           cell-class="c_${column['field']}"
                           :visible="${json.dumps(column.get('visible', True))}">
             % if hasattr(grid, 'raw_renderers') and column['field'] in grid.raw_renderers:
diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py
index c621627a..5169e599 100644
--- a/tests/grids/test_core.py
+++ b/tests/grids/test_core.py
@@ -57,6 +57,12 @@ class TestGrid(WebTestCase):
         grid = self.make_grid(default_page=42)
         self.assertEqual(grid.page, 42)
 
+        # searchable
+        grid = self.make_grid()
+        self.assertEqual(grid.searchable_columns, set())
+        grid = self.make_grid(searchable={'foo': True})
+        self.assertEqual(grid.searchable_columns, {'foo'})
+
     def test_vue_tagname(self):
 
         # default

From e52a83751e8b95c72917277214ff504a0ede13b6 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 21 Aug 2024 20:16:03 -0500
Subject: [PATCH 18/85] feat: move "most" filtering logic for grid class to
 wuttaweb

we still define all filters, and the "most important" grid methods for
filtering
---
 tailbone/grids/core.py | 295 +++++++++--------------------------------
 1 file changed, 62 insertions(+), 233 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 92452b31..969be50a 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -196,9 +196,6 @@ class Grid(WuttaGrid):
             raw_renderers={},
             extra_row_class=None,
             url='#',
-            joiners={},
-            filterable=False,
-            filters={},
             use_byte_string_filters=False,
             checkboxes=False,
             checked=None,
@@ -263,6 +260,8 @@ class Grid(WuttaGrid):
         # reference grid.vue_component etc.
         kwargs.setdefault('vue_tagname', 'tailbone-grid')
 
+        self.use_byte_string_filters = use_byte_string_filters
+
         kwargs['key'] = key
         kwargs['data'] = data
         super().__init__(request, **kwargs)
@@ -286,11 +285,6 @@ class Grid(WuttaGrid):
         self.invisible = invisible or []
         self.extra_row_class = extra_row_class
         self.url = url
-        self.joiners = joiners or {}
-
-        self.filterable = filterable
-        self.use_byte_string_filters = use_byte_string_filters
-        self.filters = self.make_filters(filters)
 
         self.checkboxes = checkboxes
         self.checked = checked
@@ -446,10 +440,14 @@ class Grid(WuttaGrid):
         self.remove(oldfield)
 
     def set_joiner(self, key, joiner):
+        """ """
         if joiner is None:
-            self.joiners.pop(key, None)
+            warnings.warn("specifying None is deprecated for Grid.set_joiner(); "
+                          "please use Grid.remove_joiner() instead",
+                          DeprecationWarning, stacklevel=2)
+            self.remove_joiner(key)
         else:
-            self.joiners[key] = joiner
+            super().set_joiner(key, joiner)
 
     def set_sorter(self, key, *args, **kwargs):
         """ """
@@ -477,33 +475,27 @@ class Grid(WuttaGrid):
             self.sorters[key] = self.make_sorter(*args, **kwargs)
 
     def set_filter(self, key, *args, **kwargs):
-        if len(args) == 1 and args[0] is None:
-            self.remove_filter(key)
+        """ """
+
+        if len(args) == 1:
+            if args[0] is None:
+                warnings.warn("specifying None is deprecated for Grid.set_filter(); "
+                              "please use Grid.remove_filter() instead",
+                              DeprecationWarning, stacklevel=2)
+                self.remove_filter(key)
+            else:
+                super().set_filter(key, args[0], **kwargs)
+
+        elif len(args) == 0:
+            super().set_filter(key, **kwargs)
+
         else:
-            if 'label' not in kwargs and key in self.labels:
-                kwargs['label'] = self.labels[key]
+            warnings.warn("multiple args are deprecated for Grid.set_filter(); "
+                          "please refactor your code accordingly",
+                          DeprecationWarning, stacklevel=2)
+            kwargs.setdefault('label', self.get_label(key))
             self.filters[key] = self.make_filter(key, *args, **kwargs)
 
-    def remove_filter(self, key):
-        self.filters.pop(key, None)
-
-    def set_label(self, key, label, column_only=False):
-        """
-        Set/override the label for a column.
-
-        This overrides
-        :meth:`~wuttaweb:wuttaweb.grids.base.Grid.set_label()` to add
-        the following params:
-
-        :param column_only: Boolean indicating whether the label
-           should be applied *only* to the column header (if
-           ``True``), vs.  applying also to the filter (if ``False``).
-        """
-        super().set_label(key, label)
-
-        if not column_only and key in self.filters:
-            self.filters[key].label = label
-
     def set_click_handler(self, key, handler):
         if handler:
             self.click_handlers[key] = handler
@@ -702,6 +694,14 @@ class Grid(WuttaGrid):
     def actions_column_format(self, column_number, row_number, item):
         return HTML.td(self.render_actions(item, row_number), class_='actions')
 
+    # TODO: upstream should handle this..
+    def make_backend_filters(self, filters=None):
+        """ """
+        final = self.get_default_filters()
+        if filters:
+            final.update(filters)
+        return final
+
     def get_default_filters(self):
         """
         Returns the default set of filters provided by the grid.
@@ -726,16 +726,6 @@ class Grid(WuttaGrid):
                 filters[prop.key] = self.make_filter(prop.key, column)
         return filters
 
-    def make_filters(self, filters=None):
-        """
-        Returns an initial set of filters which will be available to the grid.
-        The grid itself may or may not provide some default filters, and the
-        ``filters`` kwarg may contain additions and/or overrides.
-        """
-        if filters:
-            return filters
-        return self.get_default_filters()
-
     def make_filter(self, key, column, **kwargs):
         """
         Make a filter suitable for use with the given column.
@@ -888,8 +878,8 @@ class Grid(WuttaGrid):
 
         # If request has filter settings, grab those, then grab sort/pager
         # settings from request or session.
-        elif self.filterable and self.request_has_settings('filter'):
-            self.update_filter_settings(settings, 'request')
+        elif self.request_has_settings('filter'):
+            self.update_filter_settings(settings, src='request')
             if self.request_has_settings('sort'):
                 self.update_sort_settings(settings, src='request')
             else:
@@ -901,7 +891,7 @@ class Grid(WuttaGrid):
         # settings from request or session.
         elif self.request_has_settings('sort'):
             self.update_sort_settings(settings, src='request')
-            self.update_filter_settings(settings, 'session')
+            self.update_filter_settings(settings, src='session')
             self.update_page_settings(settings)
 
         # NOTE: These next two are functionally equivalent, but are kept
@@ -911,12 +901,12 @@ class Grid(WuttaGrid):
         # grab those, then grab filter/sort settings from session.
         elif self.request_has_settings('page'):
             self.update_page_settings(settings)
-            self.update_filter_settings(settings, 'session')
+            self.update_filter_settings(settings, src='session')
             self.update_sort_settings(settings, src='session')
 
         # If request has no settings, grab all from session.
         elif self.session_has_settings():
-            self.update_filter_settings(settings, 'session')
+            self.update_filter_settings(settings, src='session')
             self.update_sort_settings(settings, src='session')
             self.update_page_settings(settings)
 
@@ -1056,18 +1046,11 @@ class Grid(WuttaGrid):
             merge('page', int)
 
     def request_has_settings(self, type_):
-        """
-        Determine if the current request (GET query string) contains any
-        filter/sort settings for the grid.
-        """
-        if type_ == 'filter':
-            for filtr in self.iter_filters():
-                if filtr.key in self.request.GET:
-                    return True
-            if 'filter' in self.request.GET: # user may be applying empty filters
-                return True
+        """ """
+        if super().request_has_settings(type_):
+            return True
 
-        elif type_ == 'sort':
+        if type_ == 'sort':
 
             # TODO: remove this eventually, but some links in the wild
             # may still include these params, so leave it for now
@@ -1075,14 +1058,6 @@ class Grid(WuttaGrid):
                 if key in self.request.GET:
                     return True
 
-            if 'sort1key' in self.request.GET:
-                return True
-
-        elif type_ == 'page':
-            for key in ['pagesize', 'page']:
-                if key in self.request.GET:
-                    return True
-
         return False
 
     def session_has_settings(self):
@@ -1098,72 +1073,6 @@ class Grid(WuttaGrid):
         return any([key.startswith(f'{prefix}.filter')
                     for key in self.request.session])
 
-    def update_filter_settings(self, settings, source):
-        """
-        Updates a settings dictionary according to filter settings data found
-        in either the GET query string, or session storage.
-
-        :param settings: Dictionary of initial settings, which is to be updated.
-
-        :param source: String identifying the source to consult for settings
-           data.  Must be one of: ``('request', 'session')``.
-        """
-        if not self.filterable:
-            return
-
-        for filtr in self.iter_filters():
-            prefix = 'filter.{}'.format(filtr.key)
-
-            if source == 'request':
-                # consider filter active if query string contains a value for it
-                settings['{}.active'.format(prefix)] = filtr.key in self.request.GET
-                settings['{}.verb'.format(prefix)] = self.get_setting(
-                    settings, f'{filtr.key}.verb', src='request', default='')
-                settings['{}.value'.format(prefix)] = self.get_setting(
-                    settings, filtr.key, src='request', default='')
-
-            else: # source = session
-                settings['{}.active'.format(prefix)] = self.get_setting(
-                    settings, f'{prefix}.active', src='session',
-                    normalize=lambda v: str(v).lower() == 'true', default=False)
-                settings['{}.verb'.format(prefix)] = self.get_setting(
-                    settings, f'{prefix}.verb', src='session', default='')
-                settings['{}.value'.format(prefix)] = self.get_setting(
-                    settings, f'{prefix}.value', src='session', default='')
-
-    def update_page_settings(self, settings):
-        """
-        Updates a settings dictionary according to pager settings data found in
-        either the GET query string, or session storage.
-
-        Note that due to how the actual pager functions, the effective settings
-        will often come from *both* the request and session.  This is so that
-        e.g. the page size will remain constant (coming from the session) while
-        the user jumps between pages (which only provides the single setting).
-
-        :param settings: Dictionary of initial settings, which is to be updated.
-        """
-        if not self.paginated:
-            return
-
-        pagesize = self.request.GET.get('pagesize')
-        if pagesize is not None:
-            if pagesize.isdigit():
-                settings['pagesize'] = int(pagesize)
-        else:
-            pagesize = self.request.session.get('grid.{}.pagesize'.format(self.key))
-            if pagesize is not None:
-                settings['pagesize'] = pagesize
-
-        page = self.request.GET.get('page')
-        if page is not None:
-            if page.isdigit():
-                settings['page'] = int(page)
-        else:
-            page = self.request.session.get('grid.{}.page'.format(self.key))
-            if page is not None:
-                settings['page'] = int(page)
-
     def persist_settings(self, settings, dest='session'):
         """ """
         if dest not in ('defaults', 'session'):
@@ -1251,89 +1160,12 @@ class Grid(WuttaGrid):
 
         return data
 
-    def sort_data(self, data, sorters=None):
-        """ """
-        if sorters is None:
-            sorters = self.active_sorters
-        if not sorters:
-            return data
-
-        # nb. when data is a query, we want to apply sorters in the
-        # requested order, so the final query has order_by() in the
-        # correct "as-is" sequence.  however when data is a list we
-        # must do the opposite, applying in the reverse order, so the
-        # final list has the most "important" sort(s) applied last.
-        if not isinstance(data, orm.Query):
-            sorters = reversed(sorters)
-
-        for sorter in sorters:
-            sortkey = sorter['key']
-            sortdir = sorter['dir']
-
-            # cannot sort unless we have a sorter callable
-            sortfunc = self.sorters.get(sortkey)
-            if not sortfunc:
-                return data
-
-            # join appropriate model if needed
-            if sortkey in self.joiners and sortkey not in self.joined:
-                data = self.joiners[sortkey](data)
-                self.joined.add(sortkey)
-
-            # invoke the sorter
-            data = sortfunc(data, sortdir)
-
-        return data
-
-    def paginate_data(self, data):
-        """
-        Paginate the given data set according to current settings, and return
-        the result.
-        """
-        # we of course assume our current page is correct, at first
-        pager = self.make_pager(data)
-
-        # if pager has detected that our current page is outside the valid
-        # range, we must re-orient ourself around the "new" (valid) page
-        if pager.page != self.page:
-            self.page = pager.page
-            self.request.session['grid.{}.page'.format(self.key)] = self.page
-            pager = self.make_pager(data)
-
-        return pager
-
-    def make_pager(self, data):
-
-        # TODO: this seems hacky..normally we expect `data` to be a
-        # query of course, but in some cases it may be a list instead.
-        # if so then we can't use ORM pager
-        if isinstance(data, list):
-            import paginate
-            return paginate.Page(data,
-                                 items_per_page=self.pagesize,
-                                 page=self.page)
-
-        return SqlalchemyOrmPage(data,
-                                 items_per_page=self.pagesize,
-                                 page=self.page,
-                                 url_maker=URLMaker(self.request))
-
     def make_visible_data(self):
-        """
-        Apply various settings to the raw data set, to produce a final data
-        set.  This will page / sort / filter as necessary, according to the
-        grid's defaults and the current request etc.
-        """
-        self.joined = set()
-        data = self.data
-        if self.filterable:
-            data = self.filter_data(data)
-        if self.sortable:
-            data = self.sort_data(data)
-        if self.paginated:
-            self.pager = self.paginate_data(data)
-            data = self.pager
-        return data
+        """ """
+        warnings.warn("grid.make_visible_data() method is deprecated; "
+                      "please use grid.get_visible_data() instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.get_visible_data()
 
     def render_vue_tag(self, master=None, **kwargs):
         """ """
@@ -1356,7 +1188,7 @@ class Grid(WuttaGrid):
         includes the context menu items and grid tools.
         """
         if 'grid_columns' not in kwargs:
-            kwargs['grid_columns'] = self.get_table_columns()
+            kwargs['grid_columns'] = self.get_vue_columns()
 
         if 'grid_data' not in kwargs:
             kwargs['grid_data'] = self.get_table_data()
@@ -1379,6 +1211,7 @@ class Grid(WuttaGrid):
         return HTML.literal(html)
 
     def render_buefy(self, **kwargs):
+        """ """
         warnings.warn("Grid.render_buefy() is deprecated; "
                       "please use Grid.render_complete() instead",
                       DeprecationWarning, stacklevel=2)
@@ -1568,23 +1401,19 @@ class Grid(WuttaGrid):
 
     def get_vue_columns(self):
         """ """
-        return self.get_table_columns()
+        columns = super().get_vue_columns()
+
+        for column in columns:
+            column['visible'] = column['field'] not in self.invisible
+
+        return columns
 
     def get_table_columns(self):
-        """
-        Return a list of dicts representing all grid columns.  Meant
-        for use with the client-side JS table.
-        """
-        columns = []
-        for name in self.columns:
-            columns.append({
-                'field': name,
-                'label': self.get_label(name),
-                'sortable': self.is_sortable(name),
-                'searchable': self.is_searchable(name),
-                'visible': name not in self.invisible,
-            })
-        return columns
+        """ """
+        warnings.warn("grid.get_table_columns() method is deprecated; "
+                      "please use grid.get_vue_columns() instead",
+                      DeprecationWarning, stacklevel=2)
+        return self.get_vue_columns()
 
     def get_uuid_for_row(self, rowobj):
 
@@ -1610,7 +1439,7 @@ class Grid(WuttaGrid):
             return self._table_data
 
         # filter / sort / paginate to get "visible" data
-        raw_data = self.make_visible_data()
+        raw_data = self.get_visible_data()
         data = []
         status_map = {}
         checked = []

From b8131c83933f87eef5a05a08e919791233040b58 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 22 Aug 2024 13:49:57 -0500
Subject: [PATCH 19/85] fix: change grid reset-view param name to match
 wuttaweb

---
 tailbone/grids/core.py                 | 2 +-
 tailbone/templates/grids/complete.mako | 2 +-
 tailbone/views/master.py               | 4 ++--
 3 files changed, 4 insertions(+), 4 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 969be50a..e58315d3 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -873,7 +873,7 @@ class Grid(WuttaGrid):
 
         # If request contains instruction to reset to default filters, then we
         # can skip the rest of the request/session checks.
-        if self.request.GET.get('reset-to-default-filters') == 'true':
+        if self.request.GET.get('reset-view'):
             pass
 
         # If request has filter settings, grab those, then grab sort/pager
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index 54ad0527..49758275 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -683,7 +683,7 @@
               this.loading = true
 
               // use current url proper, plus reset param
-              let url = '?reset-to-default-filters=true'
+              let url = '?reset-view=true'
 
               // add current hash, to preserve that in redirect
               if (location.hash) {
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index e4d6c3f6..c53fd8b4 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -335,7 +335,7 @@ class MasterView(View):
 
         # If user just refreshed the page with a reset instruction, issue a
         # redirect in order to clear out the query string.
-        if self.request.GET.get('reset-to-default-filters') == 'true':
+        if self.request.GET.get('reset-view'):
             kw = {'_query': None}
             hash_ = self.request.GET.get('hash')
             if hash_:
@@ -1184,7 +1184,7 @@ class MasterView(View):
 
             # If user just refreshed the page with a reset instruction, issue a
             # redirect in order to clear out the query string.
-            if self.request.GET.get('reset-to-default-filters') == 'true':
+            if self.request.GET.get('reset-view'):
                 kw = {'_query': None}
                 hash_ = self.request.GET.get('hash')
                 if hash_:

From 8d5427e92f9fe272ad1ceb4a6a1b5b0c3cd4ef27 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 22 Aug 2024 14:53:59 -0500
Subject: [PATCH 20/85] =?UTF-8?q?bump:=20version=200.20.1=20=E2=86=92=200.?=
 =?UTF-8?q?21.0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 19 +++++++++++++++++++
 pyproject.toml |  6 +++---
 2 files changed, 22 insertions(+), 3 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4e2b348a..c54d5642 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,25 @@ All notable changes to Tailbone 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.21.0 (2024-08-22)
+
+### Feat
+
+- move "most" filtering logic for grid class to wuttaweb
+- inherit from wuttaweb templates for home, login pages
+- inherit from wuttaweb for AppInfoView, appinfo/configure template
+- add "has output file templates" config option for master view
+
+### Fix
+
+- change grid reset-view param name to match wuttaweb
+- move "searchable columns" grid feature to wuttaweb
+- use wuttaweb to get/render csrf token
+- inherit from wuttaweb for appinfo/index template
+- prefer wuttaweb config for "home redirect to login" feature
+- fix master/index template rendering for waterpark theme
+- fix spacing for navbar logo/title in waterpark theme
+
 ## v0.20.1 (2024-08-20)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 90ecd953..613d3272 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.20.1"
+version = "0.21.0"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@@ -53,13 +53,13 @@ dependencies = [
         "pyramid_mako",
         "pyramid_retry",
         "pyramid_tm",
-        "rattail[db,bouncer]>=0.18.1",
+        "rattail[db,bouncer]>=0.18.4",
         "sa-filters",
         "simplejson",
         "transaction",
         "waitress",
         "WebHelpers2",
-        "WuttaWeb>=0.11.0",
+        "WuttaWeb>=0.12.0",
         "zope.sqlalchemy>=1.5",
 ]
 

From f292850d05c7f83334cd2f4156264112e01a4377 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 22 Aug 2024 14:57:39 -0500
Subject: [PATCH 21/85] test: fix some tests

---
 tests/grids/test_core.py         | 2 +-
 tests/views/wutta/test_people.py | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/tests/grids/test_core.py b/tests/grids/test_core.py
index 5169e599..4d143c85 100644
--- a/tests/grids/test_core.py
+++ b/tests/grids/test_core.py
@@ -135,7 +135,7 @@ class TestGrid(WebTestCase):
 
     def test_set_label(self):
         model = self.app.model
-        grid = self.make_grid(model_class=model.Setting)
+        grid = self.make_grid(model_class=model.Setting, filterable=True)
         self.assertEqual(grid.labels, {})
 
         # basic
diff --git a/tests/views/wutta/test_people.py b/tests/views/wutta/test_people.py
index f178a64f..31aeb501 100644
--- a/tests/views/wutta/test_people.py
+++ b/tests/views/wutta/test_people.py
@@ -38,7 +38,7 @@ class TestPersonView(WebTestCase):
 
     def test_configure_form(self):
         model = self.app.model
-        barney = model.User(username='barney')
+        barney = model.Person(display_name="Barney Rubble")
         self.session.add(barney)
         self.session.commit()
         view = self.make_view()

From 7b40c527c860e95be4dd74e09b2344b672110d98 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 22 Aug 2024 15:14:11 -0500
Subject: [PATCH 22/85] fix: misc. bugfixes per recent changes

---
 tailbone/grids/core.py  | 23 +++++++++--------------
 tailbone/views/email.py | 11 +++++------
 2 files changed, 14 insertions(+), 20 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index e58315d3..754868bc 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -260,6 +260,9 @@ class Grid(WuttaGrid):
         # reference grid.vue_component etc.
         kwargs.setdefault('vue_tagname', 'tailbone-grid')
 
+        # nb. these must be set before super init, as they are
+        # referenced when constructing filters
+        self.assume_local_times = assume_local_times
         self.use_byte_string_filters = use_byte_string_filters
 
         kwargs['key'] = key
@@ -279,7 +282,6 @@ class Grid(WuttaGrid):
 
         self.width = width
         self.enums = enums or {}
-        self.assume_local_times = assume_local_times
         self.renderers = self.make_default_renderers(self.renderers)
         self.raw_renderers = raw_renderers or {}
         self.invisible = invisible or []
@@ -476,25 +478,18 @@ class Grid(WuttaGrid):
 
     def set_filter(self, key, *args, **kwargs):
         """ """
-
         if len(args) == 1:
             if args[0] is None:
                 warnings.warn("specifying None is deprecated for Grid.set_filter(); "
                               "please use Grid.remove_filter() instead",
                               DeprecationWarning, stacklevel=2)
                 self.remove_filter(key)
-            else:
-                super().set_filter(key, args[0], **kwargs)
+                return
 
-        elif len(args) == 0:
-            super().set_filter(key, **kwargs)
-
-        else:
-            warnings.warn("multiple args are deprecated for Grid.set_filter(); "
-                          "please refactor your code accordingly",
-                          DeprecationWarning, stacklevel=2)
-            kwargs.setdefault('label', self.get_label(key))
-            self.filters[key] = self.make_filter(key, *args, **kwargs)
+        # TODO: our make_filter() signature differs from upstream,
+        # so must call it explicitly instead of delegating to super
+        kwargs.setdefault('label', self.get_label(key))
+        self.filters[key] = self.make_filter(key, *args, **kwargs)
 
     def set_click_handler(self, key, handler):
         if handler:
@@ -1230,7 +1225,7 @@ class Grid(WuttaGrid):
         context['data_prop'] = data_prop
         context['empty_labels'] = empty_labels
         if 'grid_columns' not in context:
-            context['grid_columns'] = self.get_table_columns()
+            context['grid_columns'] = self.get_vue_columns()
         context.setdefault('paginated', False)
         if context['paginated']:
             context.setdefault('per_page', 20)
diff --git a/tailbone/views/email.py b/tailbone/views/email.py
index a99e8553..98bd4295 100644
--- a/tailbone/views/email.py
+++ b/tailbone/views/email.py
@@ -116,11 +116,12 @@ class EmailSettingView(MasterView):
         return data
 
     def configure_grid(self, g):
-        g.sorters['key'] = g.make_simple_sorter('key', foldcase=True)
-        g.sorters['prefix'] = g.make_simple_sorter('prefix', foldcase=True)
-        g.sorters['subject'] = g.make_simple_sorter('subject', foldcase=True)
-        g.sorters['enabled'] = g.make_simple_sorter('enabled')
+        super().configure_grid(g)
+
+        g.sort_on_backend = False
+        g.sort_multiple = False
         g.set_sort_defaults('key')
+
         g.set_type('enabled', 'boolean')
         g.set_link('key')
         g.set_link('subject')
@@ -130,11 +131,9 @@ class EmailSettingView(MasterView):
 
         # to
         g.set_renderer('to', self.render_to_short)
-        g.sorters['to'] = g.make_simple_sorter('to', foldcase=True)
 
         # hidden
         if self.has_perm('configure'):
-            g.sorters['hidden'] = g.make_simple_sorter('hidden')
             g.set_type('hidden', 'boolean')
         else:
             g.remove('hidden')

From 7d6f75bb05bbbe2345e0f220f9c7a536c8f119e3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 22 Aug 2024 15:33:28 -0500
Subject: [PATCH 23/85] =?UTF-8?q?bump:=20version=200.21.0=20=E2=86=92=200.?=
 =?UTF-8?q?21.1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 6 ++++++
 pyproject.toml | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index c54d5642..3bcbc6ec 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to Tailbone 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.21.1 (2024-08-22)
+
+### Fix
+
+- misc. bugfixes per recent changes
+
 ## v0.21.0 (2024-08-22)
 
 ### Feat
diff --git a/pyproject.toml b/pyproject.toml
index 613d3272..2db880ad 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.21.0"
+version = "0.21.1"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From c176d978701648904c1cd00725cf9057fafbe26e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 22 Aug 2024 15:54:15 -0500
Subject: [PATCH 24/85] fix: avoid deprecated `component` form kwarg

---
 tailbone/views/batch/core.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py
index 5dd7b548..8ee3a37d 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.py
@@ -861,7 +861,7 @@ class BatchMasterView(MasterView):
         if not schema:
             schema = colander.Schema()
 
-        kwargs['component'] = 'execute-form'
+        kwargs['vue_tagname'] = 'execute-form'
         form = forms.Form(schema=schema, request=self.request, defaults=defaults, **kwargs)
         self.configure_execute_form(form)
         return form

From 4c3e3aeb6a70ae45eb16a90cc53c1af336e6d083 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 22 Aug 2024 17:09:58 -0500
Subject: [PATCH 25/85] fix: various fixes for waterpark theme

---
 tailbone/templates/base.mako                  |  2 +-
 tailbone/templates/themes/waterpark/base.mako | 83 +++++++++++++++++++
 tailbone/templates/themes/waterpark/form.mako |  8 ++
 3 files changed, 92 insertions(+), 1 deletion(-)

diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index eb950011..c01b3b37 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -668,7 +668,7 @@
                            text="Edit This">
               </once-button>
           % endif
-          % if getattr(master, 'cloneable', False) and master.has_perm('clone'):
+          % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'):
               <once-button tag="a" href="${master.get_action_url('clone', instance)}"
                            icon-left="object-ungroup"
                            text="Clone This">
diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako
index 878090dc..520e18ce 100644
--- a/tailbone/templates/themes/waterpark/base.mako
+++ b/tailbone/templates/themes/waterpark/base.mako
@@ -7,6 +7,7 @@
 
 <%def name="base_styles()">
   ${parent.base_styles()}
+  ${h.stylesheet_link(request.static_url('tailbone:static/css/diffs.css') + '?ver={}'.format(tailbone.__version__))}
   <style>
 
     .filters .filter-fieldname .field,
@@ -171,6 +172,88 @@
   % endif
 </%def>
 
+<%def name="render_crud_header_buttons()">
+  % if master:
+      % if master.viewing:
+          % if instance_editable and master.has_perm('edit'):
+              <wutta-button once
+                            tag="a" href="${master.get_action_url('edit', instance)}"
+                            icon-left="edit"
+                            label="Edit This" />
+          % endif
+          % if getattr(master, 'cloneable', False) and not master.cloning and master.has_perm('clone'):
+              <wutta-button once
+                            tag="a" href="${master.get_action_url('clone', instance)}"
+                            icon-left="object-ungroup"
+                            label="Clone This" />
+          % endif
+          % if instance_deletable and master.has_perm('delete'):
+              <wutta-button once type="is-danger"
+                            tag="a" href="${master.get_action_url('delete', instance)}"
+                            icon-left="trash"
+                            label="Delete This" />
+          % endif
+      % elif master.editing:
+          % if master.has_perm('view'):
+              <wutta-button once
+                            tag="a" href="${master.get_action_url('view', instance)}"
+                            icon-left="eye"
+                            label="View This" />
+          % endif
+          % if instance_deletable and master.has_perm('delete'):
+              <wutta-button once type="is-danger"
+                            tag="a" href="${master.get_action_url('delete', instance)}"
+                            icon-left="trash"
+                            label="Delete This" />
+          % endif
+      % elif master.deleting:
+          % if master.has_perm('view'):
+              <wutta-button once
+                            tag="a" href="${master.get_action_url('view', instance)}"
+                            icon-left="eye"
+                            label="View This" />
+          % endif
+          % if instance_editable and master.has_perm('edit'):
+              <wutta-button once
+                            tag="a" href="${master.get_action_url('edit', instance)}"
+                            icon-left="edit"
+                            label="Edit This" />
+          % endif
+      % endif
+  % endif
+</%def>
+
+<%def name="render_prevnext_header_buttons()">
+  % if show_prev_next is not Undefined and show_prev_next:
+      % if prev_url:
+          <wutta-button once
+                        tag="a" href="${prev_url}"
+                        icon-left="arrow-left"
+                        label="Older" />
+      % else:
+          <b-button tag="a" href="#"
+                    disabled
+                    icon-pack="fas"
+                    icon-left="arrow-left">
+            Older
+          </b-button>
+      % endif
+      % if next_url:
+          <wutta-button once
+                        tag="a" href="${next_url}"
+                        icon-left="arrow-right"
+                        label="Newer" />
+      % else:
+          <b-button tag="a" href="#"
+                    disabled
+                    icon-pack="fas"
+                    icon-left="arrow-right">
+            Newer
+          </b-button>
+      % endif
+  % endif
+</%def>
+
 <%def name="render_this_page_component()">
   <this-page @change-content-title="changeContentTitle"
              % if can_edit_help:
diff --git a/tailbone/templates/themes/waterpark/form.mako b/tailbone/templates/themes/waterpark/form.mako
index cf1ddb8a..f88d6821 100644
--- a/tailbone/templates/themes/waterpark/form.mako
+++ b/tailbone/templates/themes/waterpark/form.mako
@@ -1,2 +1,10 @@
 ## -*- coding: utf-8; -*-
 <%inherit file="wuttaweb:templates/form.mako" />
+
+<%def name="render_vue_template_form()">
+  % if form is not Undefined:
+      ${form.render_vue_template(buttons=capture(self.render_form_buttons))}
+  % endif
+</%def>
+
+<%def name="render_form_buttons()"></%def>

From 29531c83c4b785e2ef7b5c4006bd4c86c7b5f045 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 22 Aug 2024 19:21:48 -0500
Subject: [PATCH 26/85] fix: some fixes for wutta people view

---
 tailbone/grids/core.py         | 35 +++++++++++++++++++++++++---------
 tailbone/views/master.py       |  6 ++++--
 tailbone/views/wutta/people.py | 12 +++++++++++-
 3 files changed, 41 insertions(+), 12 deletions(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 754868bc..afd6e11b 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -24,9 +24,10 @@
 Core Grid Classes
 """
 
-from urllib.parse import urlencode
-import warnings
+import inspect
 import logging
+import warnings
+from urllib.parse import urlencode
 
 import sqlalchemy as sa
 from sqlalchemy import orm
@@ -858,9 +859,13 @@ class Grid(WuttaGrid):
             settings['page'] = self.page
         if self.filterable:
             for filtr in self.iter_filters():
-                settings['filter.{}.active'.format(filtr.key)] = filtr.default_active
-                settings['filter.{}.verb'.format(filtr.key)] = filtr.default_verb
-                settings['filter.{}.value'.format(filtr.key)] = filtr.default_value
+                defaults = self.filter_defaults.get(filtr.key, {})
+                settings[f'filter.{filtr.key}.active'] = defaults.get('active',
+                                                                      filtr.default_active)
+                settings[f'filter.{filtr.key}.verb'] = defaults.get('verb',
+                                                                    filtr.default_verb)
+                settings[f'filter.{filtr.key}.value'] = defaults.get('value',
+                                                                     filtr.default_value)
 
         # If user has default settings on file, apply those first.
         if self.user_has_defaults():
@@ -1239,7 +1244,7 @@ class Grid(WuttaGrid):
         view = None
         for action in self.actions:
             if action.key == 'view':
-                return action.click_handler
+                return getattr(action, 'click_handler', None)
 
     def set_filters_sequence(self, filters, only=False):
         """
@@ -1475,10 +1480,22 @@ class Grid(WuttaGrid):
 
                 # leverage configured rendering logic where applicable;
                 # otherwise use "raw" data value as string
+                value = self.obtain_value(rowobj, name)
                 if self.renderers and name in self.renderers:
-                    value = self.renderers[name](rowobj, name)
-                else:
-                    value = self.obtain_value(rowobj, name)
+                    renderer = self.renderers[name]
+
+                    # TODO: legacy renderer callables require 2 args,
+                    # but wuttaweb callables require 3 args
+                    sig = inspect.signature(renderer)
+                    required = [param for param in sig.parameters.values()
+                                if param.default == param.empty]
+
+                    if len(required) == 2:
+                        # TODO: legacy renderer
+                        value = renderer(rowobj, name)
+                    else: # the future
+                        value = renderer(rowobj, name, value)
+
                 if value is None:
                     value = ""
 
diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index c53fd8b4..1028ff27 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -612,7 +612,9 @@ class MasterView(View):
 
             # delete action
             if self.rows_deletable and self.has_perm('delete_row'):
-                actions.append(self.make_action('delete', icon='trash', url=self.row_delete_action_url))
+                actions.append(self.make_action('delete', icon='trash',
+                                                url=self.row_delete_action_url,
+                                                link_class='has-text-danger'))
                 defaults['delete_speedbump'] = self.rows_deletable_speedbump
 
             defaults['actions'] = actions
@@ -3322,7 +3324,7 @@ class MasterView(View):
                                 url=self.default_clone_url)
 
     def make_grid_action_delete(self):
-        kwargs = {}
+        kwargs = {'link_class': 'has-text-danger'}
         if self.delete_confirm == 'simple':
             kwargs['click_handler'] = 'deleteObject'
         return self.make_action('delete', icon='trash', url=self.default_delete_url, **kwargs)
diff --git a/tailbone/views/wutta/people.py b/tailbone/views/wutta/people.py
index 968eaf3d..bd96bd4d 100644
--- a/tailbone/views/wutta/people.py
+++ b/tailbone/views/wutta/people.py
@@ -32,6 +32,7 @@ from wuttaweb.views import people as wutta
 from tailbone.views import people as tailbone
 from tailbone.db import Session
 from rattail.db.model import Person
+from tailbone.grids import Grid
 
 
 class PersonView(wutta.PersonView):
@@ -44,7 +45,6 @@ class PersonView(wutta.PersonView):
     """
     model_class = Person
     Session = Session
-    sort_defaults = 'display_name'
 
     labels = {
         'display_name': "Full Name",
@@ -59,6 +59,11 @@ class PersonView(wutta.PersonView):
         'merge_requested',
     ]
 
+    filter_defaults = {
+        'display_name': {'active': True, 'verb': 'contains'},
+    }
+    sort_defaults = 'display_name'
+
     form_fields = [
         'first_name',
         'middle_name',
@@ -74,6 +79,11 @@ class PersonView(wutta.PersonView):
     # CRUD methods
     ##############################
 
+    # TODO: must use older grid for now, to render filters correctly
+    def make_grid(self, **kwargs):
+        """ """
+        return Grid(self.request, **kwargs)
+
     def configure_grid(self, g):
         """ """
         super().configure_grid(g)

From cea3e4b927eab7114dd0548d6216df8c33dd37a4 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 22 Aug 2024 19:40:21 -0500
Subject: [PATCH 27/85] fix: add basic wutta view for users

just proving concepts still at this point..nothing reliable
---
 tailbone/templates/base.mako  |  6 +++-
 tailbone/views/users.py       |  6 +++-
 tailbone/views/wutta/users.py | 57 +++++++++++++++++++++++++++++++++++
 3 files changed, 67 insertions(+), 2 deletions(-)
 create mode 100644 tailbone/views/wutta/users.py

diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index c01b3b37..86b1ba1d 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -642,7 +642,11 @@
           % if request.is_root or not request.user.prevent_password_change:
               ${h.link_to("Change Password", url('change_password'), class_='navbar-item')}
           % endif
-          ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')}
+          % try:
+              ## nb. does not exist yet for wuttaweb
+              ${h.link_to("Edit Preferences", url('my.preferences'), class_='navbar-item')}
+          % except:
+          % endtry
           ${h.link_to("Logout", url('logout'), class_='navbar-item')}
         </div>
       </div>
diff --git a/tailbone/views/users.py b/tailbone/views/users.py
index 9b533efe..dfed0a11 100644
--- a/tailbone/views/users.py
+++ b/tailbone/views/users.py
@@ -801,4 +801,8 @@ def defaults(config, **kwargs):
 
 
 def includeme(config):
-    defaults(config)
+    wutta_config = config.registry.settings['wutta_config']
+    if wutta_config.get_bool('tailbone.use_wutta_views', default=False, usedb=False):
+        config.include('tailbone.views.wutta.users')
+    else:
+        defaults(config)
diff --git a/tailbone/views/wutta/users.py b/tailbone/views/wutta/users.py
new file mode 100644
index 00000000..3c3f8d52
--- /dev/null
+++ b/tailbone/views/wutta/users.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8; -*-
+################################################################################
+#
+#  Rattail -- Retail Software Framework
+#  Copyright © 2010-2024 Lance Edgar
+#
+#  This file is part of Rattail.
+#
+#  Rattail 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.
+#
+#  Rattail 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
+#  Rattail.  If not, see <http://www.gnu.org/licenses/>.
+#
+################################################################################
+"""
+User Views
+"""
+
+from wuttaweb.views import users as wutta
+from tailbone.views import users as tailbone
+from tailbone.db import Session
+from rattail.db.model import User
+from tailbone.grids import Grid
+
+
+class UserView(wutta.UserView):
+    """
+    This is the first attempt at blending newer Wutta views with
+    legacy Tailbone config.
+
+    So, this is a Wutta-based view but it should be included by a
+    Tailbone app configurator.
+    """
+    model_class = User
+    Session = Session
+
+    # TODO: must use older grid for now, to render filters correctly
+    def make_grid(self, **kwargs):
+        """ """
+        return Grid(self.request, **kwargs)
+
+
+def defaults(config, **kwargs):
+    kwargs.setdefault('UserView', UserView)
+    tailbone.defaults(config, **kwargs)
+
+
+def includeme(config):
+    defaults(config)

From 37f760959d277c2fe158c500c65684fb5af49102 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 22 Aug 2024 19:58:27 -0500
Subject: [PATCH 28/85] fix: merge filters into main grid template

to better match wuttaweb
---
 tailbone/grids/core.py                 | 22 ---------
 tailbone/templates/grids/complete.mako | 66 ++++++++++++++++++++++++-
 tailbone/templates/grids/filters.mako  | 67 --------------------------
 3 files changed, 64 insertions(+), 91 deletions(-)
 delete mode 100644 tailbone/templates/grids/filters.mako

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index afd6e11b..12e45aec 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -1318,28 +1318,6 @@ class Grid(WuttaGrid):
 
         return data
 
-    def render_filters(self, template='/grids/filters.mako', **kwargs):
-        """
-        Render the filters to a Unicode string, using the specified template.
-        Additional kwargs are passed along as context to the template.
-        """
-        # Provide default data to filters form, so renderer can do some of the
-        # work for us.
-        data = {}
-        for filtr in self.iter_active_filters():
-            data['{}.active'.format(filtr.key)] = filtr.active
-            data['{}.verb'.format(filtr.key)] = filtr.verb
-            data[filtr.key] = filtr.value
-
-        form = gridfilters.GridFiltersForm(self.filters,
-                                           request=self.request,
-                                           defaults=data)
-
-        kwargs['request'] = self.request
-        kwargs['grid'] = self
-        kwargs['form'] = form
-        return render(template, kwargs)
-
     def render_actions(self, row, i): # pragma: no cover
         """ """
         warnings.warn("grid.render_actions() is deprecated!",
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index 49758275..f5d1da95 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -10,8 +10,70 @@
       <div style="display: flex; flex-direction: column; justify-content: end;">
         <div class="filters">
           % if getattr(grid, 'filterable', False):
-              ## TODO: stop using |n filter
-              ${grid.render_filters(allow_save_defaults=allow_save_defaults)|n}
+              <form method="GET" @submit.prevent="applyFilters()">
+
+                <div style="display: flex; flex-direction: column; gap: 0.5rem;">
+                  <grid-filter v-for="key in filtersSequence"
+                               :key="key"
+                               :filter="filters[key]"
+                               ref="gridFilters">
+                  </grid-filter>
+                </div>
+
+                <div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
+
+                  <b-button type="is-primary"
+                            native-type="submit"
+                            icon-pack="fas"
+                            icon-left="check">
+                    Apply Filters
+                  </b-button>
+
+                  <b-button v-if="!addFilterShow"
+                            icon-pack="fas"
+                            icon-left="plus"
+                            @click="addFilterInit()">
+                    Add Filter
+                  </b-button>
+
+                  <b-autocomplete v-if="addFilterShow"
+                                  ref="addFilterAutocomplete"
+                                  :data="addFilterChoices"
+                                  v-model="addFilterTerm"
+                                  placeholder="Add Filter"
+                                  field="key"
+                                  :custom-formatter="formatAddFilterItem"
+                                  open-on-focus
+                                  keep-first
+                                  icon-pack="fas"
+                                  clearable
+                                  clear-on-select
+                                  @select="addFilterSelect">
+                  </b-autocomplete>
+
+                  <b-button @click="resetView()"
+                            icon-pack="fas"
+                            icon-left="home">
+                    Default View
+                  </b-button>
+
+                  <b-button @click="clearFilters()"
+                            icon-pack="fas"
+                            icon-left="trash">
+                    No Filters
+                  </b-button>
+
+                  % if allow_save_defaults and request.user:
+                      <b-button @click="saveDefaults()"
+                                icon-pack="fas"
+                                icon-left="save"
+                                :disabled="savingDefaults">
+                        {{ savingDefaults ? "Working, please wait..." : "Save Defaults" }}
+                      </b-button>
+                  % endif
+
+                </div>
+              </form>
           % endif
         </div>
       </div>
diff --git a/tailbone/templates/grids/filters.mako b/tailbone/templates/grids/filters.mako
deleted file mode 100644
index 9a80b911..00000000
--- a/tailbone/templates/grids/filters.mako
+++ /dev/null
@@ -1,67 +0,0 @@
-## -*- coding: utf-8; -*-
-
-<form action="${form.action_url}" method="GET" @submit.prevent="applyFilters()">
-
-  <div style="display: flex; flex-direction: column; gap: 0.5rem;">
-    <grid-filter v-for="key in filtersSequence"
-                 :key="key"
-                 :filter="filters[key]"
-                 ref="gridFilters">
-    </grid-filter>
-  </div>
-
-  <div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
-
-    <b-button type="is-primary"
-              native-type="submit"
-              icon-pack="fas"
-              icon-left="check">
-      Apply Filters
-    </b-button>
-
-    <b-button v-if="!addFilterShow"
-              icon-pack="fas"
-              icon-left="plus"
-              @click="addFilterInit()">
-      Add Filter
-    </b-button>
-
-    <b-autocomplete v-if="addFilterShow"
-                    ref="addFilterAutocomplete"
-                    :data="addFilterChoices"
-                    v-model="addFilterTerm"
-                    placeholder="Add Filter"
-                    field="key"
-                    :custom-formatter="formatAddFilterItem"
-                    open-on-focus
-                    keep-first
-                    icon-pack="fas"
-                    clearable
-                    clear-on-select
-                    @select="addFilterSelect">
-    </b-autocomplete>
-
-    <b-button @click="resetView()"
-              icon-pack="fas"
-              icon-left="home">
-      Default View
-    </b-button>
-
-    <b-button @click="clearFilters()"
-              icon-pack="fas"
-              icon-left="trash">
-      No Filters
-    </b-button>
-
-    % if allow_save_defaults and request.user:
-        <b-button @click="saveDefaults()"
-                  icon-pack="fas"
-                  icon-left="save"
-                  :disabled="savingDefaults">
-          {{ savingDefaults ? "Working, please wait..." : "Save Defaults" }}
-        </b-button>
-    % endif
-
-  </div>
-
-</form>

From c1a2c9cc70b36044fb7a82bedf3d5cd59f5cd487 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 23 Aug 2024 14:14:03 -0500
Subject: [PATCH 29/85] fix: tweak how grid data translates to Vue template
 context

per wuttaweb changes
---
 tailbone/grids/core.py                 | 6 ++++++
 tailbone/templates/grids/complete.mako | 3 ++-
 2 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 12e45aec..ecf462fd 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -1403,6 +1403,10 @@ class Grid(WuttaGrid):
         if hasattr(rowobj, 'uuid'):
             return rowobj.uuid
 
+    def get_vue_context(self):
+        """ """
+        return self.get_table_data()
+
     def get_vue_data(self):
         """ """
         table_data = self.get_table_data()
@@ -1506,6 +1510,8 @@ class Grid(WuttaGrid):
 
         results = {
             'data': data,
+            'row_classes': status_map,
+            # TODO: deprecate / remove this
             'row_status_map': status_map,
         }
 
diff --git a/tailbone/templates/grids/complete.mako b/tailbone/templates/grids/complete.mako
index f5d1da95..60f9a3b8 100644
--- a/tailbone/templates/grids/complete.mako
+++ b/tailbone/templates/grids/complete.mako
@@ -311,7 +311,8 @@
 
 <script type="text/javascript">
 
-  let ${grid.vue_component}CurrentData = ${json.dumps(grid.get_vue_data())|n}
+  const ${grid.vue_component}Context = ${json.dumps(grid.get_vue_context())|n}
+  let ${grid.vue_component}CurrentData = ${grid.vue_component}Context.data
 
   let ${grid.vue_component}Data = {
       loading: false,

From b7991b5dc61ff40e268f69be269adacb931519a0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 23 Aug 2024 16:18:17 -0500
Subject: [PATCH 30/85] fix: fix input/output file upload feature for configure
 pages, per oruga

---
 tailbone/templates/configure.mako | 170 ++++++++++++++++++------------
 1 file changed, 101 insertions(+), 69 deletions(-)

diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako
index 6d9c2261..463d48b1 100644
--- a/tailbone/templates/configure.mako
+++ b/tailbone/templates/configure.mako
@@ -92,7 +92,7 @@
         <b-select name="${tmpl['setting_file']}"
                   v-model="inputFileTemplateSettings['${tmpl['setting_file']}']"
                   @input="settingsNeedSaved = true">
-          <option :value="null">-new-</option>
+          <option value="">-new-</option>
           <option v-for="option in inputFileTemplateFileOptions['${tmpl['key']}']"
                   :key="option"
                   :value="option">
@@ -104,22 +104,40 @@
       <b-field label="Upload"
                v-show="inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !inputFileTemplateSettings['${tmpl['setting_file']}']">
 
-        <b-field class="file is-primary"
-                 :class="{'has-name': !!inputFileTemplateSettings['${tmpl['setting_file']}']}">
-          <b-upload name="${tmpl['setting_file']}.upload"
-                    v-model="inputFileTemplateUploads['${tmpl['key']}']"
-                    class="file-label"
-                    @input="settingsNeedSaved = true">
-            <span class="file-cta">
-              <b-icon class="file-icon" pack="fas" icon="upload"></b-icon>
-              <span class="file-label">Click to upload</span>
-            </span>
-          </b-upload>
-          <span v-if="inputFileTemplateUploads['${tmpl['key']}']"
-                class="file-name">
-            {{ inputFileTemplateUploads['${tmpl['key']}'].name }}
-          </span>
-        </b-field>
+        % if request.use_oruga:
+            <o-field class="file">
+              <o-upload name="${tmpl['setting_file']}.upload"
+                        v-model="inputFileTemplateUploads['${tmpl['key']}']"
+                        v-slot="{ onclick }"
+                        @input="settingsNeedSaved = true">
+                <o-button variant="primary"
+                          @click="onclick">
+                  <o-icon icon="upload" />
+                  <span>Click to upload</span>
+                </o-button>
+                <span class="file-name" v-if="inputFileTemplateUploads['${tmpl['key']}']">
+                  {{ inputFileTemplateUploads['${tmpl['key']}'].name }}
+                </span>
+              </o-upload>
+            </o-field>
+        % else:
+            <b-field class="file is-primary"
+                     :class="{'has-name': !!inputFileTemplateSettings['${tmpl['setting_file']}']}">
+              <b-upload name="${tmpl['setting_file']}.upload"
+                        v-model="inputFileTemplateUploads['${tmpl['key']}']"
+                        class="file-label"
+                        @input="settingsNeedSaved = true">
+                <span class="file-cta">
+                  <b-icon class="file-icon" pack="fas" icon="upload"></b-icon>
+                  <span class="file-label">Click to upload</span>
+                </span>
+              </b-upload>
+              <span v-if="inputFileTemplateUploads['${tmpl['key']}']"
+                    class="file-name">
+                {{ inputFileTemplateUploads['${tmpl['key']}'].name }}
+              </span>
+            </b-field>
+        % endif
 
       </b-field>
 
@@ -162,7 +180,7 @@
         <b-select name="${tmpl['setting_file']}"
                   v-model="outputFileTemplateSettings['${tmpl['setting_file']}']"
                   @input="settingsNeedSaved = true">
-          <option :value="null">-new-</option>
+          <option value="">-new-</option>
           <option v-for="option in outputFileTemplateFileOptions['${tmpl['key']}']"
                   :key="option"
                   :value="option">
@@ -174,23 +192,40 @@
       <b-field label="Upload"
                v-show="outputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted' && !outputFileTemplateSettings['${tmpl['setting_file']}']">
 
-        <b-field class="file is-primary"
-                 :class="{'has-name': !!outputFileTemplateSettings['${tmpl['setting_file']}']}">
-          <b-upload name="${tmpl['setting_file']}.upload"
-                    v-model="outputFileTemplateUploads['${tmpl['key']}']"
-                    class="file-label"
-                    @input="settingsNeedSaved = true">
-            <span class="file-cta">
-              <b-icon class="file-icon" pack="fas" icon="upload"></b-icon>
-              <span class="file-label">Click to upload</span>
-            </span>
-          </b-upload>
-          <span v-if="outputFileTemplateUploads['${tmpl['key']}']"
-                class="file-name">
-            {{ outputFileTemplateUploads['${tmpl['key']}'].name }}
-          </span>
-        </b-field>
-
+        % if request.use_oruga:
+            <o-field class="file">
+              <o-upload name="${tmpl['setting_file']}.upload"
+                        v-model="outputFileTemplateUploads['${tmpl['key']}']"
+                        v-slot="{ onclick }"
+                        @input="settingsNeedSaved = true">
+                <o-button variant="primary"
+                          @click="onclick">
+                  <o-icon icon="upload" />
+                  <span>Click to upload</span>
+                </o-button>
+                <span class="file-name" v-if="outputFileTemplateUploads['${tmpl['key']}']">
+                  {{ outputFileTemplateUploads['${tmpl['key']}'].name }}
+                </span>
+              </o-upload>
+            </o-field>
+        % else:
+            <b-field class="file is-primary"
+                     :class="{'has-name': !!outputFileTemplateSettings['${tmpl['setting_file']}']}">
+              <b-upload name="${tmpl['setting_file']}.upload"
+                        v-model="outputFileTemplateUploads['${tmpl['key']}']"
+                        class="file-label"
+                        @input="settingsNeedSaved = true">
+                <span class="file-cta">
+                  <b-icon class="file-icon" pack="fas" icon="upload"></b-icon>
+                  <span class="file-label">Click to upload</span>
+                </span>
+              </b-upload>
+              <span v-if="outputFileTemplateUploads['${tmpl['key']}']"
+                    class="file-name">
+                {{ outputFileTemplateUploads['${tmpl['key']}'].name }}
+              </span>
+            </b-field>
+        % endif
       </b-field>
 
     </b-field>
@@ -275,16 +310,6 @@
         ThisPageData.simpleSettings = ${json.dumps(simple_settings)|n}
     % endif
 
-    % if input_file_template_settings is not Undefined:
-        ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n}
-        ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n}
-        ThisPageData.inputFileTemplateUploads = {
-            % for key in input_file_templates:
-                '${key}': null,
-            % endfor
-        }
-    % endif
-
     ThisPageData.purgeSettingsShowDialog = false
     ThisPageData.purgingSettings = false
 
@@ -297,30 +322,7 @@
         this.purgeSettingsShowDialog = true
     }
 
-    % if input_file_template_settings is not Undefined:
-        ThisPage.methods.validateInputFileTemplateSettings = function() {
-            % for tmpl in input_file_templates.values():
-                if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
-                    if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) {
-                        if (!this.inputFileTemplateUploads['${tmpl['key']}']) {
-                            return "You must provide a file to upload for the ${tmpl['label']} template."
-                        }
-                    }
-                }
-            % endfor
-        }
-    % endif
-
-    ThisPage.methods.validateSettings = function() {
-        let msg
-
-        % if input_file_template_settings is not Undefined:
-            msg = this.validateInputFileTemplateSettings()
-            if (msg) {
-                return msg
-            }
-        % endif
-    }
+    ThisPage.methods.validateSettings = function() {}
 
     ThisPage.methods.saveSettings = function() {
         let msg
@@ -366,6 +368,36 @@
         window.addEventListener('beforeunload', this.beforeWindowUnload)
     }
 
+    ##############################
+    ## input file templates
+    ##############################
+
+    % if input_file_template_settings is not Undefined:
+
+        ThisPageData.inputFileTemplateSettings = ${json.dumps(input_file_template_settings)|n}
+        ThisPageData.inputFileTemplateFileOptions = ${json.dumps(input_file_options)|n}
+        ThisPageData.inputFileTemplateUploads = {
+            % for key in input_file_templates:
+                '${key}': null,
+            % endfor
+        }
+
+        ThisPage.methods.validateInputFileTemplateSettings = function() {
+            % for tmpl in input_file_templates.values():
+                if (this.inputFileTemplateSettings['${tmpl['setting_mode']}'] == 'hosted') {
+                    if (!this.inputFileTemplateSettings['${tmpl['setting_file']}']) {
+                        if (!this.inputFileTemplateUploads['${tmpl['key']}']) {
+                            return "You must provide a file to upload for the ${tmpl['label']} template."
+                        }
+                    }
+                }
+            % endfor
+        }
+
+        ThisPageData.validators.push(ThisPage.methods.validateInputFileTemplateSettings)
+
+    % endif
+
     ##############################
     ## output file templates
     ##############################

From d1f4c0f150f51b1fde0bdbdffa5a11d489f4ec9a Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 26 Aug 2024 14:54:45 -0500
Subject: [PATCH 31/85] fix: refactor waterpark base template to use wutta
 feedback component

although for now we still provide the template and add reply-to
---
 tailbone/templates/themes/waterpark/base.mako | 277 +++++++-----------
 1 file changed, 105 insertions(+), 172 deletions(-)

diff --git a/tailbone/templates/themes/waterpark/base.mako b/tailbone/templates/themes/waterpark/base.mako
index 520e18ce..774479ba 100644
--- a/tailbone/templates/themes/waterpark/base.mako
+++ b/tailbone/templates/themes/waterpark/base.mako
@@ -164,12 +164,7 @@
       />
   </div>
 
-  % if request.has_perm('common.feedback'):
-      <feedback-form
-         action="${url('feedback')}"
-         :message="feedbackMessage">
-      </feedback-form>
-  % endif
+  ${parent.render_feedback_button()}
 </%def>
 
 <%def name="render_crud_header_buttons()">
@@ -262,174 +257,133 @@
              />
 </%def>
 
-<%def name="render_vue_templates()">
-  ${parent.render_vue_templates()}
+<%def name="render_vue_template_feedback()">
+  <script type="text/x-template" id="feedback-template">
+    <div>
 
-  ${page_help.render_template()}
-  ${page_help.declare_vars()}
+      <div class="level-item">
+        <b-button type="is-primary"
+                  @click="showFeedback()"
+                  icon-pack="fas"
+                  icon-left="comment">
+          Feedback
+        </b-button>
+      </div>
 
-  % if request.has_perm('common.feedback'):
-      <script type="text/x-template" id="feedback-template">
-        <div>
+      <b-modal has-modal-card
+               :active.sync="showDialog">
+        <div class="modal-card">
 
-          <div class="level-item">
-            <b-button type="is-primary"
-                      @click="showFeedback()"
-                      icon-pack="fas"
-                      icon-left="comment">
-              Feedback
-            </b-button>
-          </div>
+          <header class="modal-card-head">
+            <p class="modal-card-title">User Feedback</p>
+          </header>
 
-          <b-modal has-modal-card
-                   :active.sync="showDialog">
-            <div class="modal-card">
+          <section class="modal-card-body">
+            <p class="block">
+              Questions, suggestions, comments, complaints, etc.
+              <span class="red">regarding this website</span> are
+              welcome and may be submitted below.
+            </p>
 
-              <header class="modal-card-head">
-                <p class="modal-card-title">User Feedback</p>
-              </header>
+            <b-field label="User Name">
+              <b-input v-model="userName"
+                       % if request.user:
+                           disabled
+                       % endif
+                       >
+              </b-input>
+            </b-field>
 
-              <section class="modal-card-body">
-                <p class="block">
-                  Questions, suggestions, comments, complaints, etc.
-                  <span class="red">regarding this website</span> are
-                  welcome and may be submitted below.
-                </p>
+            <b-field label="Referring URL">
+              <b-input
+                 v-model="referrer"
+                 disabled="true">
+              </b-input>
+            </b-field>
 
-                <b-field label="User Name">
-                  <b-input v-model="userName"
-                           % if request.user:
-                               disabled
-                           % endif
-                           >
-                  </b-input>
-                </b-field>
+            <b-field label="Message">
+              <b-input type="textarea"
+                       v-model="message"
+                       ref="textarea">
+              </b-input>
+            </b-field>
 
-                <b-field label="Referring URL">
-                  <b-input
-                     v-model="referrer"
-                     disabled="true">
-                  </b-input>
-                </b-field>
-
-                <b-field label="Message">
-                  <b-input type="textarea"
-                           v-model="message"
-                           ref="textarea">
-                  </b-input>
-                </b-field>
-
-                % if config.get_bool('tailbone.feedback_allows_reply'):
-                    <div class="level">
-                      <div class="level-left">
-                        <div class="level-item">
-                          <b-checkbox v-model="pleaseReply"
-                                      @input="pleaseReplyChanged">
-                            Please email me back{{ pleaseReply ? " at: " : "" }}
-                          </b-checkbox>
-                        </div>
-                        <div class="level-item" v-show="pleaseReply">
-                          <b-input v-model="userEmail"
-                                   ref="userEmail">
-                          </b-input>
-                        </div>
-                      </div>
+            % if config.get_bool('tailbone.feedback_allows_reply'):
+                <div class="level">
+                  <div class="level-left">
+                    <div class="level-item">
+                      <b-checkbox v-model="pleaseReply"
+                                  @input="pleaseReplyChanged">
+                        Please email me back{{ pleaseReply ? " at: " : "" }}
+                      </b-checkbox>
                     </div>
-                % endif
+                    <div class="level-item" v-show="pleaseReply">
+                      <b-input v-model="userEmail"
+                               ref="userEmail">
+                      </b-input>
+                    </div>
+                  </div>
+                </div>
+            % endif
 
-              </section>
-
-              <footer class="modal-card-foot">
-                <b-button @click="showDialog = false">
-                  Cancel
-                </b-button>
-                <b-button type="is-primary"
-                          icon-pack="fas"
-                          icon-left="paper-plane"
-                          @click="sendFeedback()"
-                          :disabled="sendingFeedback || !message.trim()">
-                  {{ sendingFeedback ? "Working, please wait..." : "Send Message" }}
-                </b-button>
-              </footer>
-            </div>
-          </b-modal>
+          </section>
 
+          <footer class="modal-card-foot">
+            <b-button @click="showDialog = false">
+              Cancel
+            </b-button>
+            <b-button type="is-primary"
+                      icon-pack="fas"
+                      icon-left="paper-plane"
+                      @click="sendFeedback()"
+                      :disabled="sendingFeedback || !message || !message.trim()">
+              {{ sendingFeedback ? "Working, please wait..." : "Send Message" }}
+            </b-button>
+          </footer>
         </div>
-      </script>
-      <script>
+      </b-modal>
 
-        const FeedbackForm = {
-            template: '#feedback-template',
-            mixins: [SimpleRequestMixin],
-            props: [
-                'action',
-                'message',
-            ],
-            methods: {
+    </div>
+  </script>
+</%def>
 
-                showFeedback() {
-                    this.referrer = location.href
-                    this.showDialog = true
-                    this.$nextTick(function() {
-                        this.$refs.textarea.focus()
-                    })
-                },
+<%def name="render_vue_script_feedback()">
+  ${parent.render_vue_script_feedback()}
+  <script>
 
-                % if config.get_bool('tailbone.feedback_allows_reply'):
-                    pleaseReplyChanged(value) {
-                        this.$nextTick(() => {
-                            this.$refs.userEmail.focus()
-                        })
-                    },
-                % endif
+    WuttaFeedbackForm.template = '#feedback-template'
+    WuttaFeedbackForm.props.message = String
 
-                sendFeedback() {
-                    this.sendingFeedback = true
+    % if config.get_bool('tailbone.feedback_allows_reply'):
 
-                    const params = {
-                        referrer: this.referrer,
-                        user: this.userUUID,
-                        user_name: this.userName,
-                        % if config.get_bool('tailbone.feedback_allows_reply'):
-                            please_reply_to: this.pleaseReply ? this.userEmail : null,
-                        % endif
-                        message: this.message.trim(),
-                    }
+        WuttaFeedbackFormData.pleaseReply = false
+        WuttaFeedbackFormData.userEmail = null
 
-                    this.simplePOST(this.action, params, response => {
+        WuttaFeedbackForm.methods.pleaseReplyChanged = function(value) {
+            this.$nextTick(() => {
+                this.$refs.userEmail.focus()
+            })
+        }
 
-                        this.$buefy.toast.open({
-                            message: "Message sent!  Thank you for your feedback.",
-                            type: 'is-info',
-                            duration: 4000, // 4 seconds
-                        })
-
-                        this.showDialog = false
-                        // clear out message, in case they need to send another
-                        this.message = ""
-                        this.sendingFeedback = false
-
-                    }, response => { // failure
-                        this.sendingFeedback = false
-                    })
-                },
+        WuttaFeedbackForm.methods.getExtraParams = function() {
+            return {
+                please_reply_to: this.pleaseReply ? this.userEmail : null,
             }
         }
 
-        const FeedbackFormData = {
-            referrer: null,
-            userUUID: null,
-            userName: null,
-            userEmail: null,
-            % if config.get_bool('tailbone.feedback_allows_reply'):
-                pleaseReply: false,
-            % endif
-            showDialog: false,
-            sendingFeedback: false,
-        }
+    % endif
 
-      </script>
-  % endif
+    // TODO: deprecate / remove these
+    const FeedbackForm = WuttaFeedbackForm
+    const FeedbackFormData = WuttaFeedbackFormData
+
+  </script>
+</%def>
+
+<%def name="render_vue_templates()">
+  ${parent.render_vue_templates()}
+  ${page_help.render_template()}
+  ${page_help.declare_vars()}
 </%def>
 
 <%def name="modify_vue_vars()">
@@ -528,21 +482,6 @@
 
     % endif
 
-    ##############################
-    ## feedback
-    ##############################
-
-    % if request.has_perm('common.feedback'):
-
-        WholePageData.feedbackMessage = ""
-
-        % if request.user:
-            FeedbackFormData.userUUID = ${json.dumps(request.user.uuid)|n}
-            FeedbackFormData.userName = ${json.dumps(str(request.user))|n}
-        % endif
-
-    % endif
-
     ##############################
     ## edit fields help
     ##############################
@@ -562,10 +501,4 @@
   ${h.javascript_link(request.static_url('tailbone:static/js/tailbone.buefy.timepicker.js') + f'?ver={tailbone.__version__}')}
   ${make_grid_filter_components()}
   ${page_help.make_component()}
-  % if request.has_perm('common.feedback'):
-      <script>
-        FeedbackForm.data = function() { return FeedbackFormData }
-        Vue.component('feedback-form', FeedbackForm)
-      </script>
-  % endif
 </%def>

From 3a9bf69aa7f63fc838259eef477324beee7c66a8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 26 Aug 2024 14:56:15 -0500
Subject: [PATCH 32/85] =?UTF-8?q?bump:=20version=200.21.1=20=E2=86=92=200.?=
 =?UTF-8?q?21.2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 13 +++++++++++++
 pyproject.toml |  6 +++---
 2 files changed, 16 insertions(+), 3 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3bcbc6ec..4616cf5f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,19 @@ All notable changes to Tailbone 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.21.2 (2024-08-26)
+
+### Fix
+
+- refactor waterpark base template to use wutta feedback component
+- fix input/output file upload feature for configure pages, per oruga
+- tweak how grid data translates to Vue template context
+- merge filters into main grid template
+- add basic wutta view for users
+- some fixes for wutta people view
+- various fixes for waterpark theme
+- avoid deprecated `component` form kwarg
+
 ## v0.21.1 (2024-08-22)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 2db880ad..831133c1 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.21.1"
+version = "0.21.2"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@@ -53,13 +53,13 @@ dependencies = [
         "pyramid_mako",
         "pyramid_retry",
         "pyramid_tm",
-        "rattail[db,bouncer]>=0.18.4",
+        "rattail[db,bouncer]>=0.18.5",
         "sa-filters",
         "simplejson",
         "transaction",
         "waitress",
         "WebHelpers2",
-        "WuttaWeb>=0.12.0",
+        "WuttaWeb>=0.13.1",
         "zope.sqlalchemy>=1.5",
 ]
 

From d67eb2f1cc15719478a26b8b76246947b528885e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 26 Aug 2024 15:24:40 -0500
Subject: [PATCH 33/85] fix: show non-standard config values for app info
 configure email

this page is currently showing some basic email sender/recips etc. but
the config keys traditionally used by rattail are different than
wuttjamaican..so for now we must "translate"
---
 tailbone/views/settings.py | 49 ++++++++++++++++++++++++++++++++++----
 1 file changed, 45 insertions(+), 4 deletions(-)

diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py
index 099a77e1..0180aa4b 100644
--- a/tailbone/views/settings.py
+++ b/tailbone/views/settings.py
@@ -81,15 +81,56 @@ class AppInfoView(WuttaAppInfoView):
         """ """
         simple_settings = super().configure_get_simple_settings()
 
-        # TODO: the update home page redirect setting is off by
-        # default for wuttaweb, but on for tailbone
         for setting in simple_settings:
+
+            # TODO: the update home page redirect setting is off by
+            # default for wuttaweb, but on for tailbone
             if setting['name'] == 'wuttaweb.home_redirect_to_login':
                 value = self.config.get_bool('wuttaweb.home_redirect_to_login')
                 if value is None:
                     value = self.config.get_bool('tailbone.login_is_home', default=True)
-                setting['default'] = value
-                break
+                setting['value'] = value
+
+            # TODO: sending email is off by default for wuttjamaican,
+            # but on for rattail
+            elif setting['name'] == 'rattail.mail.send_emails':
+                value = self.config.get_bool('rattail.mail.send_emails', default=True)
+                setting['value'] = value
+
+            # TODO: email defaults have different config keys in rattail
+            elif setting['name'] == 'rattail.email.default.sender':
+                value = self.config.get('rattail.email.default.sender')
+                if value is None:
+                    value = self.config.get('rattail.mail.default.from')
+                setting['value'] = value
+
+            # TODO: email defaults have different config keys in rattail
+            elif setting['name'] == 'rattail.email.default.subject':
+                value = self.config.get('rattail.email.default.subject')
+                if value is None:
+                    value = self.config.get('rattail.mail.default.subject')
+                setting['value'] = value
+
+            # TODO: email defaults have different config keys in rattail
+            elif setting['name'] == 'rattail.email.default.to':
+                value = self.config.get('rattail.email.default.to')
+                if value is None:
+                    value = self.config.get('rattail.mail.default.to')
+                setting['value'] = value
+
+            # TODO: email defaults have different config keys in rattail
+            elif setting['name'] == 'rattail.email.default.cc':
+                value = self.config.get('rattail.email.default.cc')
+                if value is None:
+                    value = self.config.get('rattail.mail.default.cc')
+                setting['value'] = value
+
+            # TODO: email defaults have different config keys in rattail
+            elif setting['name'] == 'rattail.email.default.bcc':
+                value = self.config.get('rattail.email.default.bcc')
+                if value is None:
+                    value = self.config.get('rattail.mail.default.bcc')
+                setting['value'] = value
 
         # nb. these are no longer used (deprecated), but we keep
         # them defined here so the tool auto-deletes them

From dffd951369de5ca36a877f9b8b36e344245266b0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 26 Aug 2024 15:25:56 -0500
Subject: [PATCH 34/85] =?UTF-8?q?bump:=20version=200.21.2=20=E2=86=92=200.?=
 =?UTF-8?q?21.3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 6 ++++++
 pyproject.toml | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4616cf5f..52a17a2f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to Tailbone 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.21.3 (2024-08-26)
+
+### Fix
+
+- show non-standard config values for app info configure email
+
 ## v0.21.2 (2024-08-26)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 831133c1..2c18bd02 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.21.2"
+version = "0.21.3"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 7a9d5772db794d69632ce3a8621396d08e6ec679 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 26 Aug 2024 16:11:32 -0500
Subject: [PATCH 35/85] fix: handle differing email profile keys for
 appinfo/configure

hopefully this all can improve some day soon..
---
 tailbone/templates/configure.mako |  5 +-
 tailbone/views/settings.py        | 96 +++++++++++++++++++++----------
 2 files changed, 69 insertions(+), 32 deletions(-)

diff --git a/tailbone/templates/configure.mako b/tailbone/templates/configure.mako
index 463d48b1..e6b128fc 100644
--- a/tailbone/templates/configure.mako
+++ b/tailbone/templates/configure.mako
@@ -280,15 +280,14 @@
         <b-button @click="purgeSettingsShowDialog = false">
           Cancel
         </b-button>
-        ${h.form(request.current_route_url())}
+        ${h.form(request.current_route_url(), **{'@submit': 'purgingSettings = true'})}
         ${h.csrf_token(request)}
         ${h.hidden('remove_settings', 'true')}
         <b-button type="is-danger"
                   native-type="submit"
                   :disabled="purgingSettings"
                   icon-pack="fas"
-                  icon-left="trash"
-                  @click="purgingSettings = true">
+                  icon-left="trash">
           {{ purgingSettings ? "Working, please wait..." : "Remove All Settings" }}
         </b-button>
         ${h.end_form()}
diff --git a/tailbone/views/settings.py b/tailbone/views/settings.py
index 0180aa4b..10a0c2eb 100644
--- a/tailbone/views/settings.py
+++ b/tailbone/views/settings.py
@@ -77,13 +77,41 @@ class AppInfoView(WuttaAppInfoView):
 
         return context
 
+    # nb. these email settings require special handling below
+    configure_profile_key_mismatches = [
+        'default.subject',
+        'default.to',
+        'default.cc',
+        'default.bcc',
+        'feedback.subject',
+        'feedback.to',
+    ]
+
     def configure_get_simple_settings(self):
         """ """
         simple_settings = super().configure_get_simple_settings()
 
+        # TODO:
+        # there are several email config keys which differ between
+        # wuttjamaican and rattail.  basically all of the "profile" keys
+        # have a different prefix.
+
+        # after wuttaweb has declared its settings, we examine each and
+        # overwrite the value if one is defined with rattail config key.
+        # (nb. this happens even if wuttjamaican key has a value!)
+
+        # note that we *do* declare the profile mismatch keys for
+        # rattail, as part of simple settings.  this ensures the
+        # parent logic will always remove them when saving.  however
+        # we must also include them in gather_settings() to ensure
+        # they are saved to match wuttjamaican values.
+
+        # there are also a couple of flags where rattail's default is the
+        # opposite of wuttjamaican.  so we overwrite those too as needed.
+
         for setting in simple_settings:
 
-            # TODO: the update home page redirect setting is off by
+            # nb. the update home page redirect setting is off by
             # default for wuttaweb, but on for tailbone
             if setting['name'] == 'wuttaweb.home_redirect_to_login':
                 value = self.config.get_bool('wuttaweb.home_redirect_to_login')
@@ -91,55 +119,43 @@ class AppInfoView(WuttaAppInfoView):
                     value = self.config.get_bool('tailbone.login_is_home', default=True)
                 setting['value'] = value
 
-            # TODO: sending email is off by default for wuttjamaican,
+            # nb. sending email is off by default for wuttjamaican,
             # but on for rattail
             elif setting['name'] == 'rattail.mail.send_emails':
                 value = self.config.get_bool('rattail.mail.send_emails', default=True)
                 setting['value'] = value
 
-            # TODO: email defaults have different config keys in rattail
+            # nb. this one is even more special, key is entirely different
             elif setting['name'] == 'rattail.email.default.sender':
                 value = self.config.get('rattail.email.default.sender')
                 if value is None:
                     value = self.config.get('rattail.mail.default.from')
                 setting['value'] = value
 
-            # TODO: email defaults have different config keys in rattail
-            elif setting['name'] == 'rattail.email.default.subject':
-                value = self.config.get('rattail.email.default.subject')
-                if value is None:
-                    value = self.config.get('rattail.mail.default.subject')
-                setting['value'] = value
+            else:
 
-            # TODO: email defaults have different config keys in rattail
-            elif setting['name'] == 'rattail.email.default.to':
-                value = self.config.get('rattail.email.default.to')
-                if value is None:
-                    value = self.config.get('rattail.mail.default.to')
-                setting['value'] = value
-
-            # TODO: email defaults have different config keys in rattail
-            elif setting['name'] == 'rattail.email.default.cc':
-                value = self.config.get('rattail.email.default.cc')
-                if value is None:
-                    value = self.config.get('rattail.mail.default.cc')
-                setting['value'] = value
-
-            # TODO: email defaults have different config keys in rattail
-            elif setting['name'] == 'rattail.email.default.bcc':
-                value = self.config.get('rattail.email.default.bcc')
-                if value is None:
-                    value = self.config.get('rattail.mail.default.bcc')
-                setting['value'] = value
+                # nb. fetch alternate value for profile key mismatch
+                for key in self.configure_profile_key_mismatches:
+                    if setting['name'] == f'rattail.email.{key}':
+                        value = self.config.get(f'rattail.email.{key}')
+                        if value is None:
+                            value = self.config.get(f'rattail.mail.{key}')
+                        setting['value'] = value
+                        break
 
         # nb. these are no longer used (deprecated), but we keep
         # them defined here so the tool auto-deletes them
 
         simple_settings.extend([
+            {'name': 'tailbone.login_is_home'},
             {'name': 'tailbone.buefy_version'},
             {'name': 'tailbone.vue_version'},
         ])
 
+        simple_settings.append({'name': 'rattail.mail.default.from'})
+        for key in self.configure_profile_key_mismatches:
+            simple_settings.append({'name': f'rattail.mail.{key}'})
+
         for key in self.get_weblibs():
             simple_settings.extend([
                 {'name': f'tailbone.libver.{key}'},
@@ -148,6 +164,28 @@ class AppInfoView(WuttaAppInfoView):
 
         return simple_settings
 
+    def configure_gather_settings(self, data, simple_settings=None):
+        """ """
+        settings = super().configure_gather_settings(data, simple_settings=simple_settings)
+
+        # nb. must add legacy rattail profile settings to match new ones
+        for setting in list(settings):
+
+            if setting['name'] == 'rattail.email.default.sender':
+                value = setting['value']
+                settings.append({'name': 'rattail.mail.default.from',
+                                 'value': value})
+
+            else:
+                for key in self.configure_profile_key_mismatches:
+                    if setting['name'] == f'rattail.email.{key}':
+                        value = setting['value']
+                        settings.append({'name': f'rattail.mail.{key}',
+                                         'value': value})
+                        break
+
+        return settings
+
 
 class SettingView(MasterView):
     """

From ca05e688905398758470d5dd2db0ba288b8216a5 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 26 Aug 2024 16:12:14 -0500
Subject: [PATCH 36/85] =?UTF-8?q?bump:=20version=200.21.3=20=E2=86=92=200.?=
 =?UTF-8?q?21.4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 6 ++++++
 pyproject.toml | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 52a17a2f..e18c786c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to Tailbone 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.21.4 (2024-08-26)
+
+### Fix
+
+- handle differing email profile keys for appinfo/configure
+
 ## v0.21.3 (2024-08-26)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 2c18bd02..4845708b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.21.3"
+version = "0.21.4"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 2e20fc5b7527275eaf7408dad56e3516ef6433e3 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 27 Aug 2024 13:50:30 -0500
Subject: [PATCH 37/85] fix: set empty string for "-new-" file configure option

otherwise the "-new-" option is not properly auto-selected
---
 tailbone/views/master.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 1028ff27..6e05c35d 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -5441,7 +5441,7 @@ class MasterView(View):
             for template in self.normalize_input_file_templates(
                     include_file_options=True):
                 settings[template['setting_mode']] = template['mode']
-                settings[template['setting_file']] = template['file']
+                settings[template['setting_file']] = template['file'] or ''
                 settings[template['setting_url']] = template['url']
                 file_options[template['key']] = template['file_options']
                 file_option_dirs[template['key']] = template['file_options_dir']
@@ -5457,7 +5457,7 @@ class MasterView(View):
             for template in self.normalize_output_file_templates(
                     include_file_options=True):
                 settings[template['setting_mode']] = template['mode']
-                settings[template['setting_file']] = template['file']
+                settings[template['setting_file']] = template['file'] or ''
                 settings[template['setting_url']] = template['url']
                 file_options[template['key']] = template['file_options']
                 file_option_dirs[template['key']] = template['file_options_dir']

From b30f066c41f3b758882e0d8fc68e4a61b501e186 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 28 Aug 2024 00:30:15 -0500
Subject: [PATCH 38/85] =?UTF-8?q?bump:=20version=200.21.4=20=E2=86=92=200.?=
 =?UTF-8?q?21.5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 6 ++++++
 pyproject.toml | 4 ++--
 2 files changed, 8 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index e18c786c..d3c8a92f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to Tailbone 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.21.5 (2024-08-28)
+
+### Fix
+
+- set empty string for "-new-" file configure option
+
 ## v0.21.4 (2024-08-26)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 4845708b..4743fd3b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.21.4"
+version = "0.21.5"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@@ -59,7 +59,7 @@ dependencies = [
         "transaction",
         "waitress",
         "WebHelpers2",
-        "WuttaWeb>=0.13.1",
+        "WuttaWeb>=0.14.0",
         "zope.sqlalchemy>=1.5",
 ]
 

From b81914fbf52357e3097a8f88d913c19ef30c0388 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 28 Aug 2024 00:35:15 -0500
Subject: [PATCH 39/85] test: fix broken test

---
 tests/test_app.py | 7 ++-----
 1 file changed, 2 insertions(+), 5 deletions(-)

diff --git a/tests/test_app.py b/tests/test_app.py
index e16461ba..f49f6b13 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -5,12 +5,9 @@ from unittest import TestCase
 
 from pyramid.config import Configurator
 
-from wuttjamaican.testing import FileConfigTestCase
-
 from rattail.exceptions import ConfigurationError
-from rattail.config import RattailConfig
+from rattail.testing import DataTestCase
 from tailbone import app as mod
-from tests.util import DataTestCase
 
 
 class TestRattailConfig(TestCase):
@@ -30,7 +27,7 @@ class TestRattailConfig(TestCase):
 
 class TestMakePyramidConfig(DataTestCase):
 
-    def make_config(self):
+    def make_config(self, **kwargs):
         myconf = self.write_file('web.conf', """
 [rattail.db]
 default.url = sqlite://

From 0b6cfaa9c57bbbf0ef3ad51cab4e5d5bc56d6843 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 28 Aug 2024 09:53:14 -0500
Subject: [PATCH 40/85] fix: avoid error when grid value cannot be obtained

---
 tailbone/grids/core.py | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index ecf462fd..c6257d4b 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -575,7 +575,11 @@ class Grid(WuttaGrid):
             return getattr(obj, column_name)
         except AttributeError:
             pass
-        return obj[column_name]
+
+        try:
+            return obj[column_name]
+        except TypeError:
+            pass
 
     def render_currency(self, obj, column_name):
         value = self.obtain_value(obj, column_name)

From 71d63f6b93fee7ff8ff2ff19eebe844dce9476df Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 28 Aug 2024 09:53:37 -0500
Subject: [PATCH 41/85] =?UTF-8?q?bump:=20version=200.21.5=20=E2=86=92=200.?=
 =?UTF-8?q?21.6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 6 ++++++
 pyproject.toml | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index d3c8a92f..59fcfcc9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to Tailbone 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.21.6 (2024-08-28)
+
+### Fix
+
+- avoid error when grid value cannot be obtained
+
 ## v0.21.5 (2024-08-28)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 4743fd3b..16018dbb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.21.5"
+version = "0.21.6"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From bc399182ba5eb957ae7c521f3b71701ff4bf39d1 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 28 Aug 2024 14:20:17 -0500
Subject: [PATCH 42/85] fix: avoid error when form value cannot be obtained

---
 tailbone/forms/core.py | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 059b212a..b5020975 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -1380,7 +1380,11 @@ class Form(object):
                 return getattr(record, field_name)
             except AttributeError:
                 pass
-            return record[field_name]
+
+            try:
+                return record[field_name]
+            except TypeError:
+                pass
 
         # TODO: is this always safe to do?
         elif self.defaults and field_name in self.defaults:

From 20dcdd8b86dfdbab1224676e3135ee8171b57f00 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 28 Aug 2024 14:20:51 -0500
Subject: [PATCH 43/85] =?UTF-8?q?bump:=20version=200.21.6=20=E2=86=92=200.?=
 =?UTF-8?q?21.7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 6 ++++++
 pyproject.toml | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 59fcfcc9..aee19700 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to Tailbone 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.21.7 (2024-08-28)
+
+### Fix
+
+- avoid error when form value cannot be obtained
+
 ## v0.21.6 (2024-08-28)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 16018dbb..45a2adc9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.21.6"
+version = "0.21.7"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 812d8d2349e7517e2ef5702dcf904cd0b5c5c8af Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 28 Aug 2024 14:37:18 -0500
Subject: [PATCH 44/85] fix: ignore session kwarg for
 `MasterView.make_row_grid()`

---
 tailbone/views/master.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 6e05c35d..baf63caa 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -551,7 +551,8 @@ class MasterView(View):
     def get_quickie_result_url(self, obj):
         return self.get_action_url('view', obj)
 
-    def make_row_grid(self, factory=None, key=None, data=None, columns=None, **kwargs):
+    def make_row_grid(self, factory=None, key=None, data=None, columns=None,
+                      session=None, **kwargs):
         """
         Make and return a new (configured) rows grid instance.
         """

From 9be2f6347571d5989fabad88a9fc90ebf63812f9 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 28 Aug 2024 14:37:40 -0500
Subject: [PATCH 45/85] =?UTF-8?q?bump:=20version=200.21.7=20=E2=86=92=200.?=
 =?UTF-8?q?21.8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 6 ++++++
 pyproject.toml | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index aee19700..a31b80ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to Tailbone 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.21.8 (2024-08-28)
+
+### Fix
+
+- ignore session kwarg for `MasterView.make_row_grid()`
+
 ## v0.21.7 (2024-08-28)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 45a2adc9..350803dc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.21.7"
+version = "0.21.8"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 2219cf81988c583320014492a6e114c40e025e2b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 28 Aug 2024 17:38:05 -0500
Subject: [PATCH 46/85] fix: render custom attrs in form component tag

---
 tailbone/forms/core.py | 11 ++++++-----
 1 file changed, 6 insertions(+), 5 deletions(-)

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index b5020975..601dcfb1 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -1037,9 +1037,9 @@ class Form(object):
 
     def render_vue_tag(self, **kwargs):
         """ """
-        return self.render_vuejs_component()
+        return self.render_vuejs_component(**kwargs)
 
-    def render_vuejs_component(self):
+    def render_vuejs_component(self, **kwargs):
         """
         Render the Vue.js component HTML for the form.
 
@@ -1050,10 +1050,11 @@ class Form(object):
            <tailbone-form :configure-fields-help="configureFieldsHelp">
            </tailbone-form>
         """
-        kwargs = dict(self.vuejs_component_kwargs)
+        kw = dict(self.vuejs_component_kwargs)
+        kw.update(kwargs)
         if self.can_edit_help:
-            kwargs.setdefault(':configure-fields-help', 'configureFieldsHelp')
-        return HTML.tag(self.vue_tagname, **kwargs)
+            kw.setdefault(':configure-fields-help', 'configureFieldsHelp')
+        return HTML.tag(self.vue_tagname, **kw)
 
     def set_json_data(self, key, value):
         """

From 55f45ae8a081123af3c8fc931a7745f0d7ea0b2b Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 28 Aug 2024 17:38:33 -0500
Subject: [PATCH 47/85] =?UTF-8?q?bump:=20version=200.21.8=20=E2=86=92=200.?=
 =?UTF-8?q?21.9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 6 ++++++
 pyproject.toml | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index a31b80ac..da628cf3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to Tailbone 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.21.9 (2024-08-28)
+
+### Fix
+
+- render custom attrs in form component tag
+
 ## v0.21.8 (2024-08-28)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 350803dc..2720d003 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.21.8"
+version = "0.21.9"
 description = "Backoffice Web Application for Rattail"
 readme = "README.rst"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 8df52bf2a2d8902cc1565a5e46370273db580be2 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 29 Aug 2024 17:01:28 -0500
Subject: [PATCH 48/85] fix: expose datasync consumer batch size via configure
 page

---
 tailbone/templates/datasync/configure.mako | 29 ++++++----
 tailbone/views/datasync.py                 | 65 +++++++++++++---------
 2 files changed, 55 insertions(+), 39 deletions(-)

diff --git a/tailbone/templates/datasync/configure.mako b/tailbone/templates/datasync/configure.mako
index 3651d0c4..2e444fb5 100644
--- a/tailbone/templates/datasync/configure.mako
+++ b/tailbone/templates/datasync/configure.mako
@@ -83,8 +83,8 @@
   </b-notification>
 
   <b-field>
-    <b-checkbox name="use_profile_settings"
-                v-model="useProfileSettings"
+    <b-checkbox name="rattail.datasync.use_profile_settings"
+                v-model="simpleSettings['rattail.datasync.use_profile_settings']"
                 native-value="true"
                 @input="settingsNeedSaved = true">
       Use these Settings to configure watchers and consumers
@@ -99,7 +99,7 @@
     </div>
     <div class="level-right">
       <div class="level-item"
-           v-show="useProfileSettings">
+           v-show="simpleSettings['rattail.datasync.use_profile_settings']">
         <b-button type="is-primary"
                   @click="newProfile()"
                   icon-pack="fas"
@@ -162,7 +162,7 @@
       </${b}-table-column>
       <${b}-table-column label="Actions"
                       v-slot="props"
-                      v-if="useProfileSettings">
+                      v-if="simpleSettings['rattail.datasync.use_profile_settings']">
         <a href="#"
            class="grid-action"
            @click.prevent="editProfile(props.row)">
@@ -580,18 +580,27 @@
   <b-field label="Supervisor Process Name"
            message="This should be the complete name, including group - e.g. poser:poser_datasync"
            expanded>
-    <b-input name="supervisor_process_name"
-             v-model="supervisorProcessName"
+    <b-input name="rattail.datasync.supervisor_process_name"
+             v-model="simpleSettings['rattail.datasync.supervisor_process_name']"
              @input="settingsNeedSaved = true"
              expanded>
     </b-input>
   </b-field>
 
+  <b-field label="Consumer Batch Size"
+           message="Max number of changes to be consumed at once."
+           expanded>
+    <numeric-input name="rattail.datasync.batch_size_limit"
+                   v-model="simpleSettings['rattail.datasync.batch_size_limit']"
+                   @input="settingsNeedSaved = true" />
+  </b-field>
+
+  <h3 class="is-size-3">Legacy</h3>
   <b-field label="Restart Command"
            message="This will run as '${system_user}' system user - please configure sudoers as needed.  Typical command is like:  sudo supervisorctl restart poser:poser_datasync"
            expanded>
-    <b-input name="restart_command"
-             v-model="restartCommand"
+    <b-input name="tailbone.datasync.restart"
+             v-model="simpleSettings['tailbone.datasync.restart']"
              @input="settingsNeedSaved = true"
              expanded>
     </b-input>
@@ -606,7 +615,6 @@
     ThisPageData.showConfigFilesNote = false
     ThisPageData.profilesData = ${json.dumps(profiles_data)|n}
     ThisPageData.showDisabledProfiles = false
-    ThisPageData.useProfileSettings = ${json.dumps(use_profile_settings)|n}
 
     ThisPageData.editProfileShowDialog = false
     ThisPageData.editingProfile = null
@@ -631,9 +639,6 @@
     ThisPageData.editingConsumerRunas = null
     ThisPageData.editingConsumerEnabled = true
 
-    ThisPageData.supervisorProcessName = ${json.dumps(supervisor_process_name)|n}
-    ThisPageData.restartCommand = ${json.dumps(restart_command)|n}
-
     ThisPage.computed.updateConsumerDisabled = function() {
         if (!this.editingConsumerKey) {
             return true
diff --git a/tailbone/views/datasync.py b/tailbone/views/datasync.py
index 134d6018..2b955b5f 100644
--- a/tailbone/views/datasync.py
+++ b/tailbone/views/datasync.py
@@ -202,10 +202,36 @@ class DataSyncThreadView(MasterView):
         return self.redirect(self.request.get_referrer(
             default=self.request.route_url('datasyncchanges')))
 
-    def configure_get_context(self):
+    def configure_get_simple_settings(self):
+        """ """
+        return [
+
+            # basic
+            {'section': 'rattail.datasync',
+             'option': 'use_profile_settings',
+             'type': bool},
+
+            # misc.
+            {'section': 'rattail.datasync',
+             'option': 'supervisor_process_name'},
+            {'section': 'rattail.datasync',
+             'option': 'batch_size_limit',
+             'type': int},
+
+            # legacy
+            {'section': 'tailbone',
+             'option': 'datasync.restart'},
+
+        ]
+
+    def configure_get_context(self, **kwargs):
+        """ """
+        context = super().configure_get_context(**kwargs)
+
         profiles = self.datasync_handler.get_configured_profiles(
             include_disabled=True,
             ignore_problems=True)
+        context['profiles'] = profiles
 
         profiles_data = []
         for profile in sorted(profiles.values(), key=lambda p: p.key):
@@ -243,25 +269,15 @@ class DataSyncThreadView(MasterView):
             data['consumers_data'] = consumers
             profiles_data.append(data)
 
-        return {
-            'profiles': profiles,
-            'profiles_data': profiles_data,
-            'use_profile_settings': self.datasync_handler.should_use_profile_settings(),
-            'supervisor_process_name': self.rattail_config.get(
-                'rattail.datasync', 'supervisor_process_name'),
-            'restart_command': self.rattail_config.get(
-                'tailbone', 'datasync.restart'),
-        }
+        context['profiles_data'] = profiles_data
+        return context
 
-    def configure_gather_settings(self, data):
-        settings = []
-        watch = []
+    def configure_gather_settings(self, data, **kwargs):
+        """ """
+        settings = super().configure_gather_settings(data, **kwargs)
 
-        use_profile_settings = data.get('use_profile_settings') == 'true'
-        settings.append({'name': 'rattail.datasync.use_profile_settings',
-                         'value': 'true' if use_profile_settings else 'false'})
-
-        if use_profile_settings:
+        if data.get('rattail.datasync.use_profile_settings') == 'true':
+            watch = []
 
             for profile in json.loads(data['profiles']):
                 pkey = profile['key']
@@ -323,17 +339,12 @@ class DataSyncThreadView(MasterView):
                 settings.append({'name': 'rattail.datasync.watch',
                                  'value': ', '.join(watch)})
 
-        if data['supervisor_process_name']:
-            settings.append({'name': 'rattail.datasync.supervisor_process_name',
-                             'value': data['supervisor_process_name']})
-
-        if data['restart_command']:
-            settings.append({'name': 'tailbone.datasync.restart',
-                             'value': data['restart_command']})
-
         return settings
 
-    def configure_remove_settings(self):
+    def configure_remove_settings(self, **kwargs):
+        """ """
+        super().configure_remove_settings(**kwargs)
+
         purge_datasync_settings(self.rattail_config, self.Session())
 
     @classmethod

From b9b8bbd2eae1543cb74898f95e72cee5e7de6f46 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 29 Aug 2024 17:18:32 -0500
Subject: [PATCH 49/85] fix: wrap notes text for batch view

---
 tailbone/views/batch/core.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py
index 8ee3a37d..a75fda1c 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.py
@@ -383,7 +383,7 @@ class BatchMasterView(MasterView):
         f.set_label('executed_by', "Executed by")
 
         # notes
-        f.set_type('notes', 'text')
+        f.set_type('notes', 'text_wrapped')
 
         # if self.creating and self.request.user:
         #     batch = fs.model

From 5e742eab1795fe4c53573070af264c8d8a4cf3c0 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 9 Sep 2024 08:32:28 -0500
Subject: [PATCH 50/85] fix: use better icon for submit button on login page

---
 tailbone/forms/core.py               | 2 ++
 tailbone/templates/forms/deform.mako | 2 +-
 tailbone/views/auth.py               | 6 +++---
 3 files changed, 6 insertions(+), 4 deletions(-)

diff --git a/tailbone/forms/core.py b/tailbone/forms/core.py
index 601dcfb1..4024557b 100644
--- a/tailbone/forms/core.py
+++ b/tailbone/forms/core.py
@@ -401,6 +401,8 @@ class Form(object):
         self.edit_help_url = edit_help_url
         self.route_prefix = route_prefix
 
+        self.button_icon_submit = kwargs.get('button_icon_submit', 'save')
+
     def __iter__(self):
         return iter(self.fields)
 
diff --git a/tailbone/templates/forms/deform.mako b/tailbone/templates/forms/deform.mako
index ea35ab17..2100b460 100644
--- a/tailbone/templates/forms/deform.mako
+++ b/tailbone/templates/forms/deform.mako
@@ -59,7 +59,7 @@
                       native-type="submit"
                       :disabled="${form.vue_component}Submitting"
                       icon-pack="fas"
-                      icon-left="save">
+                      icon-left="${form.button_icon_submit}">
               {{ ${form.vue_component}Submitting ? "Working, please wait..." : "${form.button_label_submit}" }}
             </b-button>
         % else:
diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py
index 730d7b6a..a54a19a9 100644
--- a/tailbone/views/auth.py
+++ b/tailbone/views/auth.py
@@ -24,8 +24,6 @@
 Auth Views
 """
 
-from rattail.db.auth import set_user_password
-
 import colander
 from deform import widget as dfwidget
 from pyramid.httpexceptions import HTTPForbidden
@@ -104,6 +102,7 @@ class AuthenticationView(View):
         form.save_label = "Login"
         form.show_reset = True
         form.show_cancel = False
+        form.button_icon_submit = 'user'
         if form.validate():
             user = self.authenticate_user(form.validated['username'],
                                           form.validated['password'])
@@ -185,7 +184,8 @@ class AuthenticationView(View):
         schema = ChangePassword().bind(user=self.request.user, request=self.request)
         form = forms.Form(schema=schema, request=self.request)
         if form.validate():
-            set_user_password(self.request.user, form.validated['new_password'])
+            auth = self.app.get_auth_handler()
+            auth.set_user_password(self.request.user, form.validated['new_password'])
             self.request.session.flash("Your password has been changed.")
             return self.redirect(self.request.get_referrer())
 

From a4d81a6e3cf431bae5fb91337ccf1c345e75c137 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 13 Sep 2024 18:16:07 -0500
Subject: [PATCH 51/85] docs: use markdown for readme file

---
 README.rst => README.md | 8 +++-----
 pyproject.toml          | 2 +-
 2 files changed, 4 insertions(+), 6 deletions(-)
 rename README.rst => README.md (56%)

diff --git a/README.rst b/README.md
similarity index 56%
rename from README.rst
rename to README.md
index 0cffc62d..74c007f6 100644
--- a/README.rst
+++ b/README.md
@@ -1,10 +1,8 @@
 
-Tailbone
-========
+# Tailbone
 
 Tailbone is an extensible web application based on Rattail.  It provides a
 "back-office network environment" (BONE) for use in managing retail data.
 
-Please see Rattail's `home page`_ for more information.
-
-.. _home page: http://rattailproject.org/
+Please see Rattail's [home page](http://rattailproject.org/) for more
+information.
diff --git a/pyproject.toml b/pyproject.toml
index 2720d003..8c6525c6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
 name = "Tailbone"
 version = "0.21.9"
 description = "Backoffice Web Application for Rattail"
-readme = "README.rst"
+readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
 license = {text = "GNU GPL v3+"}
 classifiers = [

From 0b646d2d187fafe743cb7816ab0a86d171b76646 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 14 Sep 2024 12:49:37 -0500
Subject: [PATCH 52/85] fix: update project repo links, kallithea -> forgejo

---
 pyproject.toml             |  6 ++--
 tailbone/views/upgrades.py | 69 +++++++++++---------------------------
 2 files changed, 23 insertions(+), 52 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 8c6525c6..a1c96dd4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -84,9 +84,9 @@ tailbone = "tailbone.config:ConfigExtension"
 
 [project.urls]
 Homepage = "https://rattailproject.org"
-Repository = "https://kallithea.rattailproject.org/rattail-project/tailbone"
-Issues = "https://redmine.rattailproject.org/projects/tailbone/issues"
-Changelog = "https://kallithea.rattailproject.org/rattail-project/tailbone/files/master/CHANGELOG.md"
+Repository = "https://forgejo.wuttaproject.org/rattail/tailbone"
+Issues = "https://forgejo.wuttaproject.org/rattail/tailbone/issues"
+Changelog = "https://forgejo.wuttaproject.org/rattail/tailbone/src/branch/master/CHANGELOG.md"
 
 
 [tool.commitizen]
diff --git a/tailbone/views/upgrades.py b/tailbone/views/upgrades.py
index 3276b64d..ffa88032 100644
--- a/tailbone/views/upgrades.py
+++ b/tailbone/views/upgrades.py
@@ -348,56 +348,27 @@ class UpgradeView(MasterView):
     commit_hash_pattern = re.compile(r'^.{40}$')
 
     def get_changelog_projects(self):
-        projects = {
-            'rattail': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail/files/v{new_version}/CHANGES.rst',
-            },
-            'Tailbone': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone/files/v{new_version}/CHANGES.rst',
-            },
-            'pyCOREPOS': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/pycorepos/files/v{new_version}/CHANGES.rst',
-            },
-            'rattail_corepos': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-corepos/files/v{new_version}/CHANGES.rst',
-            },
-            'tailbone_corepos': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-corepos/files/v{new_version}/CHANGES.rst',
-            },
-            'onager': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/onager/files/v{new_version}/CHANGES.rst',
-            },
-            'rattail-onager': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/rattail-onager/files/v{new_version}/CHANGELOG.md',
-            },
-            'rattail_tempmon': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-tempmon/files/v{new_version}/CHANGES.rst',
-            },
-            'tailbone-onager': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-restricted/tailbone-onager/files/v{new_version}/CHANGELOG.md',
-            },
-            'rattail_woocommerce': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/rattail-woocommerce/files/v{new_version}/CHANGES.rst',
-            },
-            'tailbone_woocommerce': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/tailbone-woocommerce/files/v{new_version}/CHANGES.rst',
-            },
-            'tailbone_theo': {
-                'commit_url': 'https://kallithea.rattailproject.org/rattail-project/theo/changelog/{new_version}/?size=10',
-                'release_url': 'https://kallithea.rattailproject.org/rattail-project/theo/files/v{new_version}/CHANGES.rst',
-            },
+        project_map = {
+            'onager': 'onager',
+            'pyCOREPOS': 'pycorepos',
+            'rattail': 'rattail',
+            'rattail_corepos': 'rattail-corepos',
+            'rattail-onager': 'rattail-onager',
+            'rattail_tempmon': 'rattail-tempmon',
+            'rattail_woocommerce': 'rattail-woocommerce',
+            'Tailbone': 'tailbone',
+            'tailbone_corepos': 'tailbone-corepos',
+            'tailbone-onager': 'tailbone-onager',
+            'tailbone_theo': 'theo',
+            'tailbone_woocommerce': 'tailbone-woocommerce',
         }
+
+        projects = {}
+        for name, repo in project_map.items():
+            projects[name] = {
+                'commit_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/compare/{{old_version}}...{{new_version}}',
+                'release_url': f'https://forgejo.wuttaproject.org/rattail/{repo}/src/tag/v{{new_version}}/CHANGELOG.md',
+            }
         return projects
 
     def get_changelog_url(self, project, old_version, new_version):

From 0b4efae392ff35ca4a0d0ac1ea59859b25e084f2 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 15 Sep 2024 10:56:01 -0500
Subject: [PATCH 53/85] =?UTF-8?q?bump:=20version=200.21.9=20=E2=86=92=200.?=
 =?UTF-8?q?21.10?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 9 +++++++++
 pyproject.toml | 2 +-
 2 files changed, 10 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index da628cf3..73c8b72b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to Tailbone 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.21.10 (2024-09-15)
+
+### Fix
+
+- update project repo links, kallithea -> forgejo
+- use better icon for submit button on login page
+- wrap notes text for batch view
+- expose datasync consumer batch size via configure page
+
 ## v0.21.9 (2024-08-28)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index a1c96dd4..3368842b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.21.9"
+version = "0.21.10"
 description = "Backoffice Web Application for Rattail"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 2308d2e2408ea5429ce196ed6c193241a21742a8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 16 Sep 2024 12:55:58 -0500
Subject: [PATCH 54/85] fix: become/stop root should redirect to previous url

for default theme; butterball already did that
---
 tailbone/templates/base.mako                   | 18 ++++++++++++++++--
 tailbone/templates/themes/butterball/base.mako | 16 ++--------------
 2 files changed, 18 insertions(+), 16 deletions(-)

diff --git a/tailbone/templates/base.mako b/tailbone/templates/base.mako
index 86b1ba1d..8228f823 100644
--- a/tailbone/templates/base.mako
+++ b/tailbone/templates/base.mako
@@ -632,9 +632,23 @@
         % endif
         <div class="navbar-dropdown">
           % if request.is_root:
-              ${h.link_to("Stop being root", url('stop_root'), class_='navbar-item root-user')}
+              ${h.form(url('stop_root'), ref='stopBeingRootForm')}
+              ${h.csrf_token(request)}
+              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
+              <a @click="$refs.stopBeingRootForm.submit()"
+                 class="navbar-item root-user">
+                Stop being root
+              </a>
+              ${h.end_form()}
           % elif request.is_admin:
-              ${h.link_to("Become root", url('become_root'), class_='navbar-item root-user')}
+              ${h.form(url('become_root'), ref='startBeingRootForm')}
+              ${h.csrf_token(request)}
+              <input type="hidden" name="referrer" value="${request.current_route_url()}" />
+              <a @click="$refs.startBeingRootForm.submit()"
+                 class="navbar-item root-user">
+                Become root
+              </a>
+              ${h.end_form()}
           % endif
           % if messaging_enabled:
               ${h.link_to("Messages{}".format(" ({})".format(inbox_count) if inbox_count else ''), url('messages.inbox'), class_='navbar-item')}
diff --git a/tailbone/templates/themes/butterball/base.mako b/tailbone/templates/themes/butterball/base.mako
index 14616474..b69eacfb 100644
--- a/tailbone/templates/themes/butterball/base.mako
+++ b/tailbone/templates/themes/butterball/base.mako
@@ -909,7 +909,7 @@
               ${h.form(url('stop_root'), ref='stopBeingRootForm')}
               ${h.csrf_token(request)}
               <input type="hidden" name="referrer" value="${request.current_route_url()}" />
-              <a @click="stopBeingRoot()"
+              <a @click="$refs.stopBeingRootForm.submit()"
                  class="navbar-item has-background-danger has-text-white">
                 Stop being root
               </a>
@@ -918,7 +918,7 @@
               ${h.form(url('become_root'), ref='startBeingRootForm')}
               ${h.csrf_token(request)}
               <input type="hidden" name="referrer" value="${request.current_route_url()}" />
-              <a @click="startBeingRoot()"
+              <a @click="$refs.startBeingRootForm.submit()"
                  class="navbar-item has-background-danger has-text-white">
                 Become root
               </a>
@@ -1103,18 +1103,6 @@
                 const key = 'menu_' + hash + '_shown'
                 this[key] = !this[key]
             },
-
-            % if request.is_admin:
-
-                startBeingRoot() {
-                    this.$refs.startBeingRootForm.submit()
-                },
-
-                stopBeingRoot() {
-                    this.$refs.stopBeingRootForm.submit()
-                },
-
-            % endif
         },
     }
 

From d520f64fee9c2c083e867816e2c90e56028c41f8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 3 Oct 2024 08:56:52 -0500
Subject: [PATCH 55/85] fix: custom method for adding grid action

since for now, we are using custom grid action class
---
 tailbone/grids/core.py | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index c6257d4b..73de42c6 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -1544,6 +1544,11 @@ class Grid(WuttaGrid):
         self._table_data = results
         return self._table_data
 
+    # TODO: remove this when we use upstream GridAction
+    def add_action(self, key, **kwargs):
+        """ """
+        self.actions.append(GridAction(self.request, key, **kwargs))
+
     def set_action_urls(self, row, rowobj, i):
         """
         Pre-generate all action URLs for the given data row.  Meant for use

From c6365f263166c53934fd81083c01d2bceccb01ab Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Thu, 3 Oct 2024 09:05:46 -0500
Subject: [PATCH 56/85] =?UTF-8?q?bump:=20version=200.21.10=20=E2=86=92=200?=
 =?UTF-8?q?.21.11?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 7 +++++++
 pyproject.toml | 2 +-
 2 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 73c8b72b..3c31ae92 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,13 @@ All notable changes to Tailbone 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.21.11 (2024-10-03)
+
+### Fix
+
+- custom method for adding grid action
+- become/stop root should redirect to previous url
+
 ## v0.21.10 (2024-09-15)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 3368842b..5b63a71f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.21.10"
+version = "0.21.11"
 description = "Backoffice Web Application for Rattail"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 072db39233dd8c0c22e429202f446cd67f578863 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 22 Oct 2024 14:26:10 -0500
Subject: [PATCH 57/85] feat: add support for new ordering batch from parsed
 file

---
 tailbone/api/batch/receiving.py             |  30 +-
 tailbone/templates/ordering/configure.mako  |  74 +++++
 tailbone/templates/receiving/configure.mako |   8 +-
 tailbone/views/batch/core.py                |   5 +-
 tailbone/views/purchasing/batch.py          | 290 +++++++++++++++++++-
 tailbone/views/purchasing/ordering.py       | 101 ++++++-
 tailbone/views/purchasing/receiving.py      | 219 +++------------
 7 files changed, 498 insertions(+), 229 deletions(-)
 create mode 100644 tailbone/templates/ordering/configure.mako

diff --git a/tailbone/api/batch/receiving.py b/tailbone/api/batch/receiving.py
index daa4290f..b23bff55 100644
--- a/tailbone/api/batch/receiving.py
+++ b/tailbone/api/batch/receiving.py
@@ -29,8 +29,7 @@ import logging
 import humanize
 import sqlalchemy as sa
 
-from rattail.db import model
-from rattail.util import pretty_quantity
+from rattail.db.model import PurchaseBatch, PurchaseBatchRow
 
 from cornice import Service
 from deform import widget as dfwidget
@@ -45,7 +44,7 @@ log = logging.getLogger(__name__)
 
 class ReceivingBatchViews(APIBatchView):
 
-    model_class = model.PurchaseBatch
+    model_class = PurchaseBatch
     default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
     route_prefix = 'receivingbatchviews'
     permission_prefix = 'receiving'
@@ -55,7 +54,8 @@ class ReceivingBatchViews(APIBatchView):
     supports_execute = True
 
     def base_query(self):
-        query = super(ReceivingBatchViews, self).base_query()
+        model = self.app.model
+        query = super().base_query()
         query = query.filter(model.PurchaseBatch.mode == self.enum.PURCHASE_BATCH_MODE_RECEIVING)
         return query
 
@@ -85,7 +85,7 @@ class ReceivingBatchViews(APIBatchView):
 
         # assume "receive from PO" if given a PO key
         if data.get('purchase_key'):
-            data['receiving_workflow'] = 'from_po'
+            data['workflow'] = 'from_po'
 
         return super().create_object(data)
 
@@ -120,6 +120,7 @@ class ReceivingBatchViews(APIBatchView):
         return self._get(obj=batch)
 
     def eligible_purchases(self):
+        model = self.app.model
         uuid = self.request.params.get('vendor_uuid')
         vendor = self.Session.get(model.Vendor, uuid) if uuid else None
         if not vendor:
@@ -176,7 +177,7 @@ class ReceivingBatchViews(APIBatchView):
 
 class ReceivingBatchRowViews(APIBatchRowView):
 
-    model_class = model.PurchaseBatchRow
+    model_class = PurchaseBatchRow
     default_handler_spec = 'rattail.batch.purchase:PurchaseBatchHandler'
     route_prefix = 'receiving.rows'
     permission_prefix = 'receiving'
@@ -185,7 +186,8 @@ class ReceivingBatchRowViews(APIBatchRowView):
     supports_quick_entry = True
 
     def make_filter_spec(self):
-        filters = super(ReceivingBatchRowViews, self).make_filter_spec()
+        model = self.app.model
+        filters = super().make_filter_spec()
         if filters:
 
             # must translate certain convenience filters
@@ -296,11 +298,11 @@ class ReceivingBatchRowViews(APIBatchRowView):
         return filters
 
     def normalize(self, row):
-        data = super(ReceivingBatchRowViews, self).normalize(row)
+        data = super().normalize(row)
+        model = self.app.model
 
         batch = row.batch
-        app = self.get_rattail_app()
-        prodder = app.get_products_handler()
+        prodder = self.app.get_products_handler()
 
         data['product_uuid'] = row.product_uuid
         data['item_id'] = row.item_id
@@ -375,7 +377,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
                 if accounted_for:
                     # some product accounted for; button should receive "remainder" only
                     if remainder:
-                        remainder = pretty_quantity(remainder)
+                        remainder = self.app.render_quantity(remainder)
                         data['quick_receive_quantity'] = remainder
                         data['quick_receive_text'] = "Receive Remainder ({} {})".format(
                             remainder, data['unit_uom'])
@@ -386,7 +388,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
                 else: # nothing yet accounted for, button should receive "all"
                     if not remainder:
                         log.warning("quick receive remainder is empty for row %s", row.uuid)
-                    remainder = pretty_quantity(remainder)
+                    remainder = self.app.render_quantity(remainder)
                     data['quick_receive_quantity'] = remainder
                     data['quick_receive_text'] = "Receive ALL ({} {})".format(
                         remainder, data['unit_uom'])
@@ -414,7 +416,7 @@ class ReceivingBatchRowViews(APIBatchRowView):
             data['received_alert'] = None
             if self.batch_handler.get_units_confirmed(row):
                 msg = "You have already received some of this product; last update was {}.".format(
-                    humanize.naturaltime(app.make_utc() - row.modified))
+                    humanize.naturaltime(self.app.make_utc() - row.modified))
                 data['received_alert'] = msg
 
         return data
@@ -423,6 +425,8 @@ class ReceivingBatchRowViews(APIBatchRowView):
         """
         View which handles "receiving" against a particular batch row.
         """
+        model = self.app.model
+
         # first do basic input validation
         schema = ReceiveRow().bind(session=self.Session())
         form = forms.Form(schema=schema, request=self.request)
diff --git a/tailbone/templates/ordering/configure.mako b/tailbone/templates/ordering/configure.mako
new file mode 100644
index 00000000..dc505c42
--- /dev/null
+++ b/tailbone/templates/ordering/configure.mako
@@ -0,0 +1,74 @@
+## -*- coding: utf-8; -*-
+<%inherit file="/configure.mako" />
+
+<%def name="form_content()">
+
+  <h3 class="block is-size-3">Workflows</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <p class="block">
+      Users can only choose from the workflows enabled below.
+    </p>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.allow_ordering_from_scratch"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_scratch']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        From Scratch
+      </b-checkbox>
+    </b-field>
+
+    <b-field>
+      <b-checkbox name="rattail.batch.purchase.allow_ordering_from_file"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_ordering_from_file']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        From Order File
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Vendors</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <b-field message="If not set, user must choose a &quot;supported&quot; vendor.">
+      <b-checkbox name="rattail.batch.purchase.allow_ordering_any_vendor"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_ordering_any_vendor']"
+                  native-value="true"
+                  @input="settingsNeedSaved = true">
+        Allow ordering for <span class="has-text-weight-bold">any</span> vendor
+      </b-checkbox>
+    </b-field>
+
+  </div>
+
+  <h3 class="block is-size-3">Order Parsers</h3>
+  <div class="block" style="padding-left: 2rem;">
+
+    <p class="block">
+      Only the selected file parsers will be exposed to users.
+    </p>
+
+    % for Parser in order_parsers:
+        <b-field message="${Parser.key}">
+          <b-checkbox name="order_parser_${Parser.key}"
+                      v-model="orderParsers['${Parser.key}']"
+                      native-value="true"
+                      @input="settingsNeedSaved = true">
+            ${Parser.title}
+          </b-checkbox>
+        </b-field>
+    % endfor
+
+  </div>
+
+</%def>
+
+<%def name="modify_vue_vars()">
+  ${parent.modify_vue_vars()}
+  <script>
+    ThisPageData.orderParsers = ${json.dumps(order_parsers_data)|n}
+  </script>
+</%def>
diff --git a/tailbone/templates/receiving/configure.mako b/tailbone/templates/receiving/configure.mako
index f613e13e..a36dde43 100644
--- a/tailbone/templates/receiving/configure.mako
+++ b/tailbone/templates/receiving/configure.mako
@@ -69,12 +69,12 @@
   <h3 class="block is-size-3">Vendors</h3>
   <div class="block" style="padding-left: 2rem;">
 
-    <b-field message="If set, user must choose a &quot;supported&quot; vendor; otherwise they may choose &quot;any&quot; vendor.">
-      <b-checkbox name="rattail.batch.purchase.supported_vendors_only"
-                  v-model="simpleSettings['rattail.batch.purchase.supported_vendors_only']"
+    <b-field message="If not set, user must choose a &quot;supported&quot; vendor.">
+      <b-checkbox name="rattail.batch.purchase.allow_receiving_any_vendor"
+                  v-model="simpleSettings['rattail.batch.purchase.allow_receiving_any_vendor']"
                   native-value="true"
                   @input="settingsNeedSaved = true">
-        Only allow batch for "supported" vendors
+        Allow receiving for <span class="has-text-weight-bold">any</span> vendor
       </b-checkbox>
     </b-field>
 
diff --git a/tailbone/views/batch/core.py b/tailbone/views/batch/core.py
index a75fda1c..c162b579 100644
--- a/tailbone/views/batch/core.py
+++ b/tailbone/views/batch/core.py
@@ -46,10 +46,11 @@ import colander
 from deform import widget as dfwidget
 from webhelpers2.html import HTML, tags
 
+from wuttaweb.util import render_csrf_token
+
 from tailbone import forms, grids
 from tailbone.db import Session
 from tailbone.views import MasterView
-from tailbone.util import csrf_token
 
 
 log = logging.getLogger(__name__)
@@ -441,7 +442,7 @@ class BatchMasterView(MasterView):
 
         form = [
             begin_form,
-            csrf_token(self.request),
+            render_csrf_token(self.request),
             tags.hidden('complete', value=value),
             submit,
             tags.end_form(),
diff --git a/tailbone/views/purchasing/batch.py b/tailbone/views/purchasing/batch.py
index 590b9af5..5e00704e 100644
--- a/tailbone/views/purchasing/batch.py
+++ b/tailbone/views/purchasing/batch.py
@@ -24,6 +24,8 @@
 Base class for purchasing batch views
 """
 
+import warnings
+
 from rattail.db.model import PurchaseBatch, PurchaseBatchRow
 
 import colander
@@ -67,6 +69,8 @@ class PurchasingBatchView(BatchMasterView):
         'store',
         'buyer',
         'vendor',
+        'description',
+        'workflow',
         'department',
         'purchase',
         'vendor_email',
@@ -158,6 +162,174 @@ class PurchasingBatchView(BatchMasterView):
     def batch_mode(self):
         raise NotImplementedError("Please define `batch_mode` for your purchasing batch view")
 
+    def get_supported_workflows(self):
+        """
+        Return the supported "create batch" workflows.
+        """
+        enum = self.app.enum
+        if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
+            return self.batch_handler.supported_ordering_workflows()
+        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
+            return self.batch_handler.supported_receiving_workflows()
+        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING:
+            return self.batch_handler.supported_costing_workflows()
+        raise ValueError("unknown batch mode")
+
+    def allow_any_vendor(self):
+        """
+        Return boolean indicating whether creating a batch for "any"
+        vendor is allowed, vs. only supported vendors.
+        """
+        enum = self.app.enum
+
+        if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
+            return self.batch_handler.allow_ordering_any_vendor()
+
+        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
+            value = self.config.get_bool('rattail.batch.purchase.allow_receiving_any_vendor')
+            if value is not None:
+                return value
+            value = self.config.get_bool('rattail.batch.purchase.supported_vendors_only')
+            if value is not None:
+                warnings.warn("setting rattail.batch.purchase.supported_vendors_only is deprecated; "
+                              "please use rattail.batch.purchase.allow_receiving_any_vendor instead",
+                              DeprecationWarning)
+                # nb. must negate this setting
+                return not value
+            return False
+
+        raise ValueError("unknown batch mode")
+
+    def get_supported_vendors(self):
+        """
+        Return the supported vendors for creating a batch.
+        """
+        return []
+
+    def create(self, form=None, **kwargs):
+        """
+        Custom view for creating a new batch.  We split the process
+        into two steps, 1) choose workflow and 2) create batch.  This
+        is because the specific form details for creating a batch will
+        depend on which "type" of batch creation is to be done, and
+        it's much easier to keep conditional logic for that in the
+        server instead of client-side etc.
+        """
+        model = self.app.model
+        enum = self.app.enum
+        route_prefix = self.get_route_prefix()
+
+        workflows = self.get_supported_workflows()
+        valid_workflows = [workflow['workflow_key']
+                           for workflow in workflows]
+
+        # if user has already identified their desired workflow, then
+        # we can just farm out to the default logic.  we will of
+        # course configure our form differently, based on workflow,
+        # but this create() method at least will not need
+        # customization for that.
+        if self.request.matched_route.name.endswith('create_workflow'):
+
+            redirect = self.redirect(self.request.route_url(f'{route_prefix}.create'))
+
+            # however we do have one more thing to check - the workflow
+            # requested must of course be valid!
+            workflow_key = self.request.matchdict['workflow_key']
+            if workflow_key not in valid_workflows:
+                self.request.session.flash(f"Not a supported workflow: {workflow_key}", 'error')
+                raise redirect
+
+            # also, we require vendor to be correctly identified.  if
+            # someone e.g. navigates to a URL by accident etc. we want
+            # to gracefully handle and redirect
+            uuid = self.request.matchdict['vendor_uuid']
+            vendor = self.Session.get(model.Vendor, uuid)
+            if not vendor:
+                self.request.session.flash("Invalid vendor selection.  "
+                                           "Please choose an existing vendor.",
+                                           'warning')
+                raise redirect
+
+            # okay now do the normal thing, per workflow
+            return super().create(**kwargs)
+
+        # on the other hand, if caller provided a form, that means we are in
+        # the middle of some other custom workflow, e.g. "add child to truck
+        # dump parent" or some such.  in which case we also defer to the normal
+        # logic, so as to not interfere with that.
+        if form:
+            return super().create(form=form, **kwargs)
+
+        # okay, at this point we need the user to select a vendor and workflow
+        self.creating = True
+        context = {}
+
+        # form to accept user choice of vendor/workflow
+        schema = colander.Schema()
+        schema.add(colander.SchemaNode(colander.String(), name='vendor'))
+        schema.add(colander.SchemaNode(colander.String(), name='workflow',
+                                       validator=colander.OneOf(valid_workflows)))
+        factory = self.get_form_factory()
+        form = factory(schema=schema, request=self.request)
+
+        # configure vendor field
+        vendor_handler = self.app.get_vendor_handler()
+        if self.allow_any_vendor():
+            # user may choose *any* available vendor
+            use_dropdown = vendor_handler.choice_uses_dropdown()
+            if use_dropdown:
+                vendors = self.Session.query(model.Vendor)\
+                                      .order_by(model.Vendor.id)\
+                                      .all()
+                vendor_values = [(vendor.uuid, f"({vendor.id}) {vendor.name}")
+                                 for vendor in vendors]
+                form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
+                if len(vendors) == 1:
+                    form.set_default('vendor', vendors[0].uuid)
+            else:
+                vendor_display = ""
+                if self.request.method == 'POST':
+                    if self.request.POST.get('vendor'):
+                        vendor = self.Session.get(model.Vendor, self.request.POST['vendor'])
+                        if vendor:
+                            vendor_display = str(vendor)
+                vendors_url = self.request.route_url('vendors.autocomplete')
+                form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget(
+                    field_display=vendor_display, service_url=vendors_url))
+        else: # only "supported" vendors allowed
+            vendors = self.get_supported_vendors()
+            vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor))
+                             for vendor in vendors]
+            form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
+        form.set_validator('vendor', self.valid_vendor_uuid)
+
+        # configure workflow field
+        values = [(workflow['workflow_key'], workflow['display'])
+                  for workflow in workflows]
+        form.set_widget('workflow',
+                        dfwidget.SelectWidget(values=values))
+        if len(workflows) == 1:
+            form.set_default('workflow', workflows[0]['workflow_key'])
+
+        form.submit_label = "Continue"
+        form.cancel_url = self.get_index_url()
+
+        # if form validates, that means user has chosen a creation
+        # type, so we just redirect to the appropriate "new batch of
+        # type X" page
+        if form.validate():
+            workflow_key = form.validated['workflow']
+            vendor_uuid = form.validated['vendor']
+            url = self.request.route_url(f'{route_prefix}.create_workflow',
+                                         workflow_key=workflow_key,
+                                         vendor_uuid=vendor_uuid)
+            raise self.redirect(url)
+
+        context['form'] = form
+        if hasattr(form, 'make_deform_form'):
+            context['dform'] = form.make_deform_form()
+        return self.render_to_response('create', context)
+
     def query(self, session):
         model = self.model
         return session.query(model.PurchaseBatch)\
@@ -226,20 +398,40 @@ class PurchasingBatchView(BatchMasterView):
 
     def configure_form(self, f):
         super().configure_form(f)
-        model = self.model
+        model = self.app.model
+        enum = self.app.enum
+        route_prefix = self.get_route_prefix()
+
+        today = self.app.today()
         batch = f.model_instance
-        app = self.get_rattail_app()
-        today = app.localtime().date()
+        workflow = self.request.matchdict.get('workflow_key')
+        vendor_handler = self.app.get_vendor_handler()
 
         # mode
-        f.set_enum('mode', self.enum.PURCHASE_BATCH_MODE)
+        f.set_enum('mode', enum.PURCHASE_BATCH_MODE)
+
+        # workflow
+        if self.creating:
+            if workflow:
+                f.set_widget('workflow', dfwidget.HiddenWidget())
+                f.set_default('workflow', workflow)
+                f.set_hidden('workflow')
+                # nb. show readonly '_workflow'
+                f.insert_after('workflow', '_workflow')
+                f.set_readonly('_workflow')
+                f.set_renderer('_workflow', self.render_workflow)
+            else:
+                f.set_readonly('workflow')
+                f.set_renderer('workflow', self.render_workflow)
+        else:
+            f.remove('workflow')
 
         # store
-        single_store = self.rattail_config.single_store()
+        single_store = self.config.single_store()
         if self.creating:
             f.replace('store', 'store_uuid')
             if single_store:
-                store = self.rattail_config.get_store(self.Session())
+                store = self.config.get_store(self.Session())
                 f.set_widget('store_uuid', dfwidget.HiddenWidget())
                 f.set_default('store_uuid', store.uuid)
                 f.set_hidden('store_uuid')
@@ -263,7 +455,6 @@ class PurchasingBatchView(BatchMasterView):
         if self.creating:
             f.replace('vendor', 'vendor_uuid')
             f.set_label('vendor_uuid', "Vendor")
-            vendor_handler = app.get_vendor_handler()
             use_dropdown = vendor_handler.choice_uses_dropdown()
             if use_dropdown:
                 vendors = self.Session.query(model.Vendor)\
@@ -313,7 +504,7 @@ class PurchasingBatchView(BatchMasterView):
                         if buyer:
                             buyer_display = str(buyer)
                 elif self.creating:
-                    buyer = app.get_employee(self.request.user)
+                    buyer = self.app.get_employee(self.request.user)
                     if buyer:
                         buyer_display = str(buyer)
                         f.set_default('buyer_uuid', buyer.uuid)
@@ -324,6 +515,30 @@ class PurchasingBatchView(BatchMasterView):
                     field_display=buyer_display, service_url=buyers_url))
                 f.set_label('buyer_uuid', "Buyer")
 
+        # order_file
+        if self.creating:
+            f.set_type('order_file', 'file', required=False)
+        else:
+            f.set_readonly('order_file')
+            f.set_renderer('order_file', self.render_downloadable_file)
+
+        # order_parser_key
+        if self.creating:
+            kwargs = {}
+            if 'vendor_uuid' in self.request.matchdict:
+                vendor = self.Session.get(model.Vendor,
+                                          self.request.matchdict['vendor_uuid'])
+                if vendor:
+                    kwargs['vendor'] = vendor
+            parsers = vendor_handler.get_supported_order_parsers(**kwargs)
+            parser_values = [(p.key, p.title) for p in parsers]
+            if len(parsers) == 1:
+                f.set_default('order_parser_key', parsers[0].key)
+            f.set_widget('order_parser_key', dfwidget.SelectWidget(values=parser_values))
+            f.set_label('order_parser_key', "Order Parser")
+        else:
+            f.remove_field('order_parser_key')
+
         # invoice_file
         if self.creating:
             f.set_type('invoice_file', 'file', required=False)
@@ -341,7 +556,7 @@ class PurchasingBatchView(BatchMasterView):
                 if vendor:
                     kwargs['vendor'] = vendor
 
-            parsers = self.handler.get_supported_invoice_parsers(**kwargs)
+            parsers = self.batch_handler.get_supported_invoice_parsers(**kwargs)
             parser_values = [(p.key, p.display) for p in parsers]
             if len(parsers) == 1:
                 f.set_default('invoice_parser_key', parsers[0].key)
@@ -400,6 +615,35 @@ class PurchasingBatchView(BatchMasterView):
                             'vendor_contact',
                             'status_code')
 
+        # tweak some things if we are in "step 2" of creating new batch
+        if self.creating and workflow:
+
+            # display vendor but do not allow changing
+            vendor = self.Session.get(model.Vendor, self.request.matchdict['vendor_uuid'])
+            if not vendor:
+                raise ValueError(f"vendor not found: {self.request.matchdict['vendor_uuid']}")
+            f.set_readonly('vendor_uuid')
+            f.set_default('vendor_uuid', str(vendor))
+
+            # cancel should take us back to choosing a workflow
+            f.cancel_url = self.request.route_url(f'{route_prefix}.create')
+
+    def render_workflow(self, batch, field):
+        key = self.request.matchdict['workflow_key']
+        info = self.get_workflow_info(key)
+        if info:
+            return info['display']
+
+    def get_workflow_info(self, key):
+        enum = self.app.enum
+        if self.batch_mode == enum.PURCHASE_BATCH_MODE_ORDERING:
+            return self.batch_handler.ordering_workflow_info(key)
+        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_RECEIVING:
+            return self.batch_handler.receiving_workflow_info(key)
+        elif self.batch_mode == enum.PURCHASE_BATCH_MODE_COSTING:
+            return self.batch_handler.costing_workflow_info(key)
+        raise ValueError("unknown batch mode")
+
     def render_store(self, batch, field):
         store = batch.store
         if not store:
@@ -515,10 +759,12 @@ class PurchasingBatchView(BatchMasterView):
 
     def get_batch_kwargs(self, batch, **kwargs):
         kwargs = super().get_batch_kwargs(batch, **kwargs)
-        model = self.model
+        model = self.app.model
 
         kwargs['mode'] = self.batch_mode
+        kwargs['workflow'] = self.request.POST['workflow']
         kwargs['truck_dump'] = batch.truck_dump
+        kwargs['order_parser_key'] = batch.order_parser_key
         kwargs['invoice_parser_key'] = batch.invoice_parser_key
 
         if batch.store:
@@ -536,6 +782,11 @@ class PurchasingBatchView(BatchMasterView):
         elif batch.vendor_uuid:
             kwargs['vendor_uuid'] = batch.vendor_uuid
 
+        # must pull vendor from URL if it was not in form data
+        if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs:
+            if 'vendor_uuid' in self.request.matchdict:
+                kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid']
+
         if batch.department:
             kwargs['department'] = batch.department
         elif batch.department_uuid:
@@ -919,6 +1170,25 @@ class PurchasingBatchView(BatchMasterView):
 #         # otherwise just view batch again
 #         return self.get_action_url('view', batch)
 
+    @classmethod
+    def defaults(cls, config):
+        cls._purchase_batch_defaults(config)
+        cls._batch_defaults(config)
+        cls._defaults(config)
+
+    @classmethod
+    def _purchase_batch_defaults(cls, config):
+        route_prefix = cls.get_route_prefix()
+        url_prefix = cls.get_url_prefix()
+        permission_prefix = cls.get_permission_prefix()
+
+        # new batch using workflow X
+        config.add_route(f'{route_prefix}.create_workflow',
+                         f'{url_prefix}/new/{{workflow_key}}/{{vendor_uuid}}')
+        config.add_view(cls, attr='create',
+                        route_name=f'{route_prefix}.create_workflow',
+                        permission=f'{permission_prefix}.create')
+
 
 class NewProduct(colander.Schema):
 
diff --git a/tailbone/views/purchasing/ordering.py b/tailbone/views/purchasing/ordering.py
index 2e24eebb..c7cc7bfc 100644
--- a/tailbone/views/purchasing/ordering.py
+++ b/tailbone/views/purchasing/ordering.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -28,14 +28,10 @@ import os
 import json
 
 import openpyxl
-from sqlalchemy import orm
 
-from rattail.db import model, api
 from rattail.core import Object
-from rattail.time import localtime
-
-from webhelpers2.html import tags
 
+from tailbone.db import Session
 from tailbone.views.purchasing import PurchasingBatchView
 
 
@@ -51,6 +47,8 @@ class OrderingBatchView(PurchasingBatchView):
     rows_editable = True
     has_worksheet = True
     default_help_url = 'https://rattailproject.org/docs/rattail-manual/features/purchasing/ordering/index.html'
+    downloadable = True
+    configurable = True
 
     labels = {
         'po_total_calculated': "PO Total",
@@ -59,9 +57,14 @@ class OrderingBatchView(PurchasingBatchView):
     form_fields = [
         'id',
         'store',
-        'buyer',
         'vendor',
+        'description',
+        'workflow',
+        'order_file',
+        'order_parser_key',
+        'buyer',
         'department',
+        'params',
         'purchase',
         'vendor_email',
         'vendor_fax',
@@ -132,15 +135,26 @@ class OrderingBatchView(PurchasingBatchView):
         return self.enum.PURCHASE_BATCH_MODE_ORDERING
 
     def configure_form(self, f):
-        super(OrderingBatchView, self).configure_form(f)
+        super().configure_form(f)
         batch = f.model_instance
+        workflow = self.request.matchdict.get('workflow_key')
 
         # purchase
         if self.creating or not batch.executed or not batch.purchase:
             f.remove_field('purchase')
 
+        # now that all fields are setup, some final tweaks based on workflow
+        if self.creating and workflow:
+
+            if workflow == 'from_scratch':
+                f.remove('order_file',
+                         'order_parser_key')
+
+            elif workflow == 'from_file':
+                f.set_required('order_file')
+
     def get_batch_kwargs(self, batch, **kwargs):
-        kwargs = super(OrderingBatchView, self).get_batch_kwargs(batch, **kwargs)
+        kwargs = super().get_batch_kwargs(batch, **kwargs)
         kwargs['ship_method'] = batch.ship_method
         kwargs['notes_to_vendor'] = batch.notes_to_vendor
         return kwargs
@@ -155,7 +169,7 @@ class OrderingBatchView(PurchasingBatchView):
         * ``cases_ordered``
         * ``units_ordered``
         """
-        super(OrderingBatchView, self).configure_row_form(f)
+        super().configure_row_form(f)
 
         # when editing, only certain fields should allow changes
         if self.editing:
@@ -308,7 +322,7 @@ class OrderingBatchView(PurchasingBatchView):
         title = self.get_instance_title(batch)
         order_date = batch.date_ordered
         if not order_date:
-            order_date = localtime(self.rattail_config).date()
+            order_date = self.app.today()
 
         return self.render_to_response('worksheet', {
             'batch': batch,
@@ -369,6 +383,7 @@ class OrderingBatchView(PurchasingBatchView):
         of being updated.  If a matching row is not found, it will not be
         created.
         """
+        model = self.app.model
         batch = self.get_instance()
 
         try:
@@ -478,13 +493,75 @@ class OrderingBatchView(PurchasingBatchView):
         return self.file_response(path)
 
     def get_execute_success_url(self, batch, result, **kwargs):
+        model = self.app.model
         if isinstance(result, model.Purchase):
             return self.request.route_url('purchases.view', uuid=result.uuid)
-        return super(OrderingBatchView, self).get_execute_success_url(batch, result, **kwargs)
+        return super().get_execute_success_url(batch, result, **kwargs)
+
+    def configure_get_simple_settings(self):
+        return [
+
+            # workflows
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_ordering_from_scratch',
+             'type': bool,
+             'default': True},
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_ordering_from_file',
+             'type': bool,
+             'default': True},
+
+            # vendors
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_ordering_any_vendor',
+             'type': bool,
+             'default': True,
+             },
+        ]
+
+    def configure_get_context(self):
+        context = super().configure_get_context()
+        vendor_handler = self.app.get_vendor_handler()
+
+        Parsers = vendor_handler.get_all_order_parsers()
+        Supported = vendor_handler.get_supported_order_parsers()
+        context['order_parsers'] = Parsers
+        context['order_parsers_data'] = dict([(Parser.key, Parser in Supported)
+                                                for Parser in Parsers])
+
+        return context
+
+    def configure_gather_settings(self, data):
+        settings = super().configure_gather_settings(data)
+        vendor_handler = self.app.get_vendor_handler()
+
+        supported = []
+        for Parser in vendor_handler.get_all_order_parsers():
+            name = f'order_parser_{Parser.key}'
+            if data.get(name) == 'true':
+                supported.append(Parser.key)
+        settings.append({'name': 'rattail.vendors.supported_order_parsers',
+                         'value': ', '.join(supported)})
+
+        return settings
+
+    def configure_remove_settings(self):
+        super().configure_remove_settings()
+
+        names = [
+            'rattail.vendors.supported_order_parsers',
+        ]
+
+        # nb. using thread-local session here; we do not use
+        # self.Session b/c it may not point to Rattail
+        session = Session()
+        for name in names:
+            self.app.delete_setting(session, name)
 
     @classmethod
     def defaults(cls, config):
         cls._ordering_defaults(config)
+        cls._purchase_batch_defaults(config)
         cls._batch_defaults(config)
         cls._defaults(config)
 
diff --git a/tailbone/views/purchasing/receiving.py b/tailbone/views/purchasing/receiving.py
index de19a2b9..01858c98 100644
--- a/tailbone/views/purchasing/receiving.py
+++ b/tailbone/views/purchasing/receiving.py
@@ -108,7 +108,7 @@ class ReceivingBatchView(PurchasingBatchView):
         'store',
         'vendor',
         'description',
-        'receiving_workflow',
+        'workflow',
         'truck_dump',
         'truck_dump_children_first',
         'truck_dump_children',
@@ -235,135 +235,18 @@ class ReceivingBatchView(PurchasingBatchView):
         if not self.handler.allow_truck_dump_receiving():
             g.remove('truck_dump')
 
-    def create(self, form=None, **kwargs):
-        """
-        Custom view for creating a new receiving batch.  We split the process
-        into two steps, 1) choose and 2) create.  This is because the specific
-        form details for creating a batch will depend on which "type" of batch
-        creation is to be done, and it's much easier to keep conditional logic
-        for that in the server instead of client-side etc.
-
-        See also
-        :meth:`tailbone.views.purchasing.costing:CostingBatchView.create()`
-        which uses similar logic.
-        """
-        model = self.model
-        route_prefix = self.get_route_prefix()
-        workflows = self.handler.supported_receiving_workflows()
-        valid_workflows = [workflow['workflow_key']
-                           for workflow in workflows]
-
-        # if user has already identified their desired workflow, then we can
-        # just farm out to the default logic.  we will of course configure our
-        # form differently, based on workflow, but this create() method at
-        # least will not need customization for that.
-        if self.request.matched_route.name.endswith('create_workflow'):
-
-            redirect = self.redirect(self.request.route_url('{}.create'.format(route_prefix)))
-
-            # however we do have one more thing to check - the workflow
-            # requested must of course be valid!
-            workflow_key = self.request.matchdict['workflow_key']
-            if workflow_key not in valid_workflows:
-                self.request.session.flash(
-                    "Not a supported workflow: {}".format(workflow_key),
-                    'error')
-                raise redirect
-
-            # also, we require vendor to be correctly identified.  if
-            # someone e.g. navigates to a URL by accident etc. we want
-            # to gracefully handle and redirect
-            uuid = self.request.matchdict['vendor_uuid']
-            vendor = self.Session.get(model.Vendor, uuid)
-            if not vendor:
-                self.request.session.flash("Invalid vendor selection.  "
-                                           "Please choose an existing vendor.",
-                                           'warning')
-                raise redirect
-
-            # okay now do the normal thing, per workflow
-            return super().create(**kwargs)
-
-        # on the other hand, if caller provided a form, that means we are in
-        # the middle of some other custom workflow, e.g. "add child to truck
-        # dump parent" or some such.  in which case we also defer to the normal
-        # logic, so as to not interfere with that.
-        if form:
-            return super().create(form=form, **kwargs)
-
-        # okay, at this point we need the user to select a vendor and workflow
-        self.creating = True
-        context = {}
-
-        # form to accept user choice of vendor/workflow
-        schema = NewReceivingBatch().bind(valid_workflows=valid_workflows)
-        form = forms.Form(schema=schema, request=self.request)
-
-        # configure vendor field
-        app = self.get_rattail_app()
-        vendor_handler = app.get_vendor_handler()
-        if self.rattail_config.getbool('rattail.batch', 'purchase.supported_vendors_only'):
-            # only show vendors for which we have dedicated invoice parsers
-            vendors = {}
-            for parser in self.batch_handler.get_supported_invoice_parsers():
-                if parser.vendor_key:
-                    vendor = vendor_handler.get_vendor(self.Session(),
-                                                       parser.vendor_key)
-                    if vendor:
-                        vendors[vendor.uuid] = vendor
-            vendors = sorted(vendors.values(), key=lambda v: v.name)
-            vendor_values = [(vendor.uuid, vendor_handler.render_vendor(vendor))
-                             for vendor in vendors]
-            form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
-        else:
-            # user may choose *any* available vendor
-            use_dropdown = vendor_handler.choice_uses_dropdown()
-            if use_dropdown:
-                vendors = self.Session.query(model.Vendor)\
-                                      .order_by(model.Vendor.id)\
-                                      .all()
-                vendor_values = [(vendor.uuid, "({}) {}".format(vendor.id, vendor.name))
-                                 for vendor in vendors]
-                form.set_widget('vendor', dfwidget.SelectWidget(values=vendor_values))
-                if len(vendors) == 1:
-                    form.set_default('vendor', vendors[0].uuid)
-            else:
-                vendor_display = ""
-                if self.request.method == 'POST':
-                    if self.request.POST.get('vendor'):
-                        vendor = self.Session.get(model.Vendor, self.request.POST['vendor'])
-                        if vendor:
-                            vendor_display = str(vendor)
-                vendors_url = self.request.route_url('vendors.autocomplete')
-                form.set_widget('vendor', forms.widgets.JQueryAutocompleteWidget(
-                    field_display=vendor_display, service_url=vendors_url))
-        form.set_validator('vendor', self.valid_vendor_uuid)
-
-        # configure workflow field
-        values = [(workflow['workflow_key'], workflow['display'])
-                  for workflow in workflows]
-        form.set_widget('workflow',
-                        dfwidget.SelectWidget(values=values))
-        if len(workflows) == 1:
-            form.set_default('workflow', workflows[0]['workflow_key'])
-
-        form.submit_label = "Continue"
-        form.cancel_url = self.get_index_url()
-
-        # if form validates, that means user has chosen a creation type, so we
-        # just redirect to the appropriate "new batch of type X" page
-        if form.validate():
-            workflow_key = form.validated['workflow']
-            vendor_uuid = form.validated['vendor']
-            url = self.request.route_url('{}.create_workflow'.format(route_prefix),
-                                         workflow_key=workflow_key,
-                                         vendor_uuid=vendor_uuid)
-            raise self.redirect(url)
-
-        context['form'] = form
-        if hasattr(form, 'make_deform_form'):
-            context['dform'] = form.make_deform_form()
-        return self.render_to_response('create', context)
+    def get_supported_vendors(self):
+        """ """
+        vendor_handler = self.app.get_vendor_handler()
+        vendors = {}
+        for parser in self.batch_handler.get_supported_invoice_parsers():
+            if parser.vendor_key:
+                vendor = vendor_handler.get_vendor(self.Session(),
+                                                   parser.vendor_key)
+                if vendor:
+                    vendors[vendor.uuid] = vendor
+        vendors = sorted(vendors.values(), key=lambda v: v.name)
+        return vendors
 
     def row_deletable(self, row):
 
@@ -404,13 +287,7 @@ class ReceivingBatchView(PurchasingBatchView):
             # cancel should take us back to choosing a workflow
             f.cancel_url = self.request.route_url('{}.create'.format(route_prefix))
 
-        # receiving_workflow
-        if self.creating and workflow:
-            f.set_readonly('receiving_workflow')
-            f.set_renderer('receiving_workflow', self.render_receiving_workflow)
-        else:
-            f.remove('receiving_workflow')
-
+        # TODO: remove this
         # batch_type
         if self.creating:
             f.set_widget('batch_type', dfwidget.HiddenWidget())
@@ -525,7 +402,7 @@ class ReceivingBatchView(PurchasingBatchView):
 
         # multiple invoice files (if applicable)
         if (not self.creating
-            and batch.get_param('receiving_workflow') == 'from_multi_invoice'):
+            and batch.get_param('workflow') == 'from_multi_invoice'):
 
             if 'invoice_files' not in f:
                 f.insert_before('invoice_file', 'invoice_files')
@@ -624,12 +501,6 @@ class ReceivingBatchView(PurchasingBatchView):
             items.append(HTML.tag('li', c=[link]))
         return HTML.tag('ul', c=items)
 
-    def render_receiving_workflow(self, batch, field):
-        key = self.request.matchdict['workflow_key']
-        info = self.handler.receiving_workflow_info(key)
-        if info:
-            return info['display']
-
     def get_visible_params(self, batch):
         params = super().get_visible_params(batch)
 
@@ -654,42 +525,40 @@ class ReceivingBatchView(PurchasingBatchView):
 
     def get_batch_kwargs(self, batch, **kwargs):
         kwargs = super().get_batch_kwargs(batch, **kwargs)
-        batch_type = self.request.POST['batch_type']
 
         # must pull vendor from URL if it was not in form data
         if 'vendor_uuid' not in kwargs and 'vendor' not in kwargs:
             if 'vendor_uuid' in self.request.matchdict:
                 kwargs['vendor_uuid'] = self.request.matchdict['vendor_uuid']
 
-        # TODO: ugh should just have workflow and no batch_type
-        kwargs['receiving_workflow'] = batch_type
-        if batch_type == 'from_scratch':
+        workflow = kwargs['workflow']
+        if workflow == 'from_scratch':
             kwargs.pop('truck_dump_batch', None)
             kwargs.pop('truck_dump_batch_uuid', None)
-        elif batch_type == 'from_invoice':
+        elif workflow == 'from_invoice':
             pass
-        elif batch_type == 'from_multi_invoice':
+        elif workflow == 'from_multi_invoice':
             pass
-        elif batch_type == 'from_po':
+        elif workflow == 'from_po':
             # TODO: how to best handle this field?  this doesn't seem flexible
             kwargs['purchase_key'] = batch.purchase_uuid
-        elif batch_type == 'from_po_with_invoice':
+        elif workflow == 'from_po_with_invoice':
             # TODO: how to best handle this field?  this doesn't seem flexible
             kwargs['purchase_key'] = batch.purchase_uuid
-        elif batch_type == 'truck_dump_children_first':
+        elif workflow == 'truck_dump_children_first':
             kwargs['truck_dump'] = True
             kwargs['truck_dump_children_first'] = True
             kwargs['order_quantities_known'] = True
             # TODO: this makes sense in some cases, but all?
             # (should just omit that field when not relevant)
             kwargs['date_ordered'] = None
-        elif batch_type == 'truck_dump_children_last':
+        elif workflow == 'truck_dump_children_last':
             kwargs['truck_dump'] = True
             kwargs['truck_dump_ready'] = True
             # TODO: this makes sense in some cases, but all?
             # (should just omit that field when not relevant)
             kwargs['date_ordered'] = None
-        elif batch_type.startswith('truck_dump_child'):
+        elif workflow.startswith('truck_dump_child'):
             truck_dump = self.get_instance()
             kwargs['store'] = truck_dump.store
             kwargs['vendor'] = truck_dump.vendor
@@ -1986,6 +1855,12 @@ class ReceivingBatchView(PurchasingBatchView):
              'type': bool},
 
             # vendors
+            {'section': 'rattail.batch',
+             'option': 'purchase.allow_receiving_any_vendor',
+             'type': bool},
+            # TODO: deprecated; can remove this once all live config
+            # is updated.  but for now it remains so this setting is
+            # auto-deleted
             {'section': 'rattail.batch',
              'option': 'purchase.supported_vendors_only',
              'type': bool},
@@ -2036,6 +1911,7 @@ class ReceivingBatchView(PurchasingBatchView):
     @classmethod
     def defaults(cls, config):
         cls._receiving_defaults(config)
+        cls._purchase_batch_defaults(config)
         cls._batch_defaults(config)
         cls._defaults(config)
 
@@ -2043,17 +1919,11 @@ class ReceivingBatchView(PurchasingBatchView):
     def _receiving_defaults(cls, config):
         rattail_config = config.registry.settings.get('rattail_config')
         route_prefix = cls.get_route_prefix()
-        url_prefix = cls.get_url_prefix()
         instance_url_prefix = cls.get_instance_url_prefix()
         model_key = cls.get_model_key()
         model_title = cls.get_model_title()
         permission_prefix = cls.get_permission_prefix()
 
-        # new receiving batch using workflow X
-        config.add_route('{}.create_workflow'.format(route_prefix), '{}/new/{{workflow_key}}/{{vendor_uuid}}'.format(url_prefix))
-        config.add_view(cls, attr='create', route_name='{}.create_workflow'.format(route_prefix),
-                        permission='{}.create'.format(permission_prefix))
-
         # row-level receiving
         config.add_route('{}.receive_row'.format(route_prefix), '{}/rows/{{row_uuid}}/receive'.format(instance_url_prefix))
         config.add_view(cls, attr='receive_row', route_name='{}.receive_row'.format(route_prefix),
@@ -2106,33 +1976,6 @@ class ReceivingBatchView(PurchasingBatchView):
                         permission='{}.auto_receive'.format(permission_prefix))
 
 
-@colander.deferred
-def valid_workflow(node, kw):
-    """
-    Deferred validator for ``workflow`` field, for new batches.
-    """
-    valid_workflows = kw['valid_workflows']
-
-    def validate(node, value):
-        # we just need to provide possible values, and let stock validator
-        # handle the rest
-        oneof = colander.OneOf(valid_workflows)
-        return oneof(node, value)
-
-    return validate
-
-
-class NewReceivingBatch(colander.Schema):
-    """
-    Schema for choosing which "type" of new receiving batch should be created.
-    """
-    vendor = colander.SchemaNode(colander.String(),
-                                 label="Vendor")
-
-    workflow = colander.SchemaNode(colander.String(),
-                                   validator=valid_workflow)
-
-
 class ReceiveRowForm(colander.MappingSchema):
 
     mode = colander.SchemaNode(colander.String(),

From 535317e4f769b2f39121060f70ed7a1c4a013aed Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 22 Oct 2024 15:04:40 -0500
Subject: [PATCH 58/85] fix: avoid deprecated method to suggest username

---
 tailbone/views/people.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index b6a4c0b9..d288b551 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -1382,8 +1382,8 @@ class PersonView(MasterView):
         }
 
         if not context['users']:
-            context['suggested_username'] = auth.generate_unique_username(self.Session(),
-                                                                          person=person)
+            context['suggested_username'] = auth.make_unique_username(self.Session(),
+                                                                      person=person)
 
         return context
 

From 28f90ad6b5777dfe1c91db2d90c5ccccc678ad5e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 22 Oct 2024 17:09:29 -0500
Subject: [PATCH 59/85] =?UTF-8?q?bump:=20version=200.21.11=20=E2=86=92=200?=
 =?UTF-8?q?.22.0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 10 ++++++++++
 pyproject.toml |  2 +-
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c31ae92..8ed82c5d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ All notable changes to Tailbone 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.22.0 (2024-10-22)
+
+### Feat
+
+- add support for new ordering batch from parsed file
+
+### Fix
+
+- avoid deprecated method to suggest username
+
 ## v0.21.11 (2024-10-03)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 5b63a71f..b928ec9b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.21.11"
+version = "0.22.0"
 description = "Backoffice Web Application for Rattail"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 9a6f8970aeb6117d9240b4bd4f024bca4ee136cf Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 23 Oct 2024 09:46:14 -0500
Subject: [PATCH 60/85] fix: avoid deprecated grid method

---
 tailbone/views/master.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index baf63caa..2e7ac147 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -412,7 +412,7 @@ class MasterView(View):
             session = self.Session()
         kwargs.setdefault('paginated', False)
         grid = self.make_grid(session=session, **kwargs)
-        return grid.make_visible_data()
+        return grid.get_visible_data()
 
     def get_grid_columns(self):
         """
@@ -1710,7 +1710,7 @@ class MasterView(View):
         kwargs.setdefault('paginated', False)
         kwargs.setdefault('sortable', sort)
         grid = self.make_row_grid(session=session, **kwargs)
-        return grid.make_visible_data()
+        return grid.get_visible_data()
 
     @classmethod
     def get_row_url_prefix(cls):

From 54220601edfde3435420d5e04b8e4883ae4b4d53 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 1 Nov 2024 17:47:46 -0500
Subject: [PATCH 61/85] fix: fix submit button for running problem report

esp. on Chrome(-based) browsers
---
 tailbone/templates/reports/problems/view.mako | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/tailbone/templates/reports/problems/view.mako b/tailbone/templates/reports/problems/view.mako
index 00ac1503..5cdf2be5 100644
--- a/tailbone/templates/reports/problems/view.mako
+++ b/tailbone/templates/reports/problems/view.mako
@@ -45,11 +45,10 @@
             <b-button @click="runReportShowDialog = false">
               Cancel
             </b-button>
-            ${h.form(master.get_action_url('execute', instance))}
+            ${h.form(master.get_action_url('execute', instance), **{'@submit': 'runReportSubmitting = true'})}
             ${h.csrf_token(request)}
             <b-button type="is-primary"
                       native-type="submit"
-                      @click="runReportSubmitting = true"
                       :disabled="runReportSubmitting"
                       icon-pack="fas"
                       icon-left="arrow-circle-right">

From 29743e70b7cba3a1b53917c24d0d5a1aaf70972e Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sat, 2 Nov 2024 16:56:28 -0500
Subject: [PATCH 62/85] =?UTF-8?q?bump:=20version=200.22.0=20=E2=86=92=200.?=
 =?UTF-8?q?22.1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 7 +++++++
 pyproject.toml | 2 +-
 2 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8ed82c5d..4dde0159 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,13 @@ All notable changes to Tailbone 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.22.1 (2024-11-02)
+
+### Fix
+
+- fix submit button for running problem report
+- avoid deprecated grid method
+
 ## v0.22.0 (2024-10-22)
 
 ### Feat
diff --git a/pyproject.toml b/pyproject.toml
index b928ec9b..a4a64038 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.22.0"
+version = "0.22.1"
 description = "Backoffice Web Application for Rattail"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 3f27f626df9f5d2ccb6ae6d52bba0abaa09ecca9 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Sun, 10 Nov 2024 19:16:45 -0600
Subject: [PATCH 63/85] fix: avoid deprecated import

---
 tailbone/api/master.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/tailbone/api/master.py b/tailbone/api/master.py
index 2d17339e..551d6428 100644
--- a/tailbone/api/master.py
+++ b/tailbone/api/master.py
@@ -26,7 +26,6 @@ Tailbone Web API - Master View
 
 import json
 
-from rattail.config import parse_bool
 from rattail.db.util import get_fieldnames
 
 from cornice import resource, Service
@@ -185,7 +184,7 @@ class APIMasterView(APIView):
             if sortcol:
                 spec = {
                     'field': sortcol.field_name,
-                    'direction': 'asc' if parse_bool(self.request.params['ascending']) else 'desc',
+                    'direction': 'asc' if self.config.parse_bool(self.request.params['ascending']) else 'desc',
                 }
                 if sortcol.model_name:
                     spec['model'] = sortcol.model_name

From 772b6610cbd99199cd4aae9bf4bbc3c5b748d829 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 12 Nov 2024 18:26:36 -0600
Subject: [PATCH 64/85] fix: always define `app` attr for ViewSupplement

---
 tailbone/views/master.py | 23 ++++++++++++-----------
 1 file changed, 12 insertions(+), 11 deletions(-)

diff --git a/tailbone/views/master.py b/tailbone/views/master.py
index 2e7ac147..21a5e58f 100644
--- a/tailbone/views/master.py
+++ b/tailbone/views/master.py
@@ -903,7 +903,7 @@ class MasterView(View):
 
     def valid_employee_uuid(self, node, value):
         if value:
-            model = self.model
+            model = self.app.model
             employee = self.Session.get(model.Employee, value)
             if not employee:
                 node.raise_invalid("Employee not found")
@@ -939,7 +939,7 @@ class MasterView(View):
 
     def valid_vendor_uuid(self, node, value):
         if value:
-            model = self.model
+            model = self.app.model
             vendor = self.Session.get(model.Vendor, value)
             if not vendor:
                 node.raise_invalid("Vendor not found")
@@ -1382,7 +1382,7 @@ class MasterView(View):
         return classes
 
     def make_revisions_grid(self, obj, empty_data=False):
-        model = self.model
+        model = self.app.model
         route_prefix = self.get_route_prefix()
         row_url = lambda txn, i: self.request.route_url(f'{route_prefix}.version',
                                                         uuid=obj.uuid,
@@ -2153,7 +2153,7 @@ class MasterView(View):
         Thread target for executing an object.
         """
         app = self.get_rattail_app()
-        model = self.model
+        model = self.app.model
         session = app.make_session()
         obj = self.get_instance_for_key(key, session)
         user = session.get(model.User, user_uuid)
@@ -2594,7 +2594,7 @@ class MasterView(View):
         """
         # nb. self.Session may differ, so use tailbone.db.Session
         session = Session()
-        model = self.model
+        model = self.app.model
         route_prefix = self.get_route_prefix()
 
         info = session.query(model.TailbonePageHelp)\
@@ -2617,7 +2617,7 @@ class MasterView(View):
         """
         # nb. self.Session may differ, so use tailbone.db.Session
         session = Session()
-        model = self.model
+        model = self.app.model
         route_prefix = self.get_route_prefix()
 
         info = session.query(model.TailbonePageHelp)\
@@ -2639,7 +2639,7 @@ class MasterView(View):
 
         # nb. self.Session may differ, so use tailbone.db.Session
         session = Session()
-        model = self.model
+        model = self.app.model
         route_prefix = self.get_route_prefix()
         schema = colander.Schema()
 
@@ -2673,7 +2673,7 @@ class MasterView(View):
 
         # nb. self.Session may differ, so use tailbone.db.Session
         session = Session()
-        model = self.model
+        model = self.app.model
         route_prefix = self.get_route_prefix()
         schema = colander.Schema()
 
@@ -5541,7 +5541,7 @@ class MasterView(View):
                                   input_file_templates=True,
                                   output_file_templates=True):
         app = self.get_rattail_app()
-        model = self.model
+        model = self.app.model
         names = []
 
         if simple_settings is None:
@@ -6100,7 +6100,7 @@ class MasterView(View):
                         renderer='json')
 
 
-class ViewSupplement(object):
+class ViewSupplement:
     """
     Base class for view "supplements" - which are sort of like plugins
     which can "supplement" certain aspects of the view.
@@ -6127,6 +6127,7 @@ class ViewSupplement(object):
     def __init__(self, master):
         self.master = master
         self.request = master.request
+        self.app = master.app
         self.model = master.model
         self.rattail_config = master.rattail_config
         self.Session = master.Session
@@ -6160,7 +6161,7 @@ class ViewSupplement(object):
         This is accomplished by subjecting the current base query to a
         join, e.g. something like::
 
-           model = self.model
+           model = self.app.model
            query = query.outerjoin(model.MyExtension)
            return query
         """

From 9e55717041f9955cb61a971a62340acb5473ab5f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 12 Nov 2024 18:28:41 -0600
Subject: [PATCH 65/85] fix: show continuum operation type when viewing version
 history

---
 tailbone/diffs.py                   | 6 +++++-
 tailbone/templates/master/view.mako | 1 +
 2 files changed, 6 insertions(+), 1 deletion(-)

diff --git a/tailbone/diffs.py b/tailbone/diffs.py
index 98253c57..8303d9e9 100644
--- a/tailbone/diffs.py
+++ b/tailbone/diffs.py
@@ -2,7 +2,7 @@
 ################################################################################
 #
 #  Rattail -- Retail Software Framework
-#  Copyright © 2010-2023 Lance Edgar
+#  Copyright © 2010-2024 Lance Edgar
 #
 #  This file is part of Rattail.
 #
@@ -27,6 +27,8 @@ Tools for displaying data diffs
 import sqlalchemy as sa
 import sqlalchemy_continuum as continuum
 
+from rattail.enum import CONTINUUM_OPERATION
+
 from pyramid.renderers import render
 from webhelpers2.html import HTML
 
@@ -273,6 +275,8 @@ class VersionDiff(Diff):
         return {
             'key': id(self.version),
             'model_title': self.title,
+            'operation': CONTINUUM_OPERATION.get(self.version.operation_type,
+                                                 self.version.operation_type),
             'diff_class': self.nature,
             'fields': self.fields,
             'values': values,
diff --git a/tailbone/templates/master/view.mako b/tailbone/templates/master/view.mako
index 0a1f9c62..118c028c 100644
--- a/tailbone/templates/master/view.mako
+++ b/tailbone/templates/master/view.mako
@@ -196,6 +196,7 @@
 
                   <p class="block has-text-weight-bold">
                     {{ version.model_title }}
+                    ({{ version.operation }})
                   </p>
 
                   <table class="diff monospace is-size-7"

From 20b3f87dbef3346de939d5eabaa18224cc146cce Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 12 Nov 2024 18:30:50 -0600
Subject: [PATCH 66/85] fix: add basic master view for Product Costs

---
 tailbone/menus.py          | 10 +++++
 tailbone/views/products.py | 77 +++++++++++++++++++++++++++++++++++++-
 2 files changed, 86 insertions(+), 1 deletion(-)

diff --git a/tailbone/menus.py b/tailbone/menus.py
index 3ddee095..09d6f3f0 100644
--- a/tailbone/menus.py
+++ b/tailbone/menus.py
@@ -394,6 +394,11 @@ class TailboneMenuHandler(WuttaMenuHandler):
                     'route': 'products',
                     'perm': 'products.list',
                 },
+                {
+                    'title': "Product Costs",
+                    'route': 'product_costs',
+                    'perm': 'product_costs.list',
+                },
                 {
                     'title': "Departments",
                     'route': 'departments',
@@ -451,6 +456,11 @@ class TailboneMenuHandler(WuttaMenuHandler):
                     'route': 'vendors',
                     'perm': 'vendors.list',
                 },
+                {
+                    'title': "Product Costs",
+                    'route': 'product_costs',
+                    'perm': 'product_costs.list',
+                },
                 {'type': 'sep'},
                 {
                     'title': "Ordering",
diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index c546a0f4..ae6c550c 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -34,7 +34,7 @@ import sqlalchemy_continuum as continuum
 
 from rattail import enum, pod, sil
 from rattail.db import api, auth, Session as RattailSession
-from rattail.db.model import Product, PendingProduct, CustomerOrderItem
+from rattail.db.model import Product, PendingProduct, ProductCost, CustomerOrderItem
 from rattail.gpc import GPC
 from rattail.threads import Thread
 from rattail.exceptions import LabelPrintingError
@@ -2668,6 +2668,78 @@ class PendingProductView(MasterView):
                         permission=f'{permission_prefix}.ignore_product')
 
 
+class ProductCostView(MasterView):
+    """
+    Master view for Product Costs
+    """
+    model_class = ProductCost
+    route_prefix = 'product_costs'
+    url_prefix = '/products/costs'
+    has_versions = True
+
+    grid_columns = [
+        '_product_key_',
+        'vendor',
+        'preference',
+        'code',
+        'case_size',
+        'case_cost',
+        'pack_size',
+        'pack_cost',
+        'unit_cost',
+    ]
+
+    def query(self, session):
+        """ """
+        query = super().query(session)
+        model = self.app.model
+
+        # always join on Product
+        return query.join(model.Product)
+
+    def configure_grid(self, g):
+        """ """
+        super().configure_grid(g)
+        model = self.app.model
+
+        # product key
+        field = self.get_product_key_field()
+        g.set_renderer(field, self.render_product_key)
+        g.set_sorter(field, getattr(model.Product, field))
+        g.set_sort_defaults(field)
+        g.set_filter(field, getattr(model.Product, field))
+
+        # vendor
+        g.set_joiner('vendor', lambda q: q.join(model.Vendor))
+        g.set_sorter('vendor', model.Vendor.name)
+        g.set_filter('vendor', model.Vendor.name, label="Vendor Name")
+
+    def render_product_key(self, cost, field):
+        """ """
+        handler = self.app.get_products_handler()
+        return handler.render_product_key(cost.product)
+
+    def configure_form(self, f):
+        """ """
+        super().configure_form(f)
+
+        # product
+        f.set_renderer('product', self.render_product)
+        if 'product_uuid' in f and 'product' in f:
+            f.remove('product')
+            f.replace('product_uuid', 'product')
+
+        # vendor
+        f.set_renderer('vendor', self.render_vendor)
+        if 'vendor_uuid' in f and 'vendor' in f:
+            f.remove('vendor')
+            f.replace('vendor_uuid', 'vendor')
+
+        # futures
+        # TODO: should eventually show a subgrid here?
+        f.remove('futures')
+
+
 def defaults(config, **kwargs):
     base = globals()
 
@@ -2677,6 +2749,9 @@ def defaults(config, **kwargs):
     PendingProductView = kwargs.get('PendingProductView', base['PendingProductView'])
     PendingProductView.defaults(config)
 
+    ProductCostView = kwargs.get('ProductCostView', base['ProductCostView'])
+    ProductCostView.defaults(config)
+
 
 def includeme(config):
     defaults(config)

From ac439c949b1760e46975292a7c19b81664b0b5f8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 12 Nov 2024 19:45:24 -0600
Subject: [PATCH 67/85] fix: use local/custom enum for continuum operations

since we can't rely on that existing in rattail proper, due to it not
always having sqlalchemy
---
 tailbone/diffs.py | 16 ++++++++++++----
 1 file changed, 12 insertions(+), 4 deletions(-)

diff --git a/tailbone/diffs.py b/tailbone/diffs.py
index 8303d9e9..2e582b15 100644
--- a/tailbone/diffs.py
+++ b/tailbone/diffs.py
@@ -27,8 +27,6 @@ Tools for displaying data diffs
 import sqlalchemy as sa
 import sqlalchemy_continuum as continuum
 
-from rattail.enum import CONTINUUM_OPERATION
-
 from pyramid.renderers import render
 from webhelpers2.html import HTML
 
@@ -272,11 +270,21 @@ class VersionDiff(Diff):
         for field in self.fields:
             values[field] = {'before': self.render_old_value(field),
                              'after': self.render_new_value(field)}
+
+        operation = None
+        if self.version.operation_type == continuum.Operation.INSERT:
+            operation = 'INSERT'
+        elif self.version.operation_type == continuum.Operation.UPDATE:
+            operation = 'UPDATE'
+        elif self.version.operation_type == continuum.Operation.DELETE:
+            operation = 'DELETE'
+        else:
+            operation = self.version.operation_type
+
         return {
             'key': id(self.version),
             'model_title': self.title,
-            'operation': CONTINUUM_OPERATION.get(self.version.operation_type,
-                                                 self.version.operation_type),
+            'operation': operation,
             'diff_class': self.nature,
             'fields': self.fields,
             'values': values,

From bcaf0d08bcab4fe040504986eee3735b814b50d9 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 18 Nov 2024 14:08:10 -0600
Subject: [PATCH 68/85] =?UTF-8?q?bump:=20version=200.22.1=20=E2=86=92=200.?=
 =?UTF-8?q?22.2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 10 ++++++++++
 pyproject.toml |  2 +-
 2 files changed, 11 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4dde0159..b7167b3c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,16 @@ All notable changes to Tailbone 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.22.2 (2024-11-18)
+
+### Fix
+
+- use local/custom enum for continuum operations
+- add basic master view for Product Costs
+- show continuum operation type when viewing version history
+- always define `app` attr for ViewSupplement
+- avoid deprecated import
+
 ## v0.22.1 (2024-11-02)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index a4a64038..ef7d3584 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.22.1"
+version = "0.22.2"
 description = "Backoffice Web Application for Rattail"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 980031f5245f814b3313a4e0438cfae4218a72dc Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Mon, 18 Nov 2024 14:59:50 -0600
Subject: [PATCH 69/85] fix: avoid error for trainwreck query when not a
 customer

when viewing a person's profile, who does not have a customer record,
the trainwreck query can't really return anything since it normally
should be matching on the customer ID
---
 tailbone/views/people.py | 18 +++++++++++-------
 1 file changed, 11 insertions(+), 7 deletions(-)

diff --git a/tailbone/views/people.py b/tailbone/views/people.py
index d288b551..405b1ca3 100644
--- a/tailbone/views/people.py
+++ b/tailbone/views/people.py
@@ -564,15 +564,19 @@ class PersonView(MasterView):
         Method which must return the base query for the profile's POS
         Transactions grid data.
         """
-        app = self.get_rattail_app()
-        customer = app.get_customer(person)
+        customer = self.app.get_customer(person)
 
-        key_field = app.get_customer_key_field()
-        customer_key = getattr(customer, key_field)
-        if customer_key is not None:
-            customer_key = str(customer_key)
+        if customer:
+            key_field = self.app.get_customer_key_field()
+            customer_key = getattr(customer, key_field)
+            if customer_key is not None:
+                customer_key = str(customer_key)
+        else:
+            # nb. this should *not* match anything, so query returns
+            # no results..
+            customer_key = person.uuid
 
-        trainwreck = app.get_trainwreck_handler()
+        trainwreck = self.app.get_trainwreck_handler()
         model = trainwreck.get_model()
         query = TrainwreckSession.query(model.Transaction)\
                                  .filter(model.Transaction.customer_id == customer_key)

From 993f066f2cb5da9bfabcf59a81627e5ff20dd7df Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 19 Nov 2024 15:45:37 -0600
Subject: [PATCH 70/85] =?UTF-8?q?bump:=20version=200.22.2=20=E2=86=92=200.?=
 =?UTF-8?q?22.3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 6 ++++++
 pyproject.toml | 2 +-
 2 files changed, 7 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7167b3c..5ec4ef5c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to Tailbone 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.22.3 (2024-11-19)
+
+### Fix
+
+- avoid error for trainwreck query when not a customer
+
 ## v0.22.2 (2024-11-18)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index ef7d3584..2dca88db 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.22.2"
+version = "0.22.3"
 description = "Backoffice Web Application for Rattail"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 7171c7fb06fa634a0688f525202a4b898868a8d7 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Tue, 19 Nov 2024 20:53:23 -0600
Subject: [PATCH 71/85] fix: use vmodel for confirm password widget input

since previously this did not work at all for butterball (vue3 +
oruga) - although it was never clear why per se..

Refs: #1
---
 tailbone/templates/deform/checked_password.pt |  4 +-
 tailbone/views/auth.py                        | 40 ++++++++-----------
 2 files changed, 19 insertions(+), 25 deletions(-)

diff --git a/tailbone/templates/deform/checked_password.pt b/tailbone/templates/deform/checked_password.pt
index f78c0b85..2121f01d 100644
--- a/tailbone/templates/deform/checked_password.pt
+++ b/tailbone/templates/deform/checked_password.pt
@@ -1,6 +1,7 @@
 <div i18n:domain="deform" tal:omit-tag=""
       tal:define="oid oid|field.oid;
                   name name|field.name;
+                  vmodel vmodel|'field_model_' + name;
                   css_class css_class|field.widget.css_class;
                   style style|field.widget.style;">
 
@@ -8,7 +9,7 @@
     ${field.start_mapping()}
     <b-input type="password"
              name="${name}"
-             value="${field.widget.redisplay and cstruct or ''}"
+             v-model="${vmodel}"
              tal:attributes="class string: form-control ${css_class or ''};
                              style style;
                              attributes|field.widget.attributes|{};"
@@ -18,7 +19,6 @@
     </b-input>
     <b-input type="password"
              name="${name}-confirm"
-             value="${field.widget.redisplay and confirm or ''}"
              tal:attributes="class string: form-control ${css_class or ''};
                              style style;
                              confirm_attributes|field.widget.confirm_attributes|{};"
diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py
index a54a19a9..1338c107 100644
--- a/tailbone/views/auth.py
+++ b/tailbone/views/auth.py
@@ -44,28 +44,6 @@ class UserLogin(colander.MappingSchema):
                                    widget=dfwidget.PasswordWidget())
 
 
-@colander.deferred
-def current_password_correct(node, kw):
-    request = kw['request']
-    app = request.rattail_config.get_app()
-    auth = app.get_auth_handler()
-    user = kw['user']
-    def validate(node, value):
-        if not auth.authenticate_user(Session(), user.username, value):
-            raise colander.Invalid(node, "The password is incorrect")
-    return validate
-
-
-class ChangePassword(colander.MappingSchema):
-
-    current_password = colander.SchemaNode(colander.String(),
-                                           widget=dfwidget.PasswordWidget(),
-                                           validator=current_password_correct)
-
-    new_password = colander.SchemaNode(colander.String(),
-                                       widget=dfwidget.CheckedPasswordWidget())
-
-
 class AuthenticationView(View):
 
     def forbidden(self):
@@ -181,7 +159,23 @@ class AuthenticationView(View):
                 self.request.user))
             return self.redirect(self.request.get_referrer())
 
-        schema = ChangePassword().bind(user=self.request.user, request=self.request)
+        def check_user_password(node, value):
+            auth = self.app.get_auth_handler()
+            user = self.request.user
+            if not auth.check_user_password(user, value):
+                node.raise_invalid("The password is incorrect")
+
+        schema = colander.Schema()
+
+        schema.add(colander.SchemaNode(colander.String(),
+                                       name='current_password',
+                                       widget=dfwidget.PasswordWidget(),
+                                       validator=check_user_password))
+
+        schema.add(colander.SchemaNode(colander.String(),
+                                       name='new_password',
+                                       widget=dfwidget.CheckedPasswordWidget()))
+
         form = forms.Form(schema=schema, request=self.request)
         if form.validate():
             auth = self.app.get_auth_handler()

From aace6033c5ba63f0ae5b6c7e458702483b2e6c5f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Wed, 20 Nov 2024 20:16:06 -0600
Subject: [PATCH 72/85] fix: avoid error in product search for duplicated key

---
 tailbone/views/products.py | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/tailbone/views/products.py b/tailbone/views/products.py
index ae6c550c..8461ae03 100644
--- a/tailbone/views/products.py
+++ b/tailbone/views/products.py
@@ -1857,7 +1857,8 @@ class ProductView(MasterView):
             lookup_fields.append('alt_code')
         if lookup_fields:
             product = self.products_handler.locate_product_for_entry(
-                session, term, lookup_fields=lookup_fields)
+                session, term, lookup_fields=lookup_fields,
+                first_if_multiple=True)
             if product:
                 final_results.append(self.search_normalize_result(product))
 

From f1c8ffedda2b88bd9b68faf3ec2161ede67ee972 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@edbob.org>
Date: Fri, 22 Nov 2024 12:57:04 -0600
Subject: [PATCH 73/85] =?UTF-8?q?bump:=20version=200.22.3=20=E2=86=92=200.?=
 =?UTF-8?q?22.4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 7 +++++++
 pyproject.toml | 2 +-
 2 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5ec4ef5c..b3b51f8d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,13 @@ All notable changes to Tailbone 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.22.4 (2024-11-22)
+
+### Fix
+
+- avoid error in product search for duplicated key
+- use vmodel for confirm password widget input
+
 ## v0.22.3 (2024-11-19)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 2dca88db..bde9bf89 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.22.3"
+version = "0.22.4"
 description = "Backoffice Web Application for Rattail"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From 2c269b640b1f72ac2cf9fea6a051d496096e0a8c Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@wuttaproject.org>
Date: Sun, 1 Dec 2024 18:12:30 -0600
Subject: [PATCH 74/85] fix: let caller request safe HTML literal for rendered
 grid table

mostly just for convenience
---
 tailbone/grids/core.py | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 73de42c6..134642dd 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -1223,6 +1223,7 @@ class Grid(WuttaGrid):
 
     def render_table_element(self, template='/grids/b-table.mako',
                              data_prop='gridData', empty_labels=False,
+                             literal=False,
                              **kwargs):
         """
         This is intended for ad-hoc "small" grids with static data.  Renders
@@ -1239,7 +1240,10 @@ class Grid(WuttaGrid):
         if context['paginated']:
             context.setdefault('per_page', 20)
         context['view_click_handler'] = self.get_view_click_handler()
-        return render(template, context)
+        result = render(template, context)
+        if literal:
+            result = HTML.literal(result)
+        return result
 
     def get_view_click_handler(self):
         """ """

From 23bdde245abae2721b02c06eec2e0e172c3e53c6 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@wuttaproject.org>
Date: Tue, 10 Dec 2024 12:34:34 -0600
Subject: [PATCH 75/85] fix: require newer wuttaweb

---
 pyproject.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pyproject.toml b/pyproject.toml
index bde9bf89..dc66e364 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -59,7 +59,7 @@ dependencies = [
         "transaction",
         "waitress",
         "WebHelpers2",
-        "WuttaWeb>=0.14.0",
+        "WuttaWeb>=0.16.2",
         "zope.sqlalchemy>=1.5",
 ]
 

From 7e559a01b3cdcfc3704b7ffa72cc2ec3df4c73f2 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@wuttaproject.org>
Date: Tue, 10 Dec 2024 12:52:49 -0600
Subject: [PATCH 76/85] fix: require newer rattail lib

---
 pyproject.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pyproject.toml b/pyproject.toml
index dc66e364..8c0c2c15 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,7 +53,7 @@ dependencies = [
         "pyramid_mako",
         "pyramid_retry",
         "pyramid_tm",
-        "rattail[db,bouncer]>=0.18.5",
+        "rattail[db,bouncer]>=0.21.1",
         "sa-filters",
         "simplejson",
         "transaction",

From 358b3b75a534daa7c84decd64566aca5d1c29328 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@wuttaproject.org>
Date: Tue, 10 Dec 2024 13:05:32 -0600
Subject: [PATCH 77/85] fix: whoops this is latest rattail

---
 pyproject.toml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/pyproject.toml b/pyproject.toml
index 8c0c2c15..759510ba 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -53,7 +53,7 @@ dependencies = [
         "pyramid_mako",
         "pyramid_retry",
         "pyramid_tm",
-        "rattail[db,bouncer]>=0.21.1",
+        "rattail[db,bouncer]>=0.20.1",
         "sa-filters",
         "simplejson",
         "transaction",

From 950db697a0306a87306facf07ca32ad1614341c9 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@wuttaproject.org>
Date: Mon, 16 Dec 2024 12:46:45 -0600
Subject: [PATCH 78/85] =?UTF-8?q?bump:=20version=200.22.4=20=E2=86=92=200.?=
 =?UTF-8?q?22.5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 9 +++++++++
 pyproject.toml | 2 +-
 2 files changed, 10 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b3b51f8d..cbacf2a5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,15 @@ All notable changes to Tailbone 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.22.5 (2024-12-16)
+
+### Fix
+
+- whoops this is latest rattail
+- require newer rattail lib
+- require newer wuttaweb
+- let caller request safe HTML literal for rendered grid table
+
 ## v0.22.4 (2024-11-22)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 759510ba..9c164772 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.22.4"
+version = "0.22.5"
 description = "Backoffice Web Application for Rattail"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From c7ee9de9eb3b86c40e99987c10843bd4bee142f9 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@wuttaproject.org>
Date: Sat, 28 Dec 2024 16:43:22 -0600
Subject: [PATCH 79/85] fix: register vue3 form component for products -> make
 batch

---
 tailbone/templates/products/batch.mako | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/tailbone/templates/products/batch.mako b/tailbone/templates/products/batch.mako
index 9f969468..db029e5a 100644
--- a/tailbone/templates/products/batch.mako
+++ b/tailbone/templates/products/batch.mako
@@ -55,19 +55,20 @@
 </%def>
 
 <%def name="render_form_template()">
-  <script type="text/x-template" id="${form.component}-template">
+  <script type="text/x-template" id="${form.vue_tagname}-template">
     ${self.render_form_innards()}
   </script>
 </%def>
 
 <%def name="modify_vue_vars()">
   ${parent.modify_vue_vars()}
+  <% request.register_component(form.vue_tagname, form.vue_component) %>
   <script>
 
     ## TODO: ugh, an awful lot of duplicated code here (from /forms/deform.mako)
 
     let ${form.vue_component} = {
-        template: '#${form.component}-template',
+        template: '#${form.vue_tagname}-template',
         methods: {
 
             ## TODO: deprecate / remove the latter option here

From e0ebd43e7abaa3292dd252135bc2d880b6b312ca Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@wuttaproject.org>
Date: Sat, 1 Feb 2025 15:18:12 -0600
Subject: [PATCH 80/85] =?UTF-8?q?bump:=20version=200.22.5=20=E2=86=92=200.?=
 =?UTF-8?q?22.6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 6 ++++++
 pyproject.toml | 4 ++--
 2 files changed, 8 insertions(+), 2 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index cbacf2a5..0b1726a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ All notable changes to Tailbone 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.22.6 (2025-02-01)
+
+### Fix
+
+- register vue3 form component for products -> make batch
+
 ## v0.22.5 (2024-12-16)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 9c164772..9e83df80 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.22.5"
+version = "0.22.6"
 description = "Backoffice Web Application for Rattail"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]
@@ -59,7 +59,7 @@ dependencies = [
         "transaction",
         "waitress",
         "WebHelpers2",
-        "WuttaWeb>=0.16.2",
+        "WuttaWeb>=0.21.0",
         "zope.sqlalchemy>=1.5",
 ]
 

From 4221fa50dd95771c84c20473381edcaff006043d Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@wuttaproject.org>
Date: Fri, 14 Feb 2025 11:37:21 -0600
Subject: [PATCH 81/85] fix: fix warning msg for deprecated Grid param

---
 tailbone/grids/core.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/tailbone/grids/core.py b/tailbone/grids/core.py
index 134642dd..56b97b86 100644
--- a/tailbone/grids/core.py
+++ b/tailbone/grids/core.py
@@ -235,7 +235,7 @@ class Grid(WuttaGrid):
 
         if 'pageable' in kwargs:
             warnings.warn("pageable param is deprecated for Grid(); "
-                          "please use vue_tagname param instead",
+                          "please use paginated param instead",
                           DeprecationWarning, stacklevel=2)
             kwargs.setdefault('paginated', kwargs.pop('pageable'))
 

From 7348eec671542fa1317ad68a0816948ee96c76ac Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@wuttaproject.org>
Date: Tue, 18 Feb 2025 11:16:23 -0600
Subject: [PATCH 82/85] fix: stop using old config for logo image url on login
 page

---
 tailbone/views/auth.py | 5 -----
 1 file changed, 5 deletions(-)

diff --git a/tailbone/views/auth.py b/tailbone/views/auth.py
index 1338c107..eceab803 100644
--- a/tailbone/views/auth.py
+++ b/tailbone/views/auth.py
@@ -94,10 +94,6 @@ class AuthenticationView(View):
             else:
                 self.request.session.flash("Invalid username or password", 'error')
 
-        image_url = self.rattail_config.get(
-            'tailbone', 'main_image_url',
-            default=self.request.static_url('tailbone:static/img/home_logo.png'))
-
         # nb. hacky..but necessary, to add the refs, for autofocus
         # (also add key handler, so ENTER acts like TAB)
         dform = form.make_deform_form()
@@ -110,7 +106,6 @@ class AuthenticationView(View):
         return {
             'form': form,
             'referrer': referrer,
-            'image_url': image_url,
             'index_title': app.get_node_title(),
             'help_url': global_help_url(self.rattail_config),
         }

From a6508154cb93a376a7ec93efa930534c674364f8 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@wuttaproject.org>
Date: Tue, 18 Feb 2025 12:13:28 -0600
Subject: [PATCH 83/85] docs: update intersphinx doc links per server migration

---
 docs/conf.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/docs/conf.py b/docs/conf.py
index 52e384f5..ade4c92a 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -27,10 +27,10 @@ templates_path = ['_templates']
 exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
 
 intersphinx_mapping = {
-    'rattail': ('https://rattailproject.org/docs/rattail/', None),
+    'rattail': ('https://docs.wuttaproject.org/rattail/', None),
     'webhelpers2': ('https://webhelpers2.readthedocs.io/en/latest/', None),
-    'wuttaweb': ('https://rattailproject.org/docs/wuttaweb/', None),
-    'wuttjamaican': ('https://rattailproject.org/docs/wuttjamaican/', None),
+    'wuttaweb': ('https://docs.wuttaproject.org/wuttaweb/', None),
+    'wuttjamaican': ('https://docs.wuttaproject.org/wuttjamaican/', None),
 }
 
 # allow todo entries to show up

From e2582ffec5f84f97df9cc7d2fdcdf5201b2d135f Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@wuttaproject.org>
Date: Wed, 19 Feb 2025 10:33:39 -0600
Subject: [PATCH 84/85] =?UTF-8?q?bump:=20version=200.22.6=20=E2=86=92=200.?=
 =?UTF-8?q?22.7?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md   | 7 +++++++
 pyproject.toml | 2 +-
 2 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b1726a4..c974b3a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,13 @@ All notable changes to Tailbone 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.22.7 (2025-02-19)
+
+### Fix
+
+- stop using old config for logo image url on login page
+- fix warning msg for deprecated Grid param
+
 ## v0.22.6 (2025-02-01)
 
 ### Fix
diff --git a/pyproject.toml b/pyproject.toml
index 9e83df80..a7214a8e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "Tailbone"
-version = "0.22.6"
+version = "0.22.7"
 description = "Backoffice Web Application for Rattail"
 readme = "README.md"
 authors = [{name = "Lance Edgar", email = "lance@edbob.org"}]

From e15045380171617b32f9dca6bcbda8b2c2472310 Mon Sep 17 00:00:00 2001
From: Lance Edgar <lance@wuttaproject.org>
Date: Wed, 5 Mar 2025 10:34:52 -0600
Subject: [PATCH 85/85] fix: add startup hack for tempmon DB model

---
 tailbone/app.py | 11 +++++++++++
 1 file changed, 11 insertions(+)

diff --git a/tailbone/app.py b/tailbone/app.py
index b7262866..d2d0c5ef 100644
--- a/tailbone/app.py
+++ b/tailbone/app.py
@@ -62,6 +62,17 @@ def make_rattail_config(settings):
     # nb. this is for compaibility with wuttaweb
     settings['wutta_config'] = rattail_config
 
+    # must import all sqlalchemy models before things get rolling,
+    # otherwise can have errors about continuum TransactionMeta class
+    # not yet mapped, when relevant pages are first requested...
+    # cf. https://docs.pylonsproject.org/projects/pyramid_cookbook/en/latest/database/sqlalchemy.html#importing-all-sqlalchemy-models
+    # hat tip to https://stackoverflow.com/a/59241485
+    if getattr(rattail_config, 'tempmon_engine', None):
+        from rattail_tempmon.db import model as tempmon_model, Session as TempmonSession
+        tempmon_session = TempmonSession()
+        tempmon_session.query(tempmon_model.Appliance).first()
+        tempmon_session.close()
+
     # configure database sessions
     if hasattr(rattail_config, 'appdb_engine'):
         tailbone.db.Session.configure(bind=rattail_config.appdb_engine)